从〇带你实现一个图片懒加载插件

2,409 阅读12分钟

网站性能好不好,全看图片少不少!

图片的多少直接影响了网页加载的速度,尤其是当你手机网不好的时候(手动@苹果)一张图片转啊转,转半天也转不出来,气的直接想把手机给砸了...

所以日常开发中我们不会一次性让图片全部加载出来,而是先加载几张图片让用户觉得网页已经加载完毕了,实际上我们在后台再悄咪咪的去加载其他图片,神不知鬼不觉图片就加载完了,而且拥有非常丝滑的体验。

开源组件库

先看开源组件库中是怎么实现图片懒加载功能的,目前比较流行的几个组件库分别是 ElementUI、Vant 和 Antdv,除了 Antdv 没有图片懒加载功能外,其他两个组件库都有懒加载功能,ElementUI 是自己写的一套,Vant 借助了第三方插件 vue-lazyload。

说明一下,本人技术栈是 Vue,所以列出的都是 Vue 相关的组件库,React 也有很多优秀的组件库,有兴趣可以自己去看一下

在 ElementUI 中实现的比较简单,loading 状态下是一个 div,等到图片渲染的时候就会替换成 img 标签,而 vue-lazyload 作为一个插件,考虑的更加周到,功能也更全面,它是通过提供一个自定义的全局指令 v-lazy 来实现的。但是他们的核心原理都是一样的,就是通过给带有滚动属性的容器加一个滚动事件的监听,只渲染可视区域内的图片,从而实现图片懒加载功能。

准备工作

看完了别人的功能我们来自己实现一个,这里我是参照了 vue-lazyload 的源码尽量实现一个简洁的插件。

首先准备几张图片,这里我就直接使用了 ElementUI 的图片,将图片放入列表中待会展示

简易的 HTML 模板

<div id="app">
  <div class="box">
    <li v-for="(img, index) in imgs" :key="index">
      <img v-lazy="img" alt="" />
    </li>
  </div>
</div>

OK,准备工作完成了,接下来直接就进入我们的正题了。

install 方法

开发 Vue 的插件必须提供一个 install 方法,这个方法的第一个参数是 Vue 构造器,第二个参数是一个可选的选项对象。然后我们通过 Vue.use 来使用它,install 方法被同一个插件多次调用时,只会被安装一次,Vue 在内部做了一个缓存优化,避免重复安装。

借用官网的一个例子:

MyPlugin.install = function (Vue, options) {
  // 1. 添加全局方法或 property
  Vue.myGlobalMethod = function () {
    // 逻辑...
  }

  // 2. 添加全局资源
  Vue.directive('my-directive', {
    bind (el, binding, vnode, oldVnode) {
      // 逻辑...
    }
    ...
  })

  // 3. 注入组件选项
  Vue.mixin({
    created: function () {
      // 逻辑...
    }
    ...
  })

  // 4. 添加实例方法
  Vue.prototype.$myMethod = function (methodOptions) {
    // 逻辑...
  }
}

当我们调用 Vue.use(MyPlugin) 的时候,Vue 内部就会自动执行 install 方法,并根据内部的合并策略自动合并到组件实例上去,届时我们就可以直接使用插件提供的功能了。

那说完了怎么开发插件,现在就动手来实现我们自己的一个插件。

我们给插件起一个名字叫 VueLazyload,然后向外导出:

const VueLazyload = {
  install: function() {
		// ...
  }
}
export default VueLazyload

install 方法里我们要做的事就是处理用户传入的 options 并给 Vue 实例上添加一个全局指令 v-lazy

install: function (Vue) {
  // 保存用户传入的 options
  const options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}

  // 函数柯里化,将每一个的功能都封装到一个类里,如果功能需要扩展的话直接添加一个新的类即可
  const LazyClass = Lazy(Vue)
  const lazy = new LazyClass(options)

  Vue.directive('lazy', {
    bind: lazy.add.bind(lazy), // 将 this 绑定到 lazy 上
  })
}

首先 install 方法的第一个参数是 Vue 构造函数,这是在使用 Vue.use() 时就会默认传的,就是为了让插件与 Vue 不会强关联上。假设没有传入这个 Vue 构造函数,插件内部使用 Vue 就必须显式引入,这样就会导致和用户使用的版本不一致时发生不可预见的异常。

