使用 Vue.js 指令和交集观察器延迟加载图像

Avatar of Mateusz Rybczonek
Mateusz Rybczonek

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 价值 200 美元的免费积分!

当我想到网站性能时,首先想到的是图像通常是页面上最后出现的元素。如今,图像在性能方面可能是一个主要问题,这很不幸,因为网站加载速度对用户成功完成他们访问页面的目的有直接影响(想想转化率)。

最近,Rahul Nanwani 撰写了一篇关于延迟加载图像的完整指南。我想从不同的方法来探讨相同主题:使用数据属性、交集观察器Vue.js 中的自定义指令

这实际上可以帮助我们解决两个问题

  1. 在不加载图像的情况下存储我们要加载的图像的 src
  2. 检测图像何时对用户可见并触发加载图像的请求。

相同的延迟加载基本概念,但使用另一种方法。

我创建了 一个示例,基于 Benjamin Taylor 在 他的博文中 描述的示例。它包含一个随机文章列表,每个列表都包含简短描述、图像和文章来源的链接。我们将逐步了解创建负责显示该列表、渲染文章以及延迟加载特定文章图像的组件的过程。

让我们变得懒惰吧!或者至少将此组件分解成一个个部分。

步骤 1:在 Vue 中创建 ImageItem 组件

让我们首先创建一个组件,该组件将显示图像(但目前还没有延迟加载)。我们将此文件命名为 ImageItem.vue。在组件模板中,我们将使用一个包含图像的 figure 标签——图像标签本身将接收指向图像文件源 URL 的 src 属性。

<template>
  <figure class="image__wrapper">
    <img
      class="image__item"
      :src="source"
      alt="random image"
    >
  </figure>
</template>

在组件的脚本部分,我们接收 source 道具,我们将用于我们显示的图像的 src URL。

export default {
  name: "ImageItem",
  props: {
    source: {
      type: String,
      required: true
    }
  }
};

所有这些都很好,并将按预期渲染图像。但是,如果我们在这里结束,图像将立即加载,而不会等待整个组件渲染完成。这不是我们想要的,所以让我们进行下一步。

步骤 2:阻止组件创建时加载图像

我们想要阻止图像加载可能听起来有点奇怪,因为我们想显示它,但这关乎在 *正确的时间* 加载图像,而不是无限期地阻止它。为了阻止图像加载,我们需要从 img 标签中删除 src 属性。但是,我们仍然需要将它存储在某个地方,以便我们可以在需要时使用它。一个不错的存储信息的地方是 data- 属性。它们允许我们存储关于标准、语义 HTML 元素的信息。实际上,您可能已经习惯于将它们用作 JavaScript 选择器。

在这种情况下,它们非常适合我们的需求!

<!--ImageItem.vue-->
<template>
  <figure class="image__wrapper">
    <img
      class="image__item"
      :data-url="source" // yay for data attributes!
      alt="random image"
    >
  </figure>
</template>

这样一来,我们的图像 *不会* 加载,因为没有源 URL 可供提取。

这是一个良好的开端,但还不是我们真正想要的。我们希望在特定条件下加载图像。我们可以通过将 src 属性替换为我们存储在 data-url 属性中的图像源 URL 来请求加载图像。这很简单。真正的挑战在于找出何时用实际的源替换它。

我们的目标是将加载固定到用户的屏幕位置。因此,当用户滚动到图像进入视野的位置时,图像将在那里加载。

我们如何检测图像是否在视野中?这是我们的下一步。

步骤 3:检测图像何时对用户可见

您可能使用过 JavaScript 来确定元素何时在视野中。您可能还使用过一些很复杂的脚本。

例如,我们可以使用事件和事件处理程序来检测滚动位置、偏移值、元素高度和视窗高度,然后计算图像是否在视窗中。但这听起来已经很复杂了,不是吗?

但它可能会变得更糟。这会对性能产生直接影响。这些计算将在每次滚动事件中触发。更糟糕的是,想象一下几十个图像,每个图像都必须在每次滚动事件中重新计算它是否可见。*太疯狂了!*

