vue-lazyload 插件源码分析

vue-lazyload 插件源码分析

简介

vue-lazyload是一个用于图片懒加载的插件,基于Vue写成,具体用法可参考(https://github.com/hilongjw/vue-lazyload)

入口分析

文章所分析的版本为1.3.3

从github上下载到源码后,观察package.json,观察其 main 选项可得知平时引用的文件为根目录下的vue-lazyload.js文件

通过其脚本代码 build:node build,分析 build.js 文件,根据 rollup 打包设置可看到,入口文件为 src/index

源码分析

Lazy类的实例化

index.js下向外暴露出的对象中包含一个install方法,用于用户的 Vue.use 调用

export default {
	install(Vue, options = {}) {
    const LazyClass = Lazy(Vue)
    const lazy = new LazyClass(options)
    const lazyContainer = new LazyContainer({ lazy })
    // 以上是对其Lazy类的实例化
    
    const isVue2 = Vue.version.split('.')[0] === '2' // 判断为Vue2.x的版本
    // 在Vue原型上注册方法,用于之后生成的每个实例中使用
    Vue.prototype.$Lazyload = lazy
    // 全局注册组件
    if (options.lazyComponent) {
      Vue.component('lazy-component', LazyComponent(lazy))
    }
    if (options.lazyImage) {
      Vue.component('lazy-image', LazyImage(lazy))
    }
    
    // vue2.x版本下的情况,进行指令注册
    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)
      })
    }
    ... 
    // Vue1.x版本类似
  }
}

接下来分析 lazy类 的代码,其实现在 lazy.js 文件下

// 默认加载loading图片
const DEFAULT_URL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
// 监听的事件列表
const DEFAULT_EVENTS = ['scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove']
const DEFAULT_OBSERVER_OPTIONS = {
  rootMargin: '0px',
  threshold: 0
}
// const LazyClass = Lazy(Vue) 在index中执行该语句则是返回Lazy类
export default function (Vue) {
  return class Lazy {
		constructor({ ... }) {  //参数解构用于下面的初始化
			...
      this.version = '__VUE_LAZYLOAD_VERSION__'
      this.mode = modeType.event
      this.ListenerQueue = []
      this.TargetIndex = 0
      this.TargetQueue = []
      this.options = {
        ...// 一系列参数初始化
      }
      ...
      this._initEvent() // 初始化事件监听
      this._imageCache = new ImageCache({ max: 200 }) // 图片缓存对象
      this.lazyLoadHandler = throttle(this._lazyLoadHandler.bind(this), this.options.throttleWait) 
      // _lazyLoadHandler是事件回调函数,throttle对其进行节流
      this.setMode(this.options.observer ? modeType.observer : modeType.event)
      // 这句是设置监听的模式,一般可认为是 this.mode = 'event'
    }
  }
}

以下是throttle的实现:

function throttle (action, delay) {
	let timeout = null
  let lastRun = 0
  return function () {
    if (timeout) return
    let elapsed = Date.now() - lastRun
    let context = this //  节流返回的函数中,this指向的是实例化的Lazy上下文
    let args = arguments
    let runCallback = function() {
      lastRun = Date.now()
      timeout = false
      action.apply(context, args)
    }
    if (elapsed >= delay) {
			runCallback()
    } else {
      timeout = setTimeout(runCallback, delay)
    }
  }
}

说回 Lazy 类的初始化:

class Lazy {
  ...
  // 初始化事件相关的函数,包括$on,$once,$off,$emit函数
  _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)
    }
    
    // 一次性事件监听
    // 结合 $off 来看实现思路:将其内部的on函数传入回调列表,在回调执行时实际上则执行了on函数,在 on 函数内部,先剔除回调列表内其on函数本身,保证下次该事件触发时不会执行到该函数,然后再执行回调函数
  	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)
      // remove实现原理:在数组中利用array.splice移除对应的回调函数
    }
    
    // 事件触发函数
    this.$emit = (event, context, inCache) => {
      if (!this.Event.listeners[event]) return
      this.Event.listeners[event].forEach(func => func(context, inCache))
    }
  }
}

初始化事件相关函数后,就是对图片缓存对象 ImageCache 的初始化;

