# 图片懒加载

本文根据神三元的文章 (opens new window)来详细写一遍图片懒加载。

# 前置知识

下面是我们需要使用到的三个 DOM 属性

# Element.clientHeight

这个属性是只读属性,对于没有定义 CSS 或者内联布局盒子的元素为 0,否则,它是元素内部的高度(单位像素),包含内边距,但不包括水平滚动条、边框和外边距。
clientHeight 可以通过 CSS height + CSS padding - 水平滚动条高度 (如果存在)来计算.

更多
  • Element.clientWidth
    • 内联元素以及没有 CSS 样式的元素的 clientWidth 属性值为 0。
    • Element.clientWidth 属性表示元素的内部宽度,以像素计。该属性包括内边距 padding,但不包括边框 border、外边距 margin 和垂直滚动条(如果有的话)。
    • 当在根元素(<html>元素)上使用 clientWidth 时(或者在<body>上,如果文档是在 quirks(怪异)模式下),将返回 viewport 的宽度(不包括任何滚动条). This is a special case of clientWidth (opens new window).

该属性值会被四舍五入为一个整数。如果你需要一个小数值,可使用 element.getBoundingClientRect()

  • element.clientLeft:表示一个元素的左边框的宽度,以像素表示。如果元素的文本方向是从右向左(RTL, right-to-left),并且由于内容溢出导致左边出现了一个垂直滚动条,则该属性包括滚动条的宽度。clientLeft 不包括左外边距和左内边距。clientLeft 是只读的。

  • Element.clientTop:一个元素顶部边框的宽度(以像素表示)。不包括顶部外边距或内边距。clientTop 是只读的。

# HTMLElement.offsetTop

HTMLElement.offsetTop 为只读属性,它返回当前元素相对于其 offsetParent 元素的顶部内边距的距离。

HTMLElement.offsetParent 是一个只读属性,返回一个指向最近的(指包含层级上的最近)包含该元素的定位元素或者最近的 table,td,th,body 元素。当元素的 style.display 设置为 "none" 时,offsetParent 返回 null

offsetParent 很有用,因为 offsetTopoffsetLeft 都是相对于其内边距边界的。

更多
  • HTMLElement.offsetHeight:是一个只读属性,它返回该元素的像素高度,高度包含该元素的垂直内边距和边框,且是一个整数。
    通常,元素的 offsetHeight 是一种元素 CSS 高度的衡量标准,包括元素的边框、内边距和元素的水平滚动条(如果存在且渲染的话),不包含:before:after 等伪类元素的高度。
    对于文档的 body 对象,它包括代替元素的 CSS 高度线性总含量高。浮动元素的向下延伸内容高度是被忽略的。
    如果元素被隐藏(例如 元素或者元素的祖先之一的元素的 style.display 被设置为 none),则返回 0

    这个属性值会被四舍五入为整数值,如果你需要一个浮点数值,请用 element.getBoundingClientRect().

  • HTMLElement.offsetLeft:是一个只读属性,返回当前元素左上角相对于 HTMLElement.offsetParent 节点的左边界偏移的像素值。
    对块级元素来说,offsetTopoffsetLeftoffsetWidthoffsetHeight 描述了元素相对于 offsetParent 的边界框。
    然而,对于可被截断到下一行的行内元素(如 span),offsetTopoffsetLeft 描述的是第一个边界框的位置(使用 Element.getClientRects() 来获取其宽度和高度),而 offsetWidthoffsetHeight 描述的是边界框的尺寸(使用 Element.getBoundingClientRect 来获取其位置)。因此,使用 offsetLeftoffsetTopoffsetWidthoffsetHeight 来对应 lefttopwidthheight 的一个盒子将不会是文本容器 span 的盒子边界。

  • HTMLElement.offsetWidth:是一个只读属性,返回一个元素的布局宽度。一个典型的(译者注:各浏览器的 offsetWidth 可能有所不同)offsetWidth 是测量包含元素的边框(border)、水平线上的内边距(padding)、竖直方向滚动条(scrollbar)(如果存在的话)、以及 CSS 设置的宽度(width)的值。

# Element.scrollTop

