文章

手写虚拟列表核心实现深度解析

手写虚拟列表核心实现深度解析

一句话概括

虚拟列表(Virtual List)通过只渲染可视区域内的 DOM 节点,将万级列表渲染性能提升到与几十条数据相当的级别,是前端大列表场景下最核心的性能优化手段。

一、背景与意义

1.1 大列表渲染的痛点

在 Web 应用开发中,渲染大规模列表数据一直是性能瓶颈。以一个典型的用户活动流页面为例:某社交平台需要展示用户最近一年的操作记录,数据量可能达到 5 万条甚至更多。

如果我们使用最朴素的方式——将所有数据一次性渲染到 DOM 中,会面临以下问题:

  • DOM 节点爆炸:5 万条数据意味着至少 5 万个 DOM 节点。每个节点在内存中占用约 200-400 字节,仅 DOM 节点就需要 10-20MB 内存。
  • 布局抖动(Layout Thrashing):浏览器在计算布局时,需要遍历所有 DOM 节点。节点越多,重排(reflow)耗时越长。
  • 滚动性能灾难:每次滚动触发 scroll 事件时,浏览器都需要重新计算大量节点的样式和布局,每秒可能触发数十次重排。

1.2 虚拟列表的诞生

虚拟列表(Virtual Scrolling / Windowed Rendering)最早出现在桌面端 UI 框架中,如 Qt 的 QAbstractItemView 和 WinForms 的 ListView 的 VirtualMode。前端领域从 React Virtualized、react-window 到 Vue Virtual Scroll List 不断演进,其核心思想恒久不变:只渲染可见区域内的元素,用空间换时间,用计算换 DOM

1.3 性能指标对比

以一个 10 万条数据的列表为例:

指标常规渲染虚拟列表
DOM 节点数100,00020-30
初始渲染时间3-5s+<100ms
滚动帧率5-15fps60fps
内存占用200MB+<10MB

数据来源:Chrome DevTools Performance 面板实测,机型 MacBook Pro M1 Pro。

二、概念与定义

2.1 虚拟列表 VS 懒加载

很多开发者容易混淆虚拟列表和懒加载(Lazy Loading):

  • 懒加载:元素最终会全部渲染到 DOM 中,只是延迟了渲染时机(如 IntersectionObserver 触发时加载)。
  • 虚拟列表:元素永不全部渲染。不可见的元素在 DOM 中不存在,滚动过程中不断创建和销毁节点。

2.2 核心术语

术语含义
可视区域(Viewport)列表容器可见的范围,通常是一个固定高度的 div
滚动内容区(Scrollable Content)一个高度等于所有数据总高度的空 div,用于撑起滚动条
起始索引(startIndex)当前可视区域中的第一条数据在完整数据集中的索引
结束索引(endIndex)当前可视区域中的最后一条数据的索引
overscan在可视区域上下额外渲染的行数,用于减少滚动时的白屏感
itemHeight定高虚拟列表中每一项的高度

2.3 两种核心模式

  • 定高虚拟列表:每一项高度固定。计算简单,性能最佳。
  • 不定高虚拟列表:每一项高度不固定(如图文混排)。需要预估高度 + 缓存实际高度 + 重排。

三、最小示例:定高虚拟列表手写

