设计决策
谁来决定「哪些项出现在 DOM 里」
结论先说:列表项的内容会跟着数据自动更新,正常写法下不需要 forceUpdate()。但哪些项出现在 DOM 里、各自摆在什么位置,始终由库自己决定,不交给框架的响应式系统。
为什么几何部分不交给框架:
性能。Vue 的
ref([...])对大数组会递归代理每个对象的每个属性,10 万条数据的初始化就要数百毫秒。React 的重渲染会触发所有子组件的 diff。虚拟滚动的核心价值是性能,不能让框架的响应式机制成为瓶颈。确定性。库完全掌控 DOM 生命周期,不与框架的 diff 算法竞争。框架只负责「填充单个项的内容」,不参与「哪些项应该出现在 DOM 中」的决策。两个系统的职责清晰分离,不会产生意外行为。
跨框架一致性。所有框架共享同一套 DOM patch 逻辑,行为完全一致。如果让每个框架用自己的响应式系统管理列表,行为差异将极难收敛。
这条边界带来一个必须处理的后果:库通过 itemKey 复用 DOM 节点,同一个 key 对应的元素不会重新调用 renderItem(只调整位置)。所以「key 没变、数据变了」需要一条专门的通道——就是下面这套。
内容是怎么跟上的:库自己不侦测数据变化,一次都不——侦测全部由框架完成,库只提供入口。 框架侧的判断只比两个标量:数组引用和长度(O(1),不遍历、不深比较)。
| 你怎么改数据 | 谁发现的 | 需要手动调用吗 |
|---|---|---|
换新数组(list = list.map(...) / [...list, x]) | 框架比较引用 → 库把最新数据交给可视区内每一项 | 不需要 |
push / splice 原地增删,列表存在 ref 里 | 数组被深度代理,长度变化触发比较 | 不需要 |
push / splice 原地增删,列表存在 shallowRef 里 | 没人发现(数组是 raw,改它不产生任何通知) | 需要 |
改某一项的字段,列表存在 ref 里 | Vue 的依赖收集直接更新那一格 | 不需要 |
改某一项的字段,列表存在 shallowRef 里 | 没人发现 | 需要 |
| React 里内容来自宿主 state | 宿主每次渲染后重刷挂载点 | 不需要 |
shallowRef 下的原地修改会「偶尔看起来能工作」
库持有的就是你传进来的那个数组对象,push 直接改到了它。所以只要随后恰好发生一次 滚动或尺寸变化,新项就会冒出来——但没有那次触发时它不会。这种时好时坏比彻底不工作更难查。
用 shallowRef 就全程换新数组,一条规则覆盖所有情况:
list.value = [...list.value, newRow]; // 增
list.value = list.value.filter((it) => it.id !== id); // 删
list.value = list.value.map((it) => (it.id === id ? { ...it, name } : it)); // 改为什么推荐 shallowRef:ref([...]) 会递归代理数组里每个对象的每个属性,十万条数据的 初始化就要数百毫秒。shallowRef 省掉这笔开销,而配合上面这种换新数组的写法,自动更新照样生效—— .value 被替换会触发组件重渲染,库随即把最新数据交给可视区内的每一项。两个好处可以兼得。
数据确实不经框架(外部 store 直写、WebSocket 推送后直接改对象)时,指明刷哪几项:
virtListRef.value.refreshItems([changedId]);纯 DOM(vanilla)用法要多写一个回调
框架包已经自动接好了这条通道。直接使用 @virt-list/vanilla 时,需要自己给出 updateItem,否则 setList 之后同一个 key 的项内容不会刷新:
new VirtList(container, {
list,
itemKey: 'id',
renderItem: (item, index, el) => paint(item, el),
updateItem: (item, index, el) => paint(item, el), // 常常就是同一个函数
});为什么从 Vue 版本中抽离
原 vue-virt-list 的核心算法与 Vue 响应式深度耦合(reactive、computed、watch)。抽离为纯 JS 带来三个直接收益:
| 收益 | 说明 |
|---|---|
| 算法可测试 | core 层可在 Node.js 环境下单元测试,无需浏览器或框架运行时 |
| 框架无关 | 同一套经过验证的算法服务所有框架,修一个 bug 全部受益 |
| 体积更小 | core 层零依赖,框架包不再捆绑 vue-demi 等兼容层 |
为什么选择独立包而非单包 + 兼容层
我们评估了三种方案:
| 方案 | 结论 |
|---|---|
| 每个框架版本独立 npm 包 | 采用 |
| 单包 + vue-demi / compat 层 | 否决 |
| 仅支持最新版框架 | 否决 |
独立包的核心理由:
- 95% 的逻辑在 core + vanilla,每个框架包仅约 300 行。复制的成本远低于构建和维护共享抽象层。
- 类型安全。Vue 2 和 Vue 3 的类型系统有根本差异(
DefineComponent签名、VNode类型等),单包需要大量any或复杂的条件类型。独立包为每个版本提供精确类型推导。 - 零运行时开销。不需要 vue-demi 等 shim 库,每个包直接从目标框架导入。
- 独立生命周期。legacy 包可进入维护模式,主包可自由使用新框架特性(Vue 3.4
defineModel、React 19use()等),不受最小公约数约束。
为什么整块定位而非逐项定位
虚拟滚动的布局有两种主流策略:
| 整块定位(本项目) | 逐项定位 | |
|---|---|---|
| 做法 | 渲染窗口整体是一个块(itemsEl),用一个 transform 摆到位;块内的项走正常文档流依次排列 | 每项 position: absolute; top: Npx 或 transform: translateY(Npx),偏移量由 JS 逐项计算 |
| 每帧写样式 | O(1)——参与滚动的几段各写一个 transform,与渲染项数无关 | O(渲染项数),每项都要写偏移量 |
与「占位元素」是两个独立选择
这一节讲的是渲染窗口内部怎么摆,与「用不用占位元素撑出滚动空间」无关。后者本项目 选择了不用(容器 overflow: hidden,滚动位置由 JS 掌管),理由见「为什么不用占位元素」。
两个选择的结果是:没有任何元素的几何尺寸与列表长度相关,但渲染窗口内部仍是文档流。
本项目选择整块定位,主要出于三个理由:
1. 不定高修正是 O(1) 而非 O(n)
这是最关键的原因。ResizeObserver 回报某项的实测尺寸后:
- 整块定位:只需改一个
itemsEl的transform,块内所有项由浏览器自动重排 - 逐项定位:该项之后所有已渲染项的偏移量都要重算并重写 style,还要维护偏移量缓存与测量结果的一致性
不定高是本项目的主打场景,把每次修正的样式写入次数从 O(n) 降到 O(1),同时也大幅简化了渐进修正的实现。
2. 保留文档流的 CSS 语义
itemGap、padding、flex/grid 布局、原生 <table> 结构在文档流下都能正常工作。逐项定位里 <tr> 无法在保持表格布局的同时绝对定位,gap 也会失效。
3. 子像素由浏览器负责
块内的累计高度交给浏览器计算,不会出现缝隙或重叠。逐项定位用 JS 累加浮点偏移量,长列表容易积累出 1px 级误差。
这么选的代价
- 无法做二维虚拟化:行列同时虚拟化必须让单元格各自定位,与「块内文档流」互斥。这也是
VirtGrid只做行虚拟化的根本原因,详见下一节。 - 布局会向后传播:块内某项变高会让其后的兄弟节点重新布局。但渲染窗口通常只有几十个节点,一次局部布局是微秒级,实测中被其他开销淹没。
- item 之间不能用 margin:见下一节。
position: sticky 只在交叉轴上可用
这不是本节这个选择的代价,而是「不用原生滚动」的代价,而且只作用于主轴(虚拟化的 那条轴):sticky 的粘附由原生滚动驱动,主轴上容器是 overflow: hidden,永远不触发。
因此 stickyHeader / stickyFooter 在主轴上改用相对视口的绝对定位——对使用方是透明的, 行为一致。item 内部自己写的、朝主轴方向粘附的 sticky(竖向列表里的 top / bottom) 不会生效,这类需求要改成绝对定位自行实现。
交叉轴反过来是原生滚动,那个方向上的 sticky 照常工作:竖向列表里 position: sticky; left: 0 的冻结列开箱可用,表头插槽与数据行里都是(表格 demo 就是 这么做的,实测横滚全程钉在视口边缘)。唯一的前提是它的父级要跨满内容宽度, sticky 才有可粘的余量。
两种策略的性能差距被高估了
稳态滚动时,逐项定位用 translateY 理论上更省(只走合成,不触发布局),但渲染窗口只有 20–40 个节点,差距在真实 profile 里基本是噪声。决定帧率的实际是:renderItem 的 DOM 复杂度、ResizeObserver 观察的节点数、框架层的 diff 开销、滚动回调里有没有强制同步布局的属性读取——这些的影响都比布局策略大一个数量级以上。
为什么不做二维虚拟化
VirtGrid 的实现是「扁平数组按 gridItems 分组成行 + 行虚拟化」,单元格等宽,列方向不虚拟化。它的定位是卡片墙 / 瀑布流,不是电子表格。
这是明确划定的边界,而不是尚未完成的功能:
- 布局策略的直接结果。二维虚拟化要求单元格各自定位,与上一节选择的「块内文档流」互斥。为了支持它而改成逐项定位,就要牺牲文档流语义、子像素精度和 O(1) 的不定高修正。
- 复杂度与列表正交。真正需要二维虚拟化的场景(冻结行列、单元格合并、列宽拖拽、排序筛选、单元格编辑)本质是表格产品的需求,其复杂度主体不在滚动上。把它塞进列表库,只想要列表的用户要为几十 KB 的表格代码付费。
- 失效策略不同。列表按
itemKey复用 DOM 节点(同 key 不重新renderItem),而表格的排序、筛选是日常操作,同 key 行的内容会频繁变化,需要另一套渲染失效策略。两者放在同一套渲染层里会互相妥协。
因此虚拟表格由独立项目承担:它复用 @virt-list/core 的区间计算与不定高修正,但自建适配表格的 DOM 结构(表头面板 + 左固定 / 中滚动 / 右固定 body + 滚动同步)与渲染失效策略。
如果你的需求是带虚拟滚动的数据表格,请关注该项目,或选择 AG Grid / VXE Table 这类成熟表格产品。
为什么 item 之间不能用 margin
虚拟滚动通过 ResizeObserver 测量每个 item 的 borderBoxSize 来计算偏移量。CSS margin 不包含在 borderBoxSize 中,且相邻元素的 margin 会发生折叠(margin collapsing),导致测量值与实际占用空间不一致。
使用 itemGap 配置项或 padding 来控制间距,可以确保测量值与实际布局一致。
为什么 scrollToIndex 需要渐进修正
不定高场景下,目标项之前的项可能尚未渲染,尺寸只有预估值,「停在某处」于是是个动态目标:项被渲染出来后报上真实尺寸,先前算出的偏移量随之失效。
做法是只记目标、每次重新求解,而不是「算出位移量再补偿」:scrollToIndex 记下「要锚在第 n 项」这个意图,此后每次 ResizeObserver 回调重新解一次目标偏移量并写入。scrollToTop() / scrollToBottom() 同理——到底的目标是 getTotalSize() - clientSize,会随测量一直变。
求解不变量是幂等的,所以不需要收敛判据、不需要重试上限、也不需要「这次是谁写的」标记窗口。实测 2000 项不定高 scrollToBottom 一轮即收敛。
这里曾经有一套重试循环
偏移量归 JS 掌管之前,这件事靠「循环重试 + 超时上限」兜着:原生滚动下既不知道浏览器会把 scrollTop 夹到哪,也分不清是谁动的它,只能写完再查、不对就再来一次。两个前提消失后,重试整个不需要了。
浏览器最大高度限制:已经不适用
浏览器对元素高度与单个 transform 位移都有同一量级的上限(Chrome 实测 33,554,428 / 33,554,400px,约合 33.5 万个 100px 的行)。靠占位元素撑开滚动空间的方案会在这里撞墙——超限的高度被直接截掉,那部分内容永远滚不到。
本项目不受这条线约束:没有占位元素,而且每一段只带自己的残差 该段的文档位置 - offset,视口里看得见的东西残差最大就一屏多一点,任何单个 transform 都碰不到上限。100 万项 × 100px(1 亿 px)实测 8 个采样点定位误差全为 0px,与限内无差别。
详见「为什么不用占位元素」。