[路飞]如何实现图片懒加载

198 阅读4分钟

「这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战」。

懒加载是什么

图片懒加载是指我们对在视口之外的图片先不加载,每次只加载视口内的图片,达到减少图片请求数量,实现优化的效果。

懒加载实现方式

在实现之前先介绍几个东西

视口之内的范围是多少

通常我们说的视口之内是指浏览器窗口可以看见的地方,会随着浏览器缩放而改变。我们如何得到呢?通过 document.body.clientHeight,这次先做竖向滚动的。

而浏览器滚动条的位置是 document.documentElement.scrollTop,所以我们的视口的区间就是 [滚动条的位置, 滚动条位置 + 视口高度]。

还有我们不可能容器是贴着左上角 0,0 点的,所以我们肯定要计算上外层容器的偏移量 offset,外层容器的偏移量如何获取?通过 element.offsetTop, elelment 是我们的容器元素,offsetTop 是元素距离父容器顶部的距离。也就是说我们拿到的偏移量是根据父容器来定位的。那么嵌套的情况下。我们需要拿到父容器的父容器的偏移量。在一层嵌套的情况下呢就是父容器对他的父容器的偏移量 + 内部节点相对于父容器的偏移量。

一层嵌套的时候,得到图片的位置是通过图片的偏移量加上外层容器的偏移量得到,然后我们就判断这个图片是否在视口的区间内,如果是就加载他,如果不是就不用管。

    滚动条位置 <= (img.offsetTop + parent.offsetTop) <= (滚动条位置 + 视口高度)

我们是用图片顶部到父元素顶部的距离,所以还有一种情况就是当图片滚上去一半的时候,图片的顶部已经离开了这个区间,而其实图片还在视口内,所以我们要加上一个图片的高度。

    滚动条位置 <= (img.offsetTop + img.offsetHeight + parent.offsetTop) &&
    (img.offsetTop + parent.offsetTop) <= (滚动条位置 + 视口高度)

data-

data- 是可以绑在dom元素上的数据。使用方法是写在标签上 data-foo="xxxx",然后就可以在 js 代码通过 element.dataset.foo 读到这个数据。当然也可以通过 element.dataset.foo = 1 的方法提供数据。

需要注意的是如果是 html 对大小写不敏感,所以写在 html 模板上的时候是单词之间用 - 隔开,而在js的时候是要用驼峰命名的方式来读,例如 html: data-foo-bar,js: dataset.fooBar

data- 是一个很好的存放 src 的方法,他区分了不同的 img 标签,还能作为标记,如果提供了这个数据,那就是希望懒加载。为后面封装通用 api 埋下伏笔。

所以先实现一个最简单的版本。

    const img = document.createElement('img')
    img.src = '初始化默认图片'
    img.dataset.src = '真正要展示的图片'
    document.body.append(img)
    
    window.addEventListener('scroll', () => {
        if (用上面的判断方法,判断是否在区间) {
            img.src = img.dataset.src
            img.onload = () => {
                // 加载成功之后做些什么
            }
        }
    })

封装成通用api

通过传入一个容器,收集容器内部的 img,然后在滚动的时候遍历这些 img,如果是在视口之内就切换 src。完事之后把监听去除。

其实有更好的办法就是把数组里面完成了的都记下来,可以通过调换位置都放到数组开头,因为有 completed 计数的关系,所以可以在完成后统一截取 slice(completed)。可以通过宏任务,微任务来实现统一截取。这样就不用每次都遍历n个,不过只是一个思路,这里并没有实现。

细节

  1. 这里实现了什么呢?首先用了 iife 来避免全局的污染,而且也用 iife 来实现了偏函数。我们希望的时候尽可能不把 dom 节点闭包到函数里面,因为这样很容易一不小心就造成内存泄漏。那么可以作为参数传入 dom 节点。

  2. 因为没有实现清除加载完的元素,所以用了另一个办法,就是将加载完的图片,赋值为空,虽然数组的长度没变,但是可以提早结束这次循环。

  3. 移除监听。这里面用了闭包,首先要保存监听函数,然后通过里面做条件判断,达到某个条件就移除监听,俗称自己干掉自己。

  4. 注意闭包了什么,能不能被回收。(一定)

  5. 通过偏函数保存偏移量计算结果。上面提到了 offsetTop 是距离父节点顶部的偏移量,那多层嵌套的时候怎么半。解决的办法就是一直向上寻找,直到找到 body 或者 document,进行 offsetTop 的累加。

    ;(function (window) {
        window.lazyImg = container => {
          const state = {
            completed: 0,
            imgs: container.querySelectorAll('img[data-src]')
          }

          // 图片计数,减少遍历数组的次数
          const imgs = (state.imgs = Array.from(
            state.imgs,
            function (img, i) {
              return {
                img,
                onload: () => {
                  this.imgs[i].onload = null
                  this.imgs[i] = null
                  this.completed++
                }
              }
            },
            state
          ))

          state.length = imgs.length

          // 懒加载,获取图片数组,然后如果还没加载并且在视口内就加载
          function lazy() {
            const len = imgs.length
            for (let i = 0; i < imgs.length; i++) {
              if (imgs[i]) {
                const { img, onload } = imgs[i]
                const src = img.dataset.src
                if (src && src !== img.src && computedPos(img)) {
                  img.src = src
                  img.onload = onload
                }
              }
            }
          }

          // 闭包外层节点的偏移量,然后加上里面的节点对父节点的偏移量
          // 只算了top,可以加上left
          const computedPos = (function (container) {
            let offsetY = 0
            // 解决嵌套的偏移量获取
            // 只计算一次
            while(container === document.body) {
              offsetY += container.offsetTop
              container = container.parentElement
            }
            
            return img => {
              const pos = offsetY + img.offsetTop
              const min = document.documentElement.scrollTop
              const max = min + document.body.clientHeight
              const isStared = pos + img.offsetHeight >= min && pos <= max
              console.log(isStared)
              return isStared
            }
          })(container)

          // 完成后移除监听
          // 封装了一个节流的函数,功能是延迟300ms,会执行传入的函数。
          const listener = window.$throttle(() => {
            state.completed === state.length
              ? window.removeEventListener('scroll', listener)
              : lazy()
          }, 300)

          // 处理一开始就在视口中的图片。
          // 留 150ms 处理一进页面就滚动。
          setTimeout(listener, 150)

          window.addEventListener('scroll', listener)
       }
   })(window)

结尾

至此图片懒加载就实现了,有些功能还没实现,比如左右滑动时视口改变,比如优化每一次遍历。一边写一边设计,把最近学的封装的知识用上,写完之后在浏览器看到效果的时候,激动不已。大概这就是写代码的魅力吧。