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

在实现一个功能之前,搞清楚概念是非常有必要的。所以在实现图片懒加载之前,弄明白什么是图片懒加载,为什么要做懒加载非常有必要。只有知道了是什么,为什么,才能想怎么做!

图片懒加载是什么?为什么要做图片懒加载?

图片懒加载就是在浏览器解析渲染的过程中,通过认为控制,只解析渲染用户可见的部分,剩余部分则在根据需要在网络空闲时或者用户想看的时候再完成。

图片懒加载是 web 性能优化的方式之一。在多图,弱网环境下,通过图片懒加载,能显著的提神用户体验。浏览器接收到 dom 文件后,立马就开始了解析工作,这个时候遇到linkscriptimage等资源引用文件,就会立马请求对应的资源,虽然图片资源的请求不会像 大部分js 脚本资源一样阻塞渲染流程,但是会占用网络资源,而且图片即使经过了压缩处理,也会比较大,所以当遇到弱网,多图的情况时,用户体验就会很差。这个时候使用懒加载,既可以减轻网络请求的压力,也可以提高用户体验。

怎么做?

现在已经知道了图片懒加载就是优先加载可视区域的图片,其它的区域根据策略来。现在我面临两个选择,第一,去网上找现成的资源,然后直接拿回来用;第二直接手搓一个。

好用的图片懒加载库

为了追求效率,工作中如果公司或者上司没有强制要求必须自己实现某个功能,去找现成的轮子是最明智的做法。所以我也搜集几个好用的轮子。

  • 1 使用浏览器原生方法,直接将 <img> 标签的loading属性值设置为lazy,告诉浏览器只加载可见区域的图片,其余图片在滚动到可见区域时再加载。简单方便,

  • 2 vanilla-lazyload js 库,这是我找到 github star 最多的一个懒加载 js 库,引入 js 文件后,直接在 img 标签上配置各种属性即可按照不同的策略进行懒加载。

有这两个轮子足可应付工作中的需求了。没有特殊要求直接使用浏览器原生的,如果需要比较多的策略用 vanilla-lazyload。

自己实现懒加载

自己实现图片的懒加载需求,首先要知道的是图片是否处于可见区域,然后根据不同的策略,知道不可见区域图片到可见区域的距离,最后就是控制图片的加载和现实。

方法一:通过 offsetParent,offsetTop,scrollTop,clientTop 等概念实现

首先了解一些概念

  • offsetParent 当前元素距离最近的祖先元素。只有符合以下 3 种条件之一的才能成为祖先元素
    1、 body标签
    2、 tableth, th,
    3、 css 属性 position 的属性值为 relative,absolute,fixed,sticky

    有以下情况之一的祖先元素为null

    1. css 属性 display 的值为 none
    2. 祖先元素是 body 或者 html
    3. position属性是 fixed
  • offsetLeft/offsetTop距离最近的祖先元素左上角为原点的x,y坐标,也是距离。

  • offsetWidth/offsetHeight 当前元素完整的宽高,包括border,padding,content

  • clientLeft/clientTop 元素内部到元素左上角的距离,大多数时候是左边 border 的宽度和上边 border 的宽度。但是如果在一些情况下滚动条在左边的话,就是左边滚动条加上左边 border 的距离。

  • clientWidth/clientHeight 元素内部大小,包括 content 和 padding 。当前元素是 html 时是视口的大小。也就是是说document.documentElement.clientHeight返回视口高度。

  • scrollWidth/scrollHeight 当当前元素没有出现滚动条前,和clientWidth/clientHeight一样,是一个元素的content 加 padding ,当出现后包裹滚动出去的部分。

  • scrollTop/scrollLeft 当前元素向上,向左滚动出去的距离。

    在了解清楚这些基本概念后,下一个要了解的就是怎么获取到某一个标签元素。js提供了document对象接口,对于普通的元素,只需要用该接口下的document.getElementById('id')document.getElementByClass('class')document.querySelector('id')等方法即可获取到,但是对于根元素,还有一个方法,就是用document.documentElement,而且在获取根元素时,更多的建议是使用第二种方法。

    在知道了元素关于距离的一些基本知识和如何获取一个元素后,就可以进入主线任务,思考怎么样才能获取到一个图片是否在可视区域内了。

    首先,已经知道了document.documentElement.clientHeight/clientWidth返回视口高度,scrollWidth/scrollHeight返回元素隐藏的和可见的高度,scrollTop/scrollLeft 当前元素向上,向左滚动出去的距离。offsetLeft/offsetTop距离最近的祖先元素左上角为原点的x,y坐标,也是距离。

    然后看下图,想要计算出红色区域是否在可视区域内,只需要知道红色区域顶部到黑色区域顶部的距离小于等于图中的scrollTop + clientHeight就行了。

    A 区域 scrollTop
    B区域 clientHeight

    C 区域

    所以,假设图中的代码实现如下, body是 A 区域,div是标签在 B 区域,也就是可视区域内,img在 c 区域内,实现一个最简单的图片懒加载。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <html>
    <head>
    </head>
    <body>
    <div></div>
    <img data-src="https://a.com/b.png" src="" />
    </body>
    </html>

  • 第一步,判断 img 是否在可视区域内
    1
    2
    3
    4
    5
    6
    7
    const offsetTop = document.querySelector('img').offsetTop
    cosnt scrollTop = document.documentElement.scrollTop
    const clientTop = document.documentElement.clientTop
    if (offsetTop < > scrollTop + clientTop) {
    console.log('图片在可视区域内,加载')
    document.setAttribute('src', document.getAttribute('data-src'))
    }
    • 第二步 监听页面滚动
      第一步实现的代码只能是在第一次加载的时候执行,之后就不会执行了,所以将它封装到一个函数里面,然后在监听页面滚动,这样当页面位置发生变化的时候,随时都可以处理。
