dwqs/blog

图片和视频的懒加载

dwqs opened this issue · 2 comments

dwqs commented

基于 Lazy Loading Images and Video 一文翻译,略有删减

对于一个网站的有效载荷而言,图片视频是非常重要的一部分内容。然而,项目的利益相关者并不愿意从已有的应用中削减媒体资源,当项目所有相关人想提高网站性能但却无法就如何实现目标达成一致时,这种障碍是非常令人沮丧的。幸运的是,懒加载(Lazy Loading)是一种可以降低初始页面负载和加载时间的解决方案,但它并不会减少内容的加载。

什么是懒加载

懒加载是一种在页面加载时,延迟加载非关键资源的技术,而这些非关键资源会在需要的时候才会被加载。对于图片而言,"非关键"资源即"屏幕外"(off-screen)的资源。如果你使用了 Lighthouse 并检查其提供的一些改善机会,就有可能从Offscreen Images audit方面看到一些指导:

Offscreen Images audit

Lighthouse 进行性能审计的一个方面就是确定屏幕外的图片资源(off screen images),这些图片资源都可以用于懒加载。

或许你已经在某些网站中看到到懒加载的实践,其过程大概如下:

  • 你访问一个页面,并滚动页面去阅读内容
  • 在某一时刻,一个占位的图片滚动到了视口中
  • 这个占位的图片里面被最终的真实图片替换了

你可以在流行的内容发布平台 Medium 上找到图片懒加载的例子。Medium 在页面加载的时候是先加载轻量地占位图片,当滚动到该区域时,占位图片会被懒加载完成的图片替换。

medium

左边是页面加载时显示的占位图片,右图是懒加载完成之后的真实图片

如果你不熟悉懒加载,那你可能想知道这样的技术是有多有用,以及使用它的优点是什么。接着往下读并找出你要的答案。

为什么要对图片或视频进行懒加载而不是直接加载?

因为直接加载可能会加载一些用户从不会看的资源,这就会导致一些问题:

  • 会浪费(用户的)流量。如果网络连接是不计流量的,这还不算是很糟糕的事情,但如果流量是有限制的,加载用户不会看到的资源则实际上是在浪费(用户的)钱
  • 会延长处理时间、浪费设备的电量以及其它系统资源。媒体(视频或图片)资源被加载之后,浏览器必须对其进行解码并网页内渲染媒体资源的内容

当我们对图片和视频进行懒加载,这不仅会减少初始页面的加载时间、大小以及系统资源的占用,还会对应用性能的提升产生积极影响。在这篇文章中,我们会针对图片和视频的懒加载实现提及一些技术和理论指导,也会列举一些常用的与懒加载相关的库。

图片的懒加载

从理论上来说,图片的懒加载机制是非常简单的,但实现上却有很多需要注意的细节。加上有几个不同的用例都可以受益于懒加载,因而让我们先从内联图片的懒加载开始。

内联图片

图片最常用的懒加载方式是使用 <img> 元素。当对 <img> 元素进行懒加载时,我们会通过 JavaScript 来判断 <img> 元素是否在视口内,如果元素在视口内,则它的 src 属性(有时也用 srcset)会用 URLs 填充来加载所需的图像内容。

使用 intersection observer

如果你之前写过实现懒加载的代码,你可能是使用事件处理的方式来判断 <img> 元素是否可见,进而实现懒加载,如 scrollresize。尽管这种方式有更好的浏览器兼容性,但现代浏览器通过 intersection observer API 提供了一个更完美且更高效的方式来判断元素是否可见。

注意:并非所有的现代浏览器支持 intersection observer,如果想要更好的浏览器兼容性,可以参考下一小节,它将告诉你怎么通过 scroll 事件和 resize 事件实现图片的懒加载。

相对于代码依赖各种各样的事件处理,intersection observer 的易用性和可读性更强,因为开发者只需要注册一个观察者(observer)来监听元素即可,而不用写非常冗长的代码来检测元素是否可见。注册观察者之后,之后需要开发者做的就是决定当元素可见时需要做什么。

假设我们需要懒加载的 <img> 元素的基础代码如下:

<img class="lazy" src="placeholder-image.jpg" data-src="image-to-lazy-load-1x.jpg" data-srcset="image-to-lazy-load-2x.jpg 2x, image-to-lazy-load-1x.jpg 1x" alt="I'm an image!">

对于上述代码,我们应该关注代码的三个相关部分:

  • class 属性,这是我们在 JavaScript 选择元素时需要用到的类选择器
  • src 属性,当页面初次加载时,它会加载一张占位图片
  • data-srcdata-srcset 属性,这两个是占位属性,其值是 <img> 元素出现在视口内时所需要加载的图片的 URL,且仅加载一次