交集观察器 来拯救我们!它提供了检测元素是否在视窗中可见的非常有效的方法。具体来说,它允许您配置一个 **回调**,当一个元素——称为 **目标**——与设备视窗或指定元素相交时触发。

那么,我们需要做什么才能使用它?一些事情

  • 创建一个新的交集观察器
  • 观察我们希望延迟加载的元素以查看可见性变化
  • 当元素进入视窗时加载元素(通过用我们的 data-url 替换 src
  • 在加载完成后停止观察可见性变化(unobserve

Vue.js 有自定义指令,可以将所有这些功能包装在一起,并在我们需要时使用它,无论需要多少次。我们的下一步是将它付诸实践。

步骤 4:创建 Vue 自定义指令

什么是自定义指令?Vue 的 文档 将其描述为在元素上获取低级 DOM 访问权限的一种方式。例如,更改特定 DOM 元素的属性,在我们的例子中,可能是更改 img 元素的 src 属性。完美!

我们将在稍后进行详细介绍,但以下是我们正在使用的代码

export default {
  inserted: el => {
    function loadImage() {
      const imageElement = Array.from(el.children).find(
      el => el.nodeName === "IMG"
      );
      if (imageElement) {
        imageElement.addEventListener("load", () => {
          setTimeout(() => el.classList.add("loaded"), 100);
        });
        imageElement.addEventListener("error", () => console.log("error"));
        imageElement.src = imageElement.dataset.url;
      }
    }

    function handleIntersect(entries, observer) {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          loadImage();
          observer.unobserve(el);
        }
      });
    }

    function createObserver() {
      const options = {
        root: null,
        threshold: "0"
      };
      const observer = new IntersectionObserver(handleIntersect, options);
      observer.observe(el);
    }
    if (window["IntersectionObserver"]) {
      createObserver();
    } else {
      loadImage();
    }
  }
};

好的,让我们逐步解决这个问题。

钩子函数 允许我们在绑定元素生命周期的特定时刻触发自定义逻辑。我们使用 inserted 钩子,因为当绑定元素被插入到其父节点时会调用它(这保证父节点存在)。由于我们希望观察元素相对于其父元素(或任何祖先元素)的可见性,因此我们需要使用该钩子。

export default {
  inserted: el => {
    ...
  }
}

loadImage 函数负责用 data-url 替换 src 值。在其中,我们可以访问我们的元素(el),我们将在此处应用指令。我们可以从该元素中提取 img

接下来,我们检查图像是否存在,如果存在,则添加一个侦听器,当加载完成时将触发回调函数。该回调函数将负责隐藏加载动画并使用 CSS 类为图像添加动画(淡入效果)。我们还添加了第二个侦听器,它将在 URL 加载失败的情况下被调用。

最后,我们将 img 元素的 src 替换为图像的源 URL 并显示它!

function loadImage() {
  const imageElement = Array.from(el.children).find(
    el => el.nodeName === "IMG"
  );
  if (imageElement) {
    imageElement.addEventListener("load", () => {
      setTimeout(() => el.classList.add("loaded"), 100);
    });
    imageElement.addEventListener("error", () => console.log("error"));
    imageElement.src = imageElement.dataset.url;
  }
}

我们使用交集观察器的 handleIntersect 函数,该函数负责在满足特定条件时触发 loadImage。具体来说,当交集观察器检测到元素进入视窗或父组件元素时,它将被触发。

该函数可以访问 entries,它是观察器观察的所有元素的数组,以及观察器本身。我们遍历 entries 并检查单个条目是否使用 isIntersecting 对用户可见——如果可见,则触发 loadImage 函数。一旦请求图像,我们就 unobserve 元素(将其从观察器的观察列表中删除),这将阻止图像再次加载。以及再次。以及再次。以及…

function handleIntersect(entries, observer) {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      loadImage();
      observer.unobserve(el);
    }
  });
}

最后一部分是 createObserver 函数。这个函数负责创建我们的交集观察器并将其附加到我们的元素。IntersectionObserver 构造函数接受一个回调(我们的 handleIntersect 函数),当观察到的元素通过指定的 threshold 并且 options 对象携带我们的观察器选项时,它将被触发。

