手写:虚拟列表核心逻辑深度解析
从零开始手写一个支持定高和动态高度的虚拟列表,逐行拆解滚动计算、可见区域渲染和高度缓存的核心逻辑。
一句话概括
手写虚拟列表的核心在于三件事:计算可见范围、渲染可见项、维持正确的滚动条高度,本文从零构建一个完整的生产级虚拟列表实现。
背景与意义
在实际项目中,许多开发者依赖第三方库(如 react-window、react-virtualized)来实现虚拟列表。这些库固然功能完善,但当遇到定制化需求时——比如列表项高度动态变化、需要嵌入动画、或者实现虚拟表格时——理解底层原理就变得不可或缺。
为什么要手写?
- 灵活定制:第三方库覆盖 80% 的场景,但剩下的 20% 需要自己实现
- 性能极致:了解底层才能针对业务场景做极致优化
- 调试能力:出现 bug 时能快速定位是库的问题还是自身用法问题
- 面试必备:手写虚拟列表是大厂前端面试的常见压轴题
本文将从最简单的定高虚拟列表开始,逐步升级到动态高度、缓存优化、双向滚动、动画支持等进阶功能,最终形成一个可在生产中使用的完整实现。
概念与定义
滚动引擎:虚拟列表的心脏,负责根据滚动位置计算可见项的起止索引。核心公式为:
- 定高:
startIndex = floor(scrollTop / itemHeight) - 动高:需要维护一个「偏移数组」,使用二分查找定位
可见窗口:由 startIndex 和 endIndex 界定的数据区间,这个区间内的数据会被渲染为 DOM 节点。
缓冲区:在可见窗口上下各多渲染 N 个项,防止快速滚动时出现白屏。缓冲区的大小既要足够大以防白屏,又要足够小以避免过多 DOM 节点。
首次渲染优化:页面前往列表位置时,可能需要直接跳转到某个特定位置(如搜索引擎结果页),此时需要支持「滚动到指定项」。
最小示例——20 行实现核心思想
先写一个最简单但可运行的版本,只有 20 行核心逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!DOCTYPE html>
<html>
<body>
<div id="container" style="height:400px;overflow:auto;border:1px solid #333"></div>
<script>
const TOTAL = 100000
const ITEM_H = 30
const VISIBLE = Math.ceil(400 / ITEM_H) + 5 // +5 缓冲区
const data = Array.from({length: TOTAL}, (_, i) => `Item ${i + 1}`)
const container = document.getElementById('container')
const inner = document.createElement('div')
inner.style.position = 'relative'
inner.style.height = TOTAL * ITEM_H + 'px'
container.appendChild(inner)
function render(scrollTop) {
const start = Math.floor(scrollTop / ITEM_H)
const end = Math.min(start + VISIBLE, TOTAL)
inner.innerHTML = data.slice(start, end)
.map((d, i) => `<div style="position:absolute;top:${(start + i) * ITEM_H}px;height:${ITEM_H}px;line-height:${ITEM_H}px;padding:0 12px">${d}</div>`)
.join('')
}
container.onscroll = () => render(container.scrollTop)
render(0)
</script>
</body>
</html>
核心知识点拆解
1. 定高虚拟列表的精确计算模型
定高虚拟列表的核心是「线性映射」——根据滚动偏移量线性推算出应显示的项的索引。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 定高虚拟列表的计算模型
class FixedVirtualList {
constructor({ containerHeight, itemHeight, totalCount, bufferCount = 5 }) {
this.containerHeight = containerHeight // 容器高度
this.itemHeight = itemHeight // 每个项的高度(固定值)
this.totalCount = totalCount // 数据总量
this.bufferCount = bufferCount // 缓冲区项数
// 派生计算
this.totalHeight = totalCount * itemHeight // 总滚动高度
this.maxVisibleCount = Math.ceil(containerHeight / itemHeight) // 可见项数
}
// 核心:根据滚动偏移量计算可见范围
calculateRange(scrollOffset) {
// 防止负值和溢出
const offset = Math.max(0, Math.min(scrollOffset, this.totalHeight - this.containerHeight))
// 计算起始索引(核心公式)
const startIndex = Math.floor(offset / this.itemHeight)
// 计算结束索引(包含缓冲区)
const endIndex = Math.min(
this.totalCount - 1,
startIndex + this.maxVisibleCount + this.bufferCount * 2
)
// 修正起始索引(包含上方缓冲区)
const adjustedStart = Math.max(0, startIndex - this.bufferCount)
return {
startIndex: adjustedStart,
endIndex: endIndex,
// 渲染列表的偏移量(用于 translateY)
offset: adjustedStart * this.itemHeight,
// 实际可见项的起始(用于判断预加载触发时机)
visibleStart: startIndex,
visibleEnd: Math.min(this.totalCount - 1, startIndex + this.maxVisibleCount),
}
}
// 反向计算:根据项索引推算出滚动到该项需要的位置
calculateOffsetForIndex(index) {
return Math.max(0, index * this.itemHeight - (this.containerHeight - this.itemHeight) / 2)
}
}
2. 动态高度虚拟列表的偏移数组
当列表项高度不固定时,无法使用 startIndex = Math.floor(scrollTop / itemHeight) 这个简单公式。我们需要一个「偏移数组」来精确记录每项的位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// 偏移数组的构建和维护
class DynamicVirtualListStore {
constructor(estimatedItemHeight = 80) {
this.estimatedItemHeight = estimatedItemHeight
this.heights = new Map() // index → actual height
this.offsets = null // 缓存的计算结果
this.totalHeight = 0
}
// 更新某项的高度
updateHeight(index, height) {
if (this.heights.get(index) === height) return false // 没变化
this.heights.set(index, height)
this.offsets = null // 标记缓存失效
return true
}
// 构建或重建偏移数组
buildOffsets(totalCount) {
if (this.offsets && this.offsets.length === totalCount + 1) {
return this.offsets // 缓存命中
}
const offsets = [0]
for (let i = 0; i < totalCount; i++) {
const height = this.heights.get(i) ?? this.estimatedItemHeight
offsets.push(offsets[i] + height)
}
this.offsets = offsets
this.totalHeight = offsets[totalCount]
return offsets
}
// 二分查找:给定滚动距离,找到对应的起始索引
findStartIndex(scrollTop, totalCount) {
const offsets = this.buildOffsets(totalCount)
let left = 0
let right = offsets.length - 1
while (left < right) {
const mid = (left + right) >>> 1 // 无符号右移,快速取中
if (offsets[mid] < scrollTop) {
left = mid + 1
} else {
right = mid
}
}
return Math.max(0, left - 1)
}
// 从起始索引开始,累加高度找到结束索引
findEndIndex(startIndex, maxBottom, totalCount) {
const offsets = this.buildOffsets(totalCount)
let current = startIndex
while (current < totalCount && offsets[current] < maxBottom) {
current++
}
return Math.min(totalCount - 1, current + 5) // +5 缓冲区
}
}
3. 与 React 结合的完整 Hook 封装
将上述逻辑封装为 React Hook,便于在项目中使用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// useVirtualList.js - 完整的虚拟列表 Hook
import { useState, useRef, useCallback, useMemo, useEffect } from 'react'
/**
* 虚拟列表 Hook
* @param {Array} data - 全部数据
* @param {Object} options
* @param {number} options.itemHeight - 定高时传入,动态高度时传预估高度
* @param {number} options.overscan - 缓冲区项数
* @param {number} options.containerHeight - 容器高度
* @returns
*/
export function useVirtualList(data, options = {}) {
const {
itemHeight = 50,
overscan = 5,
containerHeight = 400,
} = options
const [scrollTop, setScrollTop] = useState(0)
const containerRef = useRef(null)
const heightsRef = useRef(new Map())
const offsetsRef = useRef(null)
const totalCount = data.length
// 构建偏移数组(动态高度用)
const buildOffsets = useCallback(() => {
if (offsetsRef.current && offsetsRef.current.length === totalCount + 1) {
return offsetsRef.current
}
const offsets = [0]
for (let i = 0; i < totalCount; i++) {
const h = heightsRef.current.get(i) ?? itemHeight
offsets.push(offsets[i] + h)
}
offsetsRef.current = offsets
return offsets
}, [totalCount, itemHeight])
// 二分查找
const binarySearch = useCallback((offsets, target) => {
let lo = 0
let hi = offsets.length - 1
while (lo < hi) {
const mid = (lo + hi) >>> 1
if (offsets[mid] < target) lo = mid + 1
else hi = mid
}
return lo
}, [])
// 计算可见范围
const range = useMemo(() => {
if (totalCount === 0) {
return { start: 0, end: 0, offset: 0, totalHeight: 0 }
}
const offsets = buildOffsets()
const totalHeight = offsets[totalCount]
// 定高模式:直接数学计算
if (heightsRef.current.size === 0) {
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
const visibleCount = Math.ceil(containerHeight / itemHeight) + overscan * 2
const end = Math.min(totalCount, start + visibleCount)
return {
start,
end,
offset: start * itemHeight,
totalHeight,
}
}
// 动态高度模式:二分查找
const baseIndex = Math.max(0, binarySearch(offsets, scrollTop) - 1)
const start = Math.max(0, baseIndex - overscan)
const bottomLimit = scrollTop + containerHeight
let end = start
while (end < totalCount && offsets[end] < bottomLimit + overscan * itemHeight) {
end++
}
end = Math.min(totalCount, end + overscan)
return {
start,
end,
offset: offsets[start],
totalHeight,
}
}, [scrollTop, totalCount, itemHeight, overscan, containerHeight, buildOffsets, binarySearch])
// 同步滚动事件
const onScroll = useCallback((e) => {
setScrollTop(e.currentTarget.scrollTop)
}, [])
// 记录某项的实际高度
const recordHeight = useCallback((index, height) => {
heightsRef.current.set(index, height)
offsetsRef.current = null // 缓存失效
}, [])
// 滚动到指定索引
const scrollToIndex = useCallback((index) => {
const offsets = buildOffsets()
const top = offsets[index]
if (containerRef.current) {
containerRef.current.scrollTop = top
}
}, [buildOffsets])
// 可见数据
const visibleData = useMemo(
() => data.slice(range.start, range.end),
[data, range.start, range.end]
)
return {
containerRef,
onScroll,
range,
visibleData,
scrollToIndex,
recordHeight,
totalCount,
isDynamic: heightsRef.current.size > 0,
}
}
4. 排序和过滤时的特殊处理
当列表数据发生变化(排序、过滤、添加)时,高度缓存数组需要重建:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 列表变化时的处理
function useVirtualListWithFilter(allData, filterFn) {
const filteredData = useMemo(() => allData.filter(filterFn), [allData, filterFn])
const [version, setVersion] = useState(0)
// 过滤条件变化时,重置高度缓存
useEffect(() => {
setVersion((v) => v + 1)
}, [filteredData.length])
// 使用重置后的虚拟列表
const virtual = useVirtualList(filteredData, {
itemHeight: 72,
key: version, // 通过 key 变化触发内部缓存重置
})
return virtual
}
实战案例:带分组的树形虚拟列表
以下实现一个更复杂的场景——可展开的树形虚拟列表:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// TreeVirtualList.jsx - 树形虚拟列表
import { useState, useMemo, useCallback, useRef, memo } from 'react'
// 扁平化树数据,并记录层级信息
function flattenTree(nodes, parentId = null, depth = 0) {
const result = []
for (const node of nodes) {
result.push({
...node,
parentId,
depth,
isGroup: node.children && node.children.length > 0,
})
if (node.expanded && node.children) {
result.push(...flattenTree(node.children, node.id, depth + 1))
}
}
return result
}
const TreeNode = memo(function TreeNode({ node, onToggle, style }) {
return (
<div
style={{
...style,
paddingLeft: 16 + node.depth * 24,
display: 'flex',
alignItems: 'center',
borderBottom: '1px solid #eee',
}}
>
{node.isGroup && (
<button
onClick={() => onToggle(node.id)}
style={{ marginRight: 8, cursor: 'pointer', border: 'none', background: 'none' }}
>
{node.expanded ? '▼' : '▶'}
</button>
)}
<span>{node.name}</span>
{!node.isGroup && (
<span style={{ marginLeft: 12, color: '#999', fontSize: 12 }}>
{node.value}
</span>
)}
</div>
)
})
export default function TreeVirtualList({ treeData }) {
const [expandedIds, setExpandedIds] = useState(new Set(['root']))
// 1. 为每个节点添加上 expanded 状态
const enrichedData = useMemo(() => {
function enrich(node) {
return {
...node,
expanded: expandedIds.has(node.id),
children: node.children?.map(enrich),
}
}
return treeData.map(enrich)
}, [treeData, expandedIds])
// 2. 扁平化
const flatList = useMemo(() => flattenTree(enrichedData), [enrichedData])
// 3. 使用虚拟列表 Hook
const virtual = useVirtualList(flatList, { itemHeight: 48, overscan: 5 })
const containerRef = useRef(null)
const [scrollTop, setScrollTop] = useState(0)
const handleToggle = useCallback((id) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
// 展开/折叠时需要重置偏移缓存(内容高度变化了!)
const range = useMemo(() => {
const itemHeight = 48
const overscan = 5
const totalHeight = flatList.length * itemHeight
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
const visibleCount = Math.ceil((containerRef.current?.clientHeight || 600) / itemHeight) + overscan * 2
const end = Math.min(flatList.length, start + visibleCount)
return { start, end, offset: start * itemHeight, totalHeight }
}, [scrollTop, flatList.length])
return (
<div ref={containerRef} style={{ height: 600, overflow: 'auto' }}
onScroll={(e) => setScrollTop(e.target.scrollTop)}>
<div style={{ height: range.totalHeight, position: 'relative' }}>
<div style={{
position: 'absolute',
top: 0, left: 0, right: 0,
transform: `translateY(${range.offset}px)`
}}>
{flatList.slice(range.start, range.end).map((node, i) => (
<TreeNode
key={node.id}
node={node}
onToggle={handleToggle}
style={{ height: 48 }}
/>
))}
</div>
</div>
</div>
)
}
底层原理(含源码分析)
1. 浏览器滚动帧的精确控制
虚拟列表需要与浏览器的滚动机制密切配合。一组关键数字:
1
2
3
滚动事件频率:约 60~120 Hz(取决于设备和浏览器)
requestAnimationFrame:与显示器刷新率同步(通常 60Hz)
JavaScript 执行帧预算:16.67ms(60fps 下)
这意味着每次滚动事件处理 + 虚拟列表渲染的总时间必须在 16ms 内完成,否则就会出现卡顿。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 高性能滚动处理模式
class HighPerformanceScroller {
constructor(renderCallback) {
this.renderCallback = renderCallback
this.ticking = false
this.lastScrollTop = 0
this.smoothFactor = 0.3 // 平滑系数
}
handleScroll(e) {
const target = e.target
this.lastScrollTop = target.scrollTop
if (!this.ticking) {
// 使用 requestAnimationFrame 确保与帧同步
window.requestAnimationFrame(() => {
this.ticking = false
// 使用 transform 时,可以配合 will-change 创建独立合成层
// 这样即使主线程繁忙,滚动也不会卡顿
this.renderCallback(this.lastScrollTop)
})
this.ticking = true
}
// 额外的平滑处理:滚动降采样
// 当滚动速度超过阈值时,跳过中间帧的渲染
if (this.lastScrollTop % 5 !== 0) return
}
}
2. React 18 批量更新与虚拟列表
React 18 的自动批处理对虚拟列表有显著的正向影响:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// React 17 中 scroll 事件不会自动批处理
// 这意味着每次 setScrollTop 都会触发一次渲染
function ScrollHandler() {
const [scrollTop, setScrollTop] = useState(0)
const [velocity, setVelocity] = useState(0)
const handleScroll = useCallback((e) => {
const top = e.target.scrollTop
// React 17:两次 setState 会触发两次渲染
setScrollTop(top)
setVelocity(top - lastTop.current)
lastTop.current = top
}, [])
// React 18:两次 setState 合并为一次渲染
}
3. CSS containment 优化策略
现代浏览器提供了 contain 属性,可以进一步优化虚拟列表的渲染:
1
2
3
4
5
6
7
8
9
10
11
12
13
/* 虚拟列表容器优化 */
.virtual-list-container {
contain: strict; /* 完全隔离:layout、style、paint、size 均不向外传播 */
/* 等同于:contain: layout style paint size */
overflow: auto;
will-change: scroll-position; /* 创建独立合成层 */
}
.virtual-list-item {
contain: content; /* 内容的 layout 和 paint 不向外传播 */
content-visibility: auto; /* 浏览器可根据可见性自动跳过渲染 */
contain-intrinsic-size: 48px; /* 为未渲染内容提供占位大小 */
}
content-visibility: auto 是一个值得关注的「原生虚拟列表」技术。它允许浏览器自动跳过视口外元素的渲染,效果类似于框架级的虚拟列表。但需要注意的是,content-visibility 会跳过元素的 display、layout、paint,但不会跳过 JavaScript 的执行,因此与框架配合使用时需要注意:
1
2
3
4
5
6
7
8
9
10
// 使用 content-visibility 配合 React
function ListItem({ item }) {
return (
<div style={{ contentVisibility: 'auto', containIntrinsicSize: '48px' }}>
{/* React 仍然会创建 VNode 和执行 render function */}
{/* 但浏览器只渲染可见的 DOM 节点 */}
<ExpensiveContent data={item} />
</div>
)
}
4. 滚动位置恢复
在 SPA 应用中,用户从列表页点进详情页再返回时,滚动位置需要恢复:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ScrollPositionManager.js
import { useRef, useCallback, useEffect } from 'react'
function useScrollRestoration(listKey) {
const positionsRef = useRef(new Map())
const savePosition = useCallback((key, position) => {
positionsRef.current.set(key, position)
// 也可以持久化到 sessionStorage
sessionStorage.setItem(`scroll_${key}`, String(position))
}, [])
const restorePosition = useCallback((key) => {
const saved = positionsRef.current.get(key)
if (saved !== undefined) return saved
const fromStorage = sessionStorage.getItem(`scroll_${key}`)
return fromStorage ? Number(fromStorage) : 0
}, [])
useEffect(() => {
return () => {
// 组件卸载时自动保存
}
}, [listKey])
return { savePosition, restorePosition }
}
高频面试题解析
Q1: 虚拟列表在「快速滚动」时出现白屏的根因是什么?如何优化?
考察点:对浏览器渲染管线与虚拟列表耦合的深度理解。
答案核心:
白屏的根本原因是「渲染滞后于滚动」:
- 主线程阻塞:滚动事件触发 → JS 执行 → VDOM diff → DOM 操作 → Layout → Paint 整个流程如果超过 16ms,就会出现掉帧
- Chunk 加载延迟:如果数据是异步加载的,网络延迟会造成空白
优化方案:
1
2
3
4
5
6
7
8
9
10
11
12
方案一:增大缓冲区(最简单)
将 overscan 从 5 增加到 10~20
方案二:降采样渲染(更平滑)
滚动速度 > 1000px/s 时,跳过中间帧渲染
只在滚动停止后重新渲染
方案三:节点复用 + DOM 回收池
复用已创建的 DOM 节点,减少创建/销毁开销
方案四:渐进式渲染
先用骨架屏占位,500ms 内完成实际渲染
Q2: 如何处理虚拟列表中的「错位」问题(常见于动态高度场景)?
考察点:动态高度测量的边界情况处理。
答案核心:
错位的根本原因是「预估高度 ≠ 实际高度」,导致累计偏移量计算错误。
1
2
3
4
5
6
7
8
9
10
11
错位类型 1:单项错位
根源:测量失败(如图片未加载完成时测量)
解决:使用 ResizeObserver 监听尺寸变化
错位类型 2:累积错位(往后越错越远)
根源:整个偏移数组的误差累积
解决:每次渲染时重新计算偏移数组
错位类型 3:展开/折叠后的跳跃
根源:内容高度变化后偏移量未及时更新
解决:变化时触发全量重算,或使用 transition 平滑过渡
可以使用 ResizeObserver 自动监听尺寸变化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function useItemSize(containerRef) {
const sizes = useRef(new Map())
useEffect(() => {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const index = Number(entry.target.dataset.index)
const height = entry.contentBoxSize?.[0]?.blockSize
if (height && sizes.current.get(index) !== height) {
sizes.current.set(index, height)
// 触发重新计算
}
}
})
// containerRef 中的子元素都需要监听
// ...
return () => observer.disconnect()
}, [])
return sizes.current
}
Q3: 虚拟列表和 Pagination(分页)在性能上有什么区别?什么时候用哪个?
考察点:不同技术方案的选型判断。
答案核心:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
虚拟列表 分页
───────────────────── ─────────────────────
DOM 节点稳定(几十个) DOM 节点随页变化(可多可少)
滚动体验连续 需要手动翻页
所有数据在内存中 数据按页加载
适合阅读型场景 适合管理型场景
不需要「加载更多」按钮 有明确的翻页操作
选型建议:
- 社交信息流 → 虚拟列表
- IM 消息列表 → 虚拟列表
- 后端管理系统表格 → 虚拟列表(当行数 > 1000 时)
- 用户搜索列表 → 分页(搜索结果通常不会很多)
- 内容管理系统 → 分页(需要明确的分页操作)
- 移动端无限滚动 → 虚拟列表 + 分页加载混合
总结与扩展
本文从零开始构建了一个完整的虚拟列表系统。从定高列表的线性计算模型,到动态高度的偏移数组和二分查找,再到树形列表、滚动恢复等进阶场景,构建了一个完整的知识体系。
核心难点:
- 偏移量的精确计算:动态高度下的二分查找是关键
- 缓存的维护和失效:高度变化时需要重建偏移数组
- 滚动事件的节流:要与帧率同步,避免不必要的渲染
扩展思考:
在 Electron 或 Native 应用中,虚拟列表的实现会更加复杂:需要考虑双缓存(Double Buffer)渲染策略、GPU 纹理缓存、滚动惯性模拟等高级技术。
Web Platform 层面,content-visibility 和 IntersectionObserver 的融合正在改变虚拟列表的生态。理论上,浏览器未来完全可能原生支持「列表中只渲染可见项」的能力,届时开发者可能不再需要手写虚拟列表。但在那之前,手写虚拟列表依然是每个前端工程师的必备技能。