现在看一下怎么在 JavaScript 中使用 intersection observer 实现图片的懒加载:

document.addEventListener("DOMContentLoaded", function() {
  var lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));

  if ("IntersectionObserver" in window) {
    let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
      entries.forEach(function(entry) {
        if (entry.isIntersecting) {
          let lazyImage = entry.target;
          lazyImage.src = lazyImage.dataset.src;
          lazyImage.srcset = lazyImage.dataset.srcset;
          lazyImage.classList.remove("lazy");
          lazyImageObserver.unobserve(lazyImage);
        }
      });
    });

    lazyImages.forEach(function(lazyImage) {
      lazyImageObserver.observe(lazyImage);
    });
  } else {
    // Possibly fall back to a more compatible method here
  }
});

DOMContentLoaded 事件触发时,脚本会去查找所有类属性值是 lazy<img> 元素。如果浏览器支持 intersection observer,我们就会创建一个观察者,并且当 img.lazy 元素进入视口时运行 callback。可以在 Codepen 上查看这段代码的示例

注意:上述代码利用了 intersection observer 的 isIntersecting 方法,其在 Edge 15 的 intersection observer 实现中是不可用的,因此,上述代码不能运行在 Edge 15 上。参考这个 Github issue 以获得关于更完整的特征检测条件的指导。

尽管现代浏览器对 intersection observer 有良好的支持,但也并非所有浏览器均提供了支持。对于不支持 intersection observer 的浏览器,你可以使用 polyfill,或者遵照上面代码的建议,先进行特征检测,如果不支持,则提供 fall back 方法进行兼容处理。

使用事件处理(兼容性更好的方式)

虽然你应该使用 intersection observer 实现懒加载,但如果你的应用对浏览器的兼容性比较严格。对于不支持 intersection observer 的浏览器,你可以通过 polyfill 来支持,当然也可以使用 scrollresizeorientationchange 事件以及 getBoundingClientRect 来判断元素是否在视口内。

假设需要懒加载的 <img> 元素的基础代码和上面一样,则下面的 JavaScripts 代码提供了懒加载的功能:

document.addEventListener("DOMContentLoaded", function() {
  let lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));
  let active = false;

  const lazyLoad = function() {
    if (active === false) {
      active = true;

      setTimeout(function() {
        lazyImages.forEach(function(lazyImage) {
          if ((lazyImage.getBoundingClientRect().top <= window.innerHeight && lazyImage.getBoundingClientRect().bottom >= 0) && getComputedStyle(lazyImage).display !== "none") {
            lazyImage.src = lazyImage.dataset.src;
            lazyImage.srcset = lazyImage.dataset.srcset;
            lazyImage.classList.remove("lazy");

            lazyImages = lazyImages.filter(function(image) {
              return image !== lazyImage;
            });

            if (lazyImages.length === 0) {
              document.removeEventListener("scroll", lazyLoad);
              window.removeEventListener("resize", lazyLoad);
              window.removeEventListener("orientationchange", lazyLoad);
            }
          }
        });

        active = false;
      }, 200);
    }
  };

  document.addEventListener("scroll", lazyLoad);
  window.addEventListener("resize", lazyLoad);
  window.addEventListener("orientationchange", lazyLoad);
});

scroll 事件处理器中,通过使用 getBoundingClientRect 来检查是否有任何 符合 img.lazy 选择器的元素在视口之内。setTimeout 用于延迟处理,active 变量用于节流控制。当图片被懒加载之后,该元素会从元素的数组中移除,而当改数组的长度达到0,滚动事件处理器也会被移除。相关示例代码可在 CodePen 上查看。

尽管上述代码的浏览器兼容性看起来很好,但存在一个潜在的性能问题,因为 setTimeout 的调用可能被浪费了,即使代码内部作了节流处理。在这个示例中,当文档滚动或窗口调整大小时,不管是否有图片存在视口之内,每 200ms 都会运行一次检查。此外,对于开发者还有一项冗余的工作:追踪还有多少元素需要进行懒加载以及解绑滚动事件。

总之,优先使用 intersection observer,如果应用对浏览器的兼容性比较严格,则降级到事件处理。

CSS 中的图片

尽管在网页中,img 标签是使用图片最常见的方式,但图片也可以用于 CSS 的 background-image 属性(或其它属性)。不管 img 元素是否可见,浏览器均会去加载,但 CSS 中的图像加载行为有更多的推测。当CSS 对象模型渲染树构建完成时,在请求额外的资源之前,浏览器会检查 CSS 是怎么作用于文档的。如果浏览器断定某个 CSS 规则包含额外的资源,但在当前构建时并不作用于文档,浏览器是不会去请求该资源的。

