欢迎来到 DOM 的深渊:React 虚拟化滚动与动态高度回收的实战艺术
大家好,我是你们今天的讲师。先把手机放下,把那个正在疯狂滚动的列表关掉。我知道你在找什么,你可能正在为一个包含成千上万条数据的列表抓狂,你的浏览器正在流汗,你的风扇开始像直升机一样轰鸣,而你的页面却卡顿得像是一辆停在坡上的老爷车。
今天,我们要聊的就是如何拯救你的浏览器,拯救你的用户体验,顺便拯救一下你的发际线。主题是:React 虚拟化滚动动态高度回收算法。
听起来很高大上,对吧?其实没那么吓人。想象一下,你有一个巨大的仓库(你的浏览器 DOM 树),你需要在里面堆放成千上万个箱子(你的数据项)。如果你把所有箱子都拿出来堆在地上,仓库就满了,你也走不动路了。虚拟化滚动,就是教你如何只把仓库里“看得见”的箱子拿出来,其他的都扔到隔壁的储藏室里去。
但是,这世界上的箱子大小都不一样。有的只有一包烟那么大,有的像冰箱那么大。这就引出了我们今天的核心难点:动态高度回收。怎么处理这些“高矮胖瘦”不均的箱子呢?别急,今天我们就把这事儿掰开了、揉碎了,用最幽默、最接地气的方式讲给你听。
第一章:DOM 的重量与“全量渲染”的悲剧
首先,我们得搞清楚为什么我们要搞这一出。这不仅仅是炫技,这是生存。
在 React的世界里,当你渲染一个列表,比如:
const dataList = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }));
function MyComponent() {
return (
<div style={{ height: '100vh', overflowY: 'auto' }}>
{dataList.map(item => (
<div key={item.id} style={{ padding: '10px', border: '1px solid #ccc' }}>
{item.text}
</div>
))}
</div>
);
}
你心里想的是:“我只有 1 万条数据,React 渲染个 1 万个 div 应该很快吧?”
天真!太天真了!
每一个 div 都是一个真实的 DOM 节点。浏览器渲染引擎得给每个节点分配内存,计算布局,绘制像素。当你滚动的时候,你的浏览器不仅要处理滚动事件,还要去遍历这 1 万个节点,看看它们是不是被遮挡了。这就像是让一个搬运工去搬 1 万箱砖头,哪怕他只搬了 10 箱到肩膀上,他的脑子里也时刻记得剩下 9990 箱还没搬。
这就是“全量渲染”的悲剧。
虚拟化滚动的核心思想非常简单:视口外不可见,统统不要。
比如,屏幕只能放下 20 个数据项。那么,React 就应该只渲染第 1 到第 20 个。当你滚动的时候,React 隐藏第 1 个,显示第 21 个。这就是“回收”。被回收的节点被移出 DOM 树,滚回来的时候再重新挂载。
但是!生活不是童话。如果所有数据项的高度都是 50px,那太简单了,我们只需要计算 (scrollTop / 50) * 20 就能知道当前显示哪些数据。
但现实是残酷的。你的列表里可能有标题、有正文、有图片。有的标题很长,正文很短;有的图片是横着的,有的图片是竖着的。高度是动态的!
这就好比仓库里的箱子,有的高,有的矮。你以前只收“标准箱子”,现在来了“异形箱子”,你的计算逻辑得变。
第二章:动态高度的噩梦与“幽灵”策略
当高度不可预测时,虚拟化滚动就会立刻崩溃。为什么?因为当你滚动时,你不知道第 21 个元素到底有多高。你不知道它的高度,你就无法计算它应该出现在屏幕的哪个位置。
如果你强行把第 21 个元素放在一个固定的位置,它可能会遮挡第 20 个元素,或者把第 22 个元素挤出屏幕。这就像你把一堆高低不平的石头堆在一起,根本滚不动。
为了解决这个问题,业界大佬们(以及我们这些高级工程师)发明了一套名为“幽灵占位符策略”的算法。
核心概念:先藏后测
想象一下,第 21 个元素还没加载出来,或者还没渲染完。我们不知道它有多高。这时候,我们不能直接把它扔到屏幕上,否则位置就乱了。
所以,我们要先在它原来的位置放一个“幽灵”。
这个幽灵长什么样?它就是一个高度为 0 的透明盒子。它占据了 DOM 的空间,但它不显示任何内容。
当你滚动到这个幽灵的位置时,React 发现:“哦,这个位置有个幽灵,它应该是个真实的数据项。” 于是,React 立刻把幽灵替换成真实的内容。
这时候,神奇的事情发生了。真实内容渲染出来后,浏览器会自动计算它的高度(offsetHeight)。React 拿到了这个高度,心里就有数了:“原来这个元素有 100px 高!”
然后,React 就会去更新它上面的那个幽灵的高度,把幽灵撑起来。同时,React 会根据这个新测出高度,去调整它下面所有元素的位置。
这就叫动态高度回收。
第三章:算法的骨架——如何计算偏移量?
好了,理论说完了,我们开始动工。为了让你彻底掌握,我们将从零开始写一个组件。
1. 数据结构的设计
我们需要一个地方来记录每个元素的真实高度。如果每次都去 DOM 里查,那性能就炸了。所以,我们要用一个 Map 或者对象来缓存测量结果。
// 我们称之为 measurementCache
// key: index, value: height
const measurementCache = {
[0]: 50, // 第0个元素,我知道它高50px
[1]: 100, // 第1个元素,我知道它高100px
// ...
};
2. 计算可视区域的起始索引和结束索引
这是虚拟化的核心逻辑。我们需要根据当前的滚动位置 (scrollTop) 和每个元素的预估高度,算出当前屏幕应该显示哪些元素。
function getVisibleRange(scrollTop, itemHeightEstimate, totalHeight) {
// 简单的数学题:当前位置 / 单个平均高度 = 索引
// 注意:这里用的是估算高度,因为有的元素我们还没测出来
const startIndex = Math.floor(scrollTop / itemHeightEstimate);
// 结束索引 = 起始索引 + 视口能容纳的数量
const endIndex = startIndex + Math.ceil(window.innerHeight / itemHeightEstimate);
return { startIndex, endIndex };
}
但是!如果 startIndex 指向的那个元素,我们还没测过它的真实高度怎么办?那我们的 startIndex 计算就是错的。
这时候,我们就需要回溯。
如果 startIndex 对应的缓存里没有数据,我们就得往回找,找到一个有数据的元素,比如 startIndex - 1,startIndex - 2。把前面所有已知高度的元素累加起来,算出 startIndex 到底在哪里。
这就像你在玩俄罗斯方块,如果你不知道当前方块有多高,你就不知道它落在哪里。如果落点不对,你还得往上挪。
第四章:实战代码——从零构建 DynamicVirtualList
现在,让我们把代码敲出来。为了代码的可读性和演示效果,我会写一个简化版的 DynamicVirtualList 组件。
Step 1: 获取真实高度的 Hook
这是最关键的一步。我们需要一个组件,它接收一个 onMeasure 回调,渲染完内容后,把高度告诉父组件。
import React, { useRef, useEffect, useState } from 'react';
// 这是一个测量用的容器组件
// 它的作用是:渲染真实内容,然后拿到 offsetHeight 传给父组件
const MeasureContainer = ({ children, onMeasure, style }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
// 使用 requestAnimationFrame 确保 DOM 已经渲染完毕
requestAnimationFrame(() => {
const height = ref.current.offsetHeight;
if (height) {
onMeasure(height);
}
});
}
}, [children, onMeasure]);
return (
<div ref={ref} style={{ ...style, minHeight: 0, overflow: 'hidden' }}>
{children}
</div>
);
};
export const useMeasure = () => {
const [height, setHeight] = useState(0);
const [ref, setRef] = useState(null);
const handleMeasure = (h) => setHeight(h);
const setRefCallback = (node) => {
setRef(node);
// 这里我们实际上不需要直接操作 DOM,而是等待 useEffect
};
return {
ref: setRefCallback,
height,
MeasureComponent: (props) => (
<MeasureContainer onMeasure={handleMeasure} {...props} />
),
};
};
Step 2: 核心虚拟列表组件
这是重头戏。我们需要处理滚动事件、缓存高度、计算偏移量。
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { MeasureContainer } from './useMeasure';
const DynamicVirtualList = ({ data, itemHeightEstimate = 50, overscan = 5 }) => {
// 1. 状态管理
const [scrollTop, setScrollTop] = useState(0);
const [measurements, setMeasurements] = useState({}); // 缓存高度
const [isMeasured, setIsMeasured] = useState(false); // 标记是否至少测过一次
// 2. 引用
const containerRef = useRef(null);
const [containerHeight, setContainerHeight] = useState(0);
const [itemHeightCache, setItemHeightCache] = useState({}); // 临时缓存,避免重绘
// 3. 初始化容器高度
useEffect(() => {
if (containerRef.current) {
setContainerHeight(containerRef.current.clientHeight);
}
}, [data]);
// 4. 核心逻辑:处理滚动
const handleScroll = useCallback((e) => {
setScrollTop(e.target.scrollTop);
}, []);
// 5. 核心逻辑:测量元素高度
// 当我们渲染一个元素时,如果它还没被测量,我们就渲染这个 MeasureContainer
// 当它渲染完毕,MeasureContainer 会回调这个函数,告诉我们它有多高
const handleItemMeasure = useCallback((index, height) => {
setItemHeightCache(prev => {
const newCache = { ...prev };
if (newCache[index] !== height) {
newCache[index] = height;
}
return newCache;
});
}, []);
// 6. 计算偏移量
// 这是一个递归或循环查找的过程,用于找到准确的 startIndex
const getOffsetForIndex = useCallback((index) => {
let offset = 0;
for (let i = 0; i < index; i++) {
// 优先使用缓存的真实高度,如果没有,就用估算高度
offset += measurements[i] || itemHeightCache[i] || itemHeightEstimate;
}
return offset;
}, [measurements, itemHeightCache, itemHeightEstimate]);
// 7. 计算可视区域
const calculateVisibleRange = useCallback(() => {
let startIndex = Math.floor(scrollTop / itemHeightEstimate);
// 如果缓存里没有 startIndex 的数据,我们得往上找,直到找到一个有数据的,或者找到 0
while (startIndex > 0 && !measurements[startIndex] && !itemHeightCache[startIndex]) {
startIndex--;
}
let endIndex = startIndex + Math.ceil(containerHeight / itemHeightEstimate);
// 简单的边界检查
if (endIndex >= data.length) endIndex = data.length - 1;
return { startIndex, endIndex };
}, [scrollTop, itemHeightEstimate, measurements, itemHeightCache, containerHeight, data.length]);
const { startIndex, endIndex } = calculateVisibleRange();
// 8. 渲染列表
return (
<div
ref={containerRef}
style={{ height: '100%', overflowY: 'auto', position: 'relative' }}
onScroll={handleScroll}
>
{/* 1. 虚拟容器:撑开高度,防止滚动条消失 */}
<div style={{ height: calculateTotalHeight(), position: 'relative' }}>
{/* 2. 渲染可视区域内的元素 */}
{data.map((item, index) => {
if (index < startIndex || index > endIndex) return null; // 超出范围,不渲染
// 获取偏移量
const offset = getOffsetForIndex(index);
// 获取高度(优先缓存,其次估算)
const height = measurements[index] || itemHeightCache[index] || itemHeightEstimate;
// 如果已经测过高度,直接渲染真实内容
if (measurements[index] || itemHeightCache[index]) {
return (
<div
key={item.id || index}
style={{
position: 'absolute',
top: offset,
height: height,
width: '100%'
}}
>
{item.content}
</div>
);
}
// 如果还没测过高度,渲染测量容器
// 这是一个“幽灵”,它渲染真实内容,然后回调告诉我们高度
return (
<MeasureContainer
key={item.id || index}
style={{ position: 'absolute', top: offset, height: height, width: '100%' }}
onMeasure={(h) => handleItemMeasure(index, h)}
>
{item.content}
</MeasureContainer>
);
})}
</div>
</div>
);
// 计算总高度(用于撑开滚动条)
function calculateTotalHeight() {
let total = 0;
// 简单的累加,实际上应该用缓存数据
for (let i = 0; i < data.length; i++) {
total += measurements[i] || itemHeightCache[i] || itemHeightEstimate;
}
return total;
}
};
第五章:深度剖析——算法的“玄机”与性能陷阱
写完上面的代码,你以为这就结束了?天真!这只是个玩具。真正的高手都知道,生产环境的虚拟化滚动比这复杂一万倍。为什么?因为上面的代码有个致命的缺陷:抖动。
1. 首次渲染的“假象”
当你第一次打开页面,measurements 是空的。startIndex 可能是 0,也可能因为算法原因算到了 5。但是第 5 个元素是“幽灵”,它的高度是 itemHeightEstimate(比如 50px)。
当你滚动一点点,第 5 个元素变成了真实内容,假设它实际高度是 200px。这时候,getOffsetForIndex 会重新计算,发现第 5 个元素变高了,那它下面的所有元素都得往下挪。
于是,屏幕上的内容会发生剧烈的跳动。用户会觉得:“这破网页是不是抽风了?”
解决方案:交错渲染与防抖
我们不能在每次滚动都去重新渲染所有元素。我们需要把“测量”和“渲染”分开。
在 react-window 或 react-virtuoso 这些成熟的库里,它们引入了 overscan(过度渲染)的概念。
- OverScan: 我们不只渲染视口内的元素,我们多渲染一点,比如视口上下各多渲染 5 个。
- 为什么? 因为当用户快速滚动时,可能刚好测到了第 20 个元素的高度,导致第 21 个元素的位置计算错误。通过多渲染几个,我们可以给计算留出一点“缓冲区”。
2. 测量带来的二次回流
上面的代码里,MeasureContainer 渲染完会立即触发 onMeasure。但是,如果这个容器的高度变化很大(比如从 50px 变成了 500px),它会触发浏览器的回流。
浏览器:等等,上面的东西变高了?那我得重新算一下布局,下面的东西也得挪一挪。
这就像多米诺骨牌,一个元素变高,引发了一连串的连锁反应。
优化方案:批量更新
React 提供了 useEffect 的第二个参数 [height]。我们可以确保只有在高度真正变化时才触发更新。更高级的做法是使用 flushSync 强制批量更新 DOM,或者使用 ResizeObserver API(比 offsetHeight 更高效,能检测到尺寸变化但不会强制重排)。
3. 动态内容导致的“无限滚动”
如果你的列表内容包含图片,那情况就更复杂了。图片加载需要时间。
假设第 10 个元素是个图片,图片还没加载出来,高度是 0(或者默认高度)。然后用户滚到了第 10 个元素的位置。
此时,你的虚拟列表渲染了第 10 个元素,高度是 0。用户往下滚,到了第 11 个元素。第 11 个元素的位置计算是 offset(10) + height(10)。因为 height(10) 是 0,所以第 11 个元素会紧贴着第 10 个元素显示。
灾难! 用户看不到第 10 个元素,或者第 11 个元素被挤没了。
解决方案:最小高度
在渲染真实内容之前,必须给元素一个最小高度。比如 minHeight: 50px。即使图片没加载,它也占着 50px 的位置。等图片加载完了,高度变了,再触发更新。
第六章:图片与混合内容的处理
现在,我们假设你的数据项是这样的:
const items = [
{
type: 'text',
content: 'Hello World'
},
{
type: 'image',
src: 'https://example.com/large-image.jpg',
width: 300,
height: 200 // 图片有固定比例,或者你知道高度
},
{
type: 'complex',
content: 'Some very long text that wraps and changes height...'
}
];
对于图片,我们通常不需要“测量”它的真实高度。因为图片加载前,我们不知道它多高;加载后,它的高度通常是固定的(除非是响应式图片)。
所以,对于图片类型的 Item,我们可以在初始化时就把它的高度存入缓存。
// 在处理数据时
items.forEach(item => {
if (item.type === 'image') {
measurements[item.id] = item.height; // 直接缓存
}
});
这样,渲染图片时,就不需要“幽灵容器”了,可以直接渲染。只有对于文本这种“不可预测”的内容,才需要走测量流程。
交错渲染的艺术
这是高级技巧。不要把所有测量逻辑都放在滚动事件里。滚动事件非常频繁。
我们可以使用 requestAnimationFrame 来节流滚动事件。
const rafId = useRef(null);
const handleScroll = useCallback((e) => {
if (rafId.current) cancelAnimationFrame(rafId.current);
rafId.current = requestAnimationFrame(() => {
setScrollTop(e.target.scrollTop);
});
}, []);
这样,浏览器在每一帧只处理一次滚动,性能会提升不少。
第七章:边界情况与边缘测试
作为资深专家,我们不能只考虑“正常情况”。我们要考虑那些让普通开发者抓狂的边缘情况。
1. 快速滚动
用户手指在屏幕上疯狂滑动。滚动事件像雨点一样打过来。你的虚拟列表需要能够处理这种“洪水”。
这时候,不要在滚动回调里做任何 DOM 操作(比如测量)。滚动回调只更新 scrollTop 状态。真正的渲染逻辑在 useEffect 里,它会监听 scrollTop 的变化。
useEffect(() => { calculateAndRender() }, [scrollTop, measurements])
这样,即使有 100 个滚动事件,useEffect 也只会触发一次渲染。这就像把“写作业”和“吃饭”分开。滚动是“吃饭”,渲染是“写作业”。
2. 动态增删数据
如果你的列表是无限滚动的,数据是源源不断进来的。
当你插入新数据时,你需要重新计算总高度,并更新 measurements 缓存。这时候,屏幕上可能有很多元素的位置需要调整。
如果调整的元素过多,React 的 Diff 算法可能会把整个列表重绘一遍,导致闪烁。
优化: 对于动态列表,尽量保持 DOM 节点的稳定性。如果只是插入一条数据,尽量复用现有的 DOM 节点,而不是销毁重建。
3. 移动端适配
在移动端,滚动通常由触摸事件驱动。触摸事件比鼠标滚动事件更复杂,因为它涉及惯性。
虚拟化滚动在移动端必须配合 react-window 或 react-virtuoso 的原生触摸支持。自己写的滚动逻辑如果处理不好触摸的惯性,会导致“拖拽不跟手”。
第八章:总结与进阶路线图
好了,同学们,今天的讲座快要结束了。我们讲了什么?
- DOM 的负担: 不要渲染所有东西,那是浪费。
- 幽灵策略: 用占位符隐藏未知高度,渲染后测量并更新。
- 偏移量计算: 这是一个数学游戏,需要缓存高度,回溯索引。
- 性能优化: 防抖、交错渲染、最小高度。
现在,看着你手里这段代码,是不是觉得它有点“丑陋”?是的,它不够优雅,不够抽象。这就是为什么 React 社区有那么多优秀的库。
但是,理解了原理,你才能驾驭这些库。
如果你想继续深造,我给你推荐以下几条路线:
- 阅读源码: 找个周末,把
react-window的源码读一遍。看看它是怎么处理overscan的,怎么处理measure的。你会发现,其实原理和我们讲的差不多,只是它把细节做得更极致。 - 尝试自定义 Hook: 不要总是用组件。试着写一个
useVirtualList的 Hook,把逻辑抽离出来。这样你的代码会更复用。 - 处理图片懒加载: 把虚拟化滚动和图片懒加载结合起来。图片是列表性能的大杀器,处理不好图片,虚拟化滚动也是白搭。
最后,我想说,编程不仅仅是写代码,更是解决问题。当你面对一个包含百万级数据的列表时,如果你还能淡定地喝着咖啡,看着页面丝滑滚动,那你就是真正的架构师了。
好了,今天的课就到这里。下课!
(课后作业:尝试修改上面的代码,支持图片类型的 Item,并且实现一个“回到顶部”的功能。)