Element.scrollTop 属性可以获取或设置一个元素的内容垂直滚动的像素数。
一个元素的 scrollTop 值是这个元素的内容顶部(卷起来的)到它的视口可见内容(的顶部)的距离的度量。当一个元素的内容没有产生垂直方向的滚动条,那么它的 scrollTop 值为 0。

更多
  • Element.scrollHeight:这个只读属性是一个元素内容高度的度量,包括由于溢出导致的视图中不可见内容。
    scrollHeight 的值等于该元素在不使用滚动条的情况下为了适应视口中所用内容所需的最小高度。 没有垂直滚动条的情况下,scrollHeight 值与元素视图填充所有内容所需要的最小值 clientHeight 相同。包括元素的 padding,但不包括元素的 bordermarginscrollHeight 也包括 ::before::after 这样的伪元素。

    属性将会对值四舍五入取整。如果需要小数值,使用 Element.getBoundingClientRect().

  • Element.scrollLeft:属性可以读取或设置元素滚动条到元素左边的距离。
    注意如果这个元素的内容排列方向(direction) 是 rtl (right-to-left) ,那么滚动条会位于最右侧(内容开始处),并且 scrollLeft 值为 0。此时,当你从右到左拖动滚动条时,scrollLeft 会从 0 变为负数。

  • Element.scrollWidth:这个只读属性是元素内容宽度的一种度量,包括由于 overflow 溢出而在屏幕上不可见的内容。
    scrollWidth 值等于元素在不使用水平滚动条的情况下适合视口中的所有内容所需的最小宽度。 宽度的测量方式与 clientWidth 相同:它包含元素的内边距,但不包括边框,外边距或垂直滚动条(如果存在)。 它还可以包括伪元素的宽度,例如::before::after。 如果元素的内容可以适合而不需要水平滚动条,则其 scrollWidth 等于 clientWidth

    1. 这个属性会进行四舍五入并返回整数,如果你需要小数形式的值,使用 element.getBoundingClientRect().
    2. 在实际测试过程中,谷歌获取的 Element.scrollWidth 和 IE,火狐下获取的 Element.scrollWidth 并不相同

# Element 与 HTMLElement

  • Element:是一个通用性非常强的基类,所有 Document 对象下的对象都继承自它。这个接口描述了所有相同种类的元素所普遍具有的方法和属性。一些接口继承自 Element 并且增加了一些额外功能的接口描述了具体的行为。例如, HTMLElement 接口是所有 HTML 元素的基本接口,而 SVGElement 接口是所有 SVG 元素的基础。大多数功能是在这个类的更深层级(hierarchy)的接口中被进一步制定的。

  • HTMLElement:接口表示所有的 HTML 元素。一些 HTML 元素直接实现了 HTMLElement 接口,其它的间接实现 HTMLElement 接口.

    继承自父接口 ElementGlobalEventHandlers 的属性

# 方案一:clientHeight、scrollTop 和 offsetTop

思路:监听页面滚动,在图片即将滚动到窗口中显示之前加载图片。具体看下面代码和注释

 <img class="lazy-load-image" src="default.jpg" data-src="http://www.xxx.com/taarget.jpg"></img>
let imgs = document.querySelectorAll('.lazy-load-image');
let count = 0;
lazyload();

function lazyload() {
  // 计算浏览器视口高度
  let viewHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
  // 计算页面内容滚动的(卷起来的)高度
  let bodyScrollTop = document.documentElement.scrollTop || document.body.scrollTop;

  for (let i = count; i < imgs.length; i++) {
    if (imgs[i].offsetTop < viewHeight + bodyScrollTop) {
      if (imgs[i].getAttribute('src') === 'default.jpg') {
        imgs[i].src = imgs[i].getAttribute('data-src');
      }
      count++;
    }
  }
}
// 用节流函数优化滚动事件频繁触发
document.addEventListener('scroll', throttle(lazyload, 300));

阅读下面的文章了解更多关于浏览器视口高度以及页面滚动的高度的信息:

# 方案二:getBoundingClientRect (opens new window)

# getBoundingClientRect (opens new window)