1
2
3
4
5
6
7
8
9
10
function lazyLoadImg () {
const offsetTop = document.querySelector('img').offsetTop
cosnt scrollTop = document.querySelector('body').scrollTop
const clientTop = document.querySelector('body').clientTop
if (offsetTop < > scrollTop + clientTop) {
console.log('图片在可视区域内,加载')
document.setAttribute('src', document.getAttribute('data-src'))
}
}
window.addEventListener('scroll', lazyLoadImg, false);
  • 第三步 抽象,让代码可用
    完成上面两步以后,算是完成了一个可以演示的 demo,但是在实际的生产环境中,完全没法用。所以,现在需要抽象。

    1. 判断任意条件下的图片是否在可视区域内并进行处理
      可能出现的复杂情况就是图片有自己的祖先元素,然后祖先元素又有祖先元素,这样一直循环下去,直到遇到那个祖先元素等于 null 的元素或者设置了滚动的元素。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      function lazyLoadImg () {
      const imgs = document.querySelectorAll('img') // 先拿到所有的图片标签
      for (let each of imgs) {
      const needShow = judgeImgShow(each) // 判断图片是否在可视区域内
      if (needShow) {
      each.setAttribute('src', each.getAttribute('data-src'))
      }
      }
      }

      function judgeImgShow (el) {
      let top = el.offsetTop
      let viewportEle = document.querySelector('body') // 在 chrome 的测试中,document.documentElement.client 获取到的是完整的文档高度,而不是视口高度,所以这里保险起见用 body 标签
      let parent = el.offsetParent
      while ( parent !== null && getComputedStyle(parent).overflow !== 'scroll') { // 遍历,一级级的往上查
      top = top + parent.offsetTop + parent.clientTop
      parent = parent.offsetParent
      if (parent && getComputedStyle(parent).overflow === 'scroll') {
      viewportEle = parent
      }
      }
      console.log(top, viewportEle.scrollTop + viewportEle.clientHeight, 'viewportEle.scrollTop + viewportEle.clientHeight')
      return top <= viewportEle.scrollTop + viewportEle.clientHeight
      }
      window.addEventListener('scroll', lazyLoadImg, false);
      window.onload = lazyLoadImg
      1. 图片懒加载的基本功能已经实现。但是如果我们不停的上下滚动页面,我们的函数会反复遍历已经加载过的图片的祖先标签,这又给浏览器增加了一定的负担,所以这一块可以优化一下,把已经加载过的缓存起来,再次滚到的话直接跳过。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      function lazyLoadImg () {
      const imgs = document.querySelectorAll('img') // 先拿到所有的图片标签
      if (!window.hadLazyLoadingImgObj) {
      window.hadLazyLoadingImgObj = {}
      }
      for (let each of imgs) {
      if (!window.hadLazyLoadingImgObj[each.getAttribute('data-src')]) {
      const needShow = judgeImgShow(each) // 判断图片是否在可视区域内
      if (needShow) {
      each.setAttribute('src', each.getAttribute('data-src'))
      window.hadLazyLoadingImgObj[each.getAttribute('data-src')] = true
      }
      }

      }
      }

      function judgeImgShow (el) {
      let top = el.offsetTop
      let viewportEle = document.querySelector('body') // 在 chrome 的测试中,document.documentElement.client 获取到的是完整的文档高度,而不是视口高度,所以这里保险起见用 body 标签
      let parent = el.offsetParent
      while ( parent !== null && getComputedStyle(parent).overflow !== 'scroll') { // 遍历,一级级的往上查
      top = top + parent.offsetTop + parent.clientTop
      parent = parent.offsetParent
      if (parent && getComputedStyle(parent).overflow === 'scroll') {
      viewportEle = parent
      }
      }
      console.log(top, viewportEle.scrollTop + viewportEle.clientHeight, 'viewportEle.scrollTop + viewportEle.clientHeight')
      return top <= viewportEle.scrollTop + viewportEle.clientHeight
      }
      window.addEventListener('scroll', lazyLoadImg, false);
      window.onload = lazyLoadImg

      方法二:通过 getBoudingClient 实现

