首先说明:这是一个 vibe coding 的项目。大部分代码由 AI 协作产出,把关、调试、痛点收集都是我自己做的。下面要聊的所有技术细节都能在仓库里翻到对应源码。
一、这是一个什么组件
简单一句话:Vue 3 的表格组件库,API 学 ant-design-vue Table,主题系统学 Nuxt UI,靠 tailwind-variants 把两者粘起来。
具体一点:
- 基于 Vue 3 Composition API + TSX,全部子组件用 TSX 写
- 基于 Tailwind CSS 4 +
@tailwindcss/vite - 三层主题模型:默认预设 → 全局配置 → 实例 props,颗粒度细到单个 slot
- 当前内置两套预设:
antdv(默认)和element-plus,运行时可切换 - 该有的能力都有:虚拟滚动、固定列、固定表头、树形、行展开、行选择、排序筛选、列拖拽 resize、表头分组、自定义渲染、国际化
二、为什么会有这个项目
经常做中后台管理系统,table 是高频组件。市面上常见的 4 个选型,每一个我用起来都有让我别扭的地方。
ant-design-vue
- 不支持虚拟滚动。几千行数据就开始明显卡。
- 滚动条恶心。固定表头 + bordered 的时候,表头会多出一列对齐滚动条的空白列,强迫症看着难受。
- 斑马纹没有开箱支持。要自己写 CSS。
- 改样式不方便。要么覆盖 className 跟权重打架,要么用 ConfigProvider 但粒度太粗,要么改 less 变量改一片连带影响。
element-plus
- 不支持虚拟滚动 。
el-table没有,el-table-v2支持但 API 是另一套------换组件名 + 重新写 column 定义,老项目想加虚拟滚动等于换轮子。 - 不支持列配置驱动 。所有列必须用
<el-table-column>写在模板里,动态列要么 v-for 一堆,要么 JSX 绕路。 - UI 不如 ant-design-vue。这个见仁见智,但我们团队的偏好是 antdv。
vxe-table
- 太重。功能确实多,但要引入它的整套生态,单独一个 table 的成本太大。
- 样式不兼容。和现有系统的 UI 风格不一致,定制起来同样费劲。
tanstack-table
- headless。本身是个无 UI 的「状态机」,要做大量封装才能用起来。如果项目里其它组件都是有 UI 的,单独引入一个 headless 又得自己实现一整套表头/单元格/筛选/排序的 UI。
所以做了 vtable-guild,目标是补齐这些缺失,但不重新发明轮子------API 保留 antdv 风味,老项目最小改动接入;样式系统重新做,让定制变得简单。
三、vtable-guild 的特色
3.1 能用它做什么 ------ 一张能力矩阵
下面这些能力当前都已实现,可以在 playground(pnpm playground)找到对应的对照演示页。
| 能力 | 说明 | antdv 对齐 |
|---|---|---|
| 排序(单列 / 多列) | controlled (sortOrder) + uncontrolled (defaultSortOrder) |
✓ |
| 筛选(菜单 / 树形 / 搜索 / 完全自定义 dropdown) | filterMode、filterSearch、filterDropdown |
✓ |
| 行选择 | rowSelection (checkbox / radio),支持树形联动 |
✓ |
| 树形数据 | expandedRowKeys / defaultExpandedRowKeys / childrenColumnName |
✓ |
| 行展开(非树形) | expandable.expandedRowRender |
✓ |
| 固定列 / 固定表头 | column.fixed + scroll.y + sticky |
✓ |
| 表头分组 | column.children |
✓ |
| 自定义渲染 | customRender / customCell / customHeaderCell |
✓ |
| 局部化 | 内置 zh-CN / en-US + localeOverrides |
✓ |
| 列拖拽 resize | column.resizable + minWidth / maxWidth 约束 |
✓ |
| 虚拟滚动 | virtual + scroll.y 显式启用 |
独有 |
| 主题预设切换 | `themePreset: 'antdv' | 'element-plus'` |
3.2 迁移成本:从 antdv Table 切过来,column 配置一行不改
这个组件最大的「不挑食」体现在 API 上------column 配置、change 事件、rowSelection、expandable、scroll、sticky、size 这些 prop 的命名和语义全部对齐 ant-design-vue。
随手看一眼 packages/table/src/types/column.ts 中 ColumnType 的定义,熟悉 antdv 的人应该有「等等这不就是 antdv 那套吗」的感觉:
ts
export interface ColumnType<TRecord> {
key?: Key
title?: ColumnTitle<TRecord>
dataIndex?: DataIndex
width?: number | string
align?: 'left' | 'center' | 'right'
ellipsis?: boolean | { showTitle?: boolean }
fixed?: 'left' | 'right' | true
// 排序
sorter?: boolean | SorterFn | { compare?: SorterFn; multiple?: number }
sortOrder?: SortOrder // 受控
defaultSortOrder?: SortOrder // 非受控
sortDirections?: SortOrder[]
// 筛选
filters?: ColumnFilterItem[]
filteredValue?: (string | number | boolean)[] | null // 受控
defaultFilteredValue?: (string | number | boolean)[] // 非受控
onFilter?: (value, record) => boolean
filterMode?: 'menu' | 'tree'
filterSearch?: boolean | ((input, filter) => boolean)
filterDropdown?: VNodeChild | ((props) => VNodeChild)
// 渲染
customRender?: (ctx) => VNodeChild | RenderedCell
customCell?: GetComponentProps
customHeaderCell?: GetComponentProps
// 列拖拽
resizable?: boolean
minWidth?: number
maxWidth?: number
// 响应式
responsive?: Breakpoint[]
}
change 事件的签名也一致:(filters, sorter, extra),其中 extra 包含 action: 'sort' | 'filter' | 'select' 和 currentDataSource。三种状态变化(排序/筛选/选择)都走同一个 emit 出口,参见 Table.tsx:442 / 454 / 525。
受控 / 非受控双轨 是另一个对齐点。比如排序,传 column.sortOrder 就完全由你控制,不传就用 defaultSortOrder 让组件内部管。useSorter 内部通过判断有没有外部传值来决定走哪条路,行为和 antdv 一致。
实际迁移的工作量大概是这样:
diff
- import { Table } from 'ant-design-vue'
+ import { VTable } from '@vtable-guild/vtable-guild'
- <a-table :columns="columns" :data-source="data" @change="onChange" />
+ <VTable :columns="columns" :data-source="data" @change="onChange" />
columns 数组里的对象、onChange 里的参数解构,理论上都不需要动。
唯一需要注意的差异 :虚拟滚动需要显式开启 virtual=true + scroll.y,不是大数据量自动启用。
3.3 主题随便改:三层覆盖 + CSS 变量驱动 + 预设切换
这是 vtable-guild 最想解决的痛点,也是花心思最多的地方。
三层主题,从粗到细
层 1:选预设------99% 场景这一层就够了。
ts
// main.ts
import { createApp } from 'vue'
import { createVTableGuild } from '@vtable-guild/vtable-guild'
import '@vtable-guild/vtable-guild/css'
const app = createApp(App)
app.use(createVTableGuild({ themePreset: 'antdv' })) // 或 'element-plus'
插件做的事情很简单:reactive 一份配置,app.provide 进去,顺手往 <html> 上打一个 data-vtg-preset="antdv" 属性供 CSS 选择器消费(packages/core/src/plugin/index.ts:43-59)。
层 2:全局批量覆盖某个 slot------想统一改所有表格的表头样式?这一层。
ts
app.use(createVTableGuild({
themePreset: 'antdv',
theme: {
table: {
slots: {
th: 'bg-slate-900 text-white',
td: 'text-slate-700',
},
defaultVariants: { size: 'middle' },
},
},
}))
这里出现了一个关键概念:slots 。tailwind-variants 的核心是把一个组件拆成多个命名 slot------比如表格的 root / table / thead / tbody / tr / th / td,再加上排序、筛选、空态等等。每个 slot 各自一份 class 字符串。
随手看一眼 antdv 预设里 th slot 长什么样(packages/theme/src/presets/antdv/table.ts):
ts
th: [
'relative text-left font-semibold',
'bg-[color:var(--vtg-table-header-bg)]',
'text-[color:var(--vtg-table-header-color)]',
'border-b border-[color:var(--vtg-table-border-color)]',
// 表头 cell 之间的分割线(::before 伪元素)
'before:absolute before:end-0 before:top-1/2 before:-translate-y-1/2',
'before:block before:w-px before:h-[1.6em]',
'before:bg-[color:var(--vtg-table-header-split-color)]',
'last:before:bg-transparent',
].join(' ')
注意里面消费的 --vtg-table-* 是 CSS 变量------后面会讲为什么这样做。
层 3:单实例覆盖------只想改这一个表格的某个 slot。
vue
<template>
<VTable
:columns="columns"
:data-source="data"
:ui="{ th: 'bg-blue-50', tr: 'hover:bg-blue-50/40' }"
class="rounded-2xl shadow-sm"
/>
</template>
ui prop 按 slot 名覆盖,class 自动落到 root slot。
CSS 变量驱动亮暗切换
CSS 文件里有三层:语义 token → 组件 token → slots class 引用组件 token。
css
/* packages/theme/css/presets/antdv.css */
:where(:root),
[data-vtg-preset='antdv'] {
/* 语义 token */
--color-surface: #ffffff;
--color-on-surface: rgb(0 0 0 / 88%);
--color-default: #f0f0f0;
--color-primary: #1677ff;
/* 组件 token,100% 引用语义 token */
--vtg-table-bg: var(--color-surface);
--vtg-table-header-bg: var(--color-surface-hover);
--vtg-table-border-color: var(--color-default);
}
.dark [data-vtg-preset='antdv'],
[data-vtg-preset='antdv'].dark {
/* 暗色只覆盖语义 token,组件 token 自动跟随 */
--color-surface: #141414;
--color-on-surface: rgb(255 255 255 / 85%);
}
切暗色模式只要给 <html> 加 .dark,所有 slots 自动响应。要切 element-plus 预设,把 data-vtg-preset 改成 element-plus 即可。
一句话总结这一节 :想从 antdv 风切到 element-plus 风?改一个字符串。想统一全局改?传 theme 配置。想改单个表格?用 ui prop。想做完全自己的视觉?写一个 preset 对象。
3.4 大数据不卡:虚拟滚动 + 树形
虚拟滚动的接入
开启方式就一行:
vue
<VTable virtual :scroll="{ y: 400 }" :columns="columns" :data-source="bigData" />
columns 定义、排序筛选、固定列、行选择,全都和不开虚拟滚动时一样 ------这是和 element-plus v2 路线最大的区别:不是另一个组件、另一套 API,而是同一个 <VTable> 加一个 virtual prop。
启用条件在 packages/table/src/composables/useVirtual.ts:
ts
const SIZE_ITEM_HEIGHT = { small: 39, middle: 47, large: 55 }
export function useVirtual(options) {
const enabled = computed(() => !!options.virtual() && !!options.scrollY())
const itemHeight = computed(() => SIZE_ITEM_HEIGHT[options.size() ?? 'middle'] ?? 47)
const listHeight = computed(() => {
const y = options.scrollY()
if (typeof y === 'number') return y
if (typeof y === 'string') return parseInt(y, 10) || 400
return 400
})
return { enabled, itemHeight, listHeight }
}
行高按 size 查表预估,实际行高由 VirtualList 内部用 ResizeObserver 测量并修正。
一个让人纠结但最后做对了的决策
虚拟滚动一般的实现是:所有可见行用一个超长 <div> 容器 + transform: translateY 来定位,外层是绝对定位的占位 spacer 撑总高。这种方案的代价是------没有 <table> 了,单元格之间 colspan / rowspan、表头 colgroup 对齐、文本选中跨行复制都得自己实现。
vtable-guild 的做法(packages/table/src/components/VirtualTableBody.tsx:103-205):
tsx
<VirtualList data={dataSource} height={height} itemHeight={itemHeight}>
{{
default: ({ item, index }) => (
<table class={tableClass} style={{ tableLayout: 'fixed' }}>
<ColGroup columns={columns} />
<tbody>
<TableRow ...>
{columns.map((column, colIndex) => (
<TableCell record={item} column={column} colIndex={colIndex} />
))}
</TableRow>
{isExpanded && exp?.expandedRowRender && (
<tr><td colspan={columns.length}>{exp.expandedRowRender(item, ...)}</td></tr>
)}
</tbody>
</table>
),
}}
</VirtualList>
每个可见行内部仍然是一个完整的 <table> 。VirtualList 只负责行裁剪,每行内部保持 table 的标签语义,<ColGroup> 保证列宽对齐,<tbody> 让浏览器原生的文本选择行为可用。代价是多了一些 DOM 节点,但换来的可读性、无障碍兼容性、和「行展开作为 tr 嵌入同一 tbody」这种自然写法是值得的。
VirtualList 是 vendored 的
packages/core/src/components/VirtualList/ 里的 VirtualList 是从 @v-c/virtual-list(antdvNext) vendored 进来的,做了两个改造:
- 把
@v-c/resize-observer换成原生ResizeObserver - 把
@v-c/util替换为内联工具函数
少两个依赖,少两个潜在的版本漂移风险面。
树形数据:拉平后再交给虚拟列表
useTreeData 把嵌套的树形 dataSource 按当前展开状态拉平成一维可见行数组(packages/table/src/composables/useTreeData.ts),下游 VirtualList 只需要处理一维数组------所以虚拟滚动 + 树形不需要任何额外适配,天然兼容。
完整的数据流水线是这样的:
scss
dataSource
→ filterData (useFilter)
→ sortData (useSorter)
→ flattenTree (useTreeData)
→ displayData → VirtualList / 普通 tbody
每一段都是一个独立 composable,可以单独测试、单独替换。
四、致谢
这个组件的每一处设计都站在前人肩上:
- ant-design-vue ------ API 的「老师」,列配置、change 事件、双轨受控全套都在向它致敬
- antdvNext ------ 使用了它的虚拟列表组件
- Nuxt UI ------ 三层主题模型的灵感来源,slots / variants /
uiprop 这套理念全部来自它 - tailwind-variants ------ 真正让主题系统跑起来的胶水,slots 合并、variant 计算、tailwind-merge 集成都靠它
五、最后
目前 vtable-guild 已经在内部业务里跑起来了,欢迎拍砖、提 issue、提想法。
- GitHub:github.com/parade0393/...
- npm:
@vtable-guild/vtable-guild
如果你也在被 antdv 的滚动条折磨、被 element-plus v2 的 API 劝退,可以试试看这个组件是不是更合你口味。