首先,我们来说一下vue-lazyload解决了什么问题:
如果一个网页有成千上万张图片需要加载,页面滚动就会变得非常卡顿。此时很多人都会想到懒加载的概念,即只加载可视区域的图片,其他的图片暂时有一个占位图,等它们滚动到可视区域时再去请求真实图片并替换。这里,我们需要一个检查图片dom元素是否在浏览器可视区域内的方法checkInView,然后给所有图片的滚动父元素绑定一个滚动事件的监听方法scrollHandler,如下
上图已经实现了一个简易的图片懒加载的思路,带着这个思路,我们来看一下vue-lazyload的实现。
从package.json中的script命令脚本了解项目构建的配置文件是build.js。vue-lazyload库是通过rollup构建的,从build.js文件我们看出来入口文件是src目录下的index.js
// build.js
build({
input: path.resolve(__dirname, 'src/index.js'),
plugins: [
resolve(),
commonjs(),
babel({ runtimeHelpers: true })
]
}, {
format: 'es',
filename: 'vue-lazyload.esm.js'
})
通过这个我们找到src/index.js文件,具体大家可以去github下载源码,这里只截取部分代码
// 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 {
...
}
}
}
index.js文件主要为我们提供了几种方便使用的指令或组件:
- 创建lazy对象并定义lazy指令
- 创建lazyContainer对象并定义lazyContainer指令
- 构建lazy-component组件
- 构建lazy-image组件
这里lazy、lazyContainer指令和lazy-组件用法不同,从github上vue-lazyload的文档可以看出里面的区别。但是其内部实现原理基本一致,我们主要分析v-lazy的指令在vue2版本的实现。
Lazy类
// 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,
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)
}
...
}
Lazy类的构造函数中定义了一系列属性,这里可以去github上看属性描述,这里只描述部分。
| key | 描述 | 默认值 |
|---|---|---|
| preLoad | 预加载高度比例 | 1.3 |
| error | 图片加载失败显示图 | 'data-src' |
| loading | 图片加载中显示图 | 'data-src' |
| attempt | 图片加载尝试次数 | 3 |
| listenEvents | 监听的事件 | ['scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'] |
| observer | 是否使用IntersectionObserver | false |
主要了解一下构造函数中执行的三行代码:
第一行,this._initEvent(),初始化loading、loaded、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))
}
}
第二行,lazyLoadHandler方法就是_lazyLoadHandler加了一个节流包装后返回的函数,这里我们需要关心的地方有懒加载处理函数、节流处理函数
// 懒加载处理函数
// 将监听队列中loaded状态的监听对象取出存放在freeList中并删掉,判断未加载的监听对象是否处在预加载位置,如果是则执行load方法。
_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)
}
}
}
第三行,this.setMode(),设置监听模式。我们通常使用事件模式或者IntersectionObserver来判断元素是否进入视图,若进入视图则需为图片加载真实路径。如果使用监听事件模式,mode为event,如果使用IntersectionObserver,mode为observer
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的初始化,我们来了解一下lazy指令
lazy指令
Vue.directive('lazy', {
bind: lazy.add.bind(lazy),
update: lazy.update.bind(lazy),
componentUpdated: lazy.lazyLoadHandler.bind(lazy),
unbind: lazy.remove.bind(lazy)
})
看一下lazy指令中的几个钩子函数(参考vue的指令)
- bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
- update:所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新。
- componentUpdated:指令所在组件的 VNode 及其子 VNode 全部更新后调用。
- unbind:只调用一次,指令与元素解绑时调用。
bind指令
当指令第一次绑定到元素上时,调用的是lazy.add方法:
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, 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())
})
}
lazy.add方法中主要逻辑有两点:
- 当前dom若已存在监听队列ListenerQueue中,则直接调用this.update方法,在dom渲染完毕后执行懒加载处理函数this.lazyLoadHandler()
- 若当前dom不存在监听队列中
- 则创建新的监听对象newListener并将其存放在监听队列ListenerQueue中
- 设置window或$parent为scroll事件的监听目标对象
- 执行懒加载处理函数this.lazyLoadHandler()
然后,我们了解一下在lazy.add中用到的newListener对象
ReactiveListener类
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)
}
...
}
在ReactiveListener类的构造函数末尾执行了三个方法:
- this.filter():调用用户传参时定义的filter方法,动态修改图片的src,比如添加前缀或者是否支持webp(可以在github上看到)
// 截取github上的使用
Vue.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'
}
}
}
})
filter () {
ObjectKeys(this.options.filter).map(key => {
this.options.filter[key](this, this.options)
})
}
- this.initState():将图片的真实路径绑定到元素的data-src属性上,并为监听对象添加error、loaded、rendered状态
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
}
}
- this.render():实际上调用的是lazy.js中的_elRenderer方法
- 根据传递的状态参数loading设置当前图片的路径为loading状态占位图路径
- 将loading状态绑定到元素的lazy属性上
- 触发用户监听loading状态上的函数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)
}
}
_lazyLoadHandler
到这一步我们将lazy指令绑定的所有dom元素封装成一个个ReactiveListener监听对象,并将其存放在ListenerQueue队列中,当前元素显示的是loading状态的占位图,dom渲染完毕后将会执行懒加载处理函数_lazyLoadHandler。再来看一下该函数代码:
_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()
})
}
懒加载函数干的事情就两点:
- 遍历所有监听对象并删除掉不存在的listener或父元素不存在、隐藏等不需要显示的listener;
- 遍历所有监听对象并判断当前对象是否处在预加载位置,如果处在预加载位置,则执行监听对象的load方法。
我们主要了解一下_lazyLoadHandler中使用到的两个方法。
一是判断当前对象是否处在预加载位置的listener.checkInView();
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()方法 load()对处于预加载容器视图内的元素加载真实路径。
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.rendered && this.state.loaded) return
if (this._imageCache.has(this.src)) {
this.state.loaded = true
this.render('loaded', true)
this.state.rendered = true
return onFinish()
}
// 若以上条件都不成立,则调用renderLoading方法渲染loading状态的图片。
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 = true
this._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 = true
// 异步加载图片
loadImageAsync({
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(`VueLazyload log: 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)
}
}
update指令
分析完bind钩子,我们再来看lazy指令上声明的update钩子函数:
// 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())
}
update方法首先判断当前元素是否存在监听队列ListenerQueue中,若不存在则执行this.add(el, binding, vnode);若存在,则调用监听对象上的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(),将已经加载完毕的监听对象从监听队列中删除,将处于预加载容器视图内的图片元素通过异步方式加载真实路径
在初始化阶段及图片路径发生变化阶段的预加载逻辑我们整明白了,最后看下在容器发生滚动产生的图片预加载动作的整个逻辑。
元素位置发生变化
在之前的代码里我们说过setMode()设置监听模式
// 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()
}
在上面代码中我们可以看出
- event模式,则调用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']
_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)
}
}
- observer模式: 使用IntersectionObserver来监听元素是否进入了设备的可视区域之内,而不需要频繁的计算来做这个判断。这里不熟悉的同学推荐看一下阮一峰老师的日志
当使用observer模式时,主要做了两步:
- this._initListen(target.el, false) : 移除目标容器对'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'事件的监听。
_initListen (el, start) {
this.options.ListenEvents.forEach((evt) => _[start ? 'on' : 'off'](el, evt, this.lazyLoadHandler))
}
- this._initIntersectionObserver(),遍历listenerQueue,对里面的listener添加IntersectionObserver监听,监听函数是_observerHandler(),监听到元素进入可视区域,调用监听对象的load()方法加载真实图片。
_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'事件触发,调用懒加载处理函数lazyLoadHandler()
- 遍历监听队列ListenerQueue,删除状态为loaded的监听对象
- 遍历监听队列ListenerQueue,判断该监听对象是否存在预加载视图容器中,若存在,则调用load方法异步加载真实路径
当使用IntersectionObserver模式时,图片加载逻辑:
- 给目标容器解除事件'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove'的绑定
- 给每个监听对象添加IntersectionObserver监听
- 当监听对象进入设备的可视区域之内,则调用监听对象的load方法异步加载真实路径
最后,以一图结束