说到options对象,它使用root作为我们的参考对象,我们用它来作为我们观察元素可见性的基础。 它可能是对象的任何祖先,或者如果我们传递null,则为我们的浏览器视窗。 该对象还指定了一个threshold值,该值可以在01之间变化,并告诉我们目标可见度的百分比是多少,此时应执行observer回调,其中0表示即使只有一个像素可见,1表示整个元素必须可见。

然后,在创建Intersection Observer之后,我们使用observe方法将其附加到我们的元素。

function createObserver() {
  const options = {
    root: null,
    threshold: "0"
  };
  const observer = new IntersectionObserver(handleIntersect, options);
  observer.observe(el);
}

步骤 5:注册指令

要使用我们新创建的指令,我们首先需要注册它。 有两种方法可以做到这一点:全局(在应用程序中的任何地方都可用)或局部(在指定的组件级别)。

全局注册

对于全局注册,我们导入指令并使用Vue.directive方法传递我们要调用的指令名称和指令本身。 这使我们能够在代码中的任何元素上添加v-lazyload属性。

// main.js
import Vue from "vue";
import App from "./App";
import LazyLoadDirective from "./directives/LazyLoadDirective";

Vue.config.productionTip = false;

Vue.directive("lazyload", LazyLoadDirective);

new Vue({
  el: "#app",
  components: { App },
  template: "<App/>"
});

局部注册

如果我们只想在特定组件中使用指令并限制对其的访问,我们可以局部注册指令。 为此,我们需要在将使用该指令的组件中导入该指令,并在directives对象中注册它。 这将使我们能够在该组件中的任何元素上添加v-lazyload属性。

import LazyLoadDirective from "./directives/LazyLoadDirective";

export default {
  directives: {
    lazyload: LazyLoadDirective
  }
}

步骤 6:在ImageItem组件上使用指令

现在我们的指令已经注册,我们可以通过在承载我们图像的父元素(在本例中为figure标记)上添加v-lazyload来使用它。

<template>
  <figure v-lazyload class="image__wrapper">
    <ImageSpinner
      class="image__spinner"
    />
    <img
      class="image__item"
      :data-url="source"
      alt="random image"
    >
  </figure>
</template>

浏览器支持

如果我们没有注意到浏览器支持,那我们就是失职了。 尽管Intersection Observe API并非所有浏览器都支持,但它确实覆盖了 73% 的用户(截至撰写本文时)。

此浏览器支持数据来自Caniuse,其中包含更多详细信息。 数字表示浏览器在该版本及其更高版本中支持该功能。

桌面

ChromeFirefoxIEEdgeSafari
58551612.1

移动设备/平板电脑

Android ChromeAndroid FirefoxAndroidiOS Safari
12712712712.2-12.5

还不错。 真的还不错。

但是! 考虑到我们希望所有用户显示图像(请记住,使用data-url会完全阻止图像加载),我们需要在指令中添加一个额外的部分。 特别是,我们需要检查浏览器是否支持Intersection Observer,如果它不支持,则改为触发loadImage。 这将是我们的后备方案。

if (window["IntersectionObserver"]) {
    createObserver();
} else {
    loadImage();
}

总结

延迟加载图像可以显著提高页面性能,因为它会将图像占用页面的重量,仅在用户实际需要时才加载它们。

对于那些仍然不确定延迟加载是否值得一试的人,这里有一些我们一直在使用的简单示例中的原始数字。 该列表包含 11 篇文章,每篇文章一张图片。 总共 11 张图片(数学!)。 这不像有很多图片,但我们仍然可以处理它。

以下是我们在 3G 连接上渲染所有 11 张图片而没有延迟加载的结果。

11 个图像请求总共导致页面大小为 3.2 MB。 哇!

以下是同一个页面使用延迟加载的结果。

你说什么? 只有一个请求,一张图片。 我们的页面现在是 1.4 MB。 我们节省了 10 个请求,并将页面大小减少了 56%

这是一个简单而孤立的例子吗? 是的,但数字仍然说明了一切。 希望您发现延迟加载是一种有效的对抗页面膨胀的方法,并且这种使用 Vue 和 Intersection Observer 的特定方法很实用。