极客时间返利平台,你可以在上边通过山月的链接购买课程,并添加我的微信 (shanyue94) 领取返现。

# 网站开发中,如何实现图片的懒加载

更多描述

网站开发中,如何实现图片的懒加载,随着 web 技术的发展,他有没有一些更好的方案

Issue

欢迎在 Gtihub Issue 中回答此问题: Issue 1 (opens new window)

懒加载,顾名思义,在当前网页,滑动页面到能看到图片的时候再加载图片

故问题拆分成两个:

  1. 如何判断图片出现在了当前视口 (即如何判断我们能够看到图片)
  2. 如何控制图片的加载

# 方案一: 位置计算 + 滚动事件 (Scroll) + DataSet API

# 如何判断图片出现在了当前视口

clientTopoffsetTopclientHeight 以及 scrollTop 各种关于图片的高度作比对

这些高度都代表了什么意思?

这我以前有可能是知道的,那时候我比较单纯,喜欢死磕。我现在想通了,背不过的东西就不要背了

所以它有一个问题:复杂琐碎不好理解!

仅仅知道它静态的高度还不够,我们还需要知道动态的

如何动态?监听 window.scroll 事件

# 如何控制图片的加载

<img data-src="shanyue.jpg" />

首先设置一个临时 Data 属性 data-src,控制加载时使用 src 代替 data-src,可利用 DataSet API 实现

img.src = img.datset.src

# 方案二: getBoundingClientRect API + Scroll with Throttle + DataSet API

改进一下

# 如何判断图片出现在了当前视口

引入一个新的 API, Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置。

getBoundingClientRect示例图

那如何判断图片出现在了当前视口呢,根据示例图示意,代码如下,这个就比较好理解了,就可以很容易地背会(就可以愉快地去面试了)。

// clientHeight 代表当前视口的高度
img.getBoundingClientRect().top < document.documentElement.clientHeight;

监听 window.scroll 事件也优化一下

加个节流器,提高性能。工作中一般使用 lodash.throttle 就可以了,万能的 lodash 啊!

_.throttle(func, [(wait = 0)], [(options = {})]);

参考 什么是防抖和节流,他们的应用场景有哪些 (opens new window),或者前端面试题 (opens new window)

# 方案三: IntersectionObserver API + DataSet API

再改进一下

# 如何判断图片出现在了当前视口

方案二使用的方法是: window.scroll 监听 Element.getBoundingClientRect() 并使用 _.throttle 节流

一系列组合动作太复杂了,于是浏览器出了一个三合一事件: IntersectionObserver API,一个能够监听元素是否到了当前视口的事件,一步到位!

事件回调的参数是 IntersectionObserverEntry (opens new window) 的集合,代表关于是否在可见视口的一系列值

其中,entry.isIntersecting 代表目标元素可见

const observer = new IntersectionObserver((changes) => {
  // changes: 目标元素集合
  changes.forEach((change) => {
    // intersectionRatio
    if (change.isIntersecting) {
      const img = change.target;
      img.src = img.dataset.src;
      observer.unobserve(img);
    }
  });
});

observer.observe(img);

当然,IntersectionObserver 除了给图片做懒加载外,还可以对单页应用资源做预加载。

如在 next.js v9 中,会对视口内的资源做预加载,可以参考 next 9 production optimizations (opens new window)

<Link href="/about">
  <a>关于山月</a>
</Link>

# 方案四: LazyLoading 属性

浏览器觉得懒加载这事可以交给自己做,你们开发者加个属性就好了。实在是...!

<img src="shanyue.jpg" loading="lazy" />

不过目前浏览器兼容性不太好,关于 loading 属性的文章也可以查看 Native image lazy-loading for the web! (opens new window)

intersectionObserver

Author

回答者: AgnesWY (opens new window)

比较单纯,喜欢死磕。我现在想通了,背不过的东西就不要背了!!!

Author

回答者: Kiera569 (opens new window)

那时候我比较单纯,喜欢死磕。我现在想通了,背不过的东西就不要背了

Author

回答者: haiifeng (opens new window)

那时候我比较单纯,喜欢死磕。我现在想通了,背不过的东西就不要背了

Author

回答者: hwb2017 (opens new window)