Element.getBoundingClientRect() (opens new window) 方法返回元素的「大小」及其相对于视口的「位置」。

如果是标准盒子模型,元素的尺寸等于 width/height + padding + border-width 的总和。如果 box-sizing: border-box,元素的的尺寸等于 width/height

返回值是一个 DOMRect (opens new window) 对象,这个对象是由该元素的 getClientRects() (opens new window) 方法返回的一组矩形的集合,就是该元素的 CSS 边框大小。返回的结果是包含完整元素的最小矩形,并且拥有 left, top, right, bottom, x, y, width, 和 height 这几个以像素为单位的只读属性用于描述整个边框。除了 widthheight 以外的属性是相对于视图窗口的左上角来计算的。

DOMRect
  • DOMRect.x:DOMRect 原点的 x 坐标。
  • DOMRect.y:DOMRect 原点的 y 坐标。
  • DOMRect.width:DOMRect 的宽度。
  • DOMRect.height:DOMRect 的高度。
  • DOMRect.top:返回 DOMRect 的顶坐标值(与 y 具有相同的值,如果 height 为负值,则为 y + height 的值)。
  • DOMRect.right:返回 DOMRect 的右坐标值(与 x + width 具有相同的值,如果 width 为负值,则为 x 的值)。
  • DOMRect.bottom:返回 DOMRect 的底坐标值(与 y + height 具有相同的值,如果 height 为负值,则为 y 的值)。
  • DOMRect.left:返回 DOMRect 的左坐标值(与 x 具有相同的值,如果 width 为负值,则为 x + width 的值)。

使用 getBoundingClientRect,可以得到元素相对于视口的位置,比方案一来说,少了对滚动位置的计算。下面在方案一的基础上,我们修改一下:

function lazyload() {
  // 计算浏览器视口高度
  let viewHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
  for (let i = count; i < imgs.length; i++) {
    if (imgs[i].getBoundingClientRect().top < viewHeight) {
      if (imgs[i].getAttribute('src') === 'default.jpg') {
        imgs[i].src = imgs[i].getAttribute('data-src');
      }
      count++;
    }
  }
}

# 方案三: IntersectionObserver (opens new window)

IntersectionObserver (opens new window)提供了一种异步检测目标元素与祖先元素或 viewport (opens new window) 相交情况变化的方法。

更多

过去,要检测一个元素是否可见或者两个元素是否相交并不容易,很多解决办法不可靠或性能很差。然而,随着互联网的发展,这种需求却与日俱增,比如,下面这些情况都需要用到相交检测:

  • 图片懒加载——当图片滚动到可见时才进行加载
  • 内容无限滚动——也就是用户滚动到接近内容底部时直接加载更多,而无需用户操作翻页,给用户一种网页可以无限滚动的错觉
  • 检测广告的曝光情况——为了计算广告收益,需要知道广告元素的曝光情况
  • 在用户看见某个区域时执行任务或播放动画

过去,相交检测通常要用到事件监听,并且需要频繁调用 Element.getBoundingClientRect() 方法以获取相关元素的边界信息。事件监听和调用 Element.getBoundingClientRect() 都是在主线程上运行,因此频繁触发、调用可能会造成性能问题。这种检测方法极其怪异且不优雅。

假如有一个无限滚动的网页,开发者使用了一个第三方库来管理整个页面的广告,又用了另外一个库来实现消息盒子和点赞,并且页面有很多动画(动画往往意味着较高的性能消耗)。两个库都有自己的相交检测程序,都运行在主线程里,而网站的开发者对这些库的内部实现知之甚少,所以并未意识到有什么问题。但当用户滚动页面时,这些相交检测程序就会在页面滚动回调函数里不停触发调用,造成性能问题,体验效果让人失望。

Intersection Observer API 会注册一个回调函数,每当被监视的元素进入或者退出另外一个元素时(或者 viewport (opens new window) ),或者两个元素的相交部分大小发生变化时,该回调方法会被触发执行。这样,我们网站的主线程不需要再为了监听元素相交而辛苦劳作,浏览器会自行优化元素相交管理。

