vue-lazyload源码分析
项目构建的配置文件
从package.json中的script命令脚本了解项目构建的配置文件是build.js。vue-lazyload库是通过rollup构建的,其中的input属性值src/index.js
为源码入口。
// build.jsasync 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.jsexport 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
中主要做了两件事:
- 创建lazy对象并定义lazy指令
- 创建lazyContainer并定义lazy-container指令
这里lazy指令跟lazyContainer指令是两种不同的用法,从vue-lazyload文档里可以查看其中的区别。这次主要通过lazy指令来对vue-lazyload进行分析。
Lazy类
// src/lazy.jsreturn 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事件监听方法的初始化:
// 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)) }}
第二行代码对懒加载处理函数进行了节流处理,这里我们需要关心的地方有懒加载处理函数、节流处理函数
// 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
来判断,元素是否进入视图,若进入视图则需为图片加载真实路径。如果使用scroll
则mode
值为event
,如果使用IntersectionObserver
则mode
值为observer
;
// src/lazy.jsthis.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.jsVue.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方法:
// src/lazy.jsadd (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.jsexport 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.jsinitState () { 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.jsrender (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.jscheckInView () { 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.jsload (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) }}
整个调用顺序为:
- load
- renderLoading
- loadImageAsync
- 异步加载loading图片
- this.render('loading', false)
- this.attempt++ 加载真实路径尝试次数+1
- this.record('loadStart') 记录加载真实路径开始时间
- 调用loadImageAsync 异步加载图片真实路径
- this.state.loaded = true 将该对象状态设置为loaded
- this.record('loadEnd') 记录真实路径加载结束时间
- this.render('loaded', false) 将元素路径设置为真实路径。并触发loaded状态监听函数。
到这一步所有处于预加载容器视图内的元素加载真实路径完毕。
update
分析完bind钩子,我们再来看lazy指令上声明的update钩子函数:update: lazy.update.bind(lazy)
;update钩子上绑定的是lazy的update方法,进入lazy.update方法:
// src/index.jsupdate (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.jsupdate ({ 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.jssetMode (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.jsconst 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.jsconst _ = { 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模式时,图片预加载逻辑:
- 给目标容器绑定事件
'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'
'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'
事件触发,调用懒加载处理函数lazyloadHandle
- 遍历监听队列
ListenerQueue
,删除状态为loaded的监听对象 - 遍历监听队列
ListenerQueue
,判断该监听对象是否存在预加载视图容器中,若存在,则调用load方法异步加载真实路径。
当使用IntersectionObserver模式时,图片预加载逻辑
- 给目标容器解除事件
'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'
的绑定 - 给每个监听对象添加IntersectionObserver监听
- 当监听对象进入设备的可视区域之内,则调用监听对象的load方法异步加载真实路径。
总结
通过对vue-lazyload的源码分析,我们明白了lazyload的实现原理,也了解到了作者代码结构的设计方式。源码中lazy模块和listener模块的业务职责分工明确。lazy模块负责dom相关的处理,如为dom元素创建listener,为容器target绑定dom事件,dom元素的渲染等。listener模块只负责状态的控制,根据状态的不同执行不同的业务逻辑。