方案二的简单 Demo:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>图片懒加载</title>
    <style>
      img {
        width: 100%;
        height: 600px;
      }
    </style>
  </head>
  <body>
    <img
      src="https://cdn.pixabay.com/photo/2021/08/24/15/38/sand-6570980_960_720.jpg"
      alt="1"
    />
    <img
      src="https://cdn.pixabay.com/photo/2013/02/21/19/06/drink-84533_960_720.jpg"
      alt="2"
    />
    <img
      data-src="https://cdn.pixabay.com/photo/2014/12/15/17/16/boardwalk-569314_960_720.jpg"
      alt="3"
    />
    <img
      data-src="https://cdn.pixabay.com/photo/2013/07/18/20/26/sea-164989_960_720.jpg"
      alt="4"
    />
    <img
      data-src="https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885_960_720.jpg"
      alt="5"
    />
    <img
      data-src="https://cdn.pixabay.com/photo/2017/03/26/21/54/yoga-2176668_960_720.jpg"
      alt="6"
    />
    <img
      data-src="https://cdn.pixabay.com/photo/2015/03/17/14/05/sparkler-677774_960_720.jpg"
      alt="7"
    />
    <script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.20/lodash.js"></script>
    <script>
      const images = document.querySelectorAll("img");
      const lazyLoad = () => {
        images.forEach((item) => {
          // 触发条件为img元素的CSSOM对象到视口顶部的距离 < 100px + 视口高度,+100px为了提前触发图片加载
          if (
            item.getBoundingClientRect().top <
            document.documentElement.clientHeight + 100
          ) {
            if ("src" in item.dataset) {
              item.src = item.dataset.src;
            }
          }
        });
      };
      document.addEventListener("scroll", _.throttle(lazyLoad, 200));
    </script>
  </body>
</html>

@hwb2017 可以在 codepen 里写一下,然后附个地址

Author

回答者: hwb2017 (opens new window)

方案二的 Demo(CodePen) https://codepen.io/hwb2017/pen/BaZKeLa

在 react hook 中要怎么应用?看到这篇文章https://juejin.cn/post/6844903768966856717,但是改成 useRef 不行,hook 不能在循环中使用