注意 Intersection Observer API 无法提供重叠的像素个数或者具体哪个像素重叠,他的更常见的使用方式是——当两个元素相交比例在 N% 左右时,触发回调,以执行某些逻辑。

# Intersection observer 的概念和用法

Intersection Observer API 允许你配置一个回调函数,当以下情况发生时会被调用

  • 每当目标(target)元素与设备视窗或者其他指定元素发生交集的时候执行。设备视窗或者其他元素我们称它为根元素或根(root)。
  • Observer 第一次监听目标元素的时候

通常,您需要关注文档最接近的可滚动祖先元素的交集更改,如果元素不是可滚动元素的后代,则默认为设备视窗。如果要观察相对于根(root)元素的交集,请指定根(root)元素为 null。 无论您是使用视口还是其他元素作为根,API 都以相同的方式工作,只要目标元素的可见性发生变化,就会执行您提供的回调函数,以便它与所需的交叉点交叉。 目标(target)元素与根(root)元素之间的交叉度是交叉比(intersection ratio)。这是目标(target)元素相对于根(root)的交集百分比的表示,它的取值在 0.0 和 1.0 之间。

# 创建一个 intersection observer

创建一个 IntersectionObserver 对象,并传入相应参数和回调用函数,该回调函数将会在目标(target)元素和根(root)元素的交集大小超过阈值(threshold)规定的大小时候被执行。

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

let observer = new IntersectionObserver(callback, options);

阈值为 1.0 意味着目标元素完全出现在 root 选项指定的元素中可见时,回调函数将会被执行。

Intersection observer options

传递到 IntersectionObserver() 构造函数的 options 对象,允许您控制观察者的回调函数的被调用时的环境。它有以下字段:

  • root:指定根(root)元素,用于检查目标的可见性。必须是目标元素的父级元素。如果未指定或者为 null,则默认为浏览器视窗。
  • rootMargin:根(root)元素的外边距。类似于 CSS 中的 margin 属性,比如 "10px 20px 30px 40px" (top, right, bottom, left)。如果有指定 root 参数,则 rootMargin 也可以使用百分比来取值。该属性值是用作 root 元素和 target 发生交集时候的计算交集的区域范围,使用该属性可以控制 root 元素每一边的收缩或者扩张。默认值为 0。
  • threshold:可以是单一的 number 也可以是 number 数组,target 元素和 root 元素相交程度达到该值的时候 IntersectionObserver 注册的回调函数将会被执行。如果你只是想要探测当 target 元素的在 root 元素中的可见性超过 50%的时候,你可以指定该属性值为 0.5。如果你想要 target 元素在 root 元素的可见程度每多 25%就执行一次回调,那么你可以指定一个数组[0, 0.25, 0.5, 0.75, 1]。默认值是 0(意味着只要有一个 target 像素出现在 root 元素中,回调函数将会被执行)。该值为 1.0 含义是当 target 完全出现在 root 元素中时候 回调才会被执行。

Targeting an element to be observed 为每个观察者配置一个目标

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
};
let observer = new IntersectionObserver(callback, options);
// 为每个观察者配置一个目标
let target = document.querySelector('#listItem');
observer.observe(target);

每当目标满足该 IntersectionObserver 指定的 threshold 值,回调被调用。回调接收 IntersectionObserverEntry (opens new window) 对象列表和观察者 observer:

