├── .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 | ![](https://lkangd.com/_nuxt/img/pic-0.8c610f9.png) 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 | 73 | 88 | 94 | ``` 95 | 96 | 通过[faker.js](https://github.com/marak/Faker.js/)来生成一些随机数据,以满足分页加载的测试情况: 97 | 98 | ```js 99 | import faker from 'faker'; 100 | 101 | export function fetchData(count = 30) { 102 | const result = []; 103 | for (let i = 0; i < count; i++) { 104 | const item = faker.helpers.contextualCard(); 105 | item.paragraph = faker.lorem.paragraph(); 106 | result.push(item); 107 | } 108 | return result; 109 | } 110 | ``` 111 | 112 | 最后,创建滚动容器组件,引入`item`组件和随机数据,渲染列表: 113 | 114 | ```html 115 | 120 | 154 | 163 | ``` 164 | 165 | 通过路由挂载后,完成一个常规列表的渲染,如下图: 166 | 167 | ![](https://lkangd.com/_nuxt/img/pic-1.92a1651.png) 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 | 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 | 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 | 303 | 304 | 314 | ``` 315 | 316 | ### 滚动更新「可视元素」 317 | 318 | 在处理滚动逻辑之前,先引入一个概念:**「锚点元素」**,即处于「滚动容器」的「可视区域」内的**第一个元素**。我们需要在滚动时候,根据每一次滚动事件的滚动差值和方向来更新「锚点元素」,计算出「锚点元素」后,就可以根据新的「锚点元素」下标和缓冲区值`BUFFER_SIZE`、`VISIBLE_COUNT`来计算「头挂载元素」和「尾挂载元素」。 319 | 320 | ```text 321 | 「锚点元素」= 「当前滚动高度」/ FIXED_HEIGHT // 当偏移量绝对值大于 FIXED_HEIGHT 时需要重新计算; 322 | 「头挂载元素」=「锚点元素」- BUFFER_SIZE // 不能小于 0,即第一个元素; 323 | 「尾挂载元素」= 「头挂载元素」+ VISIBLE_COUNT + BUFFER_SIZE // 不能大于列表长度,即最后一个元素; 324 | ``` 325 | 326 | 「锚点元素」大部分情况下处于被**部分遮盖**的状态,被遮盖的部分为它的偏移量`offset`,其中包含指向具体元素的下标`index`,如下图所示: 327 | 328 | ![](https://lkangd.com/_nuxt/img/pic-3.9db10a7.png) 329 | 330 | --- 331 | 332 | 了解了「锚点元素」概念之后,接下来就可以处理「滚动容器」的滚动行为了,首先监听滚动事件: 333 | 334 | ```html 335 | 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 | ![](https://lkangd.com/_nuxt/img/pic-8.a90bd13.gif) 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 | ![](https://lkangd.com/_nuxt/img/pic-4.194b7ea.gif) 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 | 504 | 533 | ``` 534 | 535 | 最后,在`mounted`钩子内使用 [resize-observer-polyfill](https://github.com/que-etc/resize-observer-polyfill) 监听元素高度变化: 536 | 537 | ```js 538 | import ResizeObserver from 'resize-observer-polyfill'; 539 | 540 | export default { 541 | // ... 542 | mounted() { 543 | if (this.fixedHeight) return; 544 | 545 | const ro = new ResizeObserver((entries, observer) => { 546 | // 高度发生变化时,将 'size-change' 事件 emit 到父组件 547 | this.$emit('size-change', this.index); 548 | }); 549 | ro.observe(this.$refs.item); 550 | this.$once('hook:beforeDestroy', ro.disconnect.bind(ro)); 551 | }, 552 | // ... 553 | }; 554 | ``` 555 | 556 | 通过路由挂载后,完成一个动态高度元素列表的渲染,如下图: 557 | ![](https://lkangd.com/_nuxt/img/pic-2.42bae0b.png) 558 | 559 | ### 监听元素高度变化 560 | 561 | 在每一次「可视元素」的高度发生变化时,以「锚点元素」为基点,计算出「锚点元素」的`scrollY`,然后按「锚点元素」之前和之后的元素进行区别计算,得出所有「可视元素」的最新`scrollY`。 562 | 563 | _注意:列表元素的初次渲染和后续的高度变化都会触发`ResizeObserver`事件_ 564 | 565 | ```html 566 | 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 | ![](https://lkangd.com/_nuxt/img/pic-9.8431154.gif) 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 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 15 | 24 | -------------------------------------------------------------------------------- /src/components/item.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 90 | 91 | -------------------------------------------------------------------------------- /src/components/placeholder.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 24 | 25 | -------------------------------------------------------------------------------- /src/heightDynamic.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 228 | 229 | -------------------------------------------------------------------------------- /src/heightFixed.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 140 | 141 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker'; 2 | 3 | export function fetchData(count = 30) { 4 | const result = []; 5 | for (let i = 0; i < count; i++) { 6 | const item = faker.helpers.contextualCard(); 7 | item.paragraph = faker.lorem.paragraph(); 8 | item.img = { 9 | src: `${process.env.BASE_URL}/images/${faker.random.number({ min: 1, max: 20 })}.jpeg`, 10 | width: `${faker.random.number({ min: 100, max: 700 })}px`, 11 | }; 12 | result.push(item); 13 | } 14 | return result; 15 | } 16 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App.vue'; 3 | import Router from 'vue-router'; 4 | 5 | import navs from './navs'; 6 | import heightDynamic from './heightDynamic'; 7 | import heightFixed from './heightFixed'; 8 | 9 | Vue.use(Router); 10 | Vue.config.productionTip = false; 11 | 12 | new Vue({ 13 | router: new Router({ 14 | routes: [ 15 | { 16 | path: '/', 17 | component: navs, 18 | }, 19 | { 20 | path: '/height-dynamic', 21 | component: heightDynamic, 22 | }, 23 | { 24 | path: '/height-fixed', 25 | component: heightFixed, 26 | }, 27 | ], 28 | }), 29 | render: h => h(App), 30 | }).$mount('#app'); 31 | -------------------------------------------------------------------------------- /src/navs.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | 19 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicPath: process.env.PUBLIC_PATH || './', 3 | }; 4 | --------------------------------------------------------------------------------