方案一的实现demo (opens new window),ScrollListener 类用于监听和处理滚动,在 Controller(实现 onEnterViewport 方法)元素出现在视窗内时调用 controller.onEnterViewport(),最后移除 controller。

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>offsetTop计算实现图片懒加载</title>
    <style>
      body {
        margin: 0;
      }
      .img {
        width: 100%;
        height: 100%;
        object-fit: cover;
        object-position: center;
      }

      .wrap {
        margin: 10px;
        display: inline-block;
        width: 480px;
        height: 270px;
      }

      .container {
        width: 100vw;
        height: 100vh;
        overflow: auto;
      }

      h1 {
        text-align: center;
      }

      .main {
        margin: 0;
        width: 2000px;
      }
    </style>
  </head>

  <body>
    <section class="container">
      <h1>请滚动页面查看效果</h1>
      <div class="main"></div>
    </section>
  </body>
  <script defer>
    "use strict";

    // 图片url列表
    const images = [
      "https://h2.ioliu.cn/bing/Latern2022_ZH-CN0112710917_640x480.jpg?imageslim",
      "https://h2.ioliu.cn/bing/MaldivesHeart_ZH-CN0032539727_640x480.jpg?imageslim",
      "https://h2.ioliu.cn/bing/FaceOff_ZH-CN9969100257_640x480.jpg?imageslim",
      "https://h2.ioliu.cn/bing/DarwinsArch_ZH-CN9740478501_640x480.jpg?imageslim",
      "https://h2.ioliu.cn/bing/TeaGardensMunnar_ZH-CN9587720369_640x480.jpg?imageslim",
      "https://h2.ioliu.cn/bing/SnowyBern_ZH-CN5472524801_640x480.jpg?imageslim",
      "https://h2.ioliu.cn/bing/SevenSistersCliffs_ZH-CN5362127173_640x480.jpg?imageslim",
      "https://h2.ioliu.cn/bing/SpeloncatoSnow_ZH-CN8115437163_640x480.jpg?imageslim",
      "https://h2.ioliu.cn/bing/WinterludeIce_ZH-CN7868524911_640x480.jpg?imageslim",
      "https://h2.ioliu.cn/bing/Oymyakon_ZH-CN7758768574_640x480.jpg?imageslim",
      "https://h2.ioliu.cn/bing/MexicoMonarchs_ZH-CN7526758236_640x480.jpg?imageslim",
      "https://h2.ioliu.cn/bing/WinterOlymics_ZH-CN7384614076_640x480.jpg?imageslim",
      "233",
    ];

    // 未加载时默认url
    const defaultUrl =
      "data:image/svg+xml;base64,PHN2ZyB0PSIxNjQ0ODk5MzI0NDgwIiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjIwOTMiIHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIj48cGF0aCBkPSJNODc0LjEgODEzLjc1SDE0OS45Yy0yMi4yMiAwLTQwLjIzLTE4LjAxLTQwLjIzLTQwLjIzVjI1MC40OWMwLTIyLjIyIDE4LjAxLTQwLjIzIDQwLjIzLTQwLjIzaDcyNC4yYzIyLjIyIDAgNDAuMjMgMTguMDEgNDAuMjMgNDAuMjN2NTIzLjAzYzAgMjIuMjEtMTguMDIgNDAuMjMtNDAuMjMgNDAuMjN6TTI4MC42NiAzMTAuODRjLTM4Ljg5IDAtNzAuNDEgMzEuNTItNzAuNDEgNzAuNDFzMzEuNTIgNzAuNDEgNzAuNDEgNzAuNDEgNzAuNDEtMzEuNTIgNzAuNDEtNzAuNDEtMzEuNTItNzAuNDEtNzAuNDEtNzAuNDF6IG01MTIuOTcgMTAwLjU4YzAtMjIuMjItMTguMDEtNDAuMjMtNDAuMjMtNDAuMjNoLTQwLjIzYy02Ni42NiAwLTEyMC43IDU0LjA0LTEyMC43IDEyMC43djQwLjIzYzAgMzMuMzMtMjcuMDIgNjAuMzUtNjAuMzUgNjAuMzUtMTguMjkgMC0zNC40Ny04LjMxLTQ1LjU0LTIxLjE1LTAuMDUtMC4wNi0wLjI1LTAuMjgtMC4yOS0wLjMzLTIyLjA5LTI0LjA1LTU5Ljc3LTM4Ljg2LTk0Ljk4LTM4Ljg2LTAuNDQgMC0wLjg0IDAuMTItMS4yOCAwLjEzbC0wLjA2LTAuMDZjLTg4LjI2IDAuNzMtMTU5LjU5IDcyLjQ0LTE1OS41OSAxNjAuODYgMCAyMi4yMiAxOC4wMSA0MC4yMyA0MC4yMyA0MC4yM0g3NTMuNGMyMi4yMiAwIDQwLjIzLTE4LjAxIDQwLjIzLTQwLjIzVjQxMS40MnoiIHAtaWQ9IjIwOTQiIGZpbGw9IiNjZGNkY2QiPjwvcGF0aD48L3N2Zz4=";

    // 加载错误时代替
    const errorUrl =
      "data:image/svg+xml;base64,PHN2ZyB0PSIxNjQ0ODk5ODEzMDQ1IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjQ0OTEiIHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIj48cGF0aCBkPSJNNjQuMzgzMjM0IDUxMkM2NC4zODMyMzQgMjY0Ljc4NzgyOSAyNjQuNzg3ODI5IDY0LjM4MzIzNCA1MTIgNjQuMzgzMjM0IDc1OS4yMTIxNzEgNjQuMzgzMjM0IDk1OS42MTY3NjYgMjY0Ljc4NzgyOSA5NTkuNjE2NzY2IDUxMiA5NTkuNjE2NzY2IDc1OS4yMTIxNzEgNzU5LjIxMjE3MSA5NTkuNjE2NzY2IDUxMiA5NTkuNjE2NzY2IDI2NC43ODc4MjkgOTU5LjYxNjc2NiA2NC4zODMyMzQgNzU5LjIxMjE3MSA2NC4zODMyMzQgNTEyWk00NzQuMjMyMzc5IDc3MS4yNDUxMjRDNDc2LjQwODcxOCA3OTcuMzU1NTEyIDQ5MC41NTEzNzIgODEwLjQxMjEyMyA1MTYuNjYzMTc2IDgxMC40MTIxMjMgNTQyLjc3MzU2NCA4MTAuNDEyMTIzIDU1Ni45MTc2MzUgNzk3LjM1NTUxMiA1NTkuMDkyNTU2IDc3MS4yNDUxMjQgNTU2LjkxNzYzNSA3NDUuMTMzMzE5IDU0Mi43NzM1NjQgNzMwLjk5MDY2NiA1MTYuNjYzMTc2IDcyOC44MTQzMjcgNDkwLjU1MTM3MiA3MzAuOTkwNjY2IDQ3Ni40MDg3MTggNzQ1LjEzMzMxOSA0NzQuMjMyMzc5IDc3MS4yNDUxMjRaTTQ4MC43NTk5NzcgNjExLjMxNDc0OEM0ODAuNzU5OTc3IDYzNy40MjY1NTQgNDkyLjcyNzcxIDY1MC40ODE3NDcgNTE2LjY2MzE3NiA2NTAuNDgxNzQ3IDU0MC41OTcyMjYgNjUwLjQ4MTc0NyA1NTIuNTY0OTYgNjM3LjQyNjU1NCA1NTIuNTY0OTYgNjExLjMxNDc0OEw1NTIuNTY0OTYgMjQ5LjAyNDYxOEM1NTIuNTY0OTYgMjIyLjkxNDIzMSA1NDAuNTk3MjI2IDIwOS44NTc2MTkgNTE2LjY2MzE3NiAyMDkuODU3NjE5IDQ5Mi43Mjc3MSAyMDkuODU3NjE5IDQ4MC43NTk5NzcgMjIyLjkxNDIzMSA0ODAuNzU5OTc3IDI0OS4wMjQ2MThMNDgwLjc1OTk3NyA2MTEuMzE0NzQ4WiIgcC1pZD0iNDQ5MiIgZmlsbD0iI2NkY2RjZCI+PC9wYXRoPjwvc3ZnPg==";

    // 滚动监听和防抖
    class ScrollListener {
      entries = [];
      taskId = 0;

      constructor() {
        document.addEventListener("scroll", this.scrollDebounce.bind(this), {
          capture: true,
          passive: true,
        });
      }

      isInViewport(controller) {
        let offsetTop = 0,
          offsetLeft = 0,
          el = controller.el,
          scrollTop = 0,
          scrollLeft = 0,
          html = document.documentElement;
        while (el && el !== html) {
          offsetTop = offsetTop + el.offsetTop;
          offsetLeft = offsetLeft + el.offsetLeft;
          el = el.offsetParent;
        }

        el = controller.el;
        while (el) {
          scrollTop += el.scrollTop;
          scrollLeft += el.scrollLeft;
          el = el.parentElement;
        }
        offsetTop -= scrollTop;
        offsetLeft -= scrollLeft;

        el = controller.el;
        return (
          offsetTop < html.scrollTop + innerHeight &&
          offsetTop + el.clientHeight > html.scrollTop &&
          offsetLeft < html.scrollLeft + innerWidth &&
          offsetLeft + el.clientWidth > html.scrollLeft
        );
      }

      scrollDebounce() {
        if (this.taskId) {
          clearTimeout(this.taskId);
        }
        this.taskId = setTimeout(this.handleScroll.bind(this), 200);
      }

      addController(controller) {
        this.entries.push(controller);
        this.scrollDebounce();
      }

      handleScroll() {
        this.entries = this.entries.filter((controller) => {
          return !controller.blob;
        });
        this.entries.forEach((controller) => {
          if (this.isInViewport(controller)) {
            controller.onEnterViewport();
          }
        });
      }
    }

    // 图片控制对象
    class ImageController {
      img = "";
      blob = null;
      el = null;
      wrap = null;
      constructor(
        url = "",
        parent = document.body,
        className = "wrap",
        el = document.createElement("img")
      ) {
        el.src = defaultUrl;
        el.classList.add("img");

        this.el = el;
        this.img = url;

        this.wrap = document.createElement("div");
        this.wrap.classList.add(className);
        this.wrap.append(el);
        parent.append(this.wrap);
      }

      showImage() {
        const target = this;
        this.fetchImage().then(() => {
          target.el.src = this.blob;
        });
      }

      showLoading() {
        this.el.src = defaultUrl;
      }

      showError() {
        this.el.src = errorUrl;
      }

      onEnterViewport() {
        this.showImage();
      }

      async fetchImage() {
        if (typeof fetch !== "function") {
          this.thowError();
          throw new Error("浏览器不支持fetch接口");
        }

        // 如果已经加载过,直接返回
        if (!this.blob) {
          const target = this;
          return fetch(this.img)
            .then((res) => {
              if (res.status > 199 && res.status < 300) return res.blob();
              else return Promise.reject();
            })
            .then((blob) => {
              if (/image/.test(blob.type)) return URL.createObjectURL(blob);
              else return Promise.reject();
            })
            .then((url) => {
              target.blob = url;
            })
            .catch(() => {
              target.showError();
              throw new Error("URL不正确或MIME类型不正确");
            });
        }
      }
    }

    const scrollListener = new ScrollListener(),
      main = document.getElementsByClassName("main")[0],
      imageControllers = images.map((url) => {
        const controller = new ImageController(url, main);
        scrollListener.addController(controller);
      });
  </script>
