Vue-lazyload原理详解之源码解析

原文:https://blog.csdn.net/u010014658/article/details/73477232

前叙

本来想要研究mint-ui组件库的Lazy load组件,没想到翻看它的源码,发现它完全引用的vue-lazyload项目,直接引用,没有丝毫修改。
因此转而研究vue-lazyload,代码并不多,几百行吧,有兴趣的可以读一下。

简单接入示例

html代码:

  • js代码:

    
    
    
    
    

    官方文档和示例:
    mint-ui Lazyload文档
    vue-lazyload github文档

    原理剖析

    首先是我总结的一个lazyload的主要流程的流程图
    这里写图片描述
    原理简述:

    1. vue-lazyload是通过指令的方式实现的,定义的指令是v-lazy指令
    2. 指令被bind时会创建一个listener,并将其添加到listener queue里面, 并且搜索target dom节点,为其注册dom事件(如scroll事件)
    3. 上面的dom事件回调中,会遍历 listener queue里的listener,判断此listener绑定的dom是否处于页面中perload的位置,如果处于则加载异步加载当前图片的资源
    4. 同时listener会在当前图片加载的过程的loading,loaded,error三种状态触发当前dom渲染的函数,分别渲染三种状态下dom的内容

    源码剖析

    • 首先组件安装的函数 install函数解析:

      install (Vue, options = {}) {
          const LazyClass = Lazy(Vue)
          const lazy = new LazyClass(options) // 核心函数
          const isVueNext = Vue.version.split('.')[0] === '2' // 判断当前vue的版本
      
          Vue.prototype.$Lazyload = lazy
          // 如果支持 lazyload 组件,则定义一个 lazy-component的全局组件
          if (options.lazyComponent) {
              Vue.component('lazy-component', LazyComponent(lazy))
          }
      
          if (isVueNext) { // 2.0版本 自定义指令方式
              Vue.directive('lazy', {
                  bind: lazy.add.bind(lazy),
                  update: lazy.update.bind(lazy),
                  componentUpdated: lazy.lazyLoadHandler.bind(lazy),
                  unbind : lazy.remove.bind(lazy)
              })
          } else { // 1.0 版本自定义指令的方式
              Vue.directive('lazy', {
                  bind: lazy.lazyLoadHandler.bind(lazy),
                  update (newValue, oldValue) {
                      assign(this.vm.$refs, this.vm.$els)
                      lazy.add(this.el, {
                          modifiers: this.modifiers || {},
                          arg: this.arg,
                          value: newValue,
                          oldValue: oldValue
                      }, {
                          context: this.vm
                      })
                  },
                  unbind () {
                      lazy.remove(this.el)
                  }
              })
          }
      }
    • 下面分析LazyClass核心函数,源码如下

      function (Vue) {
          return class Lazy {};
      }
      • 了一个class对象,然后在install函数创建了一个class实例,下面首先看看它的构造函数
      constructor ({ preLoad, error, preLoadTop, loading, attempt, silent, scale, listenEvents, hasbind, filter, adapter }) {
              this.ListenerQueue = []
              this.TargetIndex = 0
              this.TargetQueue = []
              this.options = {
                  silent: silent || true,
                  preLoad: preLoad || 1.3, // 0.3的距离是 当前dom距离页面底部的高度时就开始加载图片了
                  preLoadTop: preLoadTop || 0, // dom的底部距离页面顶部多少距离还是加载
                  error: error || DEFAULT_URL, // 加载失败显示的图片
                  loading: loading || DEFAULT_URL, // 加载中显示的图片
                  attempt: attempt || 3, // 图片加载失败,最多重试的次数
                  scale: scale || getDPR(scale),
                  ListenEvents: listenEvents || DEFAULT_EVENTS, // 给dom注册dom的事件,在这些事件回调中会触发加载图片的方法
                  hasbind: false,
                  supportWebp: supportWebp(),
                  filter: filter || {},
                  adapter: adapter || {} // 状态变化的回调监听,同时也可以使用lazyload的$on()函数(注意不是vue的)来监听状态变化的回调函数
              }
              this.initEvent() // 初始化事件处理器 (实现同理 vue的事件机制)
              // 使用了节流函数
              this.lazyLoadHandler = throttle(() => {
                  let catIn = false
                  this.ListenerQueue.forEach(listener => {
                      if (listener.state.loaded) return
                      catIn = listener.checkInView() // 判断当前dom是否处于可以preload的位置
                      catIn && listener.load() // 处于preload的位置, 执行图片加载的操作
                  })
              }, 200)
          }

      关于options配置项可以参考vue-lazyload的github官网的说明。但是我还是将重要的配置在上面做了中文说明。
      lazyLoadHandler()函数是一个很重要的函数,它触发图片加载的入口函数,并且此函数是图片加载的入口。它的核心处理函数经过了节流函数的处理了,关于节流函数,我在之前的mint-ui 的inifite-scroll组件做了说明,如果想了解,请移步。

    • 下面对constructor中调用的initEvent()函数,初始化事件处理器函数的代码进行说明。

      实现方式很简单,代码大家应该都很容易读懂,我就不加注释说明了,并且vue中的事件处理也是这样实现,代码基本相同,相信读过vue源码的同学应该有感触。

    • 下面是v-lazy指令 bind时触发的lazy 的 add函数,源码如下

      add (el, binding, vnode) {
              if (some(this.ListenerQueue, item => item.el === el)) { // 判断当前监听队列里面是否含有当前dom的监听事件
                  //如果已经含有,执行它的update函数,更新即可,无需创建
                  this.update(el, binding)
                  return Vue.nextTick(this.lazyLoadHandler)
              }
      
              let { src, loading, error } = this.valueFormatter(binding.value)
      
              Vue.nextTick(() => {
                  src = getBestSelectionFromSrcset(el, this.options.scale) || src
      
                  const container = Object.keys(binding.modifiers)[0]
                  let $parent
      
                  // 如果使用了container 修饰符, 那么查找我们定义的contianer; 如果没有使用当前dom所在最近的滚动parent
                  // 这个contianer是用于 设置监听dom事件的dom对象, 他的事件触发回调会触发图片的加载操作
                  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)
                  }
      
                  // 在当前dom绑定到vdom中, 为当前dom创建一个监听事件(此事件用于触发当前dom在不同时期的不同处理操作), 并将事件添加到事件队列里面
                  const newListener = new ReactiveListener({
                      bindType: binding.arg, // 要绑定的属性
                      $parent,
                      el,
                      loading,
                      error,
                      src,
                      elRenderer: this.elRenderer.bind(this),
                      options: this.options
                  })
      
                  this.ListenerQueue.push(newListener)
                  if (inBrowser) {
                      this._addListenerTarget(window)
                      this._addListenerTarget($parent)
                  }
      
                  this.lazyLoadHandler()
                  Vue.nextTick(() => this.lazyLoadHandler())
              })
          }

      主要操作:找到对应的target(用于注册dom事件的dom节点;比如:页面滚动的dom节点),为其注册dom事件;为当前dom创建Listenr并添加到listener queue中。最后代用lazyLoadHandler()函数,加载图片

    • 下面,我们回过头来看lazyLoadHandler()的实现,其实前面已经简单解析过。

          this.lazyLoadHandler = throttle(() => {
              let catIn = false
              this.ListenerQueue.forEach(listener => {
                  if (listener.state.loaded) return
                  catIn = listener.checkInView()
                  catIn && listener.load()
              })
          }, 200)

      下面继续看checkInView()是怎么实现,简单当前dom是否位于preload的位置

      checkInView () { 
          this.getRect() // 调用dom的getBoundingClientRect()
          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)
      }

      首先看y轴方向的判断:this.rect.top < window.innerHeight * this.options.preLoad, 是dom的顶部是否到了preload的位置;this.rect.bottom > this.options.preLoadTop 判断dom的底部是否到达了preload的位置
      关于x轴方向就不做解析了,实现同y轴。

      然后是load()异步加载图片的核心函数

      load () {
          // 如果当前尝试加载图片的次数大于指定的次数, 并且当前状态还是错误的, 停止加载动作
          if ((this.attempt > this.options.attempt - 1) && this.state.error) {
              if (!this.options.silent) console.log('error end')
              return
          }
      
          if (this.state.loaded || imageCache[this.src]) {
              return this.render('loaded', true) // 使用缓存渲染图片
          }
      
          this.render('loading', false) // 调用lazy中的 elRender()函数, 用户切换img的src显示数据,并触发相应的状态的回调函数
      
          this.attempt++ // 尝试次数累加
      
          this.record('loadStart') // 记录当前状态的时间
      
          // 异步记载图片, 使用Image对象实现
          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) // 渲染 loaded状态的 dom的内容
              imageCache[this.src] = 1 // 当前图片缓存在浏览器里面了
          }, err => {
              this.state.error = true
              this.state.loaded = false
              this.render('error', false)
          })
      }

      紧接着是loadImageAsync()异步加载图片的函数

      实现很简单,就是使用的Image对象实现的网络请求。

    • 下面来看看渲染图片不同状态的render函数的实现,首先是listener中的render()函数

      render (state, cache) {
          this.elRenderer(this, state, cache) // 指向的是lazy class中的 elRenderer函数
      }

      下面来看elRenderer函数实现

      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) { // v-lazy: 后面的内容, 代表绑定的是这个属性
                  el.style[bindType] = 'url(' + src + ')'  // 用于lazy load 背景图片
              } else if (el.getAttribute('src') !== src) {
                  el.setAttribute('src', src)  // 普通lazyload image
              }
      
              el.setAttribute('lazy', state) // 自定义属性 lazy,用于给用于 根据此进行class搜索,设置指定状态的样式
      
              this.$emit(state, listener, cache) // 触发当前状态的回调函数
              // 触发adapter中的回调函数
              this.options.adapter[state] && this.options.adapter[state](listener, this.options)
          }

      上面将lazy load实现主要过程做了解析,下面对指令的update回调和lazy-component组件进行解析。

    • 从指令创建时传递的配置可知update指向的lazy class的update()函数,也就是v-lazy指令绑定的数据发生改变的时候出发的回调函数。

          update (el, binding) { // 获取当前dom绑定的 图片src的数据, 如果当前dom执行过load过程, 重置当前dom的图片数据和状态
              let { src, loading, error } = this.valueFormatter(binding.value) // 当前绑定的value是 obj, 从中选取{src, loading, error}; 是string, 则用作src
              // 找到当前dom绑定的listener
              const exist = find(this.ListenerQueue, item => item.el === el)
              // 更新listener的状态和状态对应的图片资源
              exist && exist.update({
                  src,
                  loading,
                  error
              })
              this.lazyLoadHandler()
              Vue.nextTick(() => this.lazyLoadHandler())
          }

      上面代码很简单,逻辑通过注释基本能看懂。

    • lazy-component组件
      我们看到注册全局的lazy-component组件的时候,创建组件实例是通过一个方法创建的,方法原型如下:

      export default (lazy) => { // 将lazy class的实例作为参数传入
          return {
          }
      }

      下面再来看看props,data和render。

          props: {
              tag: { // 当前组件渲染出来的外层的container的tag
                  type: String,
                  default: 'div' 
              }
          },
          render (h) {
              // 如果当前组件内的内容是隐藏状态, 只渲染外层 container
              if (this.show === false) {
                  return h(this.tag)
              }
              // 变为显示状态, 渲染组件内的slot内容,也就要显示的主体内容
              return h(this.tag, null, this.$slots.default)
          },
          data () {
              return {
                  state: { // 当前组件内容的状态
                      loaded: false
                  },
                  rect: {}, // 当前组件的dom getBoundingClientRect()内容
                  show: false // 当前组件内的内容的显示状态
              }
          }  

      然后是mounted()回调函数,在当前组件挂载上的时候的回调。

          mounted () {
              lazy.addLazyBox(this)
              lazy.lazyLoadHandler()
          }

      内部触发了lazy的addLazyBox()函数和lazyLoadHandler()函数。关于lazyLoadHandler()函数上面已经说过好多了,不在赘述。下面对addLazyBox()进行解析。

          addLazyBox (vm) {
              this.ListenerQueue.push(vm) // 将当前vue实例以Listener的方式传入到listener queue队列中;当前vue实例就是起到listener的作用
              if (inBrowser) {
                  this._addListenerTarget(window)
                  if (vm.$el && vm.$el.parentNode) { // 为当前组件的dom 父节点注册相应的dom事件
                      this._addListenerTarget(vm.$el.parentNode)
                  }
              }
          }

      通过上面的代码可知,当前组件的vue实例起到和我们上面提到的listener相同的作用,那么它可能也会有listener对应的核心的api 函数。是的,这些都在组件的methods中注册了。

          methods: {
              getRect () {
                  this.rect = this.$el.getBoundingClientRect()
              },
              checkInView () {
                  this.getRect()
                  return inBrowser &&
                      (this.rect.top < window.innerHeight * lazy.options.preLoad && this.rect.bottom > 0) &&
                      (this.rect.left < window.innerWidth * lazy.options.preLoad && this.rect.right > 0)
              },
              load () { // 执行到dom的时候,就没有网络请求了,直接将dom的内容显示出来了
                  this.show = true
                  this.state.loaded = true
                  this.$emit('show', this) // 注意: 这里的触发的回调事件是vue发出的,只能vue才能拦截
              }
          }

      上面的代码量很少,也很简单,不再赘述。但是大家有没有注意到load()方法,这里没有显示调用render()函数去渲染不同状态的内容,和listener不同。那是因为vue的mvvm数据绑定机制。data建立了observer,当里面的数据发生变化的时候,会触发update()回调,然后触发render()渲染函数。关于vue怎么实现的mvvm,可以通过阅读vue的源码得知。

    总结

    通过阅读源码我们学到了什么。

    • lazy load的实现原理
    • 作者代码结构的设计,我们可以看到Lazy load模块和listener模块他们的业务职责分工明确。lazy负责和dom相关的处理,包括为dom创建listener,为target注册dom事件,渲染dom;而listener只负责状态的控制,在不同状态执行不同的业务。

    --------------------- 作者:qiaoba_gogo 来源:CSDN 原文:https://blog.csdn.net/u010014658/article/details/73477232?utm_source=copy 版权声明:本文为博主原创文章,转载请附上博文链接!

    你可能感兴趣的:(vue)