浏览器的这种推测行为可用于 CSS 中图片懒加载,但首先需要通过 JavaScript 来判断元素是否在视口之内,然后给该元素添加一个包含背景图片的类名样式,这样图片就会在需要的时候加载,而不是文档初始加载的时候。例如,让一个元素包含一张非常大的英雄背景图:

<div class="lazy-background">
  <h1>Here's a hero heading to get your attention!</h1>
  <p>Here's hero copy to convince you to buy a thing!</p>
  <a href="/buy-a-thing">Buy a thing!</a>
</div>

div.lazy-background 元素将通过关联的 CSS 包含一张英雄背景图。在这个示例中,我们会通过 visible 类名隔离 div.lazy-background 元素的 background-image 属性,当元素进入视口之后,会给元素添加类名 visible

.lazy-background {
  background-image: url("hero-placeholder.jpg"); /* Placeholder image */
}

.lazy-background.visible {
  background-image: url("hero.jpg"); /* The final image */
}

我们需要通过 JavaScript 来检测元素是否在视口之内(使用 intersection observer),当元素出现在视口之内时,会给 div.lazy-background 元素添加 类名 visible 去加载真实的图片:

document.addEventListener("DOMContentLoaded", function() {
  var lazyBackgrounds = [].slice.call(document.querySelectorAll(".lazy-background"));

  if ("IntersectionObserver" in window) {
    let lazyBackgroundObserver = new IntersectionObserver(function(entries, observer) {
      entries.forEach(function(entry) {
        if (entry.isIntersecting) {
          entry.target.classList.add("visible");
          lazyBackgroundObserver.unobserve(entry.target);
        }
      });
    });

    lazyBackgrounds.forEach(function(lazyBackground) {
      lazyBackgroundObserver.observe(lazyBackground);
    });
  }
});

如上文所提到的,并不是所有的浏览器均支持 intersection observer,因为你需要为它提供一个回退方案或 polyfill。相关示例代码可在 CodePen 上查看。

视频的懒加载

和图片元素一样,我们也可以对视频进行懒加载。正常情况下加载视频时是通过 video 元素(也可以使用 <img>,但功能有限)。怎么对视频进行懒加载取决于使用场景,接下来我们会讨论两种场景下的实现方案。

视频不自动播放

第一种场景是视频的播放是有用户控制的(即视频不自动播放),指定 <video> 元素的 preload 属性 即可:

<video controls preload="none" poster="one-does-not-simply-placeholder.jpg">
  <source src="one-does-not-simply.webm" type="video/webm">
  <source src="one-does-not-simply.mp4" type="video/mp4">
</video>

<video> 元素的 preload 属性设置为 none 可以阻止浏览器预加载任何视频数据。为占据空间,我们通过 poster 属性给 <video> 元素一个占位图片,这样做的理由是因为不同的浏览器对视频的加载行为是有差异的:

  • 在 Chrome 中,preload 属性的默认值是 auto,但从 Chrome 64 开始,其默认值是 metadata。而在 Chrome 的桌面版本中,部分视频的预加载通过 Content-Range 头来实现的,Firefox、Edge 和 IE11 也采取类似的行为。
  • 对于桌面版本的 Chrome,Safari 11 会预加载一部分视频数据(preload a range of the video),而 Safari 11.2(当前的技术预览版本)只会预加载视频的元数据。对于 iOS Safari,则不会预加载任何视频数据
  • 当浏览器支持数据保护模式(Data Saver Mode),preload 属性的默认是 none

因为浏览器的默认行为对预加载(preload)并不是固定不变的,因为显示说明可能是最好的方式。在用户控制播放的情况下,使用 preload="none" 是跨平台实现视频懒加载最容易的方式。但 preload 属性并不是唯一实现视频懒加载的方式,通过视频预加载实现快速播放一文或许会给你提供一些 ideas,并会深入了解 JavaScript 实现视频播放的方式。

坏消息是,这并没有证明用视频代替 GIFs 动画是有用的,下一节我们将讨论这个问题。

视频替代 GIF 动画

尽管 GIFs 动画应用广泛,但它们在很多方面的表现均不如视频,尤其是输出文件大小方面。GIFs 动画可以扩展到几兆字节的数据范围,而视觉质量相似的视频往往要小得多。

使用 <video> 元素代替 GIF 动画并不像 <img> 元素那么简单,因为 GIF 动画本身有固定的三种行为:

  1. 加载时自动播放
  2. 会不断循环播放(但并不是始终如此)
  3. 没有音频轨道