这是最基础也是最核心的版本。理解了它,所有变体都可以在此之上扩展。

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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>手写定高虚拟列表</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    
    .virtual-list-container {
      position: relative;
      width: 400px;
      height: 500px;
      margin: 40px auto;
      overflow-y: auto;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      background: #fafafa;
    }

    .virtual-list-phantom {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      z-index: -1;
      pointer-events: none;
    }

    .virtual-list-content {
      position: relative;
      left: 0;
      right: 0;
      top: 0;
    }

    .virtual-list-item {
      display: flex;
      align-items: center;
      padding: 0 16px;
      border-bottom: 1px solid #eee;
      background: #fff;
      transition: background 0.15s;
    }

    .virtual-list-item:hover {
      background: #f0f7ff;
    }

    .virtual-list-item .avatar {
      width: 32px;
      height: 32px;
      border-radius: 50%;
      background: linear-gradient(135deg, #667eea, #764ba2);
      display: flex;
      align-items: center;
      justify-content: center;
      color: white;
      font-size: 14px;
      font-weight: bold;
      margin-right: 12px;
      flex-shrink: 0;
    }
  </style>
</head>
<body>
  <div id="app">
    <div class="virtual-list-container" id="container"></div>
  </div>

  <script>
    // ========== 数据生成 ==========
    function generateData(count) {
      const names = ['张三', '李四', '王五', '赵六', '陈七', '周八', '吴九', '郑十'];
      const cities = ['北京', '上海', '深圳', '杭州', '成都', '武汉', '南京', '西安'];
      return Array.from({ length: count }, (_, i) => ({
        id: i + 1,
        name: names[i % names.length],
        city: cities[i % cities.length],
        content: `这是第 ${i + 1} 条动态内容,包含一些示例文本用于展示虚拟列表的效果。`
      }));
    }

    // ========== 核心虚拟列表类 ==========
    class FixedSizeVirtualList {
      constructor(container, options) {
        this.container = container;
        this.itemHeight = options.itemHeight || 60;
        this.overscan = options.overscan || 5;
        this.data = options.data || [];
        this.renderItem = options.renderItem || ((item) => `<div>${item.id}</div>`);

        // 创建结构
        this.phantomEl = document.createElement('div');
        this.phantomEl.className = 'virtual-list-phantom';
        this.contentEl = document.createElement('div');
        this.contentEl.className = 'virtual-list-content';
        
        this.container.appendChild(this.phantomEl);
        this.container.appendChild(this.contentEl);

        // 设置总高度(撑起滚动条)
        this.totalHeight = this.data.length * this.itemHeight;
        this.phantomEl.style.height = `${this.totalHeight}px`;

        // 绑定滚动事件
        this.onScroll = this.onScroll.bind(this);
        this.container.addEventListener('scroll', this.onScroll, { passive: true });

        // 初始渲染
        this.render();
      }

      getVisibleRange() {
        const scrollTop = this.container.scrollTop;
        const containerHeight = this.container.clientHeight;

        const startIndex = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.overscan);
        const endIndex = Math.min(
          this.data.length,
          Math.ceil((scrollTop + containerHeight) / this.itemHeight) + this.overscan
        );

        return { startIndex, endIndex };
      }

      render() {
        const { startIndex, endIndex } = this.getVisibleRange();
        const visibleData = this.data.slice(startIndex, endIndex);

        // 更新偏移量——让内容区"顶"到正确位置
        this.contentEl.style.transform = `translateY(${startIndex * this.itemHeight}px)`;

        // 使用 DocumentFragment 批量更新 DOM
        const fragment = document.createDocumentFragment();
        visibleData.forEach((item) => {
          const itemEl = document.createElement('div');
          itemEl.className = 'virtual-list-item';
          itemEl.style.height = `${this.itemHeight}px`;
          itemEl.innerHTML = this.renderItem(item);
          fragment.appendChild(itemEl);
        });

        this.contentEl.innerHTML = '';
        this.contentEl.appendChild(fragment);
      }

      onScroll() {
        // 使用 requestAnimationFrame 节流
        if (this._rafId) {
          cancelAnimationFrame(this._rafId);
        }
        this._rafId = requestAnimationFrame(() => {
          this.render();
          this._rafId = null;
        });
      }

      // 外部更新数据
      updateData(newData) {
        this.data = newData;
        this.totalHeight = this.data.length * this.itemHeight;
        this.phantomEl.style.height = `${this.totalHeight}px`;
        this.render();
      }

      destroy() {
        this.container.removeEventListener('scroll', this.onScroll);
        if (this._rafId) {
          cancelAnimationFrame(this._rafId);
        }
      }
    }

    // ========== 使用示例 ==========
    const container = document.getElementById('container');
    const listData = generateData(100000);

    function renderItem(item) {
      return `
        <div class="avatar">${item.name[0]}</div>
        <div style="flex: 1; overflow: hidden;">
          <div style="display: flex; align-items: center; gap: 8px;">
            <strong>${item.name}</strong>
            <span style="color: #999; font-size: 12px;">${item.city}</span>
          </div>
          <div style="font-size: 13px; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
            ${item.content}
          </div>
        </div>
      `;
    }

    const virtualList = new FixedSizeVirtualList(container, {
      data: listData,
      itemHeight: 60,
      overscan: 5,
      renderItem,
    });
  </script>
</body>
</html>

运行与验证

将上述代码保存为 virtual-list-demo.html,用浏览器打开即可看到 10 万条数据的流畅滚动。打开 Chrome DevTools → Elements 面板,观察 DOM 节点数始终保持在 30 个左右(可视区 8 条 + overscan 5*2 条 ≈ 18 条,算上容器结构约 30 个节点)。

四、核心知识点拆解

4.1 核心计算公式

定高虚拟列表的计算核心只有两个公式:

起始索引

1
startIndex = floor(scrollTop / itemHeight)

结束索引

1
endIndex = ceil((scrollTop + viewportHeight) / itemHeight)

加上 overscan:

1
2
startIndex = max(0, startIndex - overscan)
endIndex = min(data.length, endIndex + overscan)

定理:当 itemHeight 为常数时,可视区域内的元素数量恒为 ceil(viewportHeight / itemHeight) + 2 * overscan,与数据总量无关。

4.2 组件结构拆分

一个标准的虚拟列表组件包含三层 DOM 结构:

  1. 容器层(viewport)overflow-y: auto,固定高度,作为滚动容器
  2. 占位层(phantom):高度 = 数据总量 × itemHeight,用于撑起滚动条。设为 pointer-events: none 避免干扰
  3. 内容层(content):通过 transform: translateY() 实现绝对定位偏移

4.3 requestAnimationFrame 节流

原始 scroll 事件在单个滚动动作中可能触发数十次,直接调用 render 会引发大量不必要的重排。使用 requestAnimationFrame 将渲染合并到下一帧:

1
2
3
4
5
6
7
onScroll() {
  if (this._rafId) cancelAnimationFrame(this._rafId);
  this._rafId = requestAnimationFrame(() => {
    this.render();
    this._rafId = null;
  });
}

为什么不用 setTimeout(fn, 0)

  • requestAnimationFrame 由浏览器在下一次绘制前统一调用,天然合并了同一帧内的多次触发
  • 页面不可见时(如切换标签页),requestAnimationFrame 自动暂停,节省 CPU
  • 和浏览器的渲染流水线(Style → Layout → Paint → Composite)同步

4.4 DocumentFragment 批量 DOM 操作

render() 中,我们使用 DocumentFragment 批量追加子节点:

1
2
3
4
5
6
7
8
const fragment = document.createDocumentFragment();
visibleData.forEach((item) => {
  const el = document.createElement('div');
  el.innerHTML = this.renderItem(item);
  fragment.appendChild(el);
});
this.contentEl.innerHTML = '';
this.contentEl.appendChild(fragment);

相比每次 appendChild 都触发一次重排,DocumentFragment 将所有节点一次性追加,触发一次重排,性能提升显著。

4.5 缓存策略设计(定高)

定高场景的缓存相对简单,但仍有优化空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class CacheManager {
  constructor() {
    this.cache = new Map();
    this.maxSize = 500; // 最大缓存条目
  }

  get(index) {
    return this.cache.get(index);
  }

  set(index, height) {
    if (this.cache.size >= this.maxSize) {
      // LRU 淘汰:删除最早一条
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(index, height);
  }

  clear() {
    this.cache.clear();
  }
}

4.6 不定高虚拟列表核心思路

当每项高度不同时,定高的公式不再成立。核心挑战是:我们不知道滚动到具体位置时应该展示哪一项

解决方案分为三步:

  1. 预估初始高度:在获取真实高度前,使用一个预估高度(如 80px)作为占位
  2. 缓存实际高度:item 渲染到 DOM 后,通过 getBoundingClientRect() 获取真实高度并缓存
  3. 二分查找定位:维护一个高度累积数组 positions,用二分查找确定 scrollTop 对应的索引
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 VariableSizePositions {
  constructor(estimatedHeight = 80) {
    this.estimatedHeight = estimatedHeight;
    // 每一项:{ index, top, bottom, height }
    this.positions = [];
  }

  init(count) {
    this.positions = [];
    for (let i = 0; i < count; i++) {
      this.positions.push({
        index: i,
        top: i * this.estimatedHeight,
        bottom: (i + 1) * this.estimatedHeight,
        height: this.estimatedHeight,
      });
    }
  }

  // 二分查找:找到 top <= scrollTop 的最大索引
  findIndex(scrollTop) {
    let low = 0;
    let high = this.positions.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      const midVal = this.positions[mid].top;
      if (midVal < scrollTop) {
        low = mid + 1;
      } else if (midVal > scrollTop) {
        high = mid - 1;
      } else {
        return mid;
      }
    }
    return Math.max(0, high);
  }

  // 更新某项的实际高度
  updateHeight(index, actualHeight) {
    const pos = this.positions[index];
    if (!pos) return;
    
    const diff = actualHeight - pos.height;
    if (diff === 0) return;

    pos.height = actualHeight;
    pos.bottom = pos.top + actualHeight;

    // 后续所有项的位置都要后移
    for (let i = index + 1; i < this.positions.length; i++) {
      this.positions[i].top += diff;
      this.positions[i].bottom += diff;
    }
  }

  getTotalHeight() {
    return this.positions.length > 0
      ? this.positions[this.positions.length - 1].bottom
      : 0;
  }
}

关键洞察:不定高场景的计算复杂度从 O(1) 上升到 O(log n)(查找)+ O(n)(后续位置更新),但即便对 10 万条数据,n 次 O(n) 更新也是可接受的——因为实际的更新只会发生在可视区域内,不是每次滚动都更新全部。

五、实战案例:聊天消息流虚拟列表

场景描述

实现一个 IM 聊天界面,消息列表包含:

  • 文本消息(40px 高)
  • 图片消息(150-300px 高)
  • 系统提示(30px 高)
  • 时间分隔线(30px 高)
  • 支持上拉加载历史消息
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>聊天消息虚拟列表</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    
    .chat-container {
      width: 420px;
      height: 600px;
      margin: 20px auto;
      border: 1px solid #e8e8e8;
      border-radius: 12px;
      overflow: hidden;
      display: flex;
      flex-direction: column;
      background: #f5f5f5;
    }

    .chat-header {
      padding: 14px 20px;
      background: #fff;
      border-bottom: 1px solid #e8e8e8;
      font-weight: bold;
      font-size: 16px;
    }

    .chat-messages {
      flex: 1;
      overflow-y: auto;
      position: relative;
    }

    .chat-phantom {
      position: absolute;
      top: 0; left: 0; right: 0;
      pointer-events: none;
    }

    .chat-content {
      position: relative;
    }

    .message-item {
      padding: 4px 16px;
      transition: none;
      position: absolute;
      left: 0;
      right: 0;
    }

    .message-bubble {
      max-width: 75%;
      padding: 8px 14px;
      border-radius: 18px;
      font-size: 14px;
      line-height: 1.5;
      word-break: break-word;
      box-shadow: 0 1px 2px rgba(0,0,0,0.06);
    }

    .message-self .message-bubble {
      background: #95ec69;
      margin-left: auto;
      border-bottom-right-radius: 4px;
    }

    .message-other .message-bubble {
      background: #fff;
      margin-right: auto;
      border-bottom-left-radius: 4px;
    }

    .message-image .message-bubble {
      padding: 4px;
      background: transparent;
      box-shadow: none;
    }

    .message-image img {
      max-width: 200px;
      border-radius: 12px;
      display: block;
    }

    .time-divider {
      text-align: center;
      padding: 10px 0;
      font-size: 12px;
      color: #999;
    }

    .system-message {
      text-align: center;
      padding: 6px 0;
      font-size: 12px;
      color: #b8b8b8;
    }

    .load-more-trigger {
      height: 1px;
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
    }
  </style>
</head>
<body>
  <div class="chat-container">
    <div class="chat-header">📱 群聊 · 前端技术交流群</div>
    <div class="chat-messages" id="chatMessages">
      <div class="chat-phantom" id="chatPhantom"></div>
      <div class="chat-content" id="chatContent"></div>
    </div>
  </div>

  <script>
    // ========== 消息数据生成 ==========
    const texts = [
      '有人用过最新的 React Server Components 吗?',
      '我在项目中已经用了半年,效果不错,首屏加载快了 40%',
      '但是学习曲线有点陡啊,有没有好的教程推荐?',
      '强烈推荐 React 官方文档的新版教程,讲得很清楚',
      '想问下大家对 Tailwind 怎么看?争议挺大的',
      'Atomic CSS 是趋势,不用纠结命名了',
      '但是 HTML 会变得很臃肿啊,阅读性差',
      '可以用 @apply 提取通用组合,两者不矛盾',
    ];
    
    const imageUrls = [
      'https://picsum.photos/seed/1/200/150',
      'https://picsum.photos/seed/2/200/300',
      'https://picsum.photos/seed/3/200/180',
    ];

    function generateChatData(totalCount) {
      const types = ['text', 'text', 'text', 'image', 'text', 'text', 'system', 'text', 'time'];
      const data = [];
      
      for (let i = 0; i < totalCount; i++) {
        const type = types[i % types.length];
        let height;
        
        switch (type) {
          case 'time':
            height = 36;
            break;
          case 'system':
            height = 30;
            break;
          case 'image':
            height = 180; // 预估,实际会动态调整
            break;
          default:
            height = 52;
        }

        data.push({
          id: `msg_${totalCount - i}`,
          type,
          content: type === 'text' ? texts[i % texts.length] : '',
          imageUrl: type === 'image' ? imageUrls[i % imageUrls.length] : '',
          isSelf: Math.random() > 0.5,
          estimatedHeight: height,
          actualHeight: null,
          timestamp: new Date(Date.now() - (totalCount - i) * 60000).toLocaleTimeString(),
        });
      }
      return data;
    }

    // ========== 不定高虚拟列表 ==========
    class VariableChatList {
      constructor(container, phantom, content, options = {}) {
        this.container = container;
        this.phantomEl = phantom;
        this.contentEl = content;
        this.overscan = 5;
        this.maxRenderItems = 30; // 限制每帧最大渲染数
        this.data = [];
        this.positions = [];
        this.itemRenderers = options.itemRenderers || {};
        this._cachedHeights = new Map();

        this.onScroll = this.onScroll.bind(this);
        this.container.addEventListener('scroll', this.onScroll, { passive: true });
      }

      setData(data) {
        this.data = data;
        this._initPositions();
        this._syncPhantomHeight();
        this.render();
      }

      _initPositions() {
        this.positions = [];
        let top = 0;
        for (let i = 0; i < this.data.length; i++) {
          const estimatedHeight = this.data[i].estimatedHeight || 52;
          this.positions.push({
            index: i,
            top,
            bottom: top + estimatedHeight,
            height: estimatedHeight,
            rendered: false,
          });
          top += estimatedHeight;
        }
      }

      _syncPhantomHeight() {
        const totalHeight = this.positions.length > 0
          ? this.positions[this.positions.length - 1].bottom
          : 0;
        this.phantomEl.style.height = `${totalHeight}px`;
      }

      _findIndex(scrollTop) {
        let low = 0;
        let high = this.positions.length - 1;
        while (low <= high) {
          const mid = Math.floor((low + high) / 2);
          const midVal = this.positions[mid].top;
          if (midVal < scrollTop) {
            low = mid + 1;
          } else if (midVal > scrollTop) {
            high = mid - 1;
          } else {
            return mid;
          }
        }
        return Math.max(0, high);
      }

      getVisibleRange() {
        const scrollTop = this.container.scrollTop;
        const viewportHeight = this.container.clientHeight;

        const startIndex = Math.max(0, this._findIndex(scrollTop) - this.overscan);
        const endIndex = Math.min(
          this.data.length,
          this._findIndex(scrollTop + viewportHeight) + this.overscan
        );

        return { startIndex, endIndex };
      }

      _measureHeight(el, index) {
        const rect = el.getBoundingClientRect();
        const actualHeight = rect.height;
        
        const pos = this.positions[index];
        if (pos && actualHeight !== pos.height) {
          const diff = actualHeight - pos.height;
          pos.height = actualHeight;
          pos.bottom = pos.top + actualHeight;

          // 更新后续位置
          for (let i = index + 1; i < this.positions.length; i++) {
            this.positions[i].top += diff;
            this.positions[i].bottom += diff;
          }

          this._syncPhantomHeight();
          this._cachedHeights.set(index, actualHeight);
        }
        return actualHeight;
      }

      render() {
        const { startIndex, endIndex } = this.getVisibleRange();
        const fragment = document.createDocumentFragment();

        for (let i = startIndex; i < endIndex && i < this.data.length; i++) {
          const item = this.data[i];
          const el = document.createElement('div');
          el.className = 'message-item';
          el.dataset.index = i;
          el.style.top = `${this.positions[i]?.top || 0}px`;

          const renderer = this.itemRenderers[item.type] || this.itemRenderers.text;
          el.innerHTML = renderer(item);
          fragment.appendChild(el);
        }

        this.contentEl.innerHTML = '';
        this.contentEl.appendChild(fragment);

        // 测量所有可见项的真实高度
        requestAnimationFrame(() => {
          const items = this.contentEl.querySelectorAll('.message-item');
          items.forEach((el) => {
            const index = parseInt(el.dataset.index);
            if (!isNaN(index)) {
              this._measureHeight(el, index);
            }
          });
        });
      }

      onScroll() {
        if (this._rafId) cancelAnimationFrame(this._rafId);
        this._rafId = requestAnimationFrame(() => {
          this.render();
          this._rafId = null;
        });
      }

      // 支持上拉加载更多(追加到列表尾部)
      appendData(newData) {
        this.data = [...this.data, ...newData];
        this._initPositions(); // 重新计算位置
        this._syncPhantomHeight();
        this.render();
      }

      // 支持下拉加载历史(插入到列表头部)
      prependData(oldData) {
        const prevScrollTop = this.container.scrollTop;
        const prevFirstTop = this.positions[0]?.top || 0;

        this.data = [...oldData, ...this.data];
        this._initPositions();
        this._syncPhantomHeight();

        // 保持滚动位置不变
        const newFirstTop = this.positions[oldData.length]?.top || 0;
        this.container.scrollTop = prevScrollTop + (newFirstTop - prevFirstTop);
        this.render();
      }

      destroy() {
        this.container.removeEventListener('scroll', this.onScroll);
        if (this._rafId) cancelAnimationFrame(this._rafId);
      }
    }

    // ========== 消息渲染器 ==========
    const renderers = {
      text(item) {
        const side = item.isSelf ? 'self' : 'other';
        return `
          <div class="message-${side}">
            <div class="message-bubble">${item.content}</div>
          </div>
        `;
      },
      image(item) {
        const side = item.isSelf ? 'self' : 'other';
        return `
          <div class="message-image message-${side}">
            <div class="message-bubble">
              <img src="${item.imageUrl}" alt="图片消息" 
                   onload="this.parentElement.dispatchEvent(new CustomEvent('imgloaded'))" />
            </div>
          </div>
        `;
      },
      time(item) {
        return `<div class="time-divider">—— ${item.timestamp} ——</div>`;
      },
      system(item) {
        return `<div class="system-message">${item.content || '未知系统消息'}</div>`;
      },
    };

    // ========== 初始化 ==========
    const chatMessages = document.getElementById('chatMessages');
    const chatPhantom = document.getElementById('chatPhantom');
    const chatContent = document.getElementById('chatContent');

    const chatList = new VariableChatList(chatMessages, chatPhantom, chatContent, {
      itemRenderers: renderers,
    });

    // 初始加载 5000 条消息
    const initialData = generateChatData(5000);
    chatList.setData(initialData);

    // 模拟上拉加载更多
    let loadedCount = 5000;
    chatMessages.addEventListener('scroll', () => {
      const { scrollTop, scrollHeight, clientHeight } = chatMessages;
      if (scrollTop + clientHeight >= scrollHeight - 100 && loadedCount < 20000) {
        const more = generateChatData(1000);
        chatList.appendData(more);
        loadedCount += 1000;
        console.log(`已加载: ${loadedCount} 条消息`);
      }
    });

    // 模拟下拉加载历史
    let historyLoaded = false;
    chatMessages.addEventListener('scroll', () => {
      if (chatMessages.scrollTop < 5 && !historyLoaded) {
        historyLoaded = true;
        const oldData = generateChatData(1000);
        chatList.prependData(oldData);
        console.log('已加载历史消息 1000 条');
      }
    });
  </script>
