探索vue-lazyload

有关vue的自定义指令

介绍

  对页面加载速度影响最大的就是图片,一张普通的图片可以达到几M的大小,
而代码也许就只有几十KB。当页面图片很多时,页面的加载速度缓慢,
几S钟内页面没有加载完成,也许会失去很多的用户。
  所以,对于图片过多的页面,为了加速页面加载速度,所以很多时候我们
需要将页面内未出现在可视区域内的图片先不做加载, 等到滚动到可视区
域后再去加载。这样子对于页面加载性能上会有很大的提升,也提高了用户体验。

原理

  页面加载时将img标签中的src属性指向一张默认图片或者指向空并且设置高度,
然后定义data-src属性指向真实的图片地址。
  例如: <img src="default.jpg" data-src="http://xxxxx.jpg" />
  对屏幕滚动事件进行监听,判断哪些图片是暴露在视野里的将图片的src指向
原来data-src指向的图片地址,就能实现简单的图片懒加载了

vue-lazyload实现

  • index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
* 既然是命令就需要使用加载指令的操作 先看install部分的代码
* 这部分代码声明了一个LazyClass和LazyContainer的实例,然后判断了当前vue的版本
*/
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))
}
  • lazy.js

先看构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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
}
// 注册事件 on、emit、remove、once等
this._initEvent()

this.lazyLoadHandler = throttle(this._lazyLoadHandler.bind(this), this.options.throttleWait)
// 设置监测模式 addEventListener/IntersectionObserver Api 这个后面再讲
this.setMode(this.options.observer ? modeType.observer : modeType.event)
}

_lazyLoadHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
* 这是event模式下触发懒加载的核心功能
* 遍历懒加载节点队列 将已经加载移除 没有加载的先判断是否在视野范围内 在就执行listener.load()进行加载
*/
_lazyLoadHandler () {
const freeList = []
this.ListenerQueue.forEach((listener, index) => {
if (!listener.state.error && listener.state.loaded) {
return freeList.push(listener)
}
// 判断该节点是否在视野内
// checkInView是ReactiveListener实例的方法
// 用于判断元素是否在视野里 这里就不作介绍了
const catIn = listener.checkInView()
if (!catIn) return
listener.load()
})
freeList.forEach(vm => remove(this.ListenerQueue, vm))
}

那么问题来了,这个listener到底是何方神圣呢?
在解释这个问题之前我们先看看setMode做了什么事情 这牵扯到另一个核心代码

setMode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/*
* 这是个初始化事件模式的函数 选择监听父节点的方式
* mode接收两个值 event | observer
* event模式指使用addEventListener方式做监听
* observer模式使用浏览器IntersectionObserver Api做监听事件 前提是浏览器得支持这个Api
*/
setMode (mode) {
// 兼容操作 如果浏览器不支持IntersectionObserver Api 就使用event模式
if (!hasIntersectionObserver && mode === modeType.observer) {
mode = modeType.event
}

this.mode = mode // event or observer

// event模式
if (mode === modeType.event) {
if (this._observer) {
// 移除已经存在的observer模式的监听事件
this.ListenerQueue.forEach(listener => {
this._observer.unobserve(listener.el)
})
this._observer = null
}
// 重新注册event模式的监听事件
this.TargetQueue.forEach(target => {
this._initListen(target.el, true)
})
} else {
// 移除event模式的监听事件
this.TargetQueue.forEach(target => {
this._initListen(target.el, false)
})
// 注册observer模式的监听
this._initIntersectionObserver()
}
}

_initIntersectionObserver

See: [http://www.ruanyifeng.com/blog/2016/11/intersectionobserver_api.html]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/*
* 为每个绑定指令并且选择了observer模式的节点绑定状态监听事件 并使用_observerHandler作为监听回调
* 这里涉及到IntersectionObserver Api
* 作者对此api做了浏览器兼容判断 并将isIntersecting这个属性与intersectionRatio做了代理
* 个人猜测isIntersecting更好理解吧
*/
_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)
})
}
}
/*
* entries表示的是被检测到发生可见性变化的节点 可能会有多个
*/
_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()
}
})
}
})
}

下面就看看指令周期内的做了什么? 上代码!!!

1
2
3
4
5
6
7
8
9
10
11
12
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)
})
// 指令第一次被绑定到节点上触发了bind,我们看看lazy.add

lazy.add

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/*
* 指令绑定的时候出发的操作
* 先判断该节点时候已经在监听队列 在的话就执行update
* 判断用户是否单独添加了loading和error 没有就是用默认值
* 递归向上寻找带有scroll:auto的父节点 没有就用window
* 声明一个ReactiveListener实例 添加到ListenerQueue
* 为找到的父节点和window分别创建监听事件
* 手动触发使第一批图片加载
*/
add (el, binding, vnode) {
// 判断该节点的监听是否已经存在 有就执行update操作 update下面会讲到
if (some(this.ListenerQueue, item => item.el === el)) {
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

// 支持observer模式
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) {
// 递归寻找含有scroll: auto的父组件
$parent = scrollParent(el)
}

// 为其父组件注册监听事件
// bindType是指绑定的类型 是用src的形式还是使用background-image的方式
const newListener = new ReactiveListener({
bindType: binding.arg,
$parent,
el,
loading,
error,
src,
elRenderer: this._elRenderer.bind(this),
options: this.options
})

// ListenerQueue存在所有执行add操作的指令
this.ListenerQueue.push(newListener)

// 添加监听事件
if (inBrowser) {
this._addListenerTarget(window)
this._addListenerTarget($parent)
}

// 为了加载滑动过程中节流中间的图片
// 手动触发第一屏(未滑动时需要加载的图片)
this.lazyLoadHandler()
// 这一步不知道是为啥 至今没想明白
Vue.nextTick(() => this.lazyLoadHandler())
})
}

ReactiveListener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/*
* 初始化一些参数
*/
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()
// 给每个listener设置状态 error/loaded/rendered
this.initState()
// 这里间接调用的是 lazy._elRenderer下面会讲到
this.render('loading', false)
}

lazy._elRenderer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
_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
// loaded
default:
src = listener.src
break
}

// 通过css还是src展示图片
if (bindType) {
el.style[bindType] = 'url("' + src + '")'
} else if (el.getAttribute('src') !== src) {
el.setAttribute('src', src)
}

// 这一步是为了给css加个选择器
el.setAttribute('lazy', state)

this.$emit(state, listener, cache)
// 触发adapter事件
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)
}
}

lazy._addListenerTarget

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
* 这是给懒加载节点的父节点或者window添加监听事件的函数的方法
*/
_addListenerTarget (el) {
if (!el) return
let target = find(this.TargetQueue, target => target.el === el)
if (!target) {
// childrenCount代表该节点下需要懒加载的子节点数
target = {
el: el,
id: ++this.TargetIndex,
childrenCount: 1,
listened: true
}
this.mode === modeType.event && this._initListen(target.el, true)
this.TargetQueue.push(target)
} else {
target.childrenCount++
}
return this.TargetIndex
}

小结

本章重点主要是在对于Lazy类的解读,作者实现的思路极为巧妙,而且将dom操作的逻辑和状态更新的逻辑拆开,很直观,
而且涉及到很多浏览器兼容性的处理考虑的比较全面,还是很值得学习的.