前言
实际工作中,大概率会碰到大量数据渲染,且无法进行分页的场景,称之为长列表。例如日志记录、交易流水、股票持仓。无论是从传统的 jQuery 到如今的框架繁荣,社区都已经有成熟的方案,然而停留在使用层面并不够,需要更深入的了解内部机制,才(bù)能(yào)重复造轮子。
全量渲染弊端
即使面对海量数据,最初的抉择依然是全量渲染,将所有职责压在浏览器上,优点自然是实现简单,没有维护成本,简易实现如下:
function render() {
const list = new Array(10000).fill("");
list.forEach(() => {
const ele = document.createElement("div");
ele.setAttribute("class", "alert alert-primary");
// 文字内容使用 `fakerjs` 生成,暂且忽略
ele.textContent = faker.lorem.words(5);
contaienr.appendChild(ele);
});
}
一次性将 10000 条数据插入页面。操作过于粗暴,浏览器表现如下:

初次插入操作,整体耗时 3749ms ,绝大部分事件消耗在 Layout 阶段,期间无法响应其他用户事件,用户体验极差。
即使能够容忍初始化阶段的长耗时,高节点数量,后续性能表现亦不佳:

优化初始化阶段用户体验,考虑分批插入全量数据,将时间成本分散到不同帧:
function renderByFrame() {
list.slice(0, 20).forEach(() => {
const ele = document.createElement("div");
ele.setAttribute("class", "alert alert-primary");
ele.textContent = faker.lorem.words(5);
contaienr.appendChild(ele);
});
list = list.slice(0);
if (list.length > 0) {
requestAnimationFrame(renderByFrame);
}
}
分批量插入,Layout 成本分摊到单帧,执行期间依然可以响应用户操作。节点较少时,单帧耗时 20ms。

节点较多时,单帧耗时突破 70ms。

采取分批渲染方式,只能保证初始性能, 随插入节点增多,流畅度逐渐降低,大量节点对性能压力较大。使用不同的 API 执行插入操作,对性能影响非常有限,可以自行验证。
长列表实现的核心要点,在于控制节点数量,且不影响用户的正常操作。
可视区域渲染
可视区域渲染,意为只渲染用户可感知的区域,节点数量完全可控。本质上来说,可视区域渲染就是仿真,只要更新的足够快,用户并不知道你用了多少个节点,也没必要知道。
理论上来说,可视区域渲染能够支持不同数据,不同展现形式,实际开发中,一般需要满足以下条件:
- 数据结构相似(渲染呈现相似)
- 加载数据量较大
核心要点非常简单:选择合适的时机执行删除、插入操作。后续代码基于 Intersection Observer 实现。
固定高度场景
举例说明,假定条目高度固定为 100px,方便计算,创建节点实现如下:
export function renderItem(record: Record) {
const ele = document.createElement('div');
ele.setAttribute('class', 'alert alert-primary');
ele.dataset.index = record.index;
// 初始化触发标记
ele.dataset.visible = 'pending';
// 原始数据控制长度,保证容器高度固定
ele.textContent = record.textContent;
return ele;
}
用户操作包含上下滚动,处于可滚动状态时,可视区域外需要保留部分节点,作为滚动缓冲,不至于出现暂时空白。缓冲范围之外的节点,便可以直接删除。极限条件下,渲染数量比可见数量 +2 即可,实际开发中,建议适当增大。固定高度场景下,插入、删除节点可以同时进行,因此关键帧选用节点离开缓冲区事件。
本文缓冲节点设定为 1,方便画示意图。初始化时,需要插入足够节点满足初始条件:
export function bootstrap() {
// 首次渲染,初始数量为可视数量加缓冲节点
const length = Math.ceil(window.innerHeight / height) + buffer * 2;
// slice 不包含尾标
const frames = FixedRecords.slice(0, length + 1);
// 更新范围标记
head = 0;
tail = length;
frames.forEach((frame) => {
container.appendChild(renderItem(frame));
});
}
采用静态布局,增删节点,相对位置不发生变化,草图示意如下:

