First, let’s talk about what vue-LazyLoad solves:

If a web page has thousands of images to load, scrolling can become very slow. At this point, many people will think of the concept of lazy loading, where only images in the viewable area are loaded, and the rest of the images have a placeholder for the moment, and when they scroll into the viewable area, they request the real image and replace it. Here, we need a checkInView method that checks whether the image DOM element is in the browser’s viewable area, and then bind a scrollevent listener to all image scroll elements, as shown below

The figure above has implemented a simple lazy image loading idea, with this idea in mind, let’s look at vue-lazyload implementation.

From the script command script in package.json you know that the configuration file for the project build is build.js. The vue-lazyLoad library is built by rollup, and the entry file we can see from the build.js file is index.js in the SRC directory

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

The SRC /index.js file can be downloaded from Github. Here is a snippet of the code

// 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{... }}}Copy the code

The index.js file mainly provides several directives or components that are easy to use:

  • Create a lazy object and define a lazy directive
  • Create the lazyContainer object and define the lazyContainer directive
  • Build lazy-Component components
  • Build the lazy-image component

The lazy, lazyContainer and lazy-component directives are used differently, and the vue-LazyLoad documentation on Github shows the difference. But its internal implementation principle is basically the same, we mainly analyze the implementation of V-lazy instruction in VUe2 version.

Lazy class

// 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, the 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) } ... }Copy the code

The Lazy class has a set of attributes defined in its constructor. You can see the description of the attributes on Github.

key describe The default value
preLoad Preloading height ratio 1.3
error Picture loading failure display diagram ‘data-src’
loading Picture loading display diagram ‘data-src’
attempt Number of image loading attempts 3
listenEvents Listening event [‘scroll’, ‘wheel’, ‘mousewheel’, ‘resize’, ‘animationend’, ‘transitionend’, ‘touchmove’]
observer Whether to use IntersectionObserver false

Take a look at the three lines of code that execute in the constructor:

The first line, this._initevent (), initializes event listening methods for loading, loaded, and 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))
  }
}
Copy the code

In line 2, the lazyLoadHandler method is the function returned by _lazyLoadHandler with a throttling wrapper. Here we need to worry about lazy load handlers and throttling handlers

// Remove the loaded listener from the listener queue and store it in freeList and delete it. Determine whether the unloaded listener is in the pre-loaded position. If so, execute the load method._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)
    }
  }
}
Copy the code

The third line, this.setmode (), sets the listening mode. We usually use event mode or IntersectionObserver to judge whether elements enter the view or not. If they enter the view, they need to load the real path for the image. If the monitoring event mode is used, mode is event; if IntersectionObserver is used, mode is Observer

setMode (mode) {
  if(! hasIntersectionObserver && mode === modeType.observer) { mode = modeType.event } this.mode = mode // event or observerif (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()
  }
}
Copy the code

After the initialization of lazy, let’s look at the lazy directive

Lazy instructions

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

Take a look at some hook functions in the lazy directive (see vue’s directive)

  • Bind: Called only once, the first time a directive is bound to an element. This is where you can perform one-time initialization Settings.
  • Update: called when the component’s VNode is updated, but may occur before its child VNodes are updated. The value of the instruction may or may not have changed. But you can ignore unnecessary template updates by comparing the values before and after the update.
  • ComponentUpdated: Invoked when the VNode of the component where the directive resides and its child VNodes are all updated.
  • Unbind: Called only once, when an instruction is unbound from an element.

The bind command

When the directive is first bound to an element, the lazy.add method is called:

Add (el, binding, vnode) {// Determine if the current element is in the queue, if the update method is executed before // and delay the callback to lazyLoadHandler after the next DOM update loopif (some(this.ListenerQueue, item => item.el === el)) {
    this.update(el, binding)
    returnVue.nexttick (this.lazyloadHandler)} // Obtain the real path of the image, loading the placeholder path of the state, and loading the placeholder path failedlet { 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())
  })
}
Copy the code

There are two main pieces of logic in the lazy.add method:

  • If the DOM is already in the ListenerQueue, call this.update directly and execute this.lazyloadHandler () after the DOM is rendered.
  • If the DOM does not currently exist in the listener queue
    • A newListener object, newListener, is created and stored in the ListenerQueue ListenerQueue
    • Set window or $parent to be the listening target for the Scroll event
    • Execute lazyLoadHandler this.lazyloadhandler ()

Then, look at the newListener object used in lazy.add

ReactiveListener class

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)}... }Copy the code

Three methods are executed at the end of the constructor of the ReactiveListener class:

  • This.filter () : Calls the filter method defined when the user passes the parameter to dynamically change the SRC of the image, such as adding a prefix or webP support (available on Github).
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'}}}})Copy the code
filter () {
    ObjectKeys(this.options.filter).map(key => {
      this.options.filter[key](this, this.options)
    })
}
Copy the code
  • This.initstate () : Binds the real path of the image to the element’s data-src attribute, and adds error, loaded, and Rendered status to the listener
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}}Copy the code
  • This.render () : Actually calls the _elRenderer method in lazy.js
    • The loading state parameter is used to set the path of the current picture to the loading state placeholder path
    • Bind the loading state to the lazy property of the element
    • 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)
  }
}
Copy the code

_lazyLoadHandler