</html>

Author

回答者: LMW-lmw (opens new window)

方案二有那么一点点抖动,这里重新实现了一下

<!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>
    <style>
      * {
        margin: 0px;
        padding: 0px;
      }

      body {
        margin: 0px;
        padding: 0px;
      }

      img {
        display: block;
      }
    </style>
  </head>

  <body>
    <div class="demo">
      <img
        data-src="https://cdn.pixabay.com/photo/2021/08/24/15/38/sand-6570980_960_720.jpg"
        alt="1"
      />
      <img
        data-src="https://cdn.pixabay.com/photo/2013/02/21/19/06/drink-84533_960_720.jpg"
        alt="2"
      />
      <img
        data-src="https://cdn.pixabay.com/photo/2013/07/18/20/26/sea-164989_960_720.jpg"
        alt="3"
      />
      <img
        data-src="https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885_960_720.jpg"
        alt="4"
      />
      <img
        data-src="https://cdn.pixabay.com/photo/2017/03/26/21/54/yoga-2176668_960_720.jpg"
        alt="5"
      />
    </div>
  </body>
  <script>
    const demo = document.querySelectorAll("img");
    function lazy() {
      for (let elem of demo) {
        if (
          elem.getBoundingClientRect().top <
          document.documentElement.clientHeight
        ) {
          if (elem.dataset.src && elem.src == "") {
            elem.src = elem.dataset.src;
          }
        }
      }
    }
    function throttle(t, fn) {
      let time;
      return function () {
        if (!time) {
          time = setTimeout(() => {
            time = null;
            fn();
          }, t);
        }
      };
    }
    lazy();
    window.addEventListener("scroll", throttle(500, lazy));
  </script>