可视区域边界以外,设置为 缓冲区、回收区,条目自下向上完全离开缓冲后,删除该节点,并向后插入新节点。条目自上而下完全离开缓冲区后,向头部插入新节点,删除尾部节点。循环往复,实际表现贴近全量渲染。
const bufferHeight = buffer * height;
const options = {
root: container,
rootMargin: `${bufferHeight}px 0px`,
threshold: 0,
};
const observer = new IntersectionObserver(observeCallback, options);
const elements = document.querySelectorAll('.alert');
elements.forEach((element) => {
observer.observe(element);
});
回调逻辑需要排除初始化干扰,且判断方向,完整逻辑实现如下:
const observeCallback: IntersectionObserverCallback = (entries, observer) => {
// 初始化阶段过滤干扰
const pristines = entries.filter(
(entry) => (entry.target as HTMLElement).dataset.status === 'pending'
);
const changes = entries
// 排除初始化干扰
.filter((entry) => (entry.target as HTMLElement).dataset.status === 'ready')
// 仅关注元素离开
.filter((entry) => entry.intersectionRatio === 0);
changes.forEach((entry) => {
const direction =
entry.boundingClientRect.bottom < entry.rootBounds!.top ? 'BTA' : 'ATB';
// 从下至上离开观察区域,尾部插入节点,头部回收节点
if (direction === 'BTA') {
// 确认后续存在条目
if (tail < lastIndex) {
// 更新数据范围标记
head += 1;
tail += 1;
const element = renderItem(FixedRecords[tail]);
// 尾部插入新节点
container.appendChild(element);
// 新节点纳入观察
observer.observe(element);
// 取消观察待删除节点
observer.unobserve(entry.target);
// 删除旧节点
container.removeChild(entry.target);
}
}
// 从上至下离开观察区域,头部插入节点,尾部回收节点
else {
if (head > 0) {
// 更新数据范围标记
head -= 1;
tail -= 1;
const element = renderItem(FixedRecords[head]);
// 尾部插入新节点
container.insertBefore(element, container.firstChild);
// 新节点纳入观察
observer.observe(element);
// 取消观察待删除节点
observer.unobserve(entry.target);
// 删除旧节点
container.removeChild(entry.target);
}
}
});
pristines.forEach((entry) => {
(entry.target as HTMLElement).dataset.status = 'ready';
});
};
方案中包含 DOM 节点新建与删除,可以进一步优化:初始化之后,不再创建任何新节点,完全复用现存节点,将 DOM 节点新建与删除操作,调整为 DOM 节点移动与更新操作。
节点更新代码如下:
function patchItem(item, element) {
element.dataset.index = item.index;
element.textContent = item.textContent;
return element;
}
移动逻辑相较于删除插入逻辑,改动部分如下:
// 从下至上离开观察区域
if (direction === 'BTA') {
// 确认后续存在条目
if (tail < lastIndex) {
// 更新数据范围标记
head += 1;
tail += 1;
// 回收头部节点到尾部
container.appendChild(
patchItem(FixedRecords[tail], container.firstChild as HTMLElement)
);
}
}
// 从上至下离开观察区域
else {
if (head > 0) {
// 更新数据范围标记
head -= 1;
tail -= 1;
// 回收尾部节点到头部
container.insertBefore(
patchItem(FixedRecords[head], container.lastChild as HTMLElement),
container.firstChild
);
}
}
效果如下:
关键帧如下:

整体性能表现如下:

高度固定条件下,实现虚拟列表逻辑较为简单。代码实现完全基于理想状况,距离生产环境使用还有提升空间。
可变高度场景
进一步考虑高度不固定场景,临界条件相似却有不同。固定高度场景下,插入删除可以同时进行,一增一补即可保持平衡。动态高度场景,一增一补无法保持平衡,插入删除执行时机不一致,需要分离逻辑,且缓冲区高度必须显式传入。
使用双观察者模式, Intersection Observer 参数重新标定:
// 可视区域观察者,处理新增节点
observerVisible = new IntersectionObserver(observeVisibleCallback, {
root: container,
threshold: 0,
});
// 缓冲区观察者,处理删除节点
observerBeyond = new IntersectionObserver(observeBeyondCallback, {
root: container,
rootMargin: `${BUFFER_AREA_HEIGHT}px 0px`,
threshold: 0,
});
节点进入可视区域,逻辑尾部增添新节点,需要节点时进入方向,核心代码如下:
const observeVisibleCallback: IntersectionObserverCallback = (entries) => {
enters.forEach((entry) => {
// 从下而上进入缓冲区,尾部补充节点
if (
entry.boundingClientRect.top <= entry.rootBounds!.bottom &&
entry.boundingClientRect.bottom >= entry.rootBounds!.bottom
) {
replenishTailNodes();
}
// 从上而下进入缓冲区,头部补充节点
else if (
entry.boundingClientRect.top <= entry.rootBounds!.top &&
entry.boundingClientRect.bottom >= entry.rootBounds!.top
) {
replenishHeadNodes();
} else {
// 手速太快,不知如何处理
// 保守估计,双重插入
replenishHeadNodes();
replenishTailNodes();
}
});
插入操作代码类似,以尾部插入节点为例:
function replenishTailNodes() {
// 确认后续存在条目
if (tail < lastIndex) {
// 更新数据范围标记
tail += 1;
const element = renderItem(DynamicRecords[tail]);
// 新节点纳入观察
observerVisible.observe(element);
observerBeyond.observe(element);
// 尾部插入新节点
container.appendChild(element);
}
}
节点彻底离开缓冲区,则删除非必要节点。对于删除操作,不能仅关注 entry.target 节点,必须删除 entry.target 逻辑前置节点,通过 leave 事件状态,判断逻辑前置节点,核心代码如下:
const observeBeyondCallback: IntersectionObserverCallback = (entries) => {
// 离开缓冲区立刻删除
leaves.forEach((entry) => {
const direction =
entry.boundingClientRect.bottom <= entry.rootBounds!.top ? 'BTA' : 'ATB';
if (direction === 'BTA') {
recycleAboveNodes(entry);
} else {
recycleBelowNodes(entry);
}
});
};
删除头部操作核心代码如下:
function recycleAboveNodes(entry: IntersectionObserverEntry): void {
const element = entry.target as HTMLElement;
const elements: HTMLElement[] = [];
let cursor = element.previousSibling as HTMLElement;
while (cursor) {
// queue
elements.unshift(cursor);
// loop
cursor = cursor.previousSibling as HTMLElement;
}
elements.forEach((ele) => {
// 更新游标
head += 1;
// 取消观察
observerVisible.unobserve(ele);
observerBeyond.unobserve(ele);
if (container.contains(ele)) {
// 删除节点
container.removeChild(ele);
}
});
}
运行表现如下:

此处采用直接增删操作,不建议采用节点回收方式,不再赘述。
细节说明
选用 Intersection Observer 并未如预期理想,尤其快速滚动场景下,需要特别考虑:**元素 enter 方向不确定, leave 方向确定,**以 threshold: 0 为例。
理想情况下,节点与观察区开始接触,事件低延迟触发,示意如下:
实践中,滚动过快时,则无法判断节点从上方还是下方进入,依赖前后节点数量并不靠谱,依靠观察区边界距离判断亦不可靠,除非观察区远大于节点高度,事件触发最大延迟内,节点偏移距离小于一半观察区高度,可以考虑延时批量处理,根据后续可信事件,推定当前方向。关于事件触发延迟,暂未找到确切说明。
函数 recycleAboveNodes 内部循环,删除事件节点前置节点,而不能删除事件节点,示意草图如下:

基于 Intersection Observer 存在其他思路,可能存在其他边界条件未考虑在内,引发潜在致命缺陷,生产环境建议使用成熟第三方库。文章涉及所有代码,github.com/huang-xiao-…