Skip to content

表格列扩展渲染体系重构

这次整理的对象来自 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test 里的表格相关 demo。和前几轮一样,我们不直接修改这个独立子项目,而是把里面最值得长期复用的核心设计整理成一份文档化重构方案。

这组 demo 很有代表性,因为它完整展示了一张表格从“只能按 key 渲染”演化到“支持 render 函数”,再演化到“支持具名插槽”,最后过渡到“作用域插槽桥接”的过程。它本质上是在回答同一个问题:一张表格,应该怎样把列的展示能力开放给使用者。

这次主要参考了下面这些文件:

  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/table-render/table.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/table-render/render.js
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/table-slot/table.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/table-slot2/table.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/table-slot2/slot.js
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/views/table-render.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/views/table-slot.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/views/table-slot2.vue

这组 demo 最值得学的不是表格,而是扩展点设计

table-render/table.vue 的核心非常简单:遍历 columnsdata,普通列走 row[col.key],扩展列走 col.render(h, params)

这个能力看起来只是“多了个 render 函数”,但它本质上已经把表格从一个固定展示组件,变成了一个“带扩展点的基础组件”。后面的 slotslot-scope 方案,则是在继续探索另一条开放能力的路径。

所以这轮重构的重点,不是怎么写一张表,而是怎么设计一套稳定、清晰、易扩展的列渲染协议。

原实现已经走过了三种典型方案

这组 demo 基本覆盖了表格可扩展渲染最常见的三种方案:

  • render 函数:由列配置直接控制单元格输出
  • 具名插槽:列声明 slot,由父组件提供模板
  • 作用域插槽桥接:表格内部把当前 row / column / index 注入到父级插槽

这三种方案都能解决“我要自定义单元格”,但它们的适用边界不同。

render 方案更像“代码驱动扩展”,适合基础库和高度动态化场景。插槽方案更像“模板驱动扩展”,更适合业务侧直接写视图。真正成熟的表格组件,通常会同时支持这两类能力。

目前的问题不在能不能扩展,而在协议还不够清晰

原始实现是有效的,但还有几个典型的组件库问题:

  • 列配置没有统一类型描述,key / render / slot 是靠约定并存
  • 每个表格 demo 都重复了相似的表头、表体和单元格遍历逻辑
  • render.jsslot.js 这类桥接组件能力单一,但还没有被收敛进统一协议
  • 父组件里的编辑状态与列定义强耦合,业务逻辑很容易散落在列配置里

如果按 Vue 3 的思路重构,更合理的做法是先把列渲染协议稳定下来,再围绕协议实现表头、表体和单元格层。

推荐的重构边界

更适合沉淀成长期方案的结构可以拆成下面几层:

  • components/table/BaseTable.vue:表格入口,负责 columns / data 接入
  • components/table/BaseTableHeader.vue:表头渲染
  • components/table/BaseTableBody.vue:表体渲染
  • components/table/BaseTableCell.vue:单元格渲染决策层
  • components/table/TableRenderCell.ts:render 函数桥接
  • composables/useTableColumns.ts:列标准化与列能力判断
  • types/table.ts:列配置、单元格上下文和扩展协议类型

这样拆分以后,表格主体只负责组织结构,单元格层专门负责“当前列该怎么渲染”,协议层则保证 key / render / slot 能够被统一消费。

先把列协议稳定下来

原 demo 的关键思想很简单:每一列都可以有默认渲染,也可以有自定义渲染。真正需要重构的,是把这种能力写成一套明确类型,而不是分散在模板里的 if ('render' in col)if ('slot' in col)

ts
import type { VNodeChild } from 'vue'

export interface TableCellContext<Row> {
  row: Row
  rowIndex: number
  column: TableColumn<Row>
  columnIndex: number
}

export interface TableColumn<Row> {
  key?: keyof Row | string
  title: string
  width?: number | string
  align?: 'left' | 'center' | 'right'
  render?: (context: TableCellContext<Row>) => VNodeChild
  slot?: string
}

一旦有了这层协议,表格组件就不必在模板里做零散判断,而可以先把列配置标准化,再交给单元格层统一决策。

