Skip to content

设计决策

谁来决定「哪些项出现在 DOM 里」

结论先说:列表项的内容会跟着数据自动更新,正常写法下不需要 forceUpdate()。但哪些项出现在 DOM 里、各自摆在什么位置,始终由库自己决定,不交给框架的响应式系统。

为什么几何部分不交给框架

  1. 性能。Vue 的 ref([...]) 对大数组会递归代理每个对象的每个属性,10 万条数据的初始化就要数百毫秒。React 的重渲染会触发所有子组件的 diff。虚拟滚动的核心价值是性能,不能让框架的响应式机制成为瓶颈。

  2. 确定性。库完全掌控 DOM 生命周期,不与框架的 diff 算法竞争。框架只负责「填充单个项的内容」,不参与「哪些项应该出现在 DOM 中」的决策。两个系统的职责清晰分离,不会产生意外行为。

  3. 跨框架一致性。所有框架共享同一套 DOM patch 逻辑,行为完全一致。如果让每个框架用自己的响应式系统管理列表,行为差异将极难收敛。

这条边界带来一个必须处理的后果:库通过 itemKey 复用 DOM 节点,同一个 key 对应的元素不会重新调用 renderItem(只调整位置)。所以「key 没变、数据变了」需要一条专门的通道——就是下面这套。

内容是怎么跟上的:库自己不侦测数据变化,一次都不——侦测全部由框架完成,库只提供入口。 框架侧的判断只比两个标量:数组引用长度O(1),不遍历、不深比较)。

你怎么改数据谁发现的需要手动调用吗
换新数组(list = list.map(...) / [...list, x]框架比较引用 → 库把最新数据交给可视区内每一项不需要
push / splice 原地增删,列表存在 ref数组被深度代理,长度变化触发比较不需要
push / splice 原地增删,列表存在 shallowRef没人发现(数组是 raw,改它不产生任何通知)需要
改某一项的字段,列表存在 refVue 的依赖收集直接更新那一格不需要
改某一项的字段,列表存在 shallowRef没人发现需要
React 里内容来自宿主 state宿主每次渲染后重刷挂载点不需要

shallowRef 下的原地修改会「偶尔看起来能工作」

库持有的就是你传进来的那个数组对象,push 直接改到了它。所以只要随后恰好发生一次 滚动或尺寸变化,新项就会冒出来——但没有那次触发时它不会。这种时好时坏比彻底不工作更难查。

shallowRef 就全程换新数组,一条规则覆盖所有情况:

js
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));  // 改

为什么推荐 shallowRefref([...]) 会递归代理数组里每个对象的每个属性,十万条数据的 初始化就要数百毫秒。shallowRef 省掉这笔开销,而配合上面这种换新数组的写法,自动更新照样生效—— .value 被替换会触发组件重渲染,库随即把最新数据交给可视区内的每一项。两个好处可以兼得。

数据确实不经框架(外部 store 直写、WebSocket 推送后直接改对象)时,指明刷哪几项:

js
virtListRef.value.refreshItems([changedId]);

纯 DOM(vanilla)用法要多写一个回调

框架包已经自动接好了这条通道。直接使用 @virt-list/vanilla 时,需要自己给出 updateItem,否则 setList 之后同一个 key 的项内容不会刷新:

js
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 响应式深度耦合(reactivecomputedwatch)。抽离为纯 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 19 use() 等),不受最小公约数约束。

为什么整块定位而非逐项定位

虚拟滚动的布局有两种主流策略:

整块定位(本项目)逐项定位
做法渲染窗口整体是一个块(itemsEl),用一个 transform 摆到位;块内的项走正常文档流依次排列每项 position: absolute; top: Npxtransform: translateY(Npx),偏移量由 JS 逐项计算
每帧写样式O(1)——参与滚动的几段各写一个 transform,与渲染项数无关O(渲染项数),每项都要写偏移量

与「占位元素」是两个独立选择

这一节讲的是渲染窗口内部怎么摆,与「用不用占位元素撑出滚动空间」无关。后者本项目 选择了不用(容器 overflow: hidden,滚动位置由 JS 掌管),理由见「为什么不用占位元素」。

两个选择的结果是:没有任何元素的几何尺寸与列表长度相关,但渲染窗口内部仍是文档流。

本项目选择整块定位,主要出于三个理由:

1. 不定高修正是 O(1) 而非 O(n)

这是最关键的原因。ResizeObserver 回报某项的实测尺寸后:

  • 整块定位:只需改一个 itemsEltransform,块内所有项由浏览器自动重排
  • 逐项定位:该项之后所有已渲染项的偏移量都要重算并重写 style,还要维护偏移量缓存与测量结果的一致性

不定高是本项目的主打场景,把每次修正的样式写入次数从 O(n) 降到 O(1),同时也大幅简化了渐进修正的实现。

2. 保留文档流的 CSS 语义

itemGappadding、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 分组成行 + 行虚拟化」,单元格等宽,列方向不虚拟化。它的定位是卡片墙 / 瀑布流,不是电子表格。

这是明确划定的边界,而不是尚未完成的功能:

  1. 布局策略的直接结果。二维虚拟化要求单元格各自定位,与上一节选择的「块内文档流」互斥。为了支持它而改成逐项定位,就要牺牲文档流语义、子像素精度和 O(1) 的不定高修正。
  2. 复杂度与列表正交。真正需要二维虚拟化的场景(冻结行列、单元格合并、列宽拖拽、排序筛选、单元格编辑)本质是表格产品的需求,其复杂度主体不在滚动上。把它塞进列表库,只想要列表的用户要为几十 KB 的表格代码付费。
  3. 失效策略不同。列表按 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,与限内无差别。

详见「为什么不用占位元素」。