您的位置:首页 > Web前端 > Vue.js

Vue-lazyload原理详解之源码解析

2019-03-20 23:38 441 查看

前叙

本来想要研究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只负责状态的控制,在不同状态执行不同的业务。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: