├── .browserslistrc
├── .env.gh-pages
├── .gitignore
├── .prettierrc
├── README.md
├── babel.config.js
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── images
│ ├── 1.jpeg
│ ├── 10.jpeg
│ ├── 11.jpeg
│ ├── 12.jpeg
│ ├── 13.jpeg
│ ├── 14.jpeg
│ ├── 15.jpeg
│ ├── 16.jpeg
│ ├── 17.jpeg
│ ├── 18.jpeg
│ ├── 19.jpeg
│ ├── 2.jpeg
│ ├── 20.jpeg
│ ├── 3.jpeg
│ ├── 4.jpeg
│ ├── 5.jpeg
│ ├── 6.jpeg
│ ├── 7.jpeg
│ ├── 8.jpeg
│ └── 9.jpeg
└── index.html
├── src
├── App.vue
├── components
│ ├── item.vue
│ └── placeholder.vue
├── heightDynamic.vue
├── heightFixed.vue
├── helpers.js
├── main.js
└── navs.vue
└── vue.config.js
/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 1%
2 | last 2 versions
3 | not dead
4 |
--------------------------------------------------------------------------------
/.env.gh-pages:
--------------------------------------------------------------------------------
1 | NODE_ENV=production
2 | PUBLIC_PATH=/infinite-scroll-sample/
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 |
6 | # local env files
7 | .env.local
8 | .env.*.local
9 |
10 | # Log files
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | pnpm-debug.log*
15 |
16 | # Editor directories and files
17 | .idea
18 | .vscode
19 | *.suo
20 | *.ntvs*
21 | *.njsproj
22 | *.sln
23 | *.sw?
24 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 150,
3 | "semi": true,
4 | "singleQuote": true,
5 | "trailingComma": "all",
6 | "bracketSpacing": true,
7 | "arrowParens": "avoid",
8 | "endOfLine": "lf",
9 | "htmlWhitespaceSensitivity": "strict",
10 | "tabWidth": 2,
11 | "useTabs": false,
12 | "vueIndentScriptAndStyle": false
13 | }
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 剖析无限滚动虚拟列表的实现原理
3 | date: '2020-08-26'
4 | spoiler: 长列表渲染的终极优化手段
5 | ---
6 |
7 | > **TL;DR:**「虚拟列表」的本质就是仅将**需要显示在视窗中**的列表节点挂载到 DOM,是一种优化长列表加载的技术手段。其中按照节点的高度是否固定又分为「固定高度的虚拟列表」和「动态高度的虚拟列表」。这是本文对两种虚拟列表场景实现的 [demo](https://lkangd.github.io/infinite-scroll-sample/#/)(页面托管在 github pages 可能需要爬梯子) 和 [代码库](https://github.com/lkangd/infinite-scroll-sample)(基于 Vue 2.x)。
8 |
9 | 在进行前端业务开发时,很容易遇到需要加载巨大列表的场景。比如微博的信息流、微信的朋友圈和直播平台的聊天框等,这些列表通常具有两个显著的特点:
10 |
11 | - 不能分页;
12 | - 只要用户愿意就可以无限地滚动下去。
13 |
14 | 在这种场景下,如果直接加载一个数量级很大的列表,会造成页面假死,使用传统的上拉分页加载模式或者 [window.requestAnimationFrame](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame)空闲加载模式可以在一定程度上缓解这种情况,但是在加载到一定量级的页面时,会因为页面同时存在大量的 DOM 元素而出现过渡占用内存、页面卡顿等性能问题,带来糟糕的用户体验。因此必须对这种业务场景做相应的加载优化,**只加载需要显示的元素**是这种情况的唯一解,「虚拟列表」的概念应运而生。
15 |
16 | ## 什么是虚拟列表?
17 |
18 | 首先,来说说「虚拟列表」的定义,它的本质就是仅将**需要显示在视窗中**的列表节点挂载到 DOM,以达到「减少**一次性加载节点数量**」和「减少**滚动容器内总挂载节点数量**」的目的,也即:
19 |
20 | > 通过「**单个元素高度**」计算当前列表全部加载时的高度作为「**滚动容器**」的「**可滚动高度**」,按该「**可滚动高度**」撑开「**滚动容器**」。并根据「**当前滚动高度**」,在「**可视区域**」内按需加载列表元素。
21 |
22 | ### 相关概念
23 |
24 | 上面的描述提到了几个关键的概念,它们分别是:
25 |
26 | - **单个元素高度**:列表内每个独立元素的高度,它可以是固定的或者是动态的。
27 |
28 | - **滚动容器**:意指挂载列表元素的 DOM 对象,它可以是自定义的元素或者`window`对象(默认)。
29 |
30 | - **可滚动高度**:滚动容器可滚动的纵向高度。当滚动容器的高度(宽度),小于它的子元素所占的总高度(宽度)且该滚动容器的`overflow`不为`hidden`时,此时滚动容器的`scrollHeight`为**可滚动高度**。
31 |
32 | - **可视区域**:滚动容器的视觉可见区域。如果容器元素是`window`对象,可视区域就是浏览器的视口大小(即视觉视口);如果容器元素是某个 ul 元素,其高度是 300,右侧有纵向滚动条可以滚动,那么视觉可见的区域就是可视区域,也即是该滚动容器的`offsetHeight`。
33 |
34 | - **当前滚动高度**:与平常的滚动高度概念一致。虽然虚拟列表仅加载需要显示在可视区域内的元素,但是为了维持与常规列表一致的滚动体验,必须通过监听当前滚动高度来动态更新需要显示的元素。
35 |
36 | 参考下图加深理解:
37 |
38 | 
39 |
40 | ### 实现逻辑步骤
41 |
42 | 因此,实现「虚拟列表」可以简单理解为就是在列表发生滚动时,改变「可视区域」内的渲染元素。大概的文字逻辑步骤如下:
43 |
44 | 1. 根据单个元素高度计算出滚动容器的可滚动高度,并撑开滚动容器;
45 | 2. 根据可视区域计算总挂载元素数量;
46 | 3. 根据可视区域和总挂载元素数量计算头挂载元素(初始为 0)和尾挂载元素;
47 | 4. 当发生滚动时,根据滚动差值和滚动方向,重新计算头挂载元素和尾挂载元素。
48 |
49 | 根据这些步骤,下面开始通过实际代码对「虚拟列表」进行实现。
50 |
51 | ## 固定高度的虚拟列表
52 |
53 | ### 准备工作
54 |
55 | 首先,创建列表元素组件,约定它的高度固定为`180px`:
56 |
57 | ```html
58 |
59 | {{ index }}. {{ data.name }} {{ data.dob }} E-mail: {{ data.email }} Phone: {{ data.phone }} City: {{ data.address.city }} Street: {{ data.address.street }} {{ data.paragraph }}
63 |
117 |
119 |
120 |
154 |
163 | ```
164 |
165 | 通过路由挂载后,完成一个常规列表的渲染,如下图:
166 |
167 | 
168 |
169 | ### 计算「可滚动高度」
170 |
171 | 因为元素高度是固定,所以在拿到列表数据时就可以通过 **列表长度** \* **元素高度** 获得「可滚动高度」,然后使用此高度撑开滚动容器。通过上文图一可以得知,可滚动高度由「可视区域」+「已浏览区域」+「待浏览区域」组成,关于如何撑开「已浏览区域」和「待浏览区域」,有两种常规的做法:
172 |
173 | - 直接使用 padding 撑开列表高度;
174 | - 在列表可视区域外部放置哨兵元素撑开高度。
175 |
176 | 为了更好地理解后文「动态高度的虚拟列表」的内容,这里选用第二种方法。
177 |
178 | 新增`scrollRunwayEnd`属性,在列表获取后计算总高度:
179 |
180 | ```js
181 | export default {
182 | // ...
183 | data() {
184 | return {
185 | // ...
186 | scrollRunwayEnd: 0,
187 | };
188 | },
189 | methods: {
190 | fetchData() {
191 | this.listData.push(...this.setItemIndex(fetchData()));
192 | this.scrollRunwayEnd = this.listData.length * FIXED_HEIGHT;
193 | },
194 | },
195 | // ...
196 | };
197 | ```
198 |
199 | 在模板内增加`scroll-runway`元素,根据`scrollRunwayEnd`,使用`transform: translateY`的方式撑开「滚动容器」高度:
200 |
201 | ```html
202 |
203 |
204 |
205 |
207 |
208 |
209 |
220 | ```
221 |
222 | ### 计算初始「可视元素」
223 |
224 | 「可视元素」使用`visibleData`表示,`visibleData`可使用「头挂载元素」和「尾挂载元素」分别代表的元素下标在原始的`listData`进行动态截取。
225 |
226 | 根据固定的元素高度和「滚动容器」的高度,可以轻松得出「可视元素」的个数为 **滚动容器高度** \/ **单个元素高度**,使用`VISIBLE_COUNT`表示。同时,为了在快速滚动的情况下也能获得较为良好的数据现实体验,可以适当设置「缓冲区元素」,使用`BUFFER_SIZE`表示。
227 |
228 | 新增`visibleData`数组,用于「可视元素」的装载。页面初次挂载时,「头挂载元素」`firstAttachedItem`必定为 0,再根据`VISIBLE_COUNT`和`BUFFER_SIZE`可得「尾挂载元素」`lastAttachedItem`:
229 |
230 | ```js
231 | // ...
232 | const BUFFER_SIZE = 3; // 「缓冲区元素」个数
233 | let VISIBLE_COUNT = 0;
234 |
235 | export default {
236 | name: 'height-fixed',
237 | data() {
238 | return {
239 | // ...
240 | visibleData: [],
241 | firstAttachedItem: 0, // 「头挂载元素」
242 | lastAttachedItem: 0, // 「尾挂载元素」
243 | };
244 | },
245 | mounted() {
246 | VISIBLE_COUNT = Math.ceil(this.$refs.scroller.offsetHeight / FIXED_HEIGHT);
247 | this.lastAttachedItem = VISIBLE_COUNT + BUFFER_SIZE;
248 | this.visibleData = this.listData.slice(this.firstAttachedItem, this.lastAttachedItem);
249 | },
250 | };
251 | ```
252 |
253 | 将`listData`更改为`visibleData`:
254 |
255 | ```html
256 |
257 |
258 |
259 |
261 |
262 | ```
263 |
264 | 在获得了`visibleData`后,下一步需要改变列表元素的显示方式。对每个列表元素使用绝对定位,使其脱离文档流,然后使用`transform: translateY`的方式来对元素进行定位。
265 |
266 | 将`setItemIndex`方法更改为`calItemScrollY`,并根据下标,赋值给每个元素固定的`scrollY`:
267 |
268 | ```js
269 | // setItemIndex(list) {
270 | // let latestIndex = this.listData.length;
271 | // for (let i = 0; i < list.length; i++) {
272 | // const item = list[i];
273 | // item.index = latestIndex + i;
274 | // Object.freeze(item);
275 | // }
276 | // return list;
277 | // }
278 | calItemScrollY(list) {
279 | let latestIndex = this.listData.length;
280 | for (let i = 0; i < list.length; i++) {
281 | const item = list[i];
282 | item.index = latestIndex + i;
283 | item.scrollY = this.scrollRunwayEnd + i * FIXED_HEIGHT;
284 | Object.freeze(item);
285 | }
286 | return list;
287 | },
288 | ```
289 |
290 | ```html
291 |
292 |
293 |
337 |
338 |
339 |
340 | ```
341 |
342 | 根据滚动方向和偏移量,按顺序更新「锚点元素」→「头挂载元素」→「尾挂载元素」→「可视元素」:
343 |
344 | ```js
345 | // ...
346 | export default {
347 | // ...
348 | data() {
349 | return {
350 | // ...
351 | anchorItem: { index: 0, offset: 0 }, // 「锚点元素」初始值
352 | lastScrollTop: 0, // 记录上次滚动事件时「滚动容器」的「滚动高度」
353 | };
354 | },
355 | methods: {
356 | // 「锚点元素」更新方法
357 | updateAnchorItem() {
358 | const index = Math.floor(this.$refs.scroller.scrollTop / FIXED_HEIGHT);
359 | const offset = this.$refs.scroller.scrollTop - index * FIXED_HEIGHT;
360 | this.anchorItem = { index, offset };
361 | },
362 | handleScroll() {
363 | // 滚动差值
364 | const delta = this.$refs.scroller.scrollTop - this.lastScrollTop;
365 | this.lastScrollTop = this.$refs.scroller.scrollTop;
366 |
367 | // 更新「锚点元素」偏移量
368 | this.anchorItem.offset += delta;
369 | const isPositive = delta >= 0;
370 | // 判断滚动方向
371 | if (isPositive) {
372 | // 1.当「锚点元素」偏移量大于等于固定高度时,说明视图滚动条向下,并超过一个元素,需要更新「锚点元素」
373 | if (this.anchorItem.offset >= FIXED_HEIGHT) {
374 | this.updateAnchorItem();
375 | }
376 | // 2.计算「头挂载元素」
377 | if (this.anchorItem.index - this.firstAttachedItem >= BUFFER_SIZE) {
378 | this.firstAttachedItem = Math.min(this.listData.length - VISIBLE_COUNT, this.anchorItem.index - BUFFER_SIZE);
379 | }
380 | } else {
381 | if (this.$refs.scroller.scrollTop <= 0) {
382 | // 特殊情况:处理滚动到顶部,更新「锚点元素」为初始值
383 | this.anchorItem = { index: 0, offset: 0 };
384 | } else if (this.anchorItem.offset < 0) {
385 | // 1.当「锚点元素」偏移量小于零时,说明视图滚动条向上,并超过一个元素,需要更新「锚点元素」
386 | this.updateAnchorItem();
387 | }
388 | // 2.计算「头挂载元素」
389 | if (this.anchorItem.index - this.firstAttachedItem < BUFFER_SIZE) {
390 | this.firstAttachedItem = Math.max(0, this.anchorItem.index - BUFFER_SIZE);
391 | }
392 | }
393 | // 3.更新「尾挂载元素」
394 | this.lastAttachedItem = Math.min(this.firstAttachedItem + VISIBLE_COUNT + BUFFER_SIZE * 2, this.listData.length);
395 | // 4.更新「可视元素」
396 | this.visibleData = this.listData.slice(this.firstAttachedItem, this.lastAttachedItem);
397 | },
398 | },
399 | };
400 | ```
401 |
402 | 至此,一个简单的「固定高度虚拟滚动」就实现了,打开开发者工具,可以观察到就算滚动条一直向下,列表元素的个数是恒定的:
403 |
404 | 
405 |
406 | 你可以点击[此处](https://lkangd.github.io/infinite-scroll-sample/#/height-fixed)进行体验。
407 |
408 | ## 动态高度的虚拟列表
409 |
410 | 因为不再具有固定的元素高度,所以「可滚动高度」和「可视元素」很难像实现固定高度的虚拟列表那样,可以在获取数据后进行一次性计算就完事。下面来说说动态高度虚拟列表的关键难点:
411 |
412 | ### 关键点一:如何获得元素的动态高度?
413 |
414 | 按常规情况,一个列表元素高度为动态的情况大致分为三种:
415 |
416 | 1. 列表元素内初始渲染时高度就不确定。比如**不定行数**的多行文本、列表元素内包含**不定长度**的内嵌列表等;
417 | 2. 列表元素内初始渲染后因用户操作而高度发生变化。比如展开一个**收缩项目**、**删除或增加**子元素等;
418 | 3. 列表元素内包含异步渲染元素。比如未缓存过的**图片**、**异步组件**等。
419 |
420 | 由于这些复杂的情况可能同时存在一个列表元素内,所以只能够实时监听每一个**处于可视区域**内的元素的高度。现阶段 ECMA DOM 规范下,有两个 API 可以达到这个目的:[MutationObserver](https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver)和 [ResizeObserver](https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver)。
421 |
422 | 这两个 API 都存在一定的兼容性问题,[caniuse#ResizeObserver](https://caniuse.com/#feat=resizeobserver) | [caniuse#MutationObserver](https://caniuse.com/#search=MutationObserver),可以使用对应的`polyfill`进行解决,因为`ResizeObserver`可以更直观地达到监听元素高度变动的目的,所以这里选择使用`ResizeObserver`。`ResizeObserver`的 [polyfill](https://github.com/que-etc/resize-observer-polyfill)。
423 |
424 | ### 关键点二:如何模拟「可滚动高度」?
425 |
426 | 因为列表元素的高度不再是固定的,所以「可滚动高度」不能再通过「列表元素个数」\*「固定元素高度」简单逻辑关系来获得。此时,只能基于业务的实际情况,给每个列表元素定一个「估算高度」:`ESTIMATED_HEIGHT`。
427 |
428 | 同时,还需要新增一个`cachedHeight`数组,根据上一关键点提到的元素高度变化事件,以每一个列表元素对应的下标记录最后一次变化的高度。如果元素未渲染或者被略过渲染时,用`ESTIMATED_HEIGHT`进行暂时代替。
429 |
430 | 由此可得知,「可滚动高度」`scrollRunwayEnd`只能是「动态」且「大致准确」的。在 vue 里,可以用一个「计算属性」进行实时估值:
431 |
432 | ```js
433 | // ...
434 | data() {
435 | return {
436 | // ...
437 | // scrollRunwayEnd: 0,
438 | };
439 | },
440 | computed: {
441 | scrollRunwayEnd() {
442 | // 根据当前已渲染的元素高度,求得当前所有元素总高度
443 | const maxScrollY = this.cachedHeight.reduce((sum, h) => (sum += h || ESTIMATED_HEIGHT), 0);
444 | // 根据当前所有元素总高度,求得元素平均高度
445 | const currentAverageH = maxScrollY / this.cachedHeight.length;
446 | // 返回估算高度
447 | return maxScrollY + (this.listData.length - this.cachedHeight.length) * currentAverageH;
448 | },
449 | },
450 | // ...
451 | ```
452 |
453 | ### 关键点三:如何计算每一个元素的「scrollY」?
454 |
455 | 这一步是最难的,因为除了第一个元素外的每一个元素的「scrollY」可能都会因为下面几种情况而失效:
456 |
457 | 1. **当前元素的上一个元素高度发生了变化。** 这种情况意味着从**当前元素**开始,每一个后续元素都需要按**上一个元素**的高度差值进行「scrollY」计算。
458 | 2. **用户快速拖动滚动条至底部或顶部。** 由于略过了中间元素的渲染,`cachedHeight`会缺少略过元素的真实高度,所以只能用上文的`ESTIMATED_HEIGHT`进行代替。这种情况下用户再缓慢滚动到顶部时,略过元素的初次渲染会更新`cachedHeight`中对应的记录。此时更新的高度肯定是大于或者小于`ESTIMATED_HEIGHT`的,所以当用户持续滚动缓慢滚动到`scrollTop`为 0 时,可能会出现 **_上部滚动区域_**「不足」或者「多余」的情况。因此,必须在**保证当前页面滚动情况不变**的前提下,提前对这两种情况进行实时修正,也即修正`scrollTop`的同时重新计算「锚点元素」。
459 | 3. **屏幕宽度发生改变。** 手机屏幕横竖方向改变和手动改变浏览器窗口大小都可能导致「滚动容器」的宽度发生变化,「滚动容器」的宽度决定了列表元素的高度,这种情况下每一个元素的「scrollY」都将失效,需要重新计算。同时,为了更好地的用户体验,我们应该在宽度发生变化时,保持「锚定元素」的`offset`不变,举一个 twitter 例子:
460 | 
461 |
462 | 因此,这里我们不再将「scrollY」直接赋予每一个列表元素,而是新增一个`cachedScrollY`数组用于存储所有列表元素的临时「scrollY」。在每一次滚动事件发生时,根据滚动差值是否超过「锚点元素」对应的`cachedHeight`去判断是否需要更新「锚点元素」。如果「锚点元素」发生改变,以「锚点元素」为基点,用每一个「可视元素」对应的`cachedHeight`叠加「锚点元素」的「scrollY」去计算自身的「scrollY」,然后更新每个列表元素对应`cachedScrollY`,最后渲染到「可视区域」。
463 |
464 | ### 准备工作
465 |
466 | 修改随机数据函数,给每个元素增加**随机图片**和该图片的**随机宽度**:
467 |
468 | ```js
469 | export function fetchData(count = 30) {
470 | const result = [];
471 | for (let i = 0; i < count; i++) {
472 | const item = faker.helpers.contextualCard();
473 | item.paragraph = faker.lorem.paragraph();
474 | item.img = {
475 | src: `/images/${faker.random.number({ min: 1, max: 20 })}.jpeg`, // 从给定的 20 张图片内随机
476 | width: `${faker.random.number({ min: 100, max: 700 })}px`, // 从 100px - 700px 范围内随机
477 | };
478 | result.push(item);
479 | }
480 | return result;
481 | }
482 | ```
483 |
484 | 修改`item`组件,注意加载的两张图片:一张为正常加载的图片,一张为**人工延时**加载的图片:
485 |
486 | ```html
487 |
488 |
498 |
499 |
500 |
501 |
568 |
569 |
581 |
582 |
628 | ```
629 |
630 | ### 滚动更新「可视元素」
631 |
632 | 「可滚动高度」的计算已经在上面提过,而初始「可视元素」和固定高度的虚拟列表的计算是类似的,所以这里跳过这两点,只描述如何处理滚动更新「可视元素」。
633 |
634 | 根据滚动方向和偏移量,按顺序更新「锚点元素」→「头挂载元素」→「尾挂载元素」→「可视元素」:
635 |
636 | ```js
637 | // ...
638 | export default {
639 | // ...
640 | methods: {
641 | // ...
642 | handleScroll() {
643 | const delta = this.$refs.scroller.scrollTop - this.lastScrollTop;
644 | this.lastScrollTop = this.$refs.scroller.scrollTop;
645 | // 1.更新「锚点元素」
646 | this.updateAnchorItem(delta);
647 | // 更新「头挂载元素」→「尾挂载元素」→「可视元素」
648 | this.updateVisibleData();
649 | },
650 | async updateAnchorItem(delta) {
651 | const lastIndex = this.anchorItem.index;
652 | const lastOffset = this.anchorItem.offset;
653 | delta += lastOffset;
654 |
655 | let index = lastIndex;
656 | const isPositive = delta >= 0;
657 | // 判断滚动方向
658 | if (isPositive) {
659 | // 用 delta 一直减去从「锚点元素」开始向下方向的「可视元素」高度,每减一次 index 前进一位
660 | while (index < this.listData.length && delta > (this.cachedHeight[index] || ESTIMATED_HEIGHT)) {
661 | // 当 this.cachedHeight[index] 不存在时,说明可能被快速拖动滚动条而略过渲染,此时需要填充估计高度
662 | if (!this.cachedHeight[index]) {
663 | this.$set(this.cachedHeight, index, ESTIMATED_HEIGHT);
664 | }
665 | delta -= this.cachedHeight[index];
666 | index++;
667 | }
668 | if (index >= this.listData.length) {
669 | this.anchorItem = { index: this.listData.length - 1, offset: 0 };
670 | } else {
671 | this.anchorItem = { index, offset: delta };
672 | }
673 | } else {
674 | // 用 delta 一直叠加从「锚点元素」开始向上方向的「可视元素」高度,每加一次 index 后退一位
675 | while (delta < 0) {
676 | // 当 this.cachedHeight[index] 不存在时,说明可能被快速拖动滚动条而略过渲染,此时需要填充估计高度
677 | if (!this.cachedHeight[index - 1]) {
678 | this.$set(this.cachedHeight, index - 1, ESTIMATED_HEIGHT);
679 | }
680 | delta += this.cachedHeight[index - 1];
681 | index--;
682 | }
683 | if (index < 0) {
684 | this.anchorItem = { index: 0, offset: 0 };
685 | } else {
686 | this.anchorItem = { index, offset: delta };
687 | }
688 | }
689 | },
690 | updateVisibleData() {
691 | // 2.更新「头挂载元素」,注意不能小于 0
692 | const start = (this.firstAttachedItem = Math.max(0, this.anchorItem.index - BUFFER_SIZE));
693 | // 3.更新「尾挂载元素」
694 | this.lastAttachedItem = this.firstAttachedItem + VISIBLE_COUNT + BUFFER_SIZE * 2;
695 | const end = Math.min(this.lastAttachedItem, this.listData.length);
696 | // 4.更新「可视元素」
697 | this.visibleData = this.listData.slice(start, end);
698 | },
699 | // ...
700 | },
701 | // ...
702 | };
703 | ```
704 |
705 | ### 修正滚动条
706 |
707 | 到这一步,这个「动态高度虚拟列表」已经大致可用了,但是还有一个问题,就是当用户快速拖动滚动条,因为「滚动差值」很大,所以会略过中间元素的渲染,此时这些略过的元素在`cachedHeight`中用`ESTIMATED_HEIGHT`进行存储,因此会出现两种情况:
708 |
709 | 1. **估算的「可滚动高度」小于实际的「可滚动高度」**。比如略过了中间 20 个元素,这些略过元素的估算高度总值为 ESTIMATED_HEIGHT(180) \* 20 = 3600,而假设实际元素真正渲染时的平均高度为 300,即略过元素的实际高度总值为 300 \* 20 = 6000。可以得知差值为 3600 - 6000 = -2400,滚动到顶部时,无法滚动到第一个元素。
710 | 2. **估算的「可滚动高度」大于实际的「可滚动高度」**。比如略过了中间 20 个元素,这些略过元素的估算高度总值为 ESTIMATED_HEIGHT(180) \* 20 = 3600,而假设实际元素真正渲染时的平均高度为 100,即略过元素的实际高度总值为 100 \* 20 = 2000。可以得知差值为 3600 - 2000 = 1600,滚动到顶部时会有空白部分。
711 |
712 | 考虑在这种情况下,可能会有往回滚动的场景,所以必须在发现「可滚动高度」过小或过大的时候,必须进行及时修正。修改原来的`handleScroll`、`updateAnchorItem`和`calItemScrollY`方法,添加相关逻辑。
713 |
714 | ```js
715 | export default {
716 | data() {
717 | return {
718 | // ...
719 | revising: false,
720 | };
721 | },
722 | // ...
723 | methods: {
724 | // ...
725 | handleScroll() {
726 | if (this.revising) return; // 修正滚动条时,屏蔽滚动逻辑
727 | // ...
728 | },
729 | async updateAnchorItem(delta) {
730 | // ...
731 | // 修正拖动过快导致的滚动到顶端滚动条不足的偏差
732 | if (this.cachedScrollY[this.firstAttachedItem] <= -1) {
733 | console.log('revising insufficient');
734 | this.revising = true;
735 | // 需要的修正的滚动高度为「锚点元素」之前的元素总高度 + 「锚点元素」的 offset
736 | const actualScrollTop =
737 | this.cachedHeight.slice(0, Math.max(0, this.anchorItem.index)).reduce((sum, h) => (sum += h), 0) + this.anchorItem.offset;
738 | this.$refs.scroller.scrollTop = actualScrollTop;
739 | this.lastScrollTop = this.$refs.scroller.scrollTop;
740 | if (this.$refs.scroller.scrollTop === 0) {
741 | this.anchorItem = { index: 0, offset: 0 };
742 | }
743 | // 更改了 lastScrollTop 后,需要重新计算「可视元素」的 scrollY
744 | this.calItemScrollY();
745 | this.revising = false;
746 | }
747 | },
748 | // 计算每一个「可视元素」的 scrollY
749 | async calItemScrollY() {
750 | // ...
751 | // 修正拖动过快导致的滚动到顶端有空余的偏差
752 | if (this.cachedScrollY[0] > 0) {
753 | console.log('revising redundant');
754 | this.revising = true;
755 | // 第一个列表元素的 cachedScrollY 即为多出的量
756 | const delta = this.cachedScrollY[0];
757 | const last = Math.min(this.lastAttachedItem, this.listData.length);
758 | for (let i = 0; i < last; i++) {
759 | this.$set(this.cachedScrollY, i, this.cachedScrollY[i] - delta);
760 | }
761 | const scrollTop = this.cachedScrollY[this.anchorItem.index - 1]
762 | ? this.cachedScrollY[this.anchorItem.index - 1] + this.anchorItem.offset
763 | : this.anchorItem.offset;
764 | this.$refs.scroller.scrollTop = scrollTop;
765 | this.lastScrollTop = this.$refs.scroller.scrollTop;
766 | this.revising = false;
767 | }
768 | },
769 | // ...
770 | },
771 | // ...
772 | };
773 | ```
774 |
775 | 打完收工,「动态高度虚拟列表」实现完成,打开开发者工具,可以观察到就算滚动条一直向下,列表元素的个数都是恒定的,而且无论是快速拖动滚动条还是实时改变窗口宽度,整个列表都能正确地渲染:
776 |
777 | 
778 |
779 | 你可以点击[此处](https://lkangd.github.io/infinite-scroll-sample/#/height-dynamic)进行体验。
780 |
781 | ## 总结
782 |
783 | 本文介绍了前端业务开发中长列表的常规优化手段「虚拟列表」的定义和它在 Vue 环境中的实现,就「固定高度虚拟列表」和「动态高度虚拟列表」两个场景下以一个简单的 demo 详细讲述了虚拟列表的实现思路。
784 |
785 | 阅读完本文后可以发现,以本文的思路实现「虚拟列表」的关键在于「锚点元素」的计算和更新,理解了这一点之后就可以发现后续的实现都是按部就班的。
786 |
787 | 文字表达可能会有疏漏,建议通过下载本文的[代码库](https://github.com/lkangd/infinite-scroll-sample)(基于 Vue 2.x)运行调试,加深理解。
788 |
789 | 如果有不正确或难以理解的地方,欢迎通过邮件和留言进行指正讨论。
790 |
791 | > **重要提示:** 本文所有代码及示例项目只用于探讨虚拟列表的实现原理,请勿直接使用于生产。
792 |
793 | 戳这里访问[原文地址](https://lkangd.com/post/virtual-infinite-scroll/),以获得更好的阅读体验。
794 |
795 | #### 参考
796 |
797 | [Complexities of an Infinite Scroller](https://developers.google.com/web/updates/2016/07/infinite-scroller#scroll_anchoring)
798 |
799 | [Infinite List and React](https://itsze.ro/blog/2017/04/09/infinite-list-and-react.html)
800 |
801 | [浅说虚拟列表的实现原理](https://github.com/dwqs/blog/issues/70)
802 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@vue/cli-plugin-babel/preset'
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "infinite-scroll-sample",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "serve": "vue-cli-service serve",
7 | "build": "vue-cli-service build",
8 | "update:demo": "vue-cli-service build --mode gh-pages && push-dir --dir=dist --branch=gh-pages"
9 | },
10 | "dependencies": {
11 | "core-js": "^3.6.5",
12 | "faker": "^4.1.0",
13 | "resize-observer-polyfill": "^1.5.1",
14 | "vue": "^2.6.11",
15 | "vue-router": "^3.4.3"
16 | },
17 | "devDependencies": {
18 | "@vue/cli-plugin-babel": "^4.5.0",
19 | "@vue/cli-service": "^4.5.0",
20 | "push-dir": "^0.4.1",
21 | "sass": "^1.26.5",
22 | "sass-loader": "^8.0.2",
23 | "vue-template-compiler": "^2.6.11"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/1.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/1.jpeg
--------------------------------------------------------------------------------
/public/images/10.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/10.jpeg
--------------------------------------------------------------------------------
/public/images/11.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/11.jpeg
--------------------------------------------------------------------------------
/public/images/12.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/12.jpeg
--------------------------------------------------------------------------------
/public/images/13.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/13.jpeg
--------------------------------------------------------------------------------
/public/images/14.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/14.jpeg
--------------------------------------------------------------------------------
/public/images/15.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/15.jpeg
--------------------------------------------------------------------------------
/public/images/16.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/16.jpeg
--------------------------------------------------------------------------------
/public/images/17.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/17.jpeg
--------------------------------------------------------------------------------
/public/images/18.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/18.jpeg
--------------------------------------------------------------------------------
/public/images/19.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/19.jpeg
--------------------------------------------------------------------------------
/public/images/2.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/2.jpeg
--------------------------------------------------------------------------------
/public/images/20.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/20.jpeg
--------------------------------------------------------------------------------
/public/images/3.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/3.jpeg
--------------------------------------------------------------------------------
/public/images/4.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/4.jpeg
--------------------------------------------------------------------------------
/public/images/5.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/5.jpeg
--------------------------------------------------------------------------------
/public/images/6.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/6.jpeg
--------------------------------------------------------------------------------
/public/images/7.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/7.jpeg
--------------------------------------------------------------------------------
/public/images/8.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/8.jpeg
--------------------------------------------------------------------------------
/public/images/9.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lkangd/infinite-scroll-sample/77b757bc7fe6d72c1ff05caba8688932873d89fb/public/images/9.jpeg
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ index }}. {{ data.name }}
16 |{{ data.dob }}
17 |E-mail: {{ data.email }}
20 |Phone: {{ data.phone }}
21 |City: {{ data.address.city }}
22 |Street: {{ data.address.street }}
23 | 24 | 25 |{{ data.paragraph }}
26 |p
7 |p
8 |p
10 |p
11 |p
12 |p
13 |