<video> 元素实现上述三种行为,代码可能如下:

<video autoplay muted loop playsinline>
  <source src="one-does-not-simply.webm" type="video/webm">
  <source src="one-does-not-simply.mp4" type="video/mp4">
</video>

autoplaymuted 以及 loop 等属性是 <video> 元素的自身属性, playsinline 属性对于在 iOS 上实现自动播放是必须的。现在我们有了一个可用且跨平台的视频替代 GIFs 的方式,但是怎么实现懒加载呢?Chrome 会主动为你对视频进行懒加载,但你并不能指望所有的浏览器都提供了这种优化。根据你的用户和应用对浏览器兼容性的要求,你可能需要自己动手实现视频的懒加载。在开始之前,先修改你的 <video> 标签:

<video autoplay muted loop playsinline width="610" height="254" poster="one-does-not-simply.jpg">
  <source data-src="one-does-not-simply.webm" type="video/webm">
  <source data-src="one-does-not-simply.mp4" type="video/mp4">
</video>

你会注意到添加了 poster 属性,直到视频被加载之前,它允许你指定一个占位图片占据 <video> 元素的空间。和之前实现 <img> 元素懒加载一样,我们通过每个 <source> 元素的 data-src 属性存储视频的 URL,然后使用和之前基于 intersection observer 对图片懒加载示例类似的 JavaScript 代码:

document.addEventListener("DOMContentLoaded", function() {
  var lazyVideos = [].slice.call(document.querySelectorAll("video.lazy"));

  if ("IntersectionObserver" in window) {
    var lazyVideoObserver = new IntersectionObserver(function(entries, observer) {
      entries.forEach(function(video) {
        if (video.isIntersecting) {
          for (var source in video.target.children) {
            var videoSource = video.target.children[source];
            if (typeof videoSource.tagName === "string" && videoSource.tagName === "SOURCE") {
              videoSource.src = videoSource.dataset.src;
            }
          }

          video.target.load();
          video.target.classList.remove("lazy");
          lazyVideoObserver.unobserve(video.target);
        }
      });
    });

    lazyVideos.forEach(function(lazyVideo) {
      lazyVideoObserver.observe(lazyVideo);
    });
  }
});

当懒加载一个 <video> 元素时,我们需要遍历所有的 <source> 子元素,并将 data-src 属性的值赋予给 src 属性,然后需要调用元素的 load 方法触发视频的加载,在视频加载完成之后就会开始自动播放。

使用这种方法,我们就有一个视频解决方案来模拟 GIF 动画的行为,但却不需要加载 GIFs 动画那样密集的数据,并且我们还实现了视频内容的懒加载。

一些懒加载库

如果你仅是想找一个支持懒加载功能的库而并不关心懒加载的实现细节,那么社区中有非常多的选择。大部分的库都会使用一个和本文 demo 中类似的标记模式(markup pattern),在这也推荐一些你可能觉得非常有用的库:

  • lazysizes 是一个功能丰富的库,支持图片和 iframes。它使用的标记模式和本文中的示例代码类似,会自动绑定到带 lazyload 类选择器的 <img> 元素上,并需要你通过 data-src 属性或 data-secret 属性指定图片的 URLs。该库是基于 intersection observer (可以使用 polyfill)实现懒加载的,并有大量的扩展插件,如 lazy load video
  • lozad.js 是一个非常轻量的且仅基于 intersection observer 的懒加载库。因此,它虽然性能非常好,但是如果在旧浏览器上使用则需要 polyfill
  • blazy 也是一个轻量级的懒加载库(仅1.4kb)。和 lazysizes 一样,加载它不需要任何的第三方工具,而且兼容 IE7+。但缺点是其实现不基于 intersection observer
  • yall.js 是我(原文作者)基于 IntersectionObserver 和 event handlers 写的一个库,兼容 IE11 和主流浏览器
  • 如果你在寻找一个基于 React 的懒加载库,那可以考虑一下 react-lazyload。尽管其没有使用 intersection observer,但提供了一个类似的方法实现图片的懒加载

上述的每一个懒加载库都有非常棒的文档以及满足你各种懒加载行为的标记模式(markup patterns)。

可能出错的地方

尽管图片和视频的懒加载具有积极的、可衡量的性能优势,但这并不是一项可以掉以轻心的任务。如果你做错了,可能会有意想不到的后果。因此,记住以下几点很重要:

Mind the fold

用 JavaScript 对页面上的每一个媒体资源进行懒加载是很诱人的,但你要抵制这种诱惑。任何能在一屏内显示的资源都不应该被懒加载,这样的资源应该被视为关键资源,正常加载即可。