options 可以直接通过 arguments 来取,这个不用多说。重点是生成一个 lazy 实例,这里我也是看了源码之后好长时间才理解的,使用函数柯里化将 Vue 作为参数传入到一个专门生成类的函数中,我们可以将每一个功能都封装到一个类里面,然后通过 Lazy 函数返回,vue-lazyload 的功能非常全,感兴趣的可以去了解一下他的源码

接下来就是自定义一个全局指令,使用的是 Vue.directive(),不熟悉的同学可以去文档上看一下使用说明,使用它提供的一个初始化钩子函数 bind 将指令绑定至元素上。然后将 lazy 实例上的 add 方法赋给了这个钩子函数,注意 this 的指向,需要使用 bind 方法显式指定 thislazy

Lazy 方法

首先要明白的是这个方法是用来返回一个类的,然后用这个类生成实例:

const Lazy = Vue => {
  return class LazyClass {
    constructor() {
    
    }
    add() {

    }
  }
}

这样我们基本的框架就已经完成了,接下来要做的事就是处理自定义指令。

这个 add 方法就是将指令绑定到 img 元素上的,它的参数就是 bind 函数的参数,即 elbindingsvnode,在这里面我们要处理的就是整个图片懒加载功能的核心原理:

  • 找到图片的带有滚动的父级容器,然后给它添加上监听事件
  • 判断元素是否在这个容器的可视区域内,如果不在那就不需要加载它

根据这个原理,我们一步步地去实现它。

添加监听事件

由于要找到指令绑定元素的父级元素,就必须要等到元素被插入到父元素时才能够获取到,一种方法是直接使用指令的 inserted 钩子函数,但是这里我们使用了 bind 似乎是没有办法获取到父级元素。别急,Vue 中 DOM 更新是使用的异步队列,我们可以通过 Vue.nextTick() 来获取更新后的 DOM,也就是说需要将逻辑写到 nextTick 的回调函数里。

那我们写一个方法来获取绑定元素的带有滚动属性的父元素:

const getScrollParent = el => {
  let parent = el.parentNode
  while (parent) {
    // 判断父级元素是不是可以滚动的
    if (/scroll|auto/.test(getComputedStyle(parent)['overflow'])) {
      return parent
    }
    parent = parent.parentNode
  }
  return window
}

很简单,就是通过遍历父元素的 overflow 属性,如果是 scroll 或者 auto 就返回。

拿到这个父级元素之后,我们判断一下它有没有绑定滚动事件,如果没有,就给它绑定一个滚动事件。

let scrollParent = getScrollParent(el)
// 如果父级元素存在并且没有绑定 `scroll` 事件
if (scrollParent && !this.bindHandle) {
  this.bindHandle = true
  scrollParent.addEventListener('scroll', throttle(this.handleLazyLoad.bind(this), 200))
}

滚动事件一般都需要加上一个函数节流来优化,这个节流函数你可以自己写,也可以使用 throttle-debounce 或者 lodash 提供的 _.throttle

记得要销毁事件

初次渲染

接下来就需要去判断这个元素是否在容器的可视区域内,这里我们定义一个类,用来生成每一张被监听的图片的实例,由于我们前面使用了函数柯里化来生成 lazy 实例,那现在就可以直接在 Lazy 方法中添加一个类:

// 每一个图片元素都构造成一个类的实例,方便扩展
class ReactiveListener {
  constructor({ el, src, options, elRender }) {
    this.el = el
    this.src = src
    this.options = options
    this.state = {
      loading: false, // 是否加载过
      error: false, // 是否出错
    }
    this.elRender = elRender
  }
  // 检测当前图片是否在可是区域内
  checkInView() {
    // ...
  }
  // 加载这个图片
  load() {
    // ...
  }
}

根据我们前面所说的,如果图片在可视区域内,就需要加载这张图片,所以这两个方法后面都是必要的。

再回到 add 方法里,添加完滚动事件后,就需要把绑定的图片元素推到队列里,从队列里遍历,如果没有加载过并且在可视区域内,那么就调用图片的 load 方法加载它,下面把这两个方法的完整代码放出来:

add(el, bindings, vnode) {
  Vue.nextTick(() => {
    let scrollParent = getScrollParent(el)
    if (scrollParent && !this.bindHandle) {
      this.bindHandle = true
      scrollParent.addEventListener('scroll', throttle(this.handleLazyLoad.bind(this), 2z00))
    }
    // 判断这个元素是否在容器的可视区域内,如果不在就不需要渲染
    const listener = new ReactiveListener({
      el,
      src: bindings.value,
      options: this.options,
      elRender: this.elRender.bind(this),
    })
    // 把每一个图片实例都放进数组里
    this.listenerQueue.push(listener)
    this.handleLazyLoad()
  })
}
handleLazyLoad() {
  // 遍历所有的图片,如果没有加载过并且在可视区域内就加载图片
  this.listenerQueue.forEach(listener => {
    if (!listener.state.loading) {
      let catIn = listener.checkInView()
      catIn && listener.load()
    }
  })
}

检测图片和加载图片也比较简单,我就直接写出来:

// 检测当前图片是否在可是区域内
checkInView() {
  let { top } = this.el.getBoundingClientRect()
  // 相对的是窗口的高度
  return top < window.innerHeight * (this.options.preload || 1.3)
}
// 加载这个图片
load() {
  // 先加载 loading 图片
  // 加载成功则替换成正常的图片
  this.elRender(this, 'loading')
  console.log(this.src)
  loadImageAsync(
    this.src,
    () => {
      setTimeout(() => {
        this.state.loading = true
        this.elRender(this, 'finish')
      }, 1000)
    },
    () => {
      this.state.error = true
      this.elRender(this, 'error')
    }
  )
}

需要注意的是 getBoundingClientRect 是获取元素的大小以及相对于视口的位置的,在 vue-lazyload 中还使用了一种新的 API 叫 Intersection Observer API,这个 API 能够让你以异步回调的方式监听元素的可见性,感兴趣可以直接去 MDN 了解

这个 loadImageAsync 方法就是创建一个 Image 对象,图片加载成功了会走第一个回调,加载失败会走第二个回调,这里加上 setTimeout 是为了开发的时候更容易看到效果,实际使用的时候是不需要的。这个方法你也可以将它改造成一个 Promise,变成真正的异步加载图片。

const loadImageAsync = (src, resolve, reject) => {
  let image = new Image()
  image.src = src
  image.onload = resolve
  image.onerror = reject
}

elRender

ok,到这里基本上就已经写完了核心代码,最后我们需要一个方法根据不同的状态来改变图片的 src 路径,这个方法就是 lazy 实例上的 elRender 方法:

// 渲染方法
elRender(listener, state) {
  let el = listener.el
  let src = ''
  switch (state) {
    case 'loading':
      src = listener.options.loading || '' // 用户传进来的 loading
      break
    case 'error':
      src = listener.options.error || '' // 用户传进来的 error
      break
    default:
      src = listener.src // finish
      break
  }
  el.setAttribute('src', src)
}

如果图片是正在加载中,就显示 loading 图,加载失败了就显示 error 图,加载成功了那就显示原图片。

效果图

最后我们来使用一下自己的图片懒加载插件:

Vue.use(VueLazyLoad, {
  preload: 1,
  loading: 'http://images.h-ui.net/icon/detail/loading/075.gif',
})

这里我设置了默认加载一屏,并且传入了 loading 图

从图片中可以看出前四张都已经渲染完成了,第五张图片还没有 src 地址,证明我们的懒加载已经实现了,放上几张效果图:

这是后话

当然,源码中的实现有很多细节值得我们学习的,这里只是简单的带大家实现了一个图片懒加载插件,有很多情况都没有考虑到,我还是推荐大家去看一下 vue-lazyload 的源码。

最近这段时间刚入职了新公司,全程都在搞公司里的项目,也没有多少时间看其他的东西,而且公司的项目使用的是 antdv 组件库,所以看 antdv 的源码会多一些,导致我没有多余的时间再去写 ElementUI 的源码文章,不过有时间的话我还是会把 ElementUI 系列写下去,因为它的源码看起来要比 antdv 的舒服。

