开源一个 Vue 3 Table:API 学 antdv、主题学 Nuxt UI

首先说明:这是一个 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

  1. 不支持虚拟滚动。几千行数据就开始明显卡。
  2. 滚动条恶心。固定表头 + bordered 的时候,表头会多出一列对齐滚动条的空白列,强迫症看着难受。
  3. 斑马纹没有开箱支持。要自己写 CSS。
  4. 改样式不方便。要么覆盖 className 跟权重打架,要么用 ConfigProvider 但粒度太粗,要么改 less 变量改一片连带影响。

element-plus

  1. 不支持虚拟滚动el-table 没有,el-table-v2 支持但 API 是另一套------换组件名 + 重新写 column 定义,老项目想加虚拟滚动等于换轮子。
  2. 不支持列配置驱动 。所有列必须用 <el-table-column> 写在模板里,动态列要么 v-for 一堆,要么 JSX 绕路。
  3. UI 不如 ant-design-vue。这个见仁见智,但我们团队的偏好是 antdv。

vxe-table

  1. 太重。功能确实多,但要引入它的整套生态,单独一个 table 的成本太大。
  2. 样式不兼容。和现有系统的 UI 风格不一致,定制起来同样费劲。

tanstack-table

  1. headless。本身是个无 UI 的「状态机」,要做大量封装才能用起来。如果项目里其它组件都是有 UI 的,单独引入一个 headless 又得自己实现一整套表头/单元格/筛选/排序的 UI。

所以做了 vtable-guild,目标是补齐这些缺失,但不重新发明轮子------API 保留 antdv 风味,老项目最小改动接入;样式系统重新做,让定制变得简单。

三、vtable-guild 的特色

3.1 能用它做什么 ------ 一张能力矩阵

下面这些能力当前都已实现,可以在 playground(pnpm playground)找到对应的对照演示页。

能力 说明 antdv 对齐
排序(单列 / 多列) controlled (sortOrder) + uncontrolled (defaultSortOrder)
筛选(菜单 / 树形 / 搜索 / 完全自定义 dropdown) filterModefilterSearchfilterDropdown
行选择 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 事件、rowSelectionexpandablescrollstickysize 这些 prop 的命名和语义全部对齐 ant-design-vue。

随手看一眼 packages/table/src/types/column.tsColumnType 的定义,熟悉 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 / ui prop 这套理念全部来自它
  • tailwind-variants ------ 真正让主题系统跑起来的胶水,slots 合并、variant 计算、tailwind-merge 集成都靠它

五、最后

目前 vtable-guild 已经在内部业务里跑起来了,欢迎拍砖、提 issue、提想法。

如果你也在被 antdv 的滚动条折磨、被 element-plus v2 的 API 劝退,可以试试看这个组件是不是更合你口味。

相关推荐
JiaWen技术圈1 小时前
Web 安全深入审计检查清单
前端·安全
江米小枣tonylua1 小时前
从红绿灯到方向盘:TDD 在 AI 时代的新角色
前端·设计模式·ai编程
祀爱1 小时前
Asp.net core+ Layui 项目中编辑按钮传递数据的方法
前端·c#·asp.net·layui
DanCheOo2 小时前
Prompt 工程化管理:从散落在代码里到版本化、可测试、可回滚
前端·ai编程
涛涛ing2 小时前
Vue 3.5 下一站:cached 提案,重新定义响应式缓存
前端
胖子不胖2 小时前
svg之viewBox
前端
吹牛不交税2 小时前
tree-transfer-vue3 前端插件安装问题解决(--legacy-peer-deps)(其他插件可考虑)适用
前端·javascript·vue.js
ricardo19732 小时前
Chrome DevTools + Lighthouse + Performance API:前端性能调优三件套实操指南
前端