Day11 X Lazy Loading

还记得昨天 Virtualized List 篇章开头放的 Facebook demo 影片吗?有没有发现我们好像遗漏了什麽功能没有说明?

先问大家一个问题,你有试着滑 FB 动态,一直滑一直滑,滑到底然後不能再滑动看更多贴文的经验吗?我想大多数人应该都没有吧(包括我自己也没有,喔对了,这边专指 FB 的首页喔!),每当要滑到底的时候应用程序都会再去抓更多的贴文进来,就像个无穷无尽的滚动列表一样。

没错,这样的 feature 也被称作 「Infinite Scroll」

不过 Infinite Scroll 跟今天的主题 Lazy Loading 又有什麽关联呢?答案是它其实算是「实现 Lazy Loading 的一种方式」。

到底什麽是 Lazy Loading ?

Lazy Loading 就是延迟载入,「等到真的要用的时候再载入」。

还记得昨天最後的小反思吗?昨天我们的方式是把所有的资料一次喂进 Virtualized List 里面,但使用者很可能只看前面几笔就不看了,那其他的几千笔几万笔资料不就浪费了吗?Lazy Loading 就是针对这个问题的解决方案:等到资料真的要用到的时候再载入,避免为了载入一些根本就用不到的资料而浪费网路资源与记忆体。

与其说 Lazy Loading 是一项优化技术,我更偏好於称它是一种「优化概念」,「等到真的要用的时候再载入」是它的核心思维,至於如何去实作这个概念则是开放的问题。也就是说,其实 Lazy Loading 的实作方式不只一种,今天主要要介绍的形式有:

  • 浏览器原生支援的图片 Lazy Loading
  • 搭配 infinite scroll 的 Data Lazy Loading

有写过 React 的读者可能会说,那 React.lazy 这个 React 官方提供的 API 是不是在做 Component Based 的 Lazy Loading?没有错,但是关於这个因为涉及到另外一个优化技巧,我会在 Day13 再做说明。
(当然其他框架一定有类似的 API,不过笔者对其他框架真的不熟,所以频频拿 React 来当例子,真的万分抱歉QQ)

浏览器原生支援的图片 Lazy Loading

Day006 的 Image Minimize 段落中有提到图片占了网站资源相当大的比例,因此如果在网页载入的瞬间就想把所有图片都载入下来对效能是一个硬伤。这时候可以采用 lazy loading 的方法去载入图片,一开始只需载入部分的图片例如一开始就会出现在萤幕上或是接近萤幕的图片,其他图片可以等页面滚动後快要出现在 viewport 时再去载入。例如 imgur 这种图床网站就势必会做图片的 lazy loading。

要实现 image 的 lazy load 主要有两种方式:

先来看看浏览器原生支援的部分,未来可能只需要在 img tag 或是 iframe tag 加上 loading=lazy,例如

<img src="image.png" loading="lazy" alt="image alt" width="200" height="200">

就可以自动帮我们做好 lazy loading,让图片或 iframe 在要进到 viewport 时才被载入。虽然目前浏览器支援度还不太普及,但是相信未来等支援度提升之後一定可以让工程师的开发体验提升不少。

loading 的不同属性:

  • auto: 等於没有加,浏览器会采取预设行为
  • lazy: 当图片一开始就在 Viewport 内或是靠近 Viewport 时开始载入
  • eager: 不管图片位置在哪,都马上开始载入

不是所有图片都需要 Lazy Loading

如果是一开始就在 viewport 上或是很靠近 viewport 的图片就不应该做 lazy loading,因为如果要做 Lazy Loading,浏览器得先判断图片的位置再决定要不要载入,这个空挡应该要拿来载入重要且需要马上呈现的图片。

给予 Placeholder,避免影响 CLS,造成不好的使用者体验

还记得 Day004 的时候提过的 CLS 吗?
使用 Lazy Loading 的图片时都应该使用 placeholder 来预先撑开图片所需要的空间,避免图片载入完成後造成版面偏移,影响使用者体验与 CLS 分数。常见的解法如在外层包一个特定大小的 Container:

<img src="ironman.png" width="100" height="100" />
<img src="ironman2.png" width="100" height="100" />
<div class="container">
  <img src="ironman3.png" />
</div>

不过关於 placeholder 的样式也是一大学问呢!
为了给予用户更好的使用者体验,有很多比单纯放一个灰色区块更好的选择,例如

1. 将原图的超小版本放大当作 placeholder(会产生模糊感),等图片载入再切换回原图,Medium 就是使用这种方式

2. SQIP - A SVG-based LQIP technique

不用等到真的出现在 viewport 才载入

我们已经知道图片的 Lazy Loading 是把一些需要经过卷动才会到可视区的图片做延迟载入,但通常实作时不会等到图片真的出现在 viewport 才开始载入,想想如果使用者的网速很差,他会看到许多图片缓慢载入的过程,这想必不是一个好的使用者体验。

所以可以透过加一些 margin 来解决这个问题,例如在图片距离 viewport 500px 时就开始载入,设定一个适合的 margin 既可以解决上面的问题,也不容易造成浪费网路资源的情形。

Lazy Loading With Intersection Observer

首先要了解到可以被延迟载入的不是只有图片而已,资料也是可以被延迟载入的,像是脸书的动态墙就是一个经典例子。

今天要介绍的另一种 Lazy Loading 形式为「Infinite Scroll 的 Lazy Loading」。infinite scroll 的特色在於要不停的去载入新的资料,让使用者有永远都滑不到底的使用者体验。而实作 infinite scroll 的重点就在找出「什麽时候」要去载入新的资料。

infinite scroll 载入更多资料的时机应该蛮好理解的,就是当原有资料的最後几个 item 出现在萤幕可视区或是快要出现在可视区时(代表现有资料快要用完了),就去抓取新的资料。(以脸书动态为例子可能比较好了解,但现有的最後一则动态出现在萤幕上时,就应该要去载入新的动态了)

有人可能会提问说,为什麽不要再更早一点就去载入新的资料,比方说现有资料滑到一半就去抓更多资料。其中几个原因是这样可能造成网路资源的浪费,也许使用者根本不会再往下滑,但你却花了额外的网路请求去抓取了新的资料。再来以使用者体验的角度来说,如果使用者可以看到资料正在载入的过程(例如本篇贴的脸书与 imgur 的影片中都有 placeholder 区块来提示使用者正在载入新的资源),比起在使用者不知情的状况下载入更多资源且让使用者可以不间断一直滑动,有给予使用者正在抓取更多贴文的提示会是一个更好的使用者体验。

想要得知决定要不要载入更多资料的 item 有没有出现在 viewport 里,我们可以利用 Intersection Observer 这个 Web API 来达成。

Basic Introduction Of Intersection Observer Web API

简单来说它的作用就是「能够监听目标元素在画面上出现或离开的时机,并执行我们给予它的 callback function。」

在过去要实作 intersection detection 这样的功能,可能得透过事件监听加上 looping 去执行 Element.getBoundingClientRect() 之类的 API 才能得到想要的资讯,不过这种方式会让 main thread 太过繁忙,因此有可能会造成一些效能瓶颈。Intersection Observer API 的出现,不仅让实作 intersection detection 变得容易,同时也能减轻 main thread 的负担。

How To Use It ?

老样子,用最简单的方式来 demo 一下用法。

首先在页面中放一个 element 在需要透过滚动才能被看到的位置。

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="demo-box">DEMO BOX</div>

    <script src="index.js"></script>
  </body>
</html>

index.css

body {
  height: 5000px;
}

/* 放在有点下面的位置 */
.demo-box {
  width: 100px;
  height: 100px;
  background: #003456;
  position: relative;
  top: 2000px;
  color: white;
}