源码

最后附上源码,别忘了来一波关注和点赞~

/**
 * 函数节流
 * @param {*} action 回调函数
 * @param {*} delay 延迟的时间
 */
function throttle(action, delay) {
  var timeout = null
  var lastRun = 0
  return function () {
    if (timeout) {
      return
    }
    var elapsed = Date.now() - lastRun
    var context = this
    var args = arguments
    var runCallback = function runCallback() {
      lastRun = Date.now()
      timeout = false
      action.apply(context, args)
    }
    if (elapsed >= delay) {
      runCallback()
    } else {
      timeout = setTimeout(runCallback, delay)
    }
  }
}

// 获取元素带有滚动的父级元素
const getScrollParent = el => {
  let parent = el.parentNode
  while (parent) {
    // 判断父级元素是不是可以滚动的
    if (/scroll|auto/.test(getComputedStyle(parent)['overflow'])) {
      return parent
    }
    parent = parent.parentNode
  }
  return window
}

const loadImageAsync = (src, resolve, reject) => {
  let image = new Image()
  image.src = src
  image.onload = resolve
  image.onerror = reject
}

const Lazy = Vue => {
  // 每一个图片元素都构造成一个类的实例,方便扩展
  class ReactiveListener {
    constructor({ el, src, options, elRender }) {
      this.el = el
      this.src = src
      this.options = options
      this.state = {
        loading: false, // 是否加载过
        error: false, // 是否出错
      }
      this.elRender = elRender
    }
    // 检测当前图片是否在可是区域内
    checkInView() {
      let { top } = this.el.getBoundingClientRect()
      // 相对的是窗口的高度
      return top < window.innerHeight * (this.options.preload || 1.3)
    }
    // 加载这个图片
    load() {
      // 先加载 loading 图片
      // 加载成功则替换成正常的图片
      this.elRender(this, 'loading')
      console.log(this.src)
      loadImageAsync(
        this.src,
        () => {
          setTimeout(() => {
            this.state.loading = true
            this.elRender(this, 'finish')
          }, 1000)
        },
        () => {
          this.state.error = true
          this.elRender(this, 'error')
        }
      )
    }
  }

  class LazyClass {
    constructor(options) {
      this.options = options
      this.bindHandle = false // 是否已经绑定 scroll 事件
      this.listenerQueue = []
    }

    add(el, bindings, vnode) {
      Vue.nextTick(() => {
        let scrollParent = getScrollParent(el)
        if (scrollParent && !this.bindHandle) {
          this.bindHandle = true
          scrollParent.addEventListener('scroll', throttle(this.handleLazyLoad.bind(this), 200))
        }
        // 判断这个元素是否在容器的可视区域内,如果不在就不需要渲染
        const listener = new ReactiveListener({
          el,
          src: bindings.value,
          options: this.options,
          elRender: this.elRender.bind(this),
        })
        // 把每一个图片实例都放进数组里
        this.listenerQueue.push(listener)
        this.handleLazyLoad()
      })
    }
    handleLazyLoad() {
      // 遍历所有的图片,如果没有加载过并且在可视区域内就加载图片
      this.listenerQueue.forEach(listener => {
        if (!listener.state.loading) {
          let catIn = listener.checkInView()
          catIn && listener.load()
        }
      })
    }
    // 渲染方法
    elRender(listener, state) {
      let el = listener.el
      let src = ''
      switch (state) {
        case 'loading':
          src = listener.options.loading || '' // 用户传进来的 loading
          break
        case 'error':
          src = listener.options.error || '' // 用户传进来的 error
          break
        default:
          src = listener.src
          break
      }
      el.setAttribute('src', src)
    }
  }
  
  return LazyClass
}

const VueLazyLoad = {
  install: function (Vue) {
    // 保存用户传入的 options
    const options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}

    // 函数柯里化,将每一个的功能都封装到一个类里,如果功能需要扩展的话直接添加一个新的类即可
    const LazyClass = Lazy(Vue)
    const lazy = new LazyClass(options)

    Vue.directive('lazy', {
      bind: lazy.add.bind(lazy), // 将 this 绑定到 lazy 上
    })
  },
}

export default VueLazyLoad