Vue-lazyload原理详解之源码解析
前叙本来想要研究mint-ui组件库的Lazy load组件,没想到翻看它的源码,发现它完全引用的vue-lazyload项目,直接引用,没有丝毫修改。因此转而研究vue-lazyload,代码并不多,几百行吧,有兴趣的可以读一下。简单接入示例html代码:<div id="app"><li v-for="img in imgList"><img v-lazy="im
前叙
本来想要研究mint-ui组件库的Lazy load组件,没想到翻看它的源码,发现它完全引用的vue-lazyload项目,直接引用,没有丝毫修改。
因此转而研究vue-lazyload,代码并不多,几百行吧,有兴趣的可以读一下。
简单接入示例
html代码:
<div id="app">
<li v-for="img in imgList">
<img v-lazy="img">
</li>
</div>
js代码:
<!-- 先引入 Vue -->
<script src="../js/vue.js"></script>
<!-- 引入组件库 -->
<script src="../js/index.js"></script>
<script>
Vue.use(Lazyload);
new Vue({
el: '#app',
data: {
imgList: ['img url', 'img url', 'img url']
}
});
</script>
官方文档和示例:
mint-ui Lazyload文档
vue-lazyload github文档
原理剖析
首先是我总结的一个lazyload的主要流程的流程图
原理简述:
- vue-lazyload是通过指令的方式实现的,定义的指令是v-lazy指令
- 指令被bind时会创建一个listener,并将其添加到listener queue里面, 并且搜索target dom节点,为其注册dom事件(如scroll事件)
- 上面的dom事件回调中,会遍历 listener queue里的listener,判断此listener绑定的dom是否处于页面中perload的位置,如果处于则加载异步加载当前图片的资源
- 同时listener会在当前图片加载的过程的loading,loaded,error三种状态触发当前dom渲染的函数,分别渲染三种状态下dom的内容
源码剖析
首先组件安装的函数 install函数解析:
install (Vue, options = {}) { const LazyClass = Lazy(Vue) const lazy = new LazyClass(options) // 核心函数 const isVueNext = Vue.version.split('.')[0] === '2' // 判断当前vue的版本 Vue.prototype.$Lazyload = lazy // 如果支持 lazyload 组件,则定义一个 lazy-component的全局组件 if (options.lazyComponent) { Vue.component('lazy-component', LazyComponent(lazy)) } if (isVueNext) { // 2.0版本 自定义指令方式 Vue.directive('lazy', { bind: lazy.add.bind(lazy), update: lazy.update.bind(lazy), componentUpdated: lazy.lazyLoadHandler.bind(lazy), unbind : lazy.remove.bind(lazy) }) } else { // 1.0 版本自定义指令的方式 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) } }) } }
下面分析LazyClass核心函数,源码如下
function (Vue) { return class Lazy {}; }
上面返回了一个class对象,然后在install函数创建了一个class实例,下面首先看看它的构造函数
constructor ({ preLoad, error, preLoadTop, loading, attempt, silent, scale, listenEvents, hasbind, filter, adapter }) { this.ListenerQueue = [] this.TargetIndex = 0 this.TargetQueue = [] this.options = { silent: silent || true, preLoad: preLoad || 1.3, // 0.3的距离是 当前dom距离页面底部的高度时就开始加载图片了 preLoadTop: preLoadTop || 0, // dom的底部距离页面顶部多少距离还是加载 error: error || DEFAULT_URL, // 加载失败显示的图片 loading: loading || DEFAULT_URL, // 加载中显示的图片 attempt: attempt || 3, // 图片加载失败,最多重试的次数 scale: scale || getDPR(scale), ListenEvents: listenEvents || DEFAULT_EVENTS, // 给dom注册dom的事件,在这些事件回调中会触发加载图片的方法 hasbind: false, supportWebp: supportWebp(), filter: filter || {}, adapter: adapter || {} // 状态变化的回调监听,同时也可以使用lazyload的$on()函数(注意不是vue的)来监听状态变化的回调函数 } this.initEvent() // 初始化事件处理器 (实现同理 vue的事件机制) // 使用了节流函数 this.lazyLoadHandler = throttle(() => { let catIn = false this.ListenerQueue.forEach(listener => { if (listener.state.loaded) return catIn = listener.checkInView() // 判断当前dom是否处于可以preload的位置 catIn && listener.load() // 处于preload的位置, 执行图片加载的操作 }) }, 200) }
关于options配置项可以参考vue-lazyload的github官网的说明。但是我还是将重要的配置在上面做了中文说明。
lazyLoadHandler()函数是一个很重要的函数,它触发图片加载的入口函数,并且此函数是图片加载的入口。它的核心处理函数经过了节流函数的处理了,关于节流函数,我在之前的mint-ui 的inifite-scroll组件做了说明,如果想了解,请移步。下面对constructor中调用的initEvent()函数,初始化事件处理器函数的代码进行说明。
initEvent () { this.Event = { listeners: { loading: [], loaded: [], error: [] } } this.$on = (event, func) => { 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) { this.Event.listeners[event] = [] return } remove(this.Event.listeners[event], func) } this.$emit = (event, context, inCache) => { this.Event.listeners[event].forEach(func => func(context, inCache)) } }
实现方式很简单,代码大家应该都很容易读懂,我就不加注释说明了,并且vue中的事件处理也是这样实现,代码基本相同,相信读过vue源码的同学应该有感触。
下面是v-lazy指令 bind时触发的lazy 的 add函数,源码如下
add (el, binding, vnode) { if (some(this.ListenerQueue, item => item.el === el)) { // 判断当前监听队列里面是否含有当前dom的监听事件 //如果已经含有,执行它的update函数,更新即可,无需创建 this.update(el, binding) return Vue.nextTick(this.lazyLoadHandler) } let { src, loading, error } = this.valueFormatter(binding.value) Vue.nextTick(() => { src = getBestSelectionFromSrcset(el, this.options.scale) || src const container = Object.keys(binding.modifiers)[0] let $parent // 如果使用了container 修饰符, 那么查找我们定义的contianer; 如果没有使用当前dom所在最近的滚动parent // 这个contianer是用于 设置监听dom事件的dom对象, 他的事件触发回调会触发图片的加载操作 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) } // 在当前dom绑定到vdom中, 为当前dom创建一个监听事件(此事件用于触发当前dom在不同时期的不同处理操作), 并将事件添加到事件队列里面 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()) }) }
主要操作:找到对应的target(用于注册dom事件的dom节点;比如:页面滚动的dom节点),为其注册dom事件;为当前dom创建Listenr并添加到listener queue中。最后代用lazyLoadHandler()函数,加载图片
下面,我们回过头来看lazyLoadHandler()的实现,其实前面已经简单解析过。
this.lazyLoadHandler = throttle(() => { let catIn = false this.ListenerQueue.forEach(listener => { if (listener.state.loaded) return catIn = listener.checkInView() catIn && listener.load() }) }, 200)
下面继续看checkInView()是怎么实现,简单当前dom是否位于preload的位置
checkInView () { this.getRect() // 调用dom的getBoundingClientRect() 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) }
首先看y轴方向的判断:this.rect.top < window.innerHeight * this.options.preLoad, 是dom的顶部是否到了preload的位置;this.rect.bottom > this.options.preLoadTop 判断dom的底部是否到达了preload的位置
关于x轴方向就不做解析了,实现同y轴。然后是load()异步加载图片的核心函数
load () { // 如果当前尝试加载图片的次数大于指定的次数, 并且当前状态还是错误的, 停止加载动作 if ((this.attempt > this.options.attempt - 1) && this.state.error) { if (!this.options.silent) console.log('error end') return } if (this.state.loaded || imageCache[this.src]) { return this.render('loaded', true) // 使用缓存渲染图片 } this.render('loading', false) // 调用lazy中的 elRender()函数, 用户切换img的src显示数据,并触发相应的状态的回调函数 this.attempt++ // 尝试次数累加 this.record('loadStart') // 记录当前状态的时间 // 异步记载图片, 使用Image对象实现 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) // 渲染 loaded状态的 dom的内容 imageCache[this.src] = 1 // 当前图片缓存在浏览器里面了 }, err => { this.state.error = true this.state.loaded = false this.render('error', false) }) }
紧接着是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) } }
实现很简单,就是使用的Image对象实现的网络请求。
下面来看看渲染图片不同状态的render函数的实现,首先是listener中的render()函数
render (state, cache) { this.elRenderer(this, state, cache) // 指向的是lazy class中的 elRenderer函数 }
下面来看elRenderer函数实现
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) { // v-lazy: 后面的内容, 代表绑定的是这个属性 el.style[bindType] = 'url(' + src + ')' // 用于lazy load 背景图片 } else if (el.getAttribute('src') !== src) { el.setAttribute('src', src) // 普通lazyload image } el.setAttribute('lazy', state) // 自定义属性 lazy,用于给用于 根据此进行class搜索,设置指定状态的样式 this.$emit(state, listener, cache) // 触发当前状态的回调函数 // 触发adapter中的回调函数 this.options.adapter[state] && this.options.adapter[state](listener, this.options) }
上面将lazy load实现主要过程做了解析,下面对指令的update回调和lazy-component组件进行解析。
从指令创建时传递的配置可知update指向的lazy class的update()函数,也就是v-lazy指令绑定的数据发生改变的时候出发的回调函数。
update (el, binding) { // 获取当前dom绑定的 图片src的数据, 如果当前dom执行过load过程, 重置当前dom的图片数据和状态 let { src, loading, error } = this.valueFormatter(binding.value) // 当前绑定的value是 obj, 从中选取{src, loading, error}; 是string, 则用作src // 找到当前dom绑定的listener const exist = find(this.ListenerQueue, item => item.el === el) // 更新listener的状态和状态对应的图片资源 exist && exist.update({ src, loading, error }) this.lazyLoadHandler() Vue.nextTick(() => this.lazyLoadHandler()) }
上面代码很简单,逻辑通过注释基本能看懂。
lazy-component组件
我们看到注册全局的lazy-component组件的时候,创建组件实例是通过一个方法创建的,方法原型如下:export default (lazy) => { // 将lazy class的实例作为参数传入 return { } }
下面再来看看props,data和render。
props: { tag: { // 当前组件渲染出来的外层的container的tag type: String, default: 'div' } }, render (h) { // 如果当前组件内的内容是隐藏状态, 只渲染外层 container if (this.show === false) { return h(this.tag) } // 变为显示状态, 渲染组件内的slot内容,也就要显示的主体内容 return h(this.tag, null, this.$slots.default) }, data () { return { state: { // 当前组件内容的状态 loaded: false }, rect: {}, // 当前组件的dom getBoundingClientRect()内容 show: false // 当前组件内的内容的显示状态 } }
然后是mounted()回调函数,在当前组件挂载上的时候的回调。
mounted () { lazy.addLazyBox(this) lazy.lazyLoadHandler() }
内部触发了lazy的addLazyBox()函数和lazyLoadHandler()函数。关于lazyLoadHandler()函数上面已经说过好多了,不在赘述。下面对addLazyBox()进行解析。
addLazyBox (vm) { this.ListenerQueue.push(vm) // 将当前vue实例以Listener的方式传入到listener queue队列中;当前vue实例就是起到listener的作用 if (inBrowser) { this._addListenerTarget(window) if (vm.$el && vm.$el.parentNode) { // 为当前组件的dom 父节点注册相应的dom事件 this._addListenerTarget(vm.$el.parentNode) } } }
通过上面的代码可知,当前组件的vue实例起到和我们上面提到的listener相同的作用,那么它可能也会有listener对应的核心的api 函数。是的,这些都在组件的methods中注册了。
methods: { getRect () { this.rect = this.$el.getBoundingClientRect() }, checkInView () { this.getRect() return inBrowser && (this.rect.top < window.innerHeight * lazy.options.preLoad && this.rect.bottom > 0) && (this.rect.left < window.innerWidth * lazy.options.preLoad && this.rect.right > 0) }, load () { // 执行到dom的时候,就没有网络请求了,直接将dom的内容显示出来了 this.show = true this.state.loaded = true this.$emit('show', this) // 注意: 这里的触发的回调事件是vue发出的,只能vue才能拦截 } }
上面的代码量很少,也很简单,不再赘述。但是大家有没有注意到load()方法,这里没有显示调用render()函数去渲染不同状态的内容,和listener不同。那是因为vue的mvvm数据绑定机制。data建立了observer,当里面的数据发生变化的时候,会触发update()回调,然后触发render()渲染函数。关于vue怎么实现的mvvm,可以通过阅读vue的源码得知。
总结
通过阅读源码我们学到了什么。
- lazy load的实现原理
- 作者代码结构的设计,我们可以看到Lazy load模块和listener模块他们的业务职责分工明确。lazy负责和dom相关的处理,包括为dom创建listener,为target注册dom事件,渲染dom;而listener只负责状态的控制,在不同状态执行不同的业务。
更多推荐
所有评论(0)