JavaScript 的部分先建立 IntersectionObserver 的 instance 後传进我们指定的 callback function,这个 callback function 可以接收一个 IntersectionObserver 丢回来的 Array 型别的参数,Array 内装着的是所有正在监听的元素,我们可以从这些元素里的 isIntersecting attribute 来判断当前的元素是进入 viewport,还是离开 viewport 了。
(这里的 viewport 不一定是指整个萤幕,也可以是我们自己指定的区块,例如包在列表外的 Virtualized List 的可视区域)

接着使用 IntersectionObserver 的 observe method,把想要监听的 DOM Element 当作参数传给它,就可以注册对该元素的监听器。

index.js

const intersectionObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    console.log('demo box 进到 viewport了!');
  } else {
    console.log('demo box 离开 viewport了!');
  }
});

intersectionObserver.observe(document.querySelector('.demo-box'));

跑起来的结果会长这样:

另外除了 callback 之外,new IntersectionObserver 还可以带入第二个 options 物件参数:

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}

let observer = new IntersectionObserver(callback, options);

root 让开发者可以自行指定检查目标元素有没有出现的 viewport 范围,如果没有指定的话预设会是整个 browser 的 viewport。

threshold 这个属性可以让我们指定当目标元素的多少比例进到 viewport 时要触发 callback function。在都不指定的状况下 threshold 预设会是 0,代表只要目标元素有任何一点 pixel 出现在 viewport 都会触发回呼,如果设为 1.0 则代表整个目标元素都显示在 viewport 上时才会触发 callback function。

threshold 除了传入数字以外,还能传入一个 Array。如果你想要目标元素每多出现 25% 的部分到 viewport 上,就呼叫一次 callback,那我们可以这样写:

let options = {
  threshold: [0, 0.25, 0.5, 0.75, 1],
};

const intersectionObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    console.log('demo box 进到 viewport了!');
  } else {
    console.log('demo box 离开 viewport了!');
  }
}, options);

intersectionObserver.observe(document.querySelector('.demo-box'));

可以看到蓝色目标元素每过 25% 的高度,就会触发一次 console.log 的回呼

我们都知道使用 event listener 的时候要养成好习惯把用不到的监听器移除,避免影响效能。intersectionObserver 也是如此,可以透过 unobserve method 来移除监听

intersectionObserver.unobserve(
  document.querySelector('.demo-box')
);

Intersection Observer Web API 当然没有那麽简单(基本上大部分的 Web API 都是这样,我们不太可能记起所有 properties 与 methods,都是以解决当前需求为优先),不过目前所学已经足以应付简单的 Lazy Loading 需求了,想更深入了解的读者就再自行研究文件罗!

附上简单的 demo code: https://github.com/kylemocode/it-ironman-2021/tree/master/lazy-loading-demo

Demo Time - Virtualized List + Lazy Loading

为了快速 Demo,我决定以之前应徵实习的作业考题为范例, Github Repo: https://github.com/kylemocode/dcard-reader
(Dependencies 中有些 potential security vulnerabilities,因为是一年半以前做的 project,就没有特别去修了)

题目为串接 Dcard 的 API 实现可以无限卷动的文章列表(需使用 React 开发),专案执行後画面如下:

为什麽要搭配 Virtualized List ?

这个实习作业的要求只有说当滚动到最下面时,要去载入更多的文章,很明显是在指 infinite scroll。不过当我看到题目需求的时候第一个想法就是 - Virtualized List 是作业合不合格的关键。

像 Dcard 这种社群平台,需要呈现大量的贴文让使用者观看,虽然可以透过 Lazy Loading 延迟载入资料,但是如果已载入文章的 DOM 元件都一直存在於页面上,当数量一多一定会造成效能瓶颈,导致页面变得卡顿。除此之外,使用 Virtualized List 也可以降低初次渲染的工作量,降低初频渲染时间。因此我才会说有没有想到 windowing 技术是作业合不合格的关键,考验的是面对长列表时有没有考量到效能瓶颈的 sense。