elem.getBoundClientRect返回对应元素相对于窗口的x/y坐标,盒子的width/height属性,top/bottom,left/right等属性。对应于方法一中,只需要把原先需要遍历计算距离顶部的那部分代码,替换成这里的y`即可。 和前面相比,代码简单了很多。

1
2
3
4
5
6
function judgeImgShow (el) {
let top = el.getBoudingClientRect().y
let height = window.innerHeight

return top <= height
}

方法二:通过 IntersectionObserver 实现

IntersectionObserver 是用来检测一个元素是在视口或者需要监测的祖先元素可见性变化情况的 API。

使用 InterSectionObserver 时首先需要做的就是将它实例化。实例化之前有两件事需要做两件事,第一定义一个回调函数,第二定义观测配置。

1
2
3
4
5
6
7
8
9
10
11
function callObserver () {

}

const options = {
root: null,// null 或者不填就是以视口为检测目标,检测元素在视口内的可见性变化情况,可以通过document.querySelector("#scrollArea")来指定特定元素内的滚动情况
rootMargin: "0px", // 默认值为 0,一般都是特定检测元素的 margin 值
threshold: 0 // 可见性达到多少时触发回调函数,0-1 之间用百分比计算。0 则是出现一个像素即可。还可以使用数组的形势表示每达到一定的高度调用一次,比如[0, 0.5, 0.75, 1]表示在刚出现,出现 50%,75%,完全出现时各调用一次回调函数。在图片懒加载的需求中,传 0 即可。
}

const observer = new InterSectionObserver(callObserver, options)

现在已经实例化了一个观测对象,对于实例化的时候所用的配置,已经在注释里面说的很明白了,配置项里面包括了 root,roottarget,threshoid 三个属性值,大多数情况下可以不填,默认即可,除非需要监测特定的 dom 极其内部的元素,才会去配置这些参数。

关于回调函数,会在调用时返回一个IntersectionObserverEntry的数组,里面包括了发生变化的对象,里面有对应元素的一些基本信息,在图片懒加载的需求中,只需要知道里面有一个target属性,他的值就是在监测的 dom, isIntersecting对应 dom 是否出现在视口区域。除了这个对象外,还会返回当前的Observer实例

进一步补充回调函数。

1
2
3
4
5
6
7
8
9
10
11
function callObserver (intersectionEntry, observer) {
for (let each of intersectionEntry) {
if (each.target && each.isIntersecting) {
const ele = each.target
console.log(ele, 'ele')
ele.setAttribute('src', ele.getAttribute('data-src'))
observer.unobserve(ele)
}
}

}

到了这里,回调函数已经完成了。但是最关键的一步,监测还没做。在完成监测的代码之前,先继续了解实例化后得到的一些方法。在InterSectionObserver实例化以后,有以下几个方法需要在图片懒加载的需求中用到。

  • observe(targetElement) 添加需要监测的元素
  • unobserve(targetElement) 移除不需要继续监测的元素
  • disconnect() 停止监测所有元素。

有了以上基础,就可以将所有图片加入到监测对象中去了。

1
2
3
4
5
const imgs = document.querySelectorAll('img') // 先拿到所有的图片标签
for (let each of imgs) {
observer.observe(each)
}

到了这里,基本的简单操作已经都完成了,下面将代码整合起来,封装到函数中供生产环境使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ffunction callObserver (intersectionEntry, observer) {
for (let each of intersectionEntry) {
if (each.target && each.isIntersecting) {
const ele = each.target
console.log(ele, 'ele')
ele.setAttribute('src', ele.getAttribute('data-src'))
observer.unobserve(ele)
}
}

}
function lazyLoadImg () {
const observer = new InterSectionObserver(callObserver, {
root: null,
rootMargin: '0px',
threshold: 0
})
const imgs = document.querySelectorAll('img') // 先拿到所有的图片标签
for (let each of imgs) {
observer.observe(each)
}
}
window.load = lazyLoadImg

至此,使用InterSectionObserver实现图片懒加载就完成了。其实上面的代码还可以继续优化。留给有缘人吧。

总结

可以发现自己实现图片懒加载的话方法三比方法二简单,方法二比方法一简单。但是无论哪一种和使用轮子比起来,都稍微有点麻烦,工作中使用的话就具体问题具体看待吧。

不管是造轮子还是自己通过某一个方法实现,核心是理解图片懒加载是什么,需要怎么做。当知道图片懒加载是在可见的区域加载图片,不可见区域暂缓的时候,问题就转化成了判断图片是否可见,可见的话加载。

以上代码都在本地浏览器中经过了测试,可以跑起来。

参考文献

在懒加载的学习整理过程中,我参考学习了以下链接的文档,对这些组织和个人表示感谢。
懒加载
针对 Web 的浏览器级图片延迟加载
Element size and scrolling
Intersection Observer API
vanilla-lazyload