vue-lazyload源碼分析

vue-lazyload源碼分析

項目構建的配置文件

從package.json中的script命令腳本瞭解項目構建的配置文件是build.js。vue-lazyload庫是經過rollup構建的,其中的input屬性值src/index.js爲源碼入口。vue

// build.js
async function build () {
  try {
    const bundle = await rollup.rollup({
      input: path.resolve(__dirname, 'src/index.js'),
      plugins: [
        resolve(),
        commonjs(),
        babel({ runtimeHelpers: true }),
        uglify()
      ]
    })

    let { code } = await bundle.generate({
      format: 'umd',
      name: 'VueLazyload'
    })

    code = rewriteVersion(code)

    await write(path.resolve(__dirname, 'vue-lazyload.js'), code)

    console.log('Vue-Lazyload.js v' + version + ' builded')
  } catch (e) {
    console.log(e)
  }
}

build()

源碼入口

// src/index.js
export default {
  /*
  * install function
  * @param  {Vue} Vue
  * @param  {object} options  lazyload options
  */
  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 {
      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)
        }
      })

      Vue.directive('lazy-container', {
        update (newValue, oldValue) {
          lazyContainer.update(this.el, {
            modifiers: this.modifiers || {},
            arg: this.arg,
            value: newValue,
            oldValue: oldValue
          }, {
            context: this.vm
          })
        },
        unbind () {
          lazyContainer.unbind(this.el)
        }
      })
    }
  }
}

src/index.js中主要作了兩件事:node

  • 建立lazy對象並定義lazy指令
  • 建立lazyContainer並定義lazy-container指令

這裏lazy指令跟lazyContainer指令是兩種不一樣的用法,從vue-lazyload文檔裏能夠查看其中的區別。此次主要經過lazy指令來對vue-lazyload進行分析。json

Lazy類

// src/lazy.js
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.lazyLoadHandler = throttle(this._lazyLoadHandler.bind(this), this.options.throttleWait)

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

Lazy類的構造函數中定義了一系列屬性,這些屬性一部分是內部私有屬性,一部分在vue-lazyload文檔中有介紹,這裏就不過多闡述了。主要了解一下構造函數中執行的三行代碼:緩存

// src/lazy.js
// 第一行
this._initEvent()

// 第二行
this.lazyLoadHandler = throttle(this._lazyLoadHandler.bind(this), this.options.throttleWait)

// 第三行
this.setMode(this.options.observer ? modeType.observer : modeType.event)

第一行對loading、loaded、error事件監聽方法的初始化:babel

// src/lazy.js

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

第二行代碼對懶加載處理函數進行了節流處理,這裏咱們須要關心的地方有懶加載處理函數、節流處理函數app

// src/lazy.js

// 對懶加載處理函數進行節流處理
 this.lazyLoadHandler = throttle(this._lazyLoadHandler.bind(this), this.options.throttleWait)

// 懶加載處理函數
// 將監聽隊列中loaded狀態的監聽對象取出存放在freeList中並刪掉,判斷未加載的監聽對象是否處在預加載位置,若是是則執行load方法。
_lazyLoadHandler () {
    const freeList = []
    this.ListenerQueue.forEach((listener, index) => {
        if (!listener.state.error && listener.state.loaded) {
            return freeList.push(listener)
        }
        // 判斷當前監聽對象是否在預加載位置,若是是則執行load方法開始加載
        const catIn = listener.checkInView()
        if (!catIn) return
        listener.load()
    })
    freeList.forEach(vm => remove(this.ListenerQueue, vm))
}

 // src/util.js

 // 函數節流封裝函數
 // 接收兩個參數,action爲待執行的行爲操做,delay爲節流延遲時間
 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)
    }
  }
}

第三行設置監聽模式,咱們一般使用scroll或者IntersectionObserver來判斷,元素是否進入視圖,若進入視圖則需爲圖片加載真實路徑。若是使用scrollmode值爲event,若是使用IntersectionObservermode值爲observerdom

// src/lazy.js

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

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指令

// src/index.js

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