let callback = (entries, observer) => {
  entries.forEach(entry => {
    // Each entry describes an intersection change for one observed
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};

请留意,你注册的回调函数将会在主线程中被执行。所以该函数执行速度要尽可能的快。如果有一些耗时的操作需要执行,建议使用 Window.requestIdleCallback() (opens new window) 方法。

  • boundingClientRect:返回包含目标元素的边界信息的 DOMRectReadOnly. 边界的计算方式与 Element.getBoundingClientRect() 相同.
  • intersectionRatio:返回 intersectionRect 与 boundingClientRect 的比例值.
  • intersectionRect:返回一个 DOMRectReadOnly 用来描述根和目标元素的相交区域.
  • isIntersecting:返回一个布尔值, 如果目标元素与交叉区域观察者对象(intersection observer) 的根相交,则返回 true .如果返回 true,则 IntersectionObserverEntry 描述了变换到交叉时的状态; 如果返回 false, 那么可以由此判断,变换是从交叉状态到非交叉状态.
  • rootBounds:返回一个 DOMRectReadOnly 用来描述交叉区域观察者(intersection observer)中的根.
  • target:与根出现相交区域改变的元素 (Element).
  • time:返回一个记录从 IntersectionObserver 的时间原点(time origin)到交叉被触发的时间的时间戳(DOMHighResTimeStamp).

介绍完 Intersection Observer API 的用法,下面来改写之前的例子:

let imgs = document.getElementsByTagName('img');
let options = {
  root: null,
  rootMargin: '0px',
  threshold: 1.0
};
let observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      let img = entry.target;
      if (img.getAttribute('src') === 'default.jpg') {
        img.src = img.getAttribute('data-src');
        // 请求图片后,取消对 img 的 observe
        observer.unobserve(img);
      }
    }
  });
}, options);
for (let i = 0; i < imgs.length; i++) {
  observer.observe(imgs[i]);
}

下面有个完整的伪示例:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .lazy-load-li {
        width: 400px;
        height: 200px;
        background-color: tomato;
        border: 5px solid blue;
        margin: 10px 0;
        color: #fff;
        font-size: 28px;
      }
    </style>
  </head>

  /</body>
  <script>
    var ul = document.createElement('ul');
    for (var i = 0; i < 10; i++) {
      var li = document.createElement('li');
      li.innerHTML = 'loading......';
      li.className = 'lazy-load-li';
      ul.appendChild(li);
    }
    document.body.appendChild(ul);
    // 方案一:clientHeight、scrollTop 和 offsetTop
    // function lazyLoadImage() {
    //   let lis = document.querySelectorAll('.lazy-load-li')
    //   let count = 0
    //   function lazyload() {
    //     // 计算浏览器视口高度
    //     let viewHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
    //     // 计算页面内容滚动的(卷起来的)高度
    //     let bodyScrollTop = document.documentElement.scrollTop || document.body.scrollTop;
    //     for (let i = count; i < lis.length; i++) {
    //       console.log(lis[0].getBoundingClientRect())
    //       if (lis[i].offsetTop < viewHeight + bodyScrollTop) {
    //         if (lis[i].innerHTML !== 'loaded') {
    //           // 模拟图片请求
    //           setTimeout(function () {
    //             lis[i].innerHTML = "loaded";
    //           }, 1000)
    //           count++;
    //         }
    //       }
    //     }
    //   }
    //   lazyload()
    //   window.addEventListener('scroll', lazyload)
    // }
    // 方案二:getBoundingClientRect
    // function lazyLoadImage() {
    //   let lis = document.querySelectorAll('.lazy-load-li')
    //   let count = 0
    //   function lazyload() {
    //     // 计算浏览器视口高度
    //     let viewHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
    //     for (let i = count; i < lis.length; i++) {
    //       if (lis[i].getBoundingClientRect().top < viewHeight) {
    //         if (lis[i].innerHTML !== 'loaded') {
    //           // 模拟图片请求
    //           setTimeout(function () {
    //             lis[i].innerHTML = "loaded";
    //           }, 1000)
    //           count++;
    //         }
    //       }
    //     }
    //   }
    //   lazyload()
    //   window.addEventListener('scroll', lazyload)
    // }
    // 方案三: IntersectionObserver
    function lazyLoadImage() {
      let lis = document.querySelectorAll('.lazy-load-li');
      const observer = new IntersectionObserver(
        entrys => {
          entrys.forEach(entry => {
            if (entry.isIntersecting) {
              const li = entry.target;
              if (li.innerHTML !== 'loaded') {
                observer.unobserve(li);
                // 模拟图片请求
                setTimeout(() => {
                  li.innerHTML = 'loaded';
                }, 1000);
              }
            }
          });
        },
        {
          root: null,
          rootMargin: '0px',
          threshold: 1.0
        }
      );
      for (let i = 0; i < lis.length; i++) {
        observer.observe(lis[i]);
      }
    }

    lazyLoadImage();
  </script>
</html>