表格列扩展渲染体系重构
这次整理的对象来自 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test 里的表格相关 demo。和前几轮一样,我们不直接修改这个独立子项目,而是把里面最值得长期复用的核心设计整理成一份文档化重构方案。
这组 demo 很有代表性,因为它完整展示了一张表格从“只能按 key 渲染”演化到“支持 render 函数”,再演化到“支持具名插槽”,最后过渡到“作用域插槽桥接”的过程。它本质上是在回答同一个问题:一张表格,应该怎样把列的展示能力开放给使用者。
这次主要参考了下面这些文件:
11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/table-render/table.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/table-render/render.js11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/table-slot/table.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/table-slot2/table.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/table-slot2/slot.js11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/views/table-render.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/views/table-slot.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/views/table-slot2.vue
这组 demo 最值得学的不是表格,而是扩展点设计
table-render/table.vue 的核心非常简单:遍历 columns 和 data,普通列走 row[col.key],扩展列走 col.render(h, params)。
这个能力看起来只是“多了个 render 函数”,但它本质上已经把表格从一个固定展示组件,变成了一个“带扩展点的基础组件”。后面的 slot 和 slot-scope 方案,则是在继续探索另一条开放能力的路径。
所以这轮重构的重点,不是怎么写一张表,而是怎么设计一套稳定、清晰、易扩展的列渲染协议。
原实现已经走过了三种典型方案
这组 demo 基本覆盖了表格可扩展渲染最常见的三种方案:
render函数:由列配置直接控制单元格输出- 具名插槽:列声明
slot,由父组件提供模板 - 作用域插槽桥接:表格内部把当前
row / column / index注入到父级插槽
这三种方案都能解决“我要自定义单元格”,但它们的适用边界不同。
render 方案更像“代码驱动扩展”,适合基础库和高度动态化场景。插槽方案更像“模板驱动扩展”,更适合业务侧直接写视图。真正成熟的表格组件,通常会同时支持这两类能力。
目前的问题不在能不能扩展,而在协议还不够清晰
原始实现是有效的,但还有几个典型的组件库问题:
- 列配置没有统一类型描述,
key / render / slot是靠约定并存 - 每个表格 demo 都重复了相似的表头、表体和单元格遍历逻辑
render.js、slot.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)。
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 足够,但对基础组件来说不够稳固。更好的方式是先把列配置收口成一个统一结构。
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,由它统一处理三种渲染模式。
<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 函数接入组件树”的桥接层。这个层最重要的原则就是要薄,不要在里面塞业务逻辑。
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.vue 和 table-slot2.vue 之所以有价值,不是因为它们比 render 更高级,而是因为它们更适合业务侧编写模板。
在表格支持插槽以后,业务层就能把一些可读性更高的单元格表达写回模板,比如:
- 带按钮的操作列
- 带状态标签的状态列
- 带格式化展示的时间列
- 行内编辑输入框
这类场景如果全写进 render 函数,虽然灵活,但业务代码会越来越像“内嵌渲染器”。所以长期来看,更合理的策略通常是:
- 组件库层保留
render作为底层兜底能力 - 业务层优先使用具名插槽或作用域插槽写复杂模板
页面层的编辑状态不应该散在列定义里
原始 views/table-render.vue 和 views/table-slot2.vue 都展示了一个很实用的例子:行内编辑。这里最值得学的不是“怎么写 input”,而是“编辑状态应该放在哪”。
当前 demo 里,editIndex、editName、editAge、editBirthday、editAddress 都写在页面 data 中,这个方向本身没错,因为编辑状态确实属于页面业务,而不是表格组件内部。
但如果继续演化,建议把这些状态再抽成一个 useRowEditing composable,让页面层只保留最小装配逻辑。
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
}
}这样表格组件继续保持无状态,页面业务也不会把每个字段都散落在不同列定义中。
更清晰的消费方式应该长什么样
当列协议、单元格组件和编辑状态都收口以后,页面侧的写法会更接近“声明一张可扩展表格”,而不是“在页面里拼接渲染分支”。
<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 能力是否互补,而不是互相覆盖
- 页面业务状态是否仍然被列定义绑死
只要这些问题还没有回答清楚,这个表格组件通常就还没有真正长成组件库级能力。