首先咱們來了解一下lazy指令中聲明的幾個鉤子函數異步

  • bind: 只調用一次,指令第一次綁定到元素時調用。在這裏能夠進行一次性的初始化設置。
  • update:所在組件的 VNode 更新時調用,可是可能發生在其子 VNode 更新以前。指令的值可能發生了改變,也可能沒有。可是你能夠經過比較更新先後的值來忽略沒必要要的模板更新
  • componentUpdated:指令所在組件的 VNode 及其子 VNode 所有更新後調用。
  • unbind:只調用一次,指令與元素解綁時調用。

bind

當指令第一次綁定到元素上時,調用的是lazy.add方法:async

// src/lazy.js
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 } = 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,
            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())
        })
}

lazy.add方法中的主要邏輯就兩點:函數

  • 當前dom若已存在監聽隊列ListenerQueue中,則直接調用this.update方法並再dom渲染完畢以後執行懶加載處理函數this.lazyLoadHandler
  • 若當前dom不存在監聽隊列中:
    • 則建立新的監聽對象newListener並將其存放在監聽隊列ListenerQueue中。
    • 設置window$parent爲scroll事件的監聽目標對象。
    • 執行懶加載處理函數this.lazyLoadHandler()

由於lazy指令的update鉤子函數調用的即是lazy的update方法,因此第一點咱們放在後面再講。第二點中咱們主要目標是瞭解這個newListener對象。

ReactiveListener類

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

    this.naturalHeight = 0
    this.naturalWidth = 0

    this.options = options

    this.rect = null

    this.$parent = $parent
    this.elRenderer = elRenderer

    this.performanceData = {
      init: Date.now(),
      loadStart: 0,
      loadEnd: 0
    }

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

在ReactiveListener類的構造函數末尾執行了三個方法:

  • this.filter(): 調用用戶傳參時定義的filter方法。
  • this.initState():將圖片的真實路徑綁定到元素的data-src屬性上,併爲監聽對象添加error,loaded,rendered狀態。
// src/listener.js

initState () {
    if ('dataset' in this.el) {
      this.el.dataset.src = this.src
    } else {
      this.el.setAttribute('data-src', this.src)
    }

    this.state = {
      error: false,
      loaded: false,
      rendered: false
    }
  }
  • this.render('loading', false): 實際調用的是lazy.js中的_elRenderer方法。
    • 根據傳遞的狀態參數loading設置當前圖片的路徑爲loading狀態佔位圖路徑。
    • 將loading狀態綁定到元素的lazy屬性上。
    • 觸發用戶監聽loading狀態上的函數this.$emit(state, listener, cache)
// src/listener.js
render (state, cache) {
    this.elRenderer(this, state, cache)
}

// src/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)

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

_lazyLoadHandler

到這一步咱們將lazy指令綁定的全部dom元素封裝成一個個ReactiveListener監聽對象,並將其存放在ListenerQueue隊列中,當前元素顯示的是loading狀態的佔位圖,dom渲染完畢後將會執行懶加載處理函數_lazyLoadHandler。再來看一下該函數代碼:

// src/lazy.js

_lazyLoadHandler () {
    const freeList = []
    this.ListenerQueue.forEach((listener, index) => {
    if (!listener.state.error && listener.state.loaded) {
        return freeList.push(listener)
    }
    const catIn = listener.checkInView()
    if (!catIn) return
    listener.load()
    })
    freeList.forEach(vm => remove(this.ListenerQueue, vm))
}

懶加載函數乾的事情就兩點:

  • 遍歷全部監聽對象並刪除掉已經加載完畢狀態爲loaded的listener;
  • 遍歷全部監聽對象並判斷當前對象是否處在預加載位置,若是處在預加載位置,則執行監聽對象的load方法。

第一點邏輯一目瞭然,不須要再過多闡述。咱們主要了解一下_lazyLoadHandler中使用到的兩個方法。一是判斷當前對象是否處在預加載位置的listener.checkInView();另外一個是監聽對象的load方法:listener.load();

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