</body>
</html>

实战要点

  1. 图片高度动态调整:图片加载前后高度变化剧烈,必须在 onload 后重新触发布局计算
  2. 滚动位置保持:插入历史消息时,需要精确计算新旧偏移量差值来维持视觉位置
  3. 加载阈值判断:通过 scrollTop + clientHeight >= scrollHeight - threshold 判断是否触底

六、底层原理

6.1 浏览器的像素管道(Pixel Pipeline)

虚拟列表的优化效果可以从浏览器渲染流水线(Critical Rendering Path)解释:

1
JavaScript → Style → Layout → Paint → Composite
  • Style:计算匹配的 CSS 规则。虚拟列表只有 ~30 个节点,样式计算耗时≈0.1ms
  • Layout:计算几何位置。常规渲染 10 万节点时,Layout 耗时可能超过 100ms
  • Paint:绘制像素。只有可视区域需要绘制,大幅减少绘制面积

关键数字:一次完整的 Style+Layout 耗时,30 个节点约 0.3ms,10 万节点约 120ms。虚拟列表将 Layout 耗时减少了 99.75%。

6.2 合成线程与滚动

现代浏览器的滚动操作由合成线程(Compositor Thread) 独立处理,不依赖主线程。虚拟列表利用了这一点:

1
用户滚动 → Compositor 更新图层位置(不触发主线程) → GPU 合成新帧

但 scroll 事件的回调仍然运行在主线程上。这就是为什么我们需要 requestAnimationFramepassive: true

1
2
// passive: true 告诉浏览器:我不会阻止默认滚动行为
container.addEventListener('scroll', handler, { passive: true });

没有 passive: true 时,浏览器必须等待 scroll 事件处理器执行完毕才能确定是否要 preventDefault(),这在触摸滚动中会造成明显的延迟。

6.3 transform vs top/left

在内容层偏移时,我们选择 transform: translateY() 而非 top: ...

属性触发开销
topLayout + Paint + Composite最高
transformComposite only最低

transform 只触发合成(Composite),不触发布局(Layout)和绘制(Paint)。GPU 直接将图层移动到新位置,耗时约 0.01ms。

6.4 与 React/Vue 框架的结合

现代框架中的虚拟列表(如 react-window、vue-virtual-scroller)本质上都是在框架生命周期内管理 DOM 复用。以 React 为例:

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
// react-window 的核心逻辑简化版
function FixedSizeList({ height, itemCount, itemSize, children: Component }) {
  const [scrollOffset, setScrollOffset] = useState(0);

  const { startIndex, endIndex } = calculateRange(
    scrollOffset, height, itemSize, OVERSCAN_COUNT
  );

  const items = [];
  for (let i = startIndex; i <= endIndex; i++) {
    items.push(
      <Component 
        key={i} 
        index={i} 
        style={{
          position: 'absolute',
          top: i * itemSize,
          height: itemSize,
          width: '100%',
        }} 
      />
    );
  }

  return (
    <div style={{ overflow: 'auto', height }} onScroll={handleScroll}>
      <div style={{ height: itemCount * itemSize, position: 'relative' }}>
        {items}
      </div>
    </div>
  );
}

