Day10 X 实作一个简单的 Virtualized List 吧!

Facebook、Instagram 应该都是我们日常生活中非常依赖的社群媒体了,每天闲来无事就要滑滑动态,看看朋友最近发生了什麽事。不过你有没有注意到一件事,通常在滑动态的时候不会一次就把所有贴文载入完,而是会先载入一部分,等你滑动到底下後再接着载入更多贴文。这麽做的原因其实也是为了效能的优化,通常这样的 feature 会由 Virtualized List 搭配 Lazy Loading 来完成,今天我将带领各位认识 Virtualized List 的观念,明天则会接着讲解 Lazy Loading,废话不多说,直接进入今天的正题吧!


什麽是 Virtualized List,为什麽需要它 ?

长列表 Long List,是网站中蛮常见的一个 feature,例如 FB 等社群媒体会有大量的文章列表,然而如果有 1000 篇文章,我们又将这些文章同时渲染的话,就必须生成 1000 个 DOM 节点,更不用说文章结构通常是相对复杂的,像这样同时渲染数量庞大的元素会有几个明显的缺点:

  • 载入时白屏时间会比较长
  • 在渲染了大量 DOM 节点的状况下,在滚动事件触发时会大大增加记忆体的用量
  • 容易失帧,因为渲染很慢,所以无法维持浏览器的帧率,页面会显得卡顿
  • 最惨的话网页会失去响应

而且这些问题在 Desktop 浏览器就会发生了,换作是手机浏览器只会让问题变得更严重,因此这种状况下我们应该优化长列表,提升使用者体验。

Virtualized List 就是优化长列表的一种技巧,名字听起来很深奥,不过它的概念其实并不难理解:

储存所有列表元素的位置,只渲染可视区 (viewport)内的列表元素,当可视区滚动时,根据滚动的 offset 大小以及所有列表元素的位置,计算在可视区应该渲染哪些元素,这种技术也叫做「Windowing」。

以上图来说,假设可视区最多只能显示 6 个 item,那即使我们的列表总共有 1000 个,也只会渲染出现在可视区的 6 个元素,当原本被渲染的 item 移出可视区後,就会被 unmount 掉,避免前面说的同时生成一堆 DOM 节点的状况,也因此有效的解决了上面说的几个缺点。

看看比较好理解的动图

React 官方也建议要渲染长列表时使用 Windowing 技术

Talk Is Cheap, Show Me The Code ! 实作一个极简型的 Virtualized List 吧!

基本上现在的主流前端框架都有很完整的 Virtualized List 解决方案了

ReactJS: react-window, react-virtualized
VueJS: vue-virtual-scroll-list, vue-virtual-scroller
SvelteJS: svelte-virtual-list
在 React Native 的 App 开发世界里,Virtualied List 甚至被做成了 built-in 的 component: VirtualizedList

因此,除非你有比较特殊需要客制化的需求,基本上不需要自己实作一个 Virtualized List。不过今天还是想来挑战看看实作一个极简型的 Virtualized List,毕竟亲自写 code,才能更快理解背後运作的原理,强调极简型,是因为如果要实作一个完整且顾虑到各种情境的 Virtualized List 其实蛮复杂的(去看看上面各种套件的 source code 就知道了),所以今天只会实作最简单最简单的版本,目的是让大家知道 Virtualized List 背後实作的原理。

因为笔者比较熟悉 React,所以会用 React 当作范例,不过会是一个非常简易的版本,所以应该可以轻松地把同样想法移植到其他 framework 上,主要目的还是理解 Virtualized List 的运作,就请对 React 不熟悉的读者稍微忍耐一下了?

实作 Virtualized List 最关键的地方就是要计算当前哪些元素需要被渲染到画面上,这些元素随着使用者滑动会一直改变,为了快速实作一个简易版的 Virtualized List,我决定采取一个比较简单(ㄌㄢˇ ㄉㄨㄛˋ
)的方式,这个方式有几个规则:

  • 每个 List Item 的高度都一样
  • 可视区视窗的高度是固定的
  • 因为每个 List Item 的高度都一样,可视区视窗的高度也是固定的,只要知道 List 总共有几个 item 再加上滑动距离就可以算出每个 item 相对於 Long List 的绝对距离,所以每个 item 的排版我选择用简单的 position: absolute 的方式来实作,这稍後会再详细说明。

我们已经知道要丢到 Virtualized List 的资料的每一个 item 的 index,每一个 item 的高度也已经知道了,现在需要计算出要渲染在可视区的 item 的 index。在这之前我们有一些资讯要先取得:

  • Inner Height : 整个 Long List 的长度,因为已经设定每个 item 的高度是固定的,所以会是 Item Height * Item Number。
  • Window Height : Virtualized List 视窗的高度,也就是可视区的高度,在这个极简版实作中,会由外部传来写死的值。
  • Scroll Top : 代表 Long List 被滑动的距离,也就是 Long List 的开头(第一个 item)到可视区视窗内第一个 item 的距离。

让我们先看看 SimpleVirtualizedList Component 的实作:

SimpleVirtualizedList Component 预设可以由上层传入 4 个 properities:

  • itemHeight : 每个 List Item 的高度
  • itemCount : List 里面总共的 item 数量
  • windowHeight : 可视区(滑动视窗)的高度
  • renderItem : 负责渲染在可视区内 items 的 callback function

根据这些上层传入的 properties,我们可以取得刚刚说的其中两个先备资讯:Inner Height(itemHeight * itemCount)与 Window Height。至於 Scroll Top 则会透过元件 container 的 scroll event 在使用者滚动视窗时动态去取得(也就是程序中的 onScroll function)。

得到先备资讯後再来需要计算的是在可视区内开始与结束的两个 item 的 index,对应到程序码的 startIndex 与 endIndex,这部分应该蛮好理解的。

到这里,最基本且必要的资讯都取得了,因为 item 与可视区的高度都是写死的,只要有 index 我们就可以计算出每一个 item 在整个列表(Long List)上的绝对位置,所以我选择用偷懒的 position: absoulte 的方式来做排版。

实作到这里,在使用者滚动视窗时,会去使用最新的 scrollTop 重新计算可视区内的 startIndex 与 endIndex,再根据新的 startIndex 与 endIndex 渲染新的 element 到可视区上。

再来看看要怎麽使用 SimpleVirtualizedList Component:

这边为了方便 Demo,我直接建立了一个拥有 2000 个 items 的 dummy List 丢到 SimpleVirtualizedList 内,指定每个 item 的高度是 40px,可视区的高度是 400px,所以可视区内可以显示的 item 数量为 10 个(400 / 40 = 10),renderItem 则是用来渲染 item 的 callback function。

让我们来看看跑起来的结果

可以从 Browser Devtool 看到不管怎麽滑动,都只会渲染出在可视区范围内的那 10 个 item elements。最基本款的 Virtualized List 终於可以 work 罗!

SimpleVirtualized 的 demo source code: https://github.com/kylemocode/it-ironman-2021/tree/master/simple-virtualized-list-demo

优化方向有哪些?

  • 支援每个 list item 的高度可以不一致,不过这样会让计算可视区要渲染哪些 item 比较复杂一点。
  • 从上方的影片 demo 可以看到滑动页面同时重新渲染可视区元件时会让画面有一段时间是空白的,原因是这些元件是等已经进入可视区才进行渲染。要改善这个糟糕的 UX 可以透过预先渲染可视区边界附近的小部分 element,当元素进入可视区时就可以让使用者看到已经渲染完的结果。
  • 使用 ResizeObserver API 动态决定可视区的 window height。
  • 改成使用 position: absolute 搭配 transform(translateX, translateY) 来处理版面配置,利用 GPU 特性来得到较平顺的动画体验。(这部分预计会在 Day 12 的时候介绍)

小反思,为明天做准备

虽然 Virtualized List 解决了一次渲染大量 DOM 元素的状况,不过看完 demo 你有没有发现一个很奇怪的地方,我们仍然需要将整个 List 的资料一次丢进 Virtualized List 里面,当资料量一大,会对记忆体造成非常大的负担,导致效能也变得低落。明天,我们来尝试用 Lazy Loading 解决这个问题。

本日小结

Virtualized List 如今已经成为前端应用不管是 Web 还是 App 都不可或缺的技术了,不管是逛电商是社群媒体,又或者是新闻平台,通常都会有长列表需要呈现,这时候 Virtualized List 就是优化应用效能上必备的技术了。今天除了介绍它的概念,也实作了一个最简单最简单的版本,让读者可以理解 Virtualized List 背後的实作原理。不过文章开头也说过 Virtualized List 通常会搭配 Lazy Loading 的技术,才能发挥出最大的优化效果。至於什麽是 Lazy Loading,我们留到明天再说。各位明天见罗~

References

https://web.dev/virtualize-long-lists-react-window/
https://dev.to/nishanbajracharya/what-i-learned-from-building-my-own-virtualized-list-library-for-react-45ik
https://bvaughn.github.io/forward-js-2017/#/0/0


<<:  Day10:【TypeScript 学起来】只有 TS 才有的型别 : any / unknow / void / never

>>:  Day 11 号志如何使用-等待与号志同步

Day2 介绍网页的基础架构

要开始写网页之前,首先要了解到网页是如何形成的,其实网页形成非常的简单,透过网页浏览器(google...

Day 19 规划隐私资料敏感度分级

针对个人资料的定义在个人资料保护法第2条就有做很详细的说明,欧洲的GDPR也有规定「个人资料」是指与...

Day 1 - JavaScript 的变数与基本资料型态

废话不多说~直接进入正题 变数(Variable) 变数就像一个箱子,拿来装资料,且所有变数都只能出...

Ruby on rails - 1

Mac OS 开发系统 完成基本环境设置後 开始新专案 在终端机 Terminal cd 到指定要放...

D31 - 用 Swift 和公开资讯,打造投资理财的 Apps { 台股申购功能扩充,算出价差.2}

上一篇,提到了可以在 tableView(_:willDisplay:forRowAt:) 中发动 ...