组件开发中的那些坑我替你踩过了

シ淑丽 工具 阅读 1,469
赞 2 收藏
二维码
手机扫码查看
反馈

这次的组件封装让我想重新学Vue

最近在重构一个项目的时候,被组件封装给折腾得够呛。说实话,我之前写组件都是能跑就行,也没太在意复用性和扩展性。结果这次遇到的需求稍微复杂一点,之前的写法完全不够用了。

组件开发中的那些坑我替你踩过了

主要是要做一个表格组件,里面要支持排序、筛选、分页这些功能。本来想着挺简单的,结果写着写着发现各种状态管理混乱,父子组件通信也是乱七八糟的。折腾了两天才意识到,我的组件开发思路从根本上就有问题。

父子通信那套老方法真不行

以前我做组件通信就是 props 和 emit 这一套,简单的父子关系还好,稍微复杂一点就懵了。这次做表格组件,里面要嵌套筛选器、分页器,还要把数据和当前状态传回去,直接就乱套了。

一开始我是这样写的:

// 子组件
export default {
  emits: ['update-sort', 'update-filter', 'update-page'],
  methods: {
    handleSort(column) {
      this.$emit('update-sort', column)
    },
    handleFilter(filterData) {
      this.$emit('update-filter', filterData)
    }
  }
}
// 父组件
<template>
  <TableComponent 
    :data="tableData"
    @update-sort="handleSort"
    @update-filter="handleFilter"
    @update-page="handlePageChange"
  />
</template>

这里我踩了个坑,emit 事件多了之后,维护起来特别麻烦。而且当子组件层级更深的时候,props 传递就像链条一样,一层层往下传,代码看起来就很恶心。

Provide/Inject 是个好东西,但得用对

后来试了下 provide/inject,发现这玩意儿在组件通信上是真的好用。特别是对于表格这种有多个子组件的场景,通过 provide 把数据和方法传递下去,子组件可以直接 inject 使用,不用层层传递了。

但这里也有个小问题,就是响应式的处理。最开始我写的代码是这样的:

// 表格主组件
import { ref, reactive, provide } from 'vue'

export default {
  setup() {
    const tableState = reactive({
      data: [],
      loading: false,
      currentPage: 1,
      pageSize: 10,
      sortInfo: {},
      filterInfo: {}
    })

    const updateSort = (column) => {
      tableState.sortInfo = { field: column.field, order: column.order }
      fetchData()
    }

    const updateFilter = (filter) => {
      tableState.filterInfo = filter
      fetchData()
    }

    // 提供给子组件使用
    provide('tableContext', {
      state: tableState,
      updateSort,
      updateFilter,
      updatePage: (page) => {
        tableState.currentPage = page
        fetchData()
      }
    })
  }
}
// 排序组件
import { inject } from 'vue'

export default {
  setup() {
    const { state, updateSort } = inject('tableContext')
    
    const handleSort = (column) => {
      updateSort(column)
    }

    return {
      state,
      handleSort
    }
  }
}

刚开始用起来感觉还不错,但后来发现一个问题:如果表格组件被销毁重建,子组件那边的状态可能会出错。折腾了半天发现是因为 provide 的对象引用问题,需要做一些额外的处理来确保每次都是新的实例。

插槽设计让我头疼不已

表格组件最难搞的部分其实是插槽设计。用户可能需要自定义列的内容、头部、底部,还有可能出现复杂的交互逻辑。

最开始我想着把所有地方都做成插槽,结果代码变得特别复杂:

<template>
  <div class="table-container">
    <div class="table-header">
      <slot name="header" :data="state.data">
        <!-- 默认头部 -->
      </slot>
    </div>
    <table>
      <thead>
        <tr>
          <th v-for="col in columns" :key="col.key">
            <slot :name="${col.key}-header" :column="col">
              {{ col.title }}
            </slot>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="(row, index) in state.data" :key="index">
          <td v-for="col in columns" :key="col.key">
            <slot :name="col.key" :row="row" :value="row[col.key]" :index="index">
              {{ row[col.key] }}
            </slot>
          </td>
        </tr>
      </tbody>
    </table>
    <div class="table-footer">
      <slot name="footer" :pagination="state.pagination">
        <Pagination
          :current="state.currentPage"
          :total="state.total"
          @change="updatePage"
        />
      </slot>
    </div>
  </div>
</template>

这么写虽然灵活性很高,但使用起来就很麻烦。后来我重新考虑了一下,决定把插槽分为两类:功能性插槽和展示性插槽。功能性插槽提供默认实现,让用户可以覆盖;展示性插槽主要用来定制外观。

事件系统重构花了我一整天

原来的事件系统真的是一团糟,后来我重新设计了一套。主要是把所有的事件都统一管理,然后通过事件名称来区分不同的操作类型。

// 事件管理器
class TableEventManager {
  constructor() {
    this.listeners = new Map()
  }

  on(event, callback) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, [])
    }
    this.listeners.get(event).push(callback)
  }

  emit(event, payload) {
    const callbacks = this.listeners.get(event)
    if (callbacks) {
      callbacks.forEach(callback => callback(payload))
    }
  }

  off(event, callback) {
    if (this.listeners.has(event)) {
      const callbacks = this.listeners.get(event)
      const index = callbacks.indexOf(callback)
      if (index > -1) {
        callbacks.splice(index, 1)
      }
    }
  }
}

// 在组件中使用
setup() {
  const eventManager = new TableEventManager()
  
  // 注册全局事件监听
  eventManager.on('sort-change', handleSort)
  eventManager.on('filter-change', handleFilter)
  eventManager.on('page-change', handlePage)

  provide('eventManager', eventManager)
}

这样做的好处是事件管理更加清晰,而且可以在任何子组件中触发和监听事件,不需要层层传递。缺点是要管理好事件的生命周期,避免内存泄漏。

性能优化不能忽略

组件写完了还得考虑性能。表格数据量大的时候,渲染会很卡。这里我主要做了几个优化:

  • 使用虚拟滚动来处理大数据量
  • 对计算属性进行缓存
  • 避免不必要的响应式更新
// 虚拟滚动实现
const visibleRows = computed(() => {
  const start = Math.max(0, state.scrollOffset - bufferSize)
  const end = Math.min(
    state.filteredData.length, 
    state.scrollOffset + visibleCount + bufferSize
  )
  return state.filteredData.slice(start, end).map((item, index) => ({
    ...item,
    _virtualIndex: start + index
  }))
})

虚拟滚动这块我还专门研究了一下,主要是要计算可视区域的高度,然后只渲染当前可见的数据行。这部分代码稍微有点复杂,但是效果还是很明显的。

最后的总结

这次组件重构让我对 Vue 的组件开发有了更深的理解。以前写组件就是功能能跑通就行,现在才知道组件设计其实是个大学问。

最重要的是要提前规划好组件的架构,特别是通信机制和状态管理。用好 provide/inject 可以大大简化组件间的数据传递,但要注意响应式的问题。

插槽设计要平衡灵活性和易用性,不能为了追求灵活性把所有地方都做成插槽。事件系统的统一管理也很重要,可以让代码更清晰。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论