框架注意点

  • 给每个 item 设置唯一的 key(用 index!),帮助框架复用 DOM 节点
  • 使用 position: absolute 定位,避免框架的 diff 算法做无谓的位移计算
  • 在 Vue 中配合 v-memo 指令可以进一步减少不必要的更新

6.5 性能监控关键指标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 通过 Performance Observer 监测布局性能
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'first-contentful-paint') continue;
    // 关注 Layout Shift 和 Long Tasks
    if (entry.entryType === 'layout-shift') {
      console.log('CLS:', entry.value);
    }
    if (entry.duration > 50) {
      console.warn('Long task detected:', entry.duration, 'ms');
    }
  }
});
observer.observe({ entryTypes: ['layout-shift', 'longtask'] });

七、高频面试题解析

面试题 1:定高虚拟列表的滚动时会出现白屏闪烁,是什么原因?如何解决?

问题分析:这是虚拟列表实现中最常见的 Bug。当用户快速滚动时,onScroll 回调还没有触发,但浏览器已经滚动到了新位置,此时旧的渲染内容还在显示,而新的内容尚未更新,于是出现短暂的白屏。

深度解答

主要原因有两个:

  1. scroll 事件触发的延迟:scroll 事件虽然是高频触发,但 requestAnimationFrame 节流后,渲染时机被推迟到下一帧。如果用户在此帧内滚过了 overscan 区域,就会出现白屏。

  2. Overscan 不足:对于快速滚动场景,overscan=3 可能不足以覆盖一帧内滚过的范围。

解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 方案一:增加动态 overscan
getOverscan() {
  const speed = Math.abs(this._lastScrollTop - this.container.scrollTop);
  // 滚动速度越快,overscan 越大
  return Math.min(20, Math.max(5, Math.ceil(speed / this.itemHeight) + 3));
}

// 方案二:双缓冲渲染(Double Buffering)
// 在内存中维护一个离屏 fragment,准备好后再交换到 DOM
renderOffscreen(startIndex, endIndex) {
  const offscreen = document.createDocumentFragment();
  // ... 构建 DOM
  requestAnimationFrame(() => {
    this.contentEl.innerHTML = '';
    this.contentEl.appendChild(offscreen);
  });
}

// 方案三:使用 will-change 提示浏览器
// CSS 中设置:
.virtual-list-content {
  will-change: transform;
  contain: layout style paint;
}

面试题 2:不定高虚拟列表中,如果某个图片突然加载完成导致高度变化,如何避免布局抖动?

问题分析:图片加载是异步的,加载完成后高度会从预估高度突变到真实高度,导致后续所有元素位置更新,用户看到的是整个列表”跳了一下”。

深度解答

核心策略是渐进式高度校准 + 锚点保持

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
class HeightCalibrator {
  constructor(list) {
    this.list = list;
    this.pendingMeasurements = new Map();
  }

  // 图片加载完成后调用
  onImageLoaded(itemIndex) {
    if (this.pendingMeasurements.has(itemIndex)) return;
    this.pendingMeasurements.set(itemIndex, true);

    requestAnimationFrame(() => {
      const el = this.list.getElementByIndex(itemIndex);
      if (!el) return;

      const newHeight = el.getBoundingClientRect().height;
      const oldHeight = this.list.positions[itemIndex].height;
      const delta = newHeight - oldHeight;

      // 如果高度变化超过 5px,才执行更新
      if (Math.abs(delta) < 5) return;

      // 更新当前位置
      this.list.positions[itemIndex].height = newHeight;
      this.list.positions[itemIndex].bottom = 
        this.list.positions[itemIndex].top + newHeight;

      // 找到当前视口中第一条可见元素的偏移量变化
      const viewportFirstVisible = this.list.getFirstVisibleIndex();
      let offsetDelta = 0;
      for (let i = itemIndex + 1; i <= viewportFirstVisible; i++) {
        offsetDelta += this.list.positions[i].top - this.list.positions[i - 1].bottom;
        this.list.positions[i].top = this.list.positions[i - 1].bottom;
        this.list.positions[i].bottom = this.list.positions[i].top + this.list.positions[i].height;
      }

      // 修正滚动位置,抵消高度变化带来的视觉偏移
      this.list.container.scrollTop += offsetDelta;
      this.list.syncPhantomHeight();
      this.list.render();
    });
  }
}

