1. 虚拟列表和普通列表懒加载的核心区别是什么?
面试者真正应该说出口的答案
普通列表懒加载只是把 DOM 的创建过程分批进行,用户滚得越远,DOM 通常越积越多;虚拟列表只维护可视区域和附近的一小批 DOM,滚动时复用或更新这些节点,所以 DOM 数量基本不会随着数据总量增长。
这句话是这道题最核心的区分。
面试官继续追问后的展开
比如有 10 万条数据:
普通列表懒加载:
text
滚动
↓
加载下一批数据
↓
数据越来越多
↓
DOM 也越来越多
↓
最终还是可能卡
它优化的是:
网络请求和数据加载成本。
而虚拟列表:
text
10 万条数据
↓
数据可以全部存在内存
↓
根据滚动位置计算当前应该显示哪些数据
↓
只创建这一小段数据对应的 DOM
↓
滚动时不断复用/更新这部分 DOM
它优化的是:
DOM 数量和渲染、布局、绘制相关成本。
所以二者甚至可以同时使用:
text
分页 / 懒加载
↓
控制数据加载量
虚拟列表
↓
控制 DOM 数量
2. 为什么普通列表数据一多就会卡?
真正的问题不是"数据多",而是大量数据最终变成了大量 DOM,而 DOM 增多会带来渲染、布局、绘制以及更新成本。
面试官继续追问后的展开
例如:
html
<ul>
<li>第 1 条</li>
<li>第 2 条</li>
...
<li>第 100000 条</li>
</ul>
100000 条数据如果全部渲染,就意味着浏览器需要维护大量节点。
问题会逐层出现:
text
数据量大
↓
DOM 节点多
↓
样式计算 / Layout / Paint 等成本增加
↓
首次渲染慢
↓
更新成本增加
↓
滚动、交互容易出现卡顿
所以虚拟列表真正解决的是:
让"数据量"和"DOM 数量"解耦。
这是比"减少 DOM"更重要的一层理解。
3. 虚拟列表到底是怎么实现的?
这是第一道真正的技术分水岭。
核心就是根据现在滚到的位置,算出这一屏要显示哪些数据,只渲染这一小部分 DOM,剩下的用空白区域把滚动条撑起来。
面试官继续追问后的展开
假设:
text
总数据:10000 条
每一项高度:50px
那么整个列表理论高度:
text
10000 × 50 = 500000px
但实际上屏幕可能只能显示:
text
20 条左右
所以页面不需要真的创建 10000 个 DOM。
可以变成:
text
┌─────────────────────┐
│ 空白占位 │ ← 上方 spacer
├─────────────────────┤
│ item 101 │
│ item 102 │
│ item 103 │
│ ... │ ← 真正渲染
│ item 120 │
├─────────────────────┤
│ 空白占位 │ ← 下方 spacer
└─────────────────────┘
核心就是三个东西:
text
总高度
+
当前可视范围
+
可视范围对应的数据
4. 固定高度虚拟列表怎么计算?
这是非常高频的追问。假设:
text
itemHeight = 50px
scrollTop = 5000px
那么当前滚动到了:
text
5000 / 50 = 100
也就是说:
text
startIndex ≈ floor(scrollTop / itemHeight)
假设 viewport 高度是:
text
500px
那么可视数量大约:
text
500 / 50 = 10
实际工程一般不会只渲染这 10 个,而是增加一个 buffer:
text
startIndex
↓
前面多渲染几个
[buffer]
[可视区域]
[buffer]
例如:
text
scrollTop
↓
────────────┬────────────
│
item 100
item 101
item 102
...
item 120
│
────────────┴────────────
最终得到:
javascript
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(viewportHeight / itemHeight);
const endIndex = startIndex + visibleCount + buffer;
然后:
javascript
const visibleItems = list.slice(startIndex, endIndex);
这就是最基础的固定高度虚拟列表。
5. 为什么还需要一个"占位容器"?
因为实际只渲染了一小部分 DOM,需要用占位空间撑起完整列表高度,保证滚动条正常。
比如:
text
10000 条 × 50px
= 500000px
我们实际只渲染:
text
20 条
如果没有占位高度:
text
浏览器看到的高度:
20 × 50 = 1000px
滚动条当然就只有这么长。所以虚拟列表通常会:
css
.list {
height: 500000px;
}
然后真正的内容通过:
css
transform: translateY(...)
或者:
css
top: ...
移动到正确的位置。所以你可以把它理解成:
text
完整列表的"空间"存在
完整列表的"DOM"不存在
这句话非常适合面试。
6. 滚动的时候发生了什么?
滚动时根据新的 scrollTop 重新计算 startIndex 和 endIndex,然后更新这一小段 DOM 的内容和位置。
完整流程:
text
用户滚动
↓
scrollTop 变化
↓
计算 startIndex
↓
计算 endIndex
↓
得到新的可视数据
↓
更新 DOM
↓
移动内容容器
例如:
text
第一次:
startIndex = 100
endIndex = 120
渲染:
100 ~ 120
继续往下滚:
text
startIndex = 120
endIndex = 140
于是:
text
100 ~ 120
↓
120 ~ 140
这里还有一个非常重要的工程优化:
不一定真的销毁 100~120,再创建 120~140。
优秀的虚拟列表会尽可能:
text
复用已有 DOM
+
更新数据
+
调整位置
所以它进一步减少了频繁创建和销毁 DOM 的成本。
7. 为什么虚拟列表滚动时不会出现明显跳动?
因为数据项的实际位置和滚动位置之间必须建立稳定的映射,固定高度可以直接通过索引计算,动态高度则需要额外维护每一项的真实高度。
固定高度非常简单:
text
index × itemHeight
例如:
text
第 100 项:
100 × 50 = 5000px
所以定位非常准确。
8. 如果每一项高度不固定怎么办?
这才是更高级的追问。
比如:
text
item 1 50px
item 2 100px
item 3 80px
item 4 200px
...
这时候:
javascript
index * itemHeight
已经不能用了。
因为:
text
第 100 项的位置
≠
100 × 一个固定高度
通常需要:
text
预估高度
↓
先计算一个大概位置
↓
真正渲染
↓
测量真实高度
↓
更新高度缓存
↓
修正后续位置
高度缓存可能类似:
javascript
heightMap = {
0: 50,
1: 100,
2: 80,
3: 200
};
然后通过累计高度计算某个 index 的实际位置。数据量非常大时,还会进一步使用:
text
前缀和
二分查找
Fenwick Tree / Segment Tree
等数据结构优化"根据滚动位置反查 index"的成本。
这就是动态高度虚拟列表比固定高度复杂很多的原因。
9. 虚拟列表真正优化的是什么?
text
数据量 N
↓
普通列表
N 条数据 → N 个 DOM
↓
渲染 / Layout / Paint / 更新成本随 N 增长
虚拟列表
N 条数据
↓
只保留 O(k) 个 DOM
k ≈ 可视区域 + buffer
↓
DOM 数量与总数据量解耦
所以它最核心的价值是:
把 DOM 的规模从跟数据总量相关,变成跟可视区域相关。
这比"减少 DOM"更接近底层。
10. 虚拟列表是不是一定比普通列表快?
不是。 这是非常容易被追问的边界问题。
如果只有:
text
20 条数据
你上虚拟列表反而可能增加复杂度:
text
scroll 监听
+
索引计算
+
DOM 复用
+
位置计算
+
边界处理
所以虚拟列表不是:
"任何列表都应该使用。"
而是:
当列表数据量足够大,普通渲染已经成为瓶颈时,虚拟列表才有明显收益。
11. 虚拟列表的主要矛盾是什么?
主要矛盾
数据量很大,但用户同一时刻真正能看到的数据很少。
普通列表:
text
100000 条数据
↓
100000 个 DOM
虚拟列表:
text
100000 条数据
↓
用户只看到几十条
↓
只维护几十到几百个 DOM
所以它实际上是在解决:
数据规模和可视 DOM 规模不匹配的问题。
12. 虚拟列表有哪些工程边界?
高级面试可以继续追问:
① 动态高度
最麻烦的问题之一:
text
真实高度未知
→ 位置不好计算
→ 高度变化可能导致滚动位置修正
② 快速滚动
用户快速拖动滚动条:
text
scrollTop
100
500
2000
10000
50000
如果每次 scroll 都同步计算和更新 DOM,可能造成新的性能问题。
通常需要:
text
节流
+
requestAnimationFrame
+
buffer
+
DOM 复用
③ 大量复杂子组件
虚拟列表只能减少:
text
DOM 数量
如果每个 item 本身就非常复杂:
text
Item
├── React/Vue 组件
├── 图片
├── 计算
├── 状态
└── 事件
仍然可能卡。
④ 图片加载
虚拟列表不能自动解决:
text
图片请求
图片解码
图片绘制
所以通常还需要:
text
图片懒加载
+
尺寸预留
+
缓存
⑤ 无障碍和搜索
虚拟列表只存在部分 DOM,因此:
text
键盘导航
焦点管理
浏览器查找
无障碍阅读器
都需要额外处理。
13. 最后一道:如果让你自己实现一个虚拟列表,你怎么设计?
这道题非常适合拉开 3 年和 5 年+ 前端的差距。
我会先确定列表是固定高度还是动态高度;固定高度直接通过 scrollTop 和 itemHeight 算可视索引,动态高度则需要做高度缓存和位置修正,然后通过上下占位空间保持完整滚动高度,实际只维护可视区域加 buffer 的 DOM。
然后展开:
text
Virtual List
│
┌────────────┴────────────┐
↓ ↓
固定高度 动态高度
│ │
scrollTop / itemHeight height cache
│ │
直接计算 index 计算累计高度
│ │
└────────────┬────────────┘
↓
startIndex
↓
endIndex
↓
visibleItems + buffer
↓
DOM 复用
↓
translateY 定位
↓
spacer 撑起总高度
14. 最终满分答案
面试官问:虚拟列表是什么?为什么需要它?
虚拟列表就是:数据很多,但 DOM 不需要全部创建,只根据当前滚动位置渲染可视区域附近的一小部分数据。
普通列表的问题是,数据量一大,最终会变成大量 DOM,进而增加渲染、布局、绘制和更新成本。虚拟列表把这两个东西解耦:
text
数据可以有 10 万条
↓
实际 DOM 只保留可视区域 + buffer
↓
滚动时根据 scrollTop 重新计算数据范围
↓
更新并复用这部分 DOM
同时用占位空间撑起完整列表高度,让浏览器的滚动条仍然对应 10 万条数据。
固定高度的情况下,可以直接通过:
text
startIndex ≈ scrollTop / itemHeight
计算当前应该渲染哪些数据;如果是动态高度,就需要额外维护每一项的真实高度和位置。
所以虚拟列表最核心的价值不是简单的"优化长列表",而是:
让 DOM 数量从跟总数据量相关,变成跟可视区域相关。
而普通列表懒加载解决的是"什么时候加载数据",虚拟列表解决的是"什么时候创建和保留 DOM",两者可以一起使用。
这套答案已经能从"会背概念"进入到"理解底层实现"的层次。真正的大厂追问,重点就落在:scrollTop → index → 可视数据 → DOM 复用 → 定位 → 动态高度 这条链上。