列标准化逻辑更适合抽成 useTableColumns

原实现里每个表格组件都直接拿 columns 来遍历,这对 demo 足够,但对基础组件来说不够稳固。更好的方式是先把列配置收口成一个统一结构。

ts
import { computed } from 'vue'
import type { TableColumn } from '../types/table'

export function useTableColumns<Row>(columns: TableColumn<Row>[]) {
  const normalizedColumns = computed(() => {
    return columns.map((column, columnIndex) => {
      return {
        align: 'left',
        ...column,
        __columnIndex: columnIndex,
        __mode: column.render ? 'render' : column.slot ? 'slot' : 'text'
      }
    })
  })

  return {
    normalizedColumns
  }
}

这一步的价值在于,渲染模式从模板分支提升成了结构化数据。后面无论是加排序列、选择列还是操作列,都可以继续沿着这条线扩展。

单元格应该成为真正的渲染决策中心

原始几个 table 组件最大的重复点,在于它们都在 td 里做了类似判断。更适合的方式是把这部分收口到 BaseTableCell.vue,由它统一处理三种渲染模式。

vue
<script setup lang="ts" generic="Row extends Record<string, unknown>">
import { computed, useSlots } from 'vue'
import TableRenderCell from './TableRenderCell'
import type { TableCellContext, TableColumn } from '../../types/table'

const props = defineProps<{
  row: Row
  rowIndex: number
  column: TableColumn<Row>
  columnIndex: number
}>()

const slots = useSlots()

const context = computed<TableCellContext<Row>>(() => ({
  row: props.row,
  rowIndex: props.rowIndex,
  column: props.column,
  columnIndex: props.columnIndex
}))

const textValue = computed(() => {
  if (!props.column.key) {
    return ''
  }

  return props.row[props.column.key as keyof Row]
})
</script>

<template>
  <td>
    <TableRenderCell
      v-if="props.column.render"
      :render="props.column.render"
      :context="context"
    />

    <template v-else-if="props.column.slot && slots[props.column.slot]">
      <slot :name="props.column.slot" v-bind="context" />
    </template>

    <template v-else>
      {{ textValue }}
    </template>
  </td>
</template>

这样表体组件就不需要知道列的每一种扩展模式,只负责把上下文交给单元格组件。

render 桥接层应该尽量薄

原始 render.js 的方向是对的,它本质上就是一个“把 render 函数接入组件树”的桥接层。这个层最重要的原则就是要薄,不要在里面塞业务逻辑。

ts
import { defineComponent, h, PropType } from 'vue'
import type { TableCellContext } from '../../types/table'

export default defineComponent({
  name: 'TableRenderCell',
  props: {
    context: {
      type: Object as PropType<TableCellContext<Record<string, unknown>>>,
      required: true
    },
    render: {
      type: Function as PropType<(context: TableCellContext<Record<string, unknown>>) => unknown>,
      required: true
    }
  },
  setup(props) {
    return () => h('div', props.render(props.context))
  }
})

这个组件本身不做任何判断,它唯一的职责就是把一段外部渲染能力安全接到当前单元格里。

插槽模式更适合业务侧模板表达

table-slot.vuetable-slot2.vue 之所以有价值,不是因为它们比 render 更高级,而是因为它们更适合业务侧编写模板。

在表格支持插槽以后,业务层就能把一些可读性更高的单元格表达写回模板,比如:

  • 带按钮的操作列
  • 带状态标签的状态列
  • 带格式化展示的时间列
  • 行内编辑输入框

这类场景如果全写进 render 函数,虽然灵活,但业务代码会越来越像“内嵌渲染器”。所以长期来看,更合理的策略通常是:

  • 组件库层保留 render 作为底层兜底能力
  • 业务层优先使用具名插槽或作用域插槽写复杂模板

页面层的编辑状态不应该散在列定义里

原始 views/table-render.vueviews/table-slot2.vue 都展示了一个很实用的例子:行内编辑。这里最值得学的不是“怎么写 input”,而是“编辑状态应该放在哪”。

