「组件解剖」第一篇。不写怎么用,写为什么必须这样设计------尤其是那些「文档里只说了一句、但背后有硬约束」的地方。
一个 1 万行的表格
后台的订单表有 1 万行。直接全部渲染就是 1 万个 <tr>,滚动时浏览器要重排整张表,长列表会卡到没法用。
标准解法是虚拟滚动:只渲染可视窗口里的那十几行,上下用空白块把滚动高度撑起来。

窗口是算出来的,不是猜的
核心只有 20 行代码,在 table-virtual.ts 里:
ts
export function computeWindow(scrollTop, viewportHeight, rowHeight, total, overscan = VIRTUAL_OVERSCAN) {
const firstVisible = Math.max(0, Math.floor(Math.max(0, scrollTop) / rowHeight));
const visibleCount = Math.max(0, Math.ceil(viewportHeight / rowHeight));
const end = Math.min(total, firstVisible + visibleCount + overscan);
const start = Math.min(Math.max(0, firstVisible - overscan), end);
return {
start, end,
topSpacer: start * rowHeight,
bottomSpacer: (total - end) * rowHeight,
};
}
按 maxHeight: 384、size: md 算:视口 384 / 行高 48 = 8 行,加上下各 5 行缓冲,实际只渲染 18 行。1 万行和 18 行之间差了两个数量级,滚动依然丝滑。

这个公式成立的前提只有一条:每一行的高度必须相等 。因为第 N 行的位置只能靠 N × rowHeight 推出来,行高一变,spacer 的高度立刻算错,滚动条会跳。
代价一:展开行不能用了
行高固定是虚拟滚动的命门,而展开行恰恰会改变行高------展开后那一行变成两块高度。
所以代码里直接做了互斥判断:
ts
if (props.virtual && hasMaxHeight.value && props.expandable) {
console.warn("[Table] virtual 模式下不支持 expandable(可变行高与窗口化冲突),已忽略 expandable。");
}
不是「效果差一点」,是直接忽略。这比勉强渲染出一个错位的表格要诚实。

代价二:树形也保不住
树形数据要按 children 做深度优先拍平,而且只展开未收起的子树------也就是说行数是动态的,取决于用户点开了哪些节点。
ts
const walk = (rows, depth) => {
for (const row of rows) {
const children = row[treeChildrenKey.value];
const hasChildren = Array.isArray(children) && children.length > 0;
out.push({ row, index: out.length, depth, hasChildren });
if (hasChildren && !isTreeCollapsed(row)) walk(children, depth + 1);
}
};
同一个冲突,同样处理:treeEnabled = treeProps != null && !virtualEnabled,虚拟滚动优先。

代价三:没有 maxHeight 就没有虚拟滚动
虚拟滚动需要一个「有边界的滚动容器」才知道视口多高。不传 maxHeight,组件根本不知道窗口多大,代码里会警告并且不生效:
ts
if (props.virtual && !hasMaxHeight.value) {
console.warn("[Table] virtual 需配合 maxHeight 使用,未设置 maxHeight 时虚拟滚动不生效。");
}
传了 maxHeight 之后,表头靠 position: sticky 固定,滚动时不会跟着走:

还有两个不显眼但会咬人的约束
固定列必须写 width。 左右固定列靠 position: sticky + left/right 偏移实现,没有宽度就没有偏移基准,粘不住。这不是偷懒,是 sticky 的物理限制。

合计行不参与虚拟窗口。 它是独立的一行,sticky 在表体底部,永远可见:

三个尺寸,对应的就是三个行高
size 不只是视觉大小,它直接决定虚拟滚动的计算基数:
ts
export const TABLE_ROW_HEIGHTS: Record<Size, number> = { sm: 40, md: 48, lg: 56 };

排序、筛选、斑马纹这些「正常功能」
它们不改变行高,所以和虚拟滚动没有冲突,可以随便叠。




一张表看完取舍
| 你想同时用 | 实际结果 |
|---|---|
virtual + expandable |
expandable 被忽略,控制台警告 |
virtual + treeProps |
树形被忽略,虚拟滚动优先 |
virtual + 不传 maxHeight |
虚拟滚动不生效,控制台警告 |
fixed 列 + 不写 width |
列宽无基准,固定失效 |
组件不是功能堆叠
这张表里每一行都是「两个都想要,但物理上不成立」。一个组件成熟不成熟,看的不是功能列表有多长,而是它有没有把不能同时成立的事写进约束里,并且在越界时明确警告,而不是静默降级出一个错位的界面。
下一篇拆 Affix:为什么滚动监听绑在 window 上,放进 overflow 容器就失效。
在线示例:ui.ruanno.top/components/... 源码:gitee.com/ruanno-top/...