vue-lazyload源码解析

3,064 阅读7分钟

首先,我们来说一下vue-lazyload解决了什么问题:

如果一个网页有成千上万张图片需要加载,页面滚动就会变得非常卡顿。此时很多人都会想到懒加载的概念,即只加载可视区域的图片,其他的图片暂时有一个占位图,等它们滚动到可视区域时再去请求真实图片并替换。这里,我们需要一个检查图片dom元素是否在浏览器可视区域内的方法checkInView,然后给所有图片的滚动父元素绑定一个滚动事件的监听方法scrollHandler,如下

上图已经实现了一个简易的图片懒加载的思路,带着这个思路,我们来看一下vue-lazyload的实现。

从package.json中的script命令脚本了解项目构建的配置文件是build.js。vue-lazyload库是通过rollup构建的,从build.js文件我们看出来入口文件是src目录下的index.js

// build.js
build({
  input: path.resolve(__dirname, 'src/index.js'),
  plugins: [
    resolve(),
    commonjs(),
    babel({ runtimeHelpers: true })
  ]
}, {
  format: 'es',
  filename: 'vue-lazyload.esm.js'
})

通过这个我们找到src/index.js文件,具体大家可以去github下载源码,这里只截取部分代码

// src/index.js
export default {
  install (Vue, options = {}) {
    const LazyClass = Lazy(Vue)
    const lazy = new LazyClass(options)
    const lazyContainer = new LazyContainer({ lazy })

    const isVue2 = Vue.version.split('.')[0] === '2'
    Vue.prototype.$Lazyload = lazy

    if (options.lazyComponent) {
      Vue.component('lazy-component', LazyComponent(lazy))
    }

    if (options.lazyImage) {
      Vue.component('lazy-image', LazyImage(lazy))
    }

    if (isVue2) {
      Vue.directive('lazy', {
        bind: lazy.add.bind(lazy),
        update: lazy.update.bind(lazy),
        componentUpdated: lazy.lazyLoadHandler.bind(lazy),
        unbind: lazy.remove.bind(lazy)
      })
      Vue.directive('lazy-container', {
        bind: lazyContainer.bind.bind(lazyContainer),
        componentUpdated: lazyContainer.update.bind(lazyContainer),
        unbind: lazyContainer.unbind.bind(lazyContainer)
      })
    } else {
       ...
    }
  }
}

index.js文件主要为我们提供了几种方便使用的指令或组件:

  • 创建lazy对象并定义lazy指令
  • 创建lazyContainer对象并定义lazyContainer指令
  • 构建lazy-component组件
  • 构建lazy-image组件

这里lazy、lazyContainer指令和lazy-组件用法不同,从github上vue-lazyload的文档可以看出里面的区别。但是其内部实现原理基本一致,我们主要分析v-lazy的指令在vue2版本的实现。

Lazy类