class ImageCache {
  // 初始化
  constructor ({ max }) {
    this.options = {
      max: max || 200
    }
    this._caches = []
  }
  // 判断是否已经缓存某张图片
  has (key) {
    return this._caches.indexOf(key) > -1
  }
  // 加入缓存列表
  add (key) {
		if (this.has(key)) return
    this._caches.push(key)
    if (this._caches.length > this.options.max) {
      this.free()
    }
  }
  // 当列表达到上限,剔除第一项
  free() {
    this._caches.shift()
  }
}

下面是 _lazyLoadHandler 回调函数的实现

class Lazy {
	...
  _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()
    })
  }
}

其实实现思路很简单,当该事件回调函数被执行时,会将数组 listenerQueue 进行一次遍历,满足 checkInView 的项将调用该项的load方法

至此,Lazy类的实例化构建已经分析完成了,下面对其关于Vue的调用进行分析

Vue的调用

可以看到在 index.js 中,install方法 里有这么一部分代码

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全局指令的定义方法,也是我们平时用法中 v-lazy的由来,其中里面各个函数通过bind绑定的作用域lazy,也就是之前实例化的到的Lazy类实例

首先是lazy里的 add 函数实现

add (el, binding, vnode) {
  // 若绑定元素已经在监听队列中,直接执行类中的 update 方法,然后执行 lazyLoadHandler方法
  if (some(this.listenerQueue, item => item.el === el)) {
    this.update(el, binding)
    return Vue.nextTick(this.lazyLoadHandler)
  }
  // 取出绑定的 src图,loading图,error图,若没指定则用默认配置
  let { src, loading, error } = 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]
      $parent = $parent ? $parent.$el || $parent : document.getElementById(container)
    }
    if (!$parent) {
      $parent = scrollParent(el) // 获取到最近上层可滚动(有设置overflow为scroll或auto)元素
    }
    // 针对每一个绑定的元素创建一个监听对象
    const newListener = new ReactiveListener({
      bindType: binding.arg,
      $parent,
      el,
      loading,
      error,
      src,
      elRenderer: this._elRenderer.bind(this),
      options: this.options,
      imageCache: this._imageCache
    })
    // 监听对象加入到监听队列
    this.ListenerQueue.push(newListener)

    if (inBrowser) { // 添加window和上层可滚动元素到监听队列
      this._addListenerTarget(window)
      this._addListenerTarget($parent)
    }
    // 绑定执行完先调用一次,这样一来一开始就在屏幕可见区域内的图片即可加载
    this.lazyLoadHandler()
    Vue.nextTick(() => this.lazyLoadHandler())
  })
}
// 针对window和目标可滚动元素的事件监听
_addListenerTarget (el) {
  if (!el) return
  let target = find(this.TargetQueue, target => target.el === el)
  if (!target) {
    target = {
      el: el,
      id: ++this.TargetIndex,
      childrenCount: 1,
      listened: true
    }
    this.mode === modeType.event && this._initListen(target.el, true)
    this.TargetQueue.push(target)
  } else {
    target.childrenCount++
  }
  return this.TargetIndex
}
// 事件监听
_initListen (el, start) {
  this.options.ListenEvents.forEach((evt) => _[start ? 'on' : 'off'](el, evt, this.lazyLoadHandler))
}
// ListenEvents具体为['scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove']

接下来看一下监听对象的创建(ReactiveListener)

// listener.js

export default class ReactiveListener {
	constructor({ el, src, error, loading, bindType, $parent, options, elRenderer, imageCache }) {
    this.el = el
    this.src = src
    this.error = error
    this.loading = loading
    this.bindType = bindType
	 ...
   // 一系列参数初始化
    this.filter()
    this.initState()
    this.render('loading', false)
  }
  // 对filter做处理
  filter() {
    ObjectKeys(this.options.filter).map(key => {
      this.options.filter[key](this, this.options)
    })
  }
  // 设置监听器状态
  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
    }
  }
  // 进行各个状态的渲染
  render (state, cache) {
    this.elRenderer(this, state, cache) // elRenderer可追溯回lazy类内部,在初始化监听器时传入
  }
}

// lazy.js
_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)
  // 针对各个状态的自定义的函数执行
  ...
}

下面这个是如何获取上层可滚动元素的函数实现:

// util.js

// scrollParent函数:从自身元素开始,父元素循环查找符合条件的元素
const scrollParent = (el) => {
  if (!inBrowser) return
  if (!(el instanceof HTMLElement)) {
    return window
  }
  let parent = el
  while (parent) {
    if (parent === document.body || parent === document.documentElement) {
      break
    }
    if (!parent.parentNode) {
      break
    }
    if (/(scroll|auto)/.test(overflow(parent))) {
      return parent
    }
    parent = parent.parentNode
  }
  return window
}

// overflow函数
const overflow = (el) => {
  return style(el, 'overflow') + style(el, 'overflow-y') + style(el, 'overflow-x')
}
// style函数:通过getComputedStyle获取计算属性值
const style = (el, prop) => {
  return typeof getComputedStyle !== 'undefined'
    ? getComputedStyle(el, null).getPropertyValue(prop)
    : el.style[prop]
}

至此,整个懒加载初始化过程结束,接下来就是当页面发生变化,如滚动,resize等事件时候的处理

由之前初始化事件监听的代码可知

// 事件监听
_initListen (el, start) {
  this.options.ListenEvents.forEach((evt) => _[start ? 'on' : 'off'](el, evt, this.lazyLoadHandler))
}

对于事件的处理函数是 lazyLoadHandler 函数,分析可知也就是利用 throttle 封装的 _lazyLoadHandler 函数

_lazyLoadHandler () {
  const freeList = []
  // 针对监听列表进行循环
  this.ListenerQueue.forEach((listener, index) => {
    if (!listener.el || !listener.el.parentNode) {
      freeList.push(listener)
    }
    // 若是符合checkInview 函数条件的则执行监听器内部的load方法
    const catIn = listener.checkInView()
    if (!catIn) return
    listener.load()
  })
  freeList.forEach(item => {
    remove(this.ListenerQueue, item)
    item.$destroy()
  })
}

那么来看看 checkInview 函数的实现,这是在 ReactiveListener 监听器内部实现的,比较简单

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()
}

其内部实现逻辑:

1.通过 getBoundingClientRect 获取元素的尺寸

2.将元素距屏幕边界的距离 与其 预设值 进行计算对比,若是符合预算值范围内的则返回 true

当监听器返回 true 的结果后,就会执行其内部的 load 方法

load (onFinish = noop) {
  // onFinish 为传入的函数
  // 在初始化监听器时,会设置一个尝试次数(attemp),每执行一次load会从0开始加1,若当次 load 方法执行时已经超过该尝试次数,且之前都还未加载出来,则return
  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
  }
  // 若之前已成功加载,直接return
  if (this.state.rendered && this.state.loaded) return
  // 若图片缓存里有,则直接改变状态,然后return
  if (this._imageCache.has(this.src)) {
    this.state.loaded = true
    this.render('loaded', true)
    this.state.rendered = true
    return onFinish()
  }
	// 若以上都不符合,则该图片需要进行加载,使用 renderLoading 方法进行加载,该函数接收回调函数做处理
  this.renderLoading(() => {
    this.attempt++
    this.options.adapter['beforeLoad'] && this.options.adapter['beforeLoad'](this, this.options)
    this.record('loadStart')
    loadImageAsync({
      src: this.src
    }, 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
  loadImgAsyc({
    src: this.loading
  }, data => {
    this.render('loading', false)
    this.state.loading = false
    cb()
  }, () => {
    cb()
    this.state.loading = false
    ...
  })
}

其中关于 loadImgAsyc 的实现如下

// util.js

const loadImageAsync = (item, resolve, render) => {
  let image = new Image()
  ...
  image.src = item.src
  image.onload = function() {
    resolve({
      naturalHeight: image.naturalHeight,
      naturalWidth: image.naturalWidth,
      src: image.src
    })
  }
  image.onerror = function() {
    reject(e)
  }
}

所以说,一张图片在使用懒加载过程中,会执行两次 loadImageAsync,一次加载loading,一次加载src

posted @ 2020-02-04 14:30  garfill  阅读(529)  评论(0编辑  收藏  举报