更高级的方法:在图片外层包裹一个固定宽高比的容器:

1
2
3
4
5
<!-- 预设宽高比,避免布局偏移 -->
<div style="position: relative; padding-bottom: 75%; /* 4:3 比例 */">
  <img style="position: absolute; width: 100%; height: 100%; object-fit: cover;" 
       src="..." loading="lazy" />
</div>

面试题 3:设计一个支持百万级数据的虚拟列表方案,需要考虑哪些因素?

问题分析:这道题考察系统设计能力。百万级不仅挑战 DOM 渲染,还挑战 JavaScript 层面的计算性能。

深度解答

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
百万级虚拟列表架构:
┌─────────────────────────────────────────────┐
│  Worker 线程(数据层)                       │
│  ┌──────────────────┐  ┌──────────────┐    │
│  │ 数据分片加载      │  │ 索引计算     │    │
│  │ (Chunked Loader)  │  │ (Binary Search)│   │
│  └──────────────────┘  └──────────────┘    │
│          │ postMessage                        │
├──────────┼──────────────────────────────────┤
│  Main 线程(渲染层)                          │
│  ┌──────────────────────────────────────┐   │
│  │ React/Vue 组件树                      │   │
│  │  - 仅维护可视区 20-30 个节点          │   │
│  │  - VirtualNodePool 节点池复用          │   │
│  └──────────────────────────────────────┘   │
└─────────────────────────────────────────────┘

需考虑的关键点:

  1. 数据分片:百万级数据不可能一次性加载到内存。采用 Chunked Loader:
    • 前 10 页数据(约 1000 条)预加载
    • 滚动时按需加载后续 chunk(每个 chunk 500 条)
    • 远离可视区的 chunk 可释放(数据从内存中移除)
  2. 索引计算下放到 Web Worker
    1
    2
    3
    4
    5
    6
    7
    
    // worker.js
    self.onmessage = (e) => {
      const { scrollTop, positions } = e.data;
      const index = binarySearch(positions, scrollTop);
      const visibleRange = calculateRange(scrollTop, positions);
      self.postMessage({ index, visibleRange });
    };
    

    因为二分查找 + 高度累积数组更新在百万级时会引起主线程卡顿(约 2-5ms),放到 Worker 中可以保证 UI 线程 16ms 的帧预算。

  3. DOM 节点池复用:不销毁离屏节点,而是放入池中重用。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
    class NodePool {
      constructor(maxSize = 50) {
        this.pool = [];
        this.maxSize = maxSize;
      }
         
      acquire() {
        return this.pool.pop() || document.createElement('div');
      }
         
      release(el) {
        if (this.pool.length < this.maxSize) {
          el.innerHTML = '';
          this.pool.push(el);
        }
      }
    }
    

    节点池避免频繁的 DOM 创建和垃圾回收(GC),在大数据量场景下 GC 停顿可能超过 100ms。

  4. 滚动节流策略分层
    • 高频滚动(>500px/s):只更新 transform 偏移,不重建 DOM
    • 中速滚动(100-500px/s):正常 overscan 渲染
    • 停止滚动(0-100px/s):再校准一次精确位置
  5. 内存警戒:当可视区外的图片等资源占用内存超过阈值时,主动释放不可见图层的资源。

八、总结与扩展

核心要点回顾

  1. 虚拟列表的本质:用 JavaScript 计算替代大量 DOM 操作,只在可视区内维护有限节点
  2. 定高是最优解:定高场景下时间复杂度 O(1),应优先使用
  3. 不定高的三个核心步骤:预估 → 二分查找定位 → 实际高度校准
  4. 性能三板斧transform 偏移 + requestAnimationFrame 节流 + passive 事件

扩展阅读

  • react-window(React):使用 FixedSizeGrid / VariableSizeList,源码核心仅 300 行
  • vue-virtual-scroller(Vue):支持动态尺寸回收的虚拟滚动
  • TanStack Virtual(Framework-agnostic):2023 年新秀,TypeScript 重写,RSC 友好
  • IntersectionObserver 替代方案:有些场景(如无限滚动但不需固定容器高度)可以用 IntersectionObserver + 占位符实现类似效果,但性能不如虚拟列表

写在最后

手写虚拟列表是检验前端工程师对浏览器渲染机制理解深度的试金石。它融合了 DOM 操作、事件系统、动画帧调度、数据结构设计、性能监控等多个维度的知识。掌握它,不仅是通过面试的手段,更是构建高性能 Web 应用的基本功。

本文由作者按照 CC BY 4.0 进行授权