当前 demo 里,editIndexeditNameeditAgeeditBirthdayeditAddress 都写在页面 data 中,这个方向本身没错,因为编辑状态确实属于页面业务,而不是表格组件内部。

但如果继续演化,建议把这些状态再抽成一个 useRowEditing composable,让页面层只保留最小装配逻辑。

ts
import { reactive, shallowRef } from 'vue'

export function useRowEditing<Row extends Record<string, unknown>>() {
  const editingRowIndex = shallowRef(-1)
  const draft = reactive<Record<string, unknown>>({})

  function startEdit(row: Row, rowIndex: number) {
    Object.assign(draft, row)
    editingRowIndex.value = rowIndex
  }

  function cancelEdit() {
    editingRowIndex.value = -1
    Object.keys(draft).forEach(key => delete draft[key])
  }

  function save<RowList extends Row[]>(rows: RowList) {
    if (editingRowIndex.value < 0) {
      return
    }

    Object.assign(rows[editingRowIndex.value], draft)
    cancelEdit()
  }

  return {
    editingRowIndex,
    draft,
    startEdit,
    cancelEdit,
    save
  }
}

这样表格组件继续保持无状态,页面业务也不会把每个字段都散落在不同列定义中。

更清晰的消费方式应该长什么样

当列协议、单元格组件和编辑状态都收口以后,页面侧的写法会更接近“声明一张可扩展表格”,而不是“在页面里拼接渲染分支”。

vue
<script setup lang="ts">
import BaseTable from '../components/table/BaseTable.vue'
import { useRowEditing } from '../composables/useRowEditing'

const rows = [
  { name: '王小明', age: 18, birthday: '919526400000', address: '北京市朝阳区芍药居' },
  { name: '张小刚', age: 25, birthday: '696096000000', address: '北京市海淀区西二旗' }
]

const editing = useRowEditing<typeof rows[number]>()

const columns = [
  { title: '姓名', slot: 'name' },
  { title: '年龄', slot: 'age' },
  { title: '出生日期', slot: 'birthday' },
  { title: '地址', slot: 'address' },
  { title: '操作', slot: 'action' }
]
</script>

<template>
  <BaseTable :columns="columns" :data="rows">
    <template #name="{ row, rowIndex }">
      <input v-if="editing.editingRowIndex === rowIndex" v-model="editing.draft.name" />
      <span v-else>{{ row.name }}</span>
    </template>

    <template #age="{ row, rowIndex }">
      <input v-if="editing.editingRowIndex === rowIndex" v-model="editing.draft.age" />
      <span v-else>{{ row.age }}</span>
    </template>

    <template #action="{ row, rowIndex }">
      <template v-if="editing.editingRowIndex === rowIndex">
        <button type="button" @click="editing.save(rows)">保存</button>
        <button type="button" @click="editing.cancelEdit()">取消</button>
      </template>
      <button v-else type="button" @click="editing.startEdit(row, rowIndex)">编辑</button>
    </template>
  </BaseTable>
</template>

这个版本里,表格负责渲染协议,页面负责业务状态,职责边界会比原 demo 更稳定。

这次重构真正沉淀下来的模式

这组 demo 最重要的收获,不是“学会 render 函数”或者“学会 slot-scope”,而是理解一个基础组件该如何逐步开放扩展点。

这次真正沉淀下来的经验有五个:

  • 先把列扩展协议稳定成明确类型
  • 把单元格渲染决策从表格主体中拆出来
  • render 桥接层保持为薄组件
  • 同时保留 render 与插槽两类扩展能力
  • 让页面层持有编辑等业务状态,而不是让表格内部偷偷接管

以后再整理类似“基础表格组件”时,可以优先检查这几个问题:

  • 列配置里是否已经形成统一协议
  • 渲染模式判断是否还散落在模板多处
  • 插槽和 render 能力是否互补,而不是互相覆盖
  • 页面业务状态是否仍然被列定义绑死

只要这些问题还没有回答清楚,这个表格组件通常就还没有真正长成组件库级能力。

共 20 个模块,1301 篇 Markdown 文档。