长段不展开,只存段起点
按值自动合并把段起点存进 Int32Array,查询走二分、每帧按渲染窗口裁剪。所以「业务域」那一段真实跨两万行,DOM 里的 rowspan 却只有十几 —— 与数据量无关。
一套框架无关的虚拟表格内核。Vanilla / Vue / React 共用同一份逻辑,复杂表格能力齐备, 每条性能结论都有实测数字撑着。这一页里跑着的就是真的 10 万行,不是截图。
左右滚动看列虚拟化,点「跳到第 10 万行」看纵向。HUD 读数现场采样,行 DOM 取实测上界。这张表不开合并 —— 合并单独放在下一屏演。
Merged cells, still virtual
合并是虚拟表格最容易破功的地方:一个 rowspan 跨两万行的格子, 天真的实现只能把它物化成两万个 <tr>,虚拟滚动当场失效。 下面这张表 10 万行、三档合并段同屏、每 20 行还横插一条跨列汇总带 —— HUD 里那条「真实跨度 → DOM rowspan」就是全部答案。
视口首行 #1 · 当前行中间段 colspan 合计 —/— —— 这条恒等式在任何横滚位置都成立,跨界的合并格一个都没丢,所以行永远对得上表头。 「业务域」段长 2 万行、「区域」2 千行、「班组」8 行三档同屏;每 20 行一条汇总带横跨「本次运行」整组 4 列。合并列自己横滚出窗口时第一格会显示「—」:那几列此刻确实不在 DOM 里。
按值自动合并把段起点存进 Int32Array,查询走二分、每帧按渲染窗口裁剪。所以「业务域」那一段真实跨两万行,DOM 里的 rowspan 却只有十几 —— 与数据量无关。
滚进长段中间时,合并格的起点被抬到窗口起点,内容锚在可视区(AG Grid 的 Cell Span 同样如此)。换来的是渲染区间**不被拉宽** —— 这正是让自动合并在虚拟滚动下成立的关键。标签的逐帧跟随交给 position: sticky,主线程零开销。
按值合并只产出纵向段;任意几何用静态 merges。横滚后合并带的起始列会落到窗口左侧之外,此时渲染列区间被反向撑回去,屏外那段折叠成**一个**留白格 —— 行的 colspan 始终配平。
Measured
下面每一项都能复现:纯逻辑走 vitest,DOM 行为与帧耗时走 Chrome DevTools Protocol(headless + 合成器手势滚动)。测法与陷阱都写在文档里。
p95 9.2ms;开按值自动合并后与不开完全一致
与数据量无关;滚到 9.3 万行长段中间仍是 25
把 thead/tfoot 移出 body table 之前是 41%
Int32Array 存段起点,占 781 KB,零对象分配
插件与内置组件各自成文件,全量 45 kB
36 个测试文件,含 4 份契约测试与 17 份 DOM 测试
How it stays smooth
虚拟滚动最难的部分不是「算哪些行可见」,而是每帧只做必要的那点工作、并且不让 浏览器替你重算全表。这六条都是踩过之后定下来的。
纵向窗口由框架无关的 @virt-list/core 算,横向由列前缀和 + colBuffer 自己裁。宽表和长表都只把「看得见的那一屏」变成 DOM。
<table> 是整体布局上下文:虚拟滚动每帧改 tbody,会让内容根本没变的 <thead> 一起失效、重新光栅化,主线程忙时就是整条表头闪白。于是把它们搬成滚动容器内的独立 sticky table。
transform 会给后代建立包含块,让固定列的 position: sticky 失去参照、跟着内容被推走。写残差不建包含块,还顺手消掉了元素高度上限与「滚不到底」两个老坑。
按值自动合并把段起点存进 Int32Array,查询走二分、每帧按渲染窗口裁剪。展开成全量 merges 会把 rowspan=50000 物化成 5 万个 <tr>,虚拟滚动直接失效。
tooltip 的文本截断判定放进延迟计时器,而不是 mouseover 里。滚动时指针底下的行在动,布局是脏的,那一刻读 scrollWidth 会强制同步布局。
不定高表格的内容总高随实测行高一直回填,按像素算滑块位置会让它在手指底下反复跳。所以两条滚动条都自绘成 overlay 浮层,分母取「滚到底时视口顶部那一行」。
One core, three bindings
所有表格行为都在框架无关的 vanilla 层。Vue 与 React 只做 VNode / JSX ↔ DOM 的桥接和生命周期,因此 API、事件与插件在三端逐条对齐;插件的方法还会自动透传到 组件 ref 上。
示例也按端各出一份:Vanilla 76 个,Vue / React 同步跟进。
import { VirtTable } from '@virt-table/vanilla';
import '@virt-table/vanilla/core.css';
const table = new VirtTable(el, {
list, // 10 万行照常整个传进来
columns,
itemKey: 'id',
estimatedSize: 40, // 行高估值,动态行高会自动回填
});
table.scrollToIndex(99_999);
Capabilities
核心只留「不可选」的部分,其余做成插件与内置组件按需装载 —— 只用基础表格时 CSS 27 kB,全量装齐 45 kB。
pinnedmergeKey,且不禁用排序getState / setStatevtAIQuery 自然语言查询,不绑任何模型厂商vtAIColumn AI 列,只为可见行付费vtAIStream 流式表格,列从首批数据推断--vt-* 令牌换肤Engineering
表格是最容易在第二年崩掉的组件:行高、列宽、滚动几何、样式特异度互相牵扯, 改一处坏三处。所以这里的护栏不止单元测试。
纯逻辑跑 node 环境,DOM 行为跑 happy-dom 并按文件开启 —— 避免 11 个纯逻辑文件白付每文件约 90ms 的环境成本。
CSS 令牌算术(行高等式)、JS 写出的 class ↔ CSS 选择器、内置组件契约、文档站样式与 demo 的隔离。守的是编译器管不到、运行时也测不出来的耦合。
Chrome headless 里用合成器手势驱动真滚动(synthesizeScrollGesture),按区带亮度判空白帧。复现步骤写在文档里,包括两个会把人带偏的测量陷阱。
列宽内容自适应、逐格 spanMethod、tooltip 挂 body —— 每条为什么不做都写清了依据,避免下一个人再走一遍。