在这个专案中我选择使用了第三方 Virtualized List 套件:React-Window,比起昨天实作的简易版本多了非常多功能,有兴趣的读者可以再自行研究一下。

看看刚认识的新朋友 Intersection Observer 怎麽发挥作用

虽然看起来稍微复杂了点,不过其实只是透过 Intersection Observer 去监听当前贴文资料的最後一则贴文有没有出现在萤幕上,有的话就更新 state 去记录当前最後一个贴文的 id,因为通常有做 pagination 的 API 的格式会是这样

BASE_URL + '&limit=' + LIMIT + '&before=' + lastId

透过更新 lastId,在重新 call API 资料的时候就可以指定要从哪个 item 之後开始拿,一次抓取的数量则可以用 LIMIT 指定。

测试一下 Frame Rate

透过 Chrome Devtool 的 FPS meter,我们可以检测页面的 frame rate 与 memory 使用量。来看看 MDN 官方怎麽描述 frame rate 吧

帧速率是一个网站的响应的量度。「低或不一致」的帧速率可以使一个网站出现反应迟钝或 janky,闹出不好的使用者体验。

60fps 的帧频被算为是平稳的性能目标,给你所有的需要在应对某些事件做出同步更新16.7毫秒的时间预算。

这个 Demo Project 在滚动时可以维持在接近 60fps 且十分稳定的 frame rate,使用者体验与动画品质都还不错。

最後来比较一下使用 Virtualized List 前後的差别:

从上图可以看到使用 windowing 技术渲染有一万个 items 的列表相比没有优化的版本有几个显着的提升:

  • Frame Rate 可以维持在 60 fps 左右
  • Memory 用量低了许多
  • Initial Render 花费的时间差了将近 100 倍

本日小结

昨天介绍了 Virtualized List 的观念,了解到 Windowing 技术对於渲染大量列表的重要性。不过通常渲染长列表的资料还可以透过 Lazy Loading 来减少流量与记忆体的浪费。

而要实现 Lazy Loading 最重要的是知道要载入更多资料的时机。我们可以透过 Intersection Observer 去监听目标元素是不是出现在我们指定的 viewport 里,再进而去载入更多的资料。

今日的最後也以一个过去的 Side Project 当作 Demo,看完後读者们应该也可以感受到 Virtualized List 加上 Lazy Loading 对於页面效能的影响。

话说这两天写 JavaScript 是不是有点写腻了呢?明天我们换个心境,来写点 CSS。不过不是随便乱写喔,我们要来写一些 「High Performance 的 CSS」,大家明天见!

References & 图片来源

https://calibreapp.com/blog/investigate-animation-performance-with-devtools

https://addyosmani.com/blog/react-window/

https://medium.com/starbugs/%E7%94%A8%E5%8E%9F%E7%94%9F%E7%9A%84-javascript-intersection-observer-api-%E5%AF%A6%E7%8F%BE-lazy-loading-6bedccd0950


<<:  [Day26] Flutter - Presentation Login & Splash Screen (part10)

>>:  Day 11:AWS是什麽?30天从动漫/影视作品看AWS服务应用 -《JoJo的奇妙冒险》第三季 part 1

刺蝟跟狐狸理论,我当然全都要

「我google一下,等我。」​ 「这些网路上都有了啊,为什麽要记?记了也没用」​ ​ «知识大迁移...

Facade 外观模式

在 Structural patterns 当中,最後要来谈的是外观模式。 外观模式提供我们一个简单...

JavaScript Day 4. ParseInt / ToString

ParseInt() 直到我在写这篇之前,parseInt 在我心中都还是一个很简单的概念,一个可以...

Day5 利用 Vscode 执行 PHP

真的是大坑,从我开始学程序起,就都是用Vscode这个IDE,也因此我蛮习惯用这个IDE,不想再换别...

[day6] AES-CBC 内文加密机制(Message)

讯息文本使用AES-CBC模式加密传送,接收的结果亦以相同规则加密 必要的参数 如何取得 JSON讯...