In this step, we wrapped all the DOM elements bound to the lazy directive into a ReactiveListener listener and stored it in the ListenerQueue queue. The current element displays a placeholder map of the loading state. After dom rendering is complete, the lazy loading handler _lazyLoadHandler is executed. Let’s look at the function code again:

_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()})}Copy the code

Lazy loading functions do two things:

  • Iterate over all listener objects and delete listeners that do not exist, parent elements do not exist, hidden, and do not need to be displayed.
  • Iterate over all listener objects and determine if the current object is in the preloaded position. If it is in the preloaded position, the load method of the listener is executed.

Let’s focus on the two methods used in _lazyLoadHandler.

One is a listener.checkinView () that checks whether the current object is in a preloaded position.

listener.checkInView()

Internal implementation of the checkInView method: Checks if the element is inside the preloaded view, returns true if it is inside the view, and false if it is not.

// 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()
}
Copy the code

Load () loads the real path to the elements in the preloaded container view.

listener.load()

// SRC /listener.js load (onFinish = noop) {// If the number of attempts is complete and the object status is Error, an error message is displayed.if ((this.attempt > this.options.attempt - 1) && this.state.error) {
      if(! this.options.silent) console.log(`VueLazyloadlog: ${this.src} tried too more than ${this.options.attempt} times`)
      onFinish()
      return} // If the current object is loaded and the path is cached in imageCache, call this.render('loaded'.trueRender the real dom path.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
      returnOnFinish ()} // If none of the above conditions is true, renderLoading is called. 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 = truethis._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 = trueLoadImageAsync ({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(`VueLazyloadlog: 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)
      }
}
Copy the code

The update instruction

After analyzing the bind hook, let’s look at the update function declared on the lazy directive:

// 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())
}
Copy the code

The update method checks whether the current element is in the ListenerQueue. If not, this. Add (el, binding, vnode). If so, this.lazyloadHandler () is called after the update method on the listener is executed.

// src/listener.js update ({ src, loading, Error}) {const oldSrc = this.src // Set the new image path to the listening object's real path this.src = SRC this.loading = loading This.error = error this.filter() // Compares whether two paths are equal, if not, initializes the load count and initializes the object state.if(oldSrc ! == this.src) { this.attempt = 0 this.initState() } }Copy the code

After analyzing the bind and update hook functions of lazy, we can see that the image preloading logic is as follows:

  • Encapsulate the image element into a ReactiveListener object, set its real path SRC, preload the placeholder path loading, and load the placeholder path error
  • Store each listener ReactiveListener in the ListenerQueue
  • Call the preloading handler function lazyLoadHandler() to remove the loaded listener from the listener queue, and load the real path of the picture elements in the preloading container view asynchronously

We understand the preloading logic during initialization and image path changes. Finally, we take a look at the whole preloading logic for image scrolling in the container.

The position of the element changes

In the previous code we said setMode() sets the listening mode

// 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()
}
Copy the code

We can see this in the code above

  1. In event mode, this._initListen(target.el, true) is called to add event listeners to the target container. Listen for ‘scroll’, ‘wheel’, ‘mousewheel’, ‘resize’, ‘animationEnd ‘,’ TransitionEnd ‘, ‘TouchMove’ by default, Call the preload handler function lazyLoadHandler() when the event is triggered
// 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)
  }
}
Copy the code
  1. Observer mode: IntersectionObserver is used to monitor whether elements enter the visible area of the device without frequent calculation. The unfamiliar students here recommend ruan Yifeng teacher’s diary

When using the Observer pattern, there are two main steps:

  • this._initListen(target.el, false) : Remove target container listening for ‘scroll’, ‘wheel’, ‘mousewheel’, ‘resize’, ‘animationEnd ‘,’ TransitionEnd ‘, ‘TouchMove’ events.
_initListen (el, start) {
  this.options.ListenEvents.forEach((evt) => _[start ? 'on' : 'off'](el, evt, this.lazyLoadHandler))
}
Copy the code
  • This._initintersectionobserver () traverses the listenerQueue and adds IntersectionObserver to the listenerQueue listener. The listener function is _observerHandler(). When the element is in view, call the load() method of the listener to load the real image.
_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()
        }
      })
    }
  })
}
Copy the code

summary

When using Scroll mode, image loading logic:

  1. Bind events’ Scroll ‘, ‘wheel’, ‘mousewheel’, ‘resize’, ‘animationEnd ‘,’ TransitionEnd ‘, ‘touchMove ‘to the target container
  2. ‘Scroll ‘, ‘wheel’, ‘mousewheel’, ‘resize’,’ animationEnd ‘, ‘TransitionEnd ‘,’ TouchMove ‘event triggered to call lazyLoadHandler()
  3. Traverse the ListenerQueue ListenerQueue and delete the listener whose state is loaded
  4. The ListenerQueue is traversed to determine whether the listener exists in the preloaded view container. If so, the load method is called to load the real path asynchronously

When using IntersectionObserver mode, the image loading logic:

  1. Unbind the target container events ‘scroll’, ‘wheel’, ‘mousewheel’, ‘resize’, ‘animationEnd ‘,’ TransitionEnd ‘, ‘touchMove’
  2. Add IntersectionObserver monitoring to each listening object
  3. When the listener enters the visible area of the device, the load method of the listener is called to load the real path asynchronously

Finally, we end with a diagram