// 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.loaded || imageCache[this.src]) {
      this.state.loaded = true
      onFinish()
      return this.render('loaded', true)
    }

    // 若以上條件都不成立,則調用renderLoading方法渲染loading狀態的圖片。
    this.renderLoading(() => {
      this.attempt++

      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)
        imageCache[this.src] = 1
        onFinish()
      }, err => {
        !this.options.silent && console.error(err)
        this.state.error = true
        this.state.loaded = false
        this.render('error', false)
      })
    })
  }

  // renderLoading方法
  renderLoading (cb) {
    // 異步加載圖片
    loadImageAsync(
        {
            src: this.loading
        },
        data => {
            this.render('loading', false)
            cb()
        },
        () => {
            // handler `loading image` load failed
            cb()
            if (!this.options.silent) console.warn(`VueLazyload log: load failed with loading image(${this.loading})`)
        }
    )
  }

  // loadImageAsync方法
  const loadImageAsync = (item, resolve, reject) => {
    let image = new Image()
    image.src = item.src

    image.onload = function () {
        resolve({
            naturalHeight: image.naturalHeight,
            naturalWidth: image.naturalWidth,
            src: image.src
        })
    }

    image.onerror = function (e) {
        reject(e)
    }
}

整個調用順序爲:

  1. load
  2. renderLoading
  3. loadImageAsync
  4. 異步加載loading圖片
  5. this.render('loading', false)
  6. this.attempt++ 加載真實路徑嘗試次數+1
  7. this.record('loadStart') 記錄加載真實路徑開始時間
  8. 調用loadImageAsync 異步加載圖片真實路徑
  9. this.state.loaded = true 將該對象狀態設置爲loaded
  10. this.record('loadEnd') 記錄真實路徑加載結束時間
  11. this.render('loaded', false) 將元素路徑設置爲真實路徑。並觸發loaded狀態監聽函數。

到這一步全部處於預加載容器視圖內的元素加載真實路徑完畢。

update

分析完bind鉤子,咱們再來看lazy指令上聲明的update鉤子函數:update: lazy.update.bind(lazy);update鉤子上綁定的是lazy的update方法,進入lazy.update方法:

// src/index.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);add方法在分析bind鉤子時候已經講過,這裏可參考上文。若存在,則調用監聽對象上的update方法: exist.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,將已經加載完畢的監聽對象從監聽隊列中刪除掉,將處於預加載容器視圖內的圖片元素經過異步方式加載真實路徑。

在初始化階段以及圖片路徑發生變化階段的預加載邏輯咱們已經整明白了。最後咱們來看一下在容器發生滾動產生的圖片預加載動做的整個邏輯。

元素位置發生變化

在以前的代碼裏就添加過目標容器,咱們來重溫一下這段代碼:

// src/lazy.js
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()
      }
    }

scroll

若是使用scroll形式,則調用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']

// this.options.ListenEvents : listenEvents || DEFAULT_EVENTS,

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

IntersectionObserver

對IntersectionObserver的使用你們能夠在網上查詢相關文檔。它能夠用來監聽元素是否進入了設備的可視區域以內,而不須要頻繁的計算來作這個判斷。

當使用IntersectionObserver模式時,主要作兩步處理:

  • this._initListen(target.el, false) : 移除目標容器對'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'事件的監聽。
  • this._initIntersectionObserver() 添加IntersectionObserver監聽
// src/lazy.js

_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'事件觸發,調用懶加載處理函數lazyloadHandle
  3. 遍歷監聽隊列ListenerQueue,刪除狀態爲loaded的監聽對象
  4. 遍歷監聽隊列ListenerQueue,判斷該監聽對象是否存在預加載視圖容器中,若存在,則調用load方法異步加載真實路徑。

當使用IntersectionObserver模式時,圖片預加載邏輯

  1. 給目標容器解除事件'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'的綁定
  2. 給每一個監聽對象添加IntersectionObserver監聽
  3. 當監聽對象進入設備的可視區域以內,則調用監聽對象的load方法異步加載真實路徑。

總結

經過對vue-lazyload的源碼分析,咱們明白了lazyload的實現原理,也瞭解到了做者代碼結構的設計方式。源碼中lazy模塊和listener模塊的業務職責分工明確。lazy模塊負責dom相關的處理,如爲dom元素建立listener,爲容器target綁定dom事件,dom元素的渲染等。listener模塊只負責狀態的控制,根據狀態的不一樣執行不一樣的業務邏輯。

相關文章
相關標籤/搜索