// src/lazy.js
export default function (Vue) {
  return class Lazy {
    constructor ({ preLoad, error, throttleWait, preLoadTop, dispatchEvent, loading, attempt, silent = true, scale, listenEvents, hasbind, filter, adapter, observer, observerOptions }) {
      this.version = '__VUE_LAZYLOAD_VERSION__'
      this.mode = modeType.event
      this.ListenerQueue = []
      this.TargetIndex = 0
      this.TargetQueue = []
      this.options = {
        silent: silent,
        dispatchEvent: !!dispatchEvent,
        throttleWait: throttleWait || 200,
        preLoad: preLoad || 1.3,
        preLoadTop: preLoadTop || 0,
        error: error || DEFAULT_URL,
        loading: loading || DEFAULT_URL,
        attempt: attempt || 3,
        scale: scale || getDPR(scale),
        ListenEvents: listenEvents || DEFAULT_EVENTS,
        hasbind: false,
        supportWebp: supportWebp(),
        filter: filter || {},
        adapter: adapter || {},
        observer: !!observer,
        observerOptions: observerOptions || DEFAULT_OBSERVER_OPTIONS
      }
      this._initEvent()
      this._imageCache = new ImageCache({ max: 200 })
      this.lazyLoadHandler = throttle(this._lazyLoadHandler.bind(this), this.options.throttleWait)

      this.setMode(this.options.observer ? modeType.observer : modeType.event)
    }
    ...
}

Lazy类的构造函数中定义了一系列属性,这里可以去github上看属性描述,这里只描述部分。

key 描述 默认值
preLoad 预加载高度比例 1.3
error 图片加载失败显示图 'data-src'
loading 图片加载中显示图 'data-src'
attempt 图片加载尝试次数 3
listenEvents 监听的事件 ['scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove']
observer 是否使用IntersectionObserver false

主要了解一下构造函数中执行的三行代码:

第一行,this._initEvent(),初始化loading、loaded、error的事件监听方法

_initEvent () {
  this.Event = {
    listeners: {
      loading: [],
      loaded: [],
      error: []
    }
  }

  this.$on = (event, func) => {
    if (!this.Event.listeners[event]) this.Event.listeners[event] = []
    this.Event.listeners[event].push(func)
  }

  this.$once = (event, func) => {
    const vm = this
    function on () {
      vm.$off(event, on)
      func.apply(vm, arguments)
    }
    this.$on(event, on)
  }

  this.$off = (event, func) => {
    if (!func) {
      if (!this.Event.listeners[event]) return
      this.Event.listeners[event].length = 0
      return
    }
    remove(this.Event.listeners[event], func)
  }

  this.$emit = (event, context, inCache) => {
    if (!this.Event.listeners[event]) return
    this.Event.listeners[event].forEach(func => func(context, inCache))
  }
}

第二行,lazyLoadHandler方法就是_lazyLoadHandler加了一个节流包装后返回的函数,这里我们需要关心的地方有懒加载处理函数、节流处理函数

// 懒加载处理函数
// 将监听队列中loaded状态的监听对象取出存放在freeList中并删掉,判断未加载的监听对象是否处在预加载位置,如果是则执行load方法。
_lazyLoadHandler () {
  const freeList = []
  this.ListenerQueue.forEach((listener, index) => {
    if (!listener.el || !listener.el.parentNode) {
      freeList.push(listener)
    }
    const catIn = listener.checkInView()
    if (!catIn) return
    listener.load()
  })
  freeList.forEach(item => {
    remove(this.ListenerQueue, item)
    item.$destroy()
  })
}

// src/utils.js
function throttle (action, delay) {
  let timeout = null
  let lastRun = 0
  return function () {
    if (timeout) {
      return
    }
    let elapsed = Date.now() - lastRun
    let context = this
    let args = arguments
    let runCallback = function () {
      lastRun = Date.now()
      timeout = false
      action.apply(context, args)
    }
    if (elapsed >= delay) {
      runCallback()
    } else {
      timeout = setTimeout(runCallback, delay)
    }
  }
}

第三行,this.setMode(),设置监听模式。我们通常使用事件模式或者IntersectionObserver来判断元素是否进入视图,若进入视图则需为图片加载真实路径。如果使用监听事件模式,mode为event,如果使用IntersectionObserver,mode为observer

setMode (mode) {
  if (!hasIntersectionObserver && mode === modeType.observer) {
    mode = modeType.event
  }

  this.mode = mode // event or observer

  if (mode === modeType.event) {
    if (this._observer) {
      this.ListenerQueue.forEach(listener => {
        this._observer.unobserve(listener.el)
      })
      this._observer = null
    }

    this.TargetQueue.forEach(target => {
      this._initListen(target.el, true)
    })
  } else {
    this.TargetQueue.forEach(target => {
      this._initListen(target.el, false)
    })
    this._initIntersectionObserver()
  }
}

看完lazy的初始化,我们来了解一下lazy指令

lazy指令

Vue.directive('lazy', {
    bind: lazy.add.bind(lazy),
    update: lazy.update.bind(lazy),
    componentUpdated: lazy.lazyLoadHandler.bind(lazy),
    unbind: lazy.remove.bind(lazy)
})

看一下lazy指令中的几个钩子函数(参考vue的指令

  • bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
  • update:所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新。
  • componentUpdated:指令所在组件的 VNode 及其子 VNode 全部更新后调用。
  • unbind:只调用一次,指令与元素解绑时调用。

bind指令

当指令第一次绑定到元素上时,调用的是lazy.add方法:

add (el, binding, vnode) {
  // 判断当前元素是否在监听队列中,如果在就执行update方法
  // 并在下次dom更新循环结束之后延迟回调懒加载方法lazyLoadHandler
  if (some(this.ListenerQueue, item => item.el === el)) {
    this.update(el, binding)
    return Vue.nextTick(this.lazyLoadHandler)
  }
  
  // 获取图片真实路径,loading状态占位图路径,加载失败占位图路径
  let { src, loading, error, cors } = this._valueFormatter(binding.value)

  Vue.nextTick(() => {
    src = getBestSelectionFromSrcset(el, this.options.scale) || src
    this._observer && this._observer.observe(el)

    const container = Object.keys(binding.modifiers)[0]
    let $parent

    if (container) {
      $parent = vnode.context.$refs[container]
      // if there is container passed in, try ref first, then fallback to getElementById to support the original usage
      $parent = $parent ? $parent.$el || $parent : document.getElementById(container)
    }

    if (!$parent) {
      $parent = scrollParent(el)
    }

    const newListener = new ReactiveListener({
      bindType: binding.arg,
      $parent,
      el,
      loading,
      error,
      src,
      cors,
      elRenderer: this._elRenderer.bind(this),
      options: this.options,
      imageCache: this._imageCache
    })

    this.ListenerQueue.push(newListener)

    if (inBrowser) {
      this._addListenerTarget(window)
      this._addListenerTarget($parent)
    }

    this.lazyLoadHandler()
    Vue.nextTick(() => this.lazyLoadHandler())
  })
}

lazy.add方法中主要逻辑有两点:

  • 当前dom若已存在监听队列ListenerQueue中,则直接调用this.update方法,在dom渲染完毕后执行懒加载处理函数this.lazyLoadHandler()
  • 若当前dom不存在监听队列中
    • 则创建新的监听对象newListener并将其存放在监听队列ListenerQueue中
    • 设置window或$parent为scroll事件的监听目标对象
    • 执行懒加载处理函数this.lazyLoadHandler()

然后,我们了解一下在lazy.add中用到的newListener对象

ReactiveListener类

export default class ReactiveListener {
  constructor ({ el, src, error, loading, bindType, $parent, options, cors, elRenderer, imageCache }) {
    this.el = el
    this.src = src
    this.error = error
    this.loading = loading
    this.bindType = bindType
    this.attempt = 0
    this.cors = cors

    this.naturalHeight = 0
    this.naturalWidth = 0

    this.options = options

    this.rect = null

    this.$parent = $parent
    this.elRenderer = elRenderer
    this._imageCache = imageCache
    this.performanceData = {
      init: Date.now(),
      loadStart: 0,
      loadEnd: 0
    }

    this.filter()
    this.initState()
    this.render('loading', false)
  }
  ...
 }

在ReactiveListener类的构造函数末尾执行了三个方法:

  • this.filter():调用用户传参时定义的filter方法,动态修改图片的src,比如添加前缀或者是否支持webp(可以在github上看到)
// 截取github上的使用
Vue.use(vueLazy, {
    filter: {
      progressive (listener, options) {
          const isCDN = /qiniudn.com/
          if (isCDN.test(listener.src)) {
              listener.el.setAttribute('lazy-progressive', 'true')
              listener.loading = listener.src + '?imageView2/1/w/10/h/10'
          }
      },
      webp (listener, options) {
          if (!options.supportWebp) return
          const isCDN = /qiniudn.com/
          if (isCDN.test(listener.src)) {
              listener.src += '?imageView2/2/format/webp'
          }
      }
    }
})
filter () {
    ObjectKeys(this.options.filter).map(key => {
      this.options.filter[key](this, this.options)
    })
}
  • this.initState():将图片的真实路径绑定到元素的data-src属性上,并为监听对象添加error、loaded、rendered状态
initState () {
    if ('dataset' in this.el) {
      this.el.dataset.src = this.src
    } else {
      this.el.setAttribute('data-src', this.src)
    }
    
    this.state = {
      loading: false,
      error: false,
      loaded: false,
      rendered: false
    }
}
  • this.render():实际上调用的是lazy.js中的_elRenderer方法
    • 根据传递的状态参数loading设置当前图片的路径为loading状态占位图路径
    • 将loading状态绑定到元素的lazy属性上
    • 触发用户监听loading状态上的函数this.$emit(state, listener, cache)
_elRenderer (listener, state, cache) {
  if (!listener.el) return
  const { el, bindType } = listener

  let src
  switch (state) {
    case 'loading':
      src = listener.loading
      break
    case 'error':
      src = listener.error
      break
    default:
      src = listener.src
      break
  }

  if (bindType) {
    el.style[bindType] = 'url("' + src + '")'
  } else if (el.getAttribute('src') !== src) {
    el.setAttribute('src', src)
  }

  el.setAttribute('lazy', state)

  this.$emit(state, listener, cache)
  this.options.adapter[state] && this.options.adapter[state](listener, this.options)

  if (this.options.dispatchEvent) {
    const event = new CustomEvent(state, {
      detail: listener
    })
    el.dispatchEvent(event)
  }
}

_lazyLoadHandler

到这一步我们将lazy指令绑定的所有dom元素封装成一个个ReactiveListener监听对象,并将其存放在ListenerQueue队列中,当前元素显示的是loading状态的占位图,dom渲染完毕后将会执行懒加载处理函数_lazyLoadHandler。再来看一下该函数代码:

_lazyLoadHandler () {
  const freeList = []
  this.ListenerQueue.forEach((listener, index) => {
    if (!listener.el || !listener.el.parentNode) {
      freeList.push(listener)
    }
    const catIn = listener.checkInView()
    if (!catIn) return
    listener.load()
  })
  freeList.forEach(item => {
    remove(this.ListenerQueue, item)
    item.$destroy()
  })
}

懒加载函数干的事情就两点:

  • 遍历所有监听对象并删除掉不存在的listener或父元素不存在、隐藏等不需要显示的listener;
  • 遍历所有监听对象并判断当前对象是否处在预加载位置,如果处在预加载位置,则执行监听对象的load方法。

我们主要了解一下_lazyLoadHandler中使用到的两个方法。

一是判断当前对象是否处在预加载位置的listener.checkInView();

listener.checkInView()

checkInView方法内部实现:判断元素位置是否处在预加载视图内,若元素处在视图内部则返回true,反之则返回false。

// src/listener.js
checkInView () {
    this.getRect()
    return (this.rect.top < window.innerHeight * this.options.preLoad && this.rect.bottom > this.options.preLoadTop) &&
            (this.rect.left < window.innerWidth * this.options.preLoad && this.rect.right > 0)
}
  
getRect () {
    this.rect = this.el.getBoundingClientRect()
}

二是监听对象的listener.load()方法 load()对处于预加载容器视图内的元素加载真实路径。

listener.load()

// src/listener.js
load (onFinish = noop) {
    // 若尝试次数完毕并且对象状态为error,则打印错误提示并结束。
    if ((this.attempt > this.options.attempt - 1) && this.state.error) {
      if (!this.options.silent) console.log(`VueLazyload log: ${this.src} tried too more than ${this.options.attempt} times`)
      onFinish()
      return
    }
    
    // 若当前对象状态为loaded并且路径已缓存在imageCache中,则调用this.render('loaded', true)渲染dom真实路径。
    if (this.state.rendered && this.state.loaded) return
    if (this._imageCache.has(this.src)) {
      this.state.loaded = true
      this.render('loaded', true)
      this.state.rendered = true
      return onFinish()
    }
    
    // 若以上条件都不成立,则调用renderLoading方法渲染loading状态的图片。
    this.renderLoading(() => {
      this.attempt++
    
      this.options.adapter['beforeLoad'] && this.options.adapter['beforeLoad'](this, this.options)
      this.record('loadStart')
    
      loadImageAsync({
        src: this.src,
        cors: this.cors
      }, data => {
        this.naturalHeight = data.naturalHeight
        this.naturalWidth = data.naturalWidth
        this.state.loaded = true
        this.state.error = false
        this.record('loadEnd')
        this.render('loaded', false)
        this.state.rendered = true
        this._imageCache.add(this.src)
        onFinish()
      }, err => {
        !this.options.silent && console.error(err)
        this.state.error = true
        this.state.loaded = false
        this.render('error', false)
      })
    })
}
  
renderLoading (cb) {
    this.state.loading = true
    // 异步加载图片
    loadImageAsync({
      src: this.loading,
      cors: this.cors
    }, data => {
      this.render('loading', false)
      this.state.loading = false
      cb()
    }, () => {
      // handler `loading image` load failed
      cb()
      this.state.loading = false
      if (!this.options.silent) console.warn(`VueLazyload log: load failed with loading image(${this.loading})`)
    })
}

const loadImageAsync = (item, resolve, reject) => {
      let image = new Image()
      if (!item || !item.src) {
        const err = new Error('image src is required')
        return reject(err)
      }
    
      image.src = item.src
      if (item.cors) {
        image.crossOrigin = item.cors
      }
    
      image.onload = function () {
        resolve({
          naturalHeight: image.naturalHeight,
          naturalWidth: image.naturalWidth,
          src: image.src
        })
      }
    
      image.onerror = function (e) {
        reject(e)
      }
}

update指令

分析完bind钩子,我们再来看lazy指令上声明的update钩子函数:

// src/lazy.js
update (el, binding, vnode) {
      let { src, loading, error } = this._valueFormatter(binding.value)
      src = getBestSelectionFromSrcset(el, this.options.scale) || src
    
      const exist = find(this.ListenerQueue, item => item.el === el)
      if (!exist) {
        this.add(el, binding, vnode)
      } else {
        exist.update({
          src,
          loading,
          error
        })
      }
      if (this._observer) {
        this._observer.unobserve(el)
        this._observer.observe(el)
      }
      this.lazyLoadHandler()
      Vue.nextTick(() => this.lazyLoadHandler())
}

update方法首先判断当前元素是否存在监听队列ListenerQueue中,若不存在则执行this.add(el, binding, vnode);若存在,则调用监听对象上的update方法执行完后调用懒加载处理函数this.lazyLoadHandler()

// src/listener.js
update ({ src, loading, error }) {
    // 取出之前图片的真实路径
    const oldSrc = this.src
    // 将新的图片路径设置为监听对象的真实路径
    this.src = src
    this.loading = loading
    this.error = error
    this.filter()
    // 比较两个路径是否相等,若不相等,则初始化加载次数以及初始化对象状态。
    if (oldSrc !== this.src) {
      this.attempt = 0
      this.initState()
    }
}

分析完lazy指令的bind,update钩子函数,我们了解到图片预加载逻辑如下:

  • 将图片元素封装成ReactiveListener对象,设置其真实路径src,预加载占位图路径loading,加载失败占位图路径error
  • 将每个监听对象ReactiveListener存放在ListenerQueue中
  • 调用预加载处理函数lazyLoadHandler(),将已经加载完毕的监听对象从监听队列中删除,将处于预加载容器视图内的图片元素通过异步方式加载真实路径

在初始化阶段及图片路径发生变化阶段的预加载逻辑我们整明白了,最后看下在容器发生滚动产生的图片预加载动作的整个逻辑。

元素位置发生变化

在之前的代码里我们说过setMode()设置监听模式

// src/lazy.js
if (mode === modeType.event) {
    if (this._observer) {
      this.ListenerQueue.forEach(listener => {
        this._observer.unobserve(listener.el)
      })
      this._observer = null
    }
    
    this.TargetQueue.forEach(target => {
      this._initListen(target.el, true)
    })
    } else {
    this.TargetQueue.forEach(target => {
      this._initListen(target.el, false)
    })
    this._initIntersectionObserver()
}

在上面代码中我们可以看出

  1. event模式,则调用this._initListen(target.el, true)这段代码为目标容器添加事件监听。默认监听'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'这些事件,当事件触发时调用预加载处理函数lazyLoadHandler()
// src/lazy.js
const DEFAULT_EVENTS = ['scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove']

_initListen (el, start) {
    this.options.ListenEvents.forEach((evt) => _[start ? 'on' : 'off'](el, evt, this.lazyLoadHandler))
}
    
// src/util.js
const _ = {
  on (el, type, func, capture = false) {
    if (supportsPassive) {
      el.addEventListener(type, func, {
        capture: capture,
        passive: true
      })
    } else {
      el.addEventListener(type, func, capture)
    }
  },
  off (el, type, func, capture = false) {
    el.removeEventListener(type, func, capture)
  }
}
  1. observer模式: 使用IntersectionObserver来监听元素是否进入了设备的可视区域之内,而不需要频繁的计算来做这个判断。这里不熟悉的同学推荐看一下阮一峰老师的日志

当使用observer模式时,主要做了两步:

  • this._initListen(target.el, false) : 移除目标容器对'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'事件的监听。
_initListen (el, start) {
  this.options.ListenEvents.forEach((evt) => _[start ? 'on' : 'off'](el, evt, this.lazyLoadHandler))
}
  • this._initIntersectionObserver(),遍历listenerQueue,对里面的listener添加IntersectionObserver监听,监听函数是_observerHandler(),监听到元素进入可视区域,调用监听对象的load()方法加载真实图片。
_initIntersectionObserver () {
  if (!hasIntersectionObserver) return
  this._observer = new IntersectionObserver(this._observerHandler.bind(this), this.options.observerOptions)
  if (this.ListenerQueue.length) {
    this.ListenerQueue.forEach(listener => {
      this._observer.observe(listener.el)
    })
  }
}

_observerHandler (entries, observer) {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      this.ListenerQueue.forEach(listener => {
        if (listener.el === entry.target) {
          if (listener.state.loaded) return this._observer.unobserve(listener.el)
          listener.load()
        }
      })
    }
  })
}

小结

当使用scroll模式时,图片加载逻辑:

  1. 给目标容器绑定事件'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'
  2. 'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'事件触发,调用懒加载处理函数lazyLoadHandler()
  3. 遍历监听队列ListenerQueue,删除状态为loaded的监听对象
  4. 遍历监听队列ListenerQueue,判断该监听对象是否存在预加载视图容器中,若存在,则调用load方法异步加载真实路径

当使用IntersectionObserver模式时,图片加载逻辑:

  1. 给目标容器解除事件'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'的绑定
  2. 给每个监听对象添加IntersectionObserver监听
  3. 当监听对象进入设备的可视区域之内,则调用监听对象的load方法异步加载真实路径

最后,以一图结束