</html>

Author

回答者: gethinzz (opens new window)

方法三的简单 demo

<!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>3.IntersectionObserver API + DataSet API</title>
    <style>

      * {
        margin: 0px;
        padding: 0px;
      }

      body {
        margin: 0px;
        padding: 0px;
      }

      img {
        width: 100%;
        height: 600px;
      }
    </style>
  </head>
  <body>
    <img src="https://cdn.pixabay.com/photo/2021/08/24/15/38/sand-6570980_960_720.jpg" alt="1" />
    <img src="https://cdn.pixabay.com/photo/2013/02/21/19/06/drink-84533_960_720.jpg" alt="2" />
    <img data-src="https://cdn.pixabay.com/photo/2014/12/15/17/16/boardwalk-569314_960_720.jpg" alt="3" />
    <img data-src="https://cdn.pixabay.com/photo/2013/07/18/20/26/sea-164989_960_720.jpg" alt="4" />
    <img data-src="https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885_960_720.jpg" alt="5" />
    <img data-src="https://cdn.pixabay.com/photo/2017/03/26/21/54/yoga-2176668_960_720.jpg" alt="6" />
    <img data-src="https://cdn.pixabay.com/photo/2015/03/17/14/05/sparkler-677774_960_720.jpg" alt="7" />
    <script>
      const images = document.querySelectorAll('img')

      // 新的 api  IntersectionObserver
      const observer = new IntersectionObserver((changes) => {
        changes.forEach(change => {
          if (change.isIntersecting) {
            const img = change.target
            // if (img.dataset.src && img.src == "") {
            //   img.src = img.dataset.src
            // }
            img.dataset.src && img.src == "" && (img.src = img.dataset.src)
            observer.unobserve(img)
          }
        })
      })

      images.forEach(img => observer.observe(img))
    </script>
  </body>
</html>


::: tip Author
回答者: [yanshuaidong](https://github.com/yanshuaidong)
:::

在vue中实现图片懒加载
https://github.com/wangkaiwd/vue-image-lazy
Last Updated: 6/26/2022, 10:48:10 AM