用正常方式而非懒加载的方式去加载关键资源的主要理由是因为懒加载是直到脚本完成加载并开始执行时,才会延迟加载这些资源。对于视口之外(below the fold)的图片,采用懒加载时可以的,但是对于视口之内(above the fold)的图片使用标准的 <img> 元素能更快的加载关键资源。

此外,如果对触发资源懒加载的条件没有严格要求,则更理想的做法是在视口之外建立缓冲区,以便在用户将它们滚动到视口之前开始加载图片。例如,在创建 IntersectionObserver 实例时,intersection observer API 允许在可选对象中指定一个 rootMargin 属性,这会给目标元素建立一个缓冲区,在元素进入视口之前触发懒加载:

let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
  // Lazy loading image code goes here
}, {
  rootMargin: "0px 0px 256px 0px"
});

rootMargin 属性的定义方式和 CSS 的 margin 属性类型,用于定义根元素(默认是浏览器视口,可以通过 root 属性指定一个具体元素)的 margin。上述代码中 rootMargin 的定义表示当图片与视口元素底部的距离小于 256px 时,图片便会开始加载。

布局的改变和占位

如果没有使用占位元素,那么当图片或视频正在加载时会改变原有的页面布局,这不仅会牺牲用户体验,也会带来昂贵的 DOM 布局操作。因而最基本的方式是使用一个单色的占位元素占据和目标图片元素一样尺寸的大小,或者使用类似 LQIPSQIP 的技术来暗示用户这是一个媒体资源类的元素。

对于 <img> 元素,其 src 属性应该初始化为一个占位图片,直到该属性值更新为最终需要显示图片的 URL;对于 <video> 元素,可以利用 poster 属性指向一个占位图片。此外,为 <img><video> 元素添加 widthheight 属性,确保在资源加载时,从占位图片向最终图片过渡时不会改变已渲染的大小。

图像解码延迟

通过 JavaScript 加载大图并将其放入到 DOM 中时会占用主线程,而大图解码时可能会造成短时间内的用户交互无响应现象,通过 decode 方法异步进行图片解码之后再将图片元素插入到 DOM 中能有效避免这种现象。但并不是所有浏览器均支持 decode 方法,所以在使用前要先进行功能校验:

var newImage = new Image();
newImage.src = "my-awesome-image.jpg";

if ("decode" in newImage) {
  // Fancy decoding logic
  newImage.decode().then(function() {
    imageContainer.appendChild(newImage);
  });
} else {
  // Regular image load
  imageContainer.appendChild(newImage);
}

当资源没有加载

媒体资源并不总能加载成功,当资源加载失败时该怎么处理呢?这完全取决于你,但你应该对这种情况有一个备份计划。对于图片,可能的处理方式如下:

var newImage = new Image();
newImage.src = "my-awesome-image.jpg";

newImage.onerror = function(){
  // Decide what to do on error
};
newImage.onload = function(){
  // Load the image
};

JavaScript 的可用性

我们不应该总假设 JavaScript 是可用的。如果你打算对图片进行懒加载,可以考虑提供 <noscript> 标记在 JavaScript 不可用的情况下显示图片:

<!-- An image that eventually gets lazy loaded by JavaScript -->
<img class="lazy" src="placeholder-image.jpg" data-src="image-to-lazy-load.jpg" alt="I'm an image!">
<!-- An image that is shown if JavaScript is turned off -->
<noscript>
  <img src="image-to-lazy-load.jpg" alt="I'm an image!">
</noscript>

如果 JavaScript 不可用,用户会同时看到占位图片和 <noscript> 标记内的图片,因而,我们需要在 <html> 元素上添加一个 no-js 的 CSS 类:

<html class="no-js">

然后,我们在 <head> 元素内添加一行内联脚本用于移除 <html> 元素的 no-js 类,并且脚本要置于 <link> 标签之前:

<script>document.documentElement.classList.remove("no-js");</script>

最后,当 JavaScript 不可用时,我们可以使用一些简单的 CSS 规则来隐藏需要懒加载的元素:

.no-js .lazy {
  display: none;
}

总结

对图片和视频进行懒加载能有效降低应用的初始加载时间和负荷,用户也不需要担心非必要的网络行为和处理他们永远也不会看到的媒体资源的消耗,但仍可以看到他们想看到的资源。

就性能提升的技术而言,懒加载是一种没有任何争议的方式。如果你的网站有大量的内联图片,懒加载是一种有效降低非必要下载消耗的技术,你的网站用户和项目利益相关者也都会因此而感激你为提升性能而做出的努力。

参考

很实用的东西

学到了