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
- 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
- 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:
- Bind events’ Scroll ‘, ‘wheel’, ‘mousewheel’, ‘resize’, ‘animationEnd ‘,’ TransitionEnd ‘, ‘touchMove ‘to the target container
- ‘Scroll ‘, ‘wheel’, ‘mousewheel’, ‘resize’,’ animationEnd ‘, ‘TransitionEnd ‘,’ TouchMove ‘event triggered to call lazyLoadHandler()
- Traverse the ListenerQueue ListenerQueue and delete the listener whose state is loaded
- 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:
- Unbind the target container events ‘scroll’, ‘wheel’, ‘mousewheel’, ‘resize’, ‘animationEnd ‘,’ TransitionEnd ‘, ‘touchMove’
- Add IntersectionObserver monitoring to each listening object
- 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