网站性能好不好,全看图片少不少!
图片的多少直接影响了网页加载的速度,尤其是当你手机网不好的时候(手动@苹果)一张图片转啊转,转半天也转不出来,气的直接想把手机给砸了...
所以日常开发中我们不会一次性让图片全部加载出来,而是先加载几张图片让用户觉得网页已经加载完毕了,实际上我们在后台再悄咪咪的去加载其他图片,神不知鬼不觉图片就加载完了,而且拥有非常丝滑的体验。
开源组件库
先看开源组件库中是怎么实现图片懒加载功能的,目前比较流行的几个组件库分别是 ElementUI、Vant 和 Antdv,除了 Antdv 没有图片懒加载功能外,其他两个组件库都有懒加载功能,ElementUI 是自己写的一套,Vant 借助了第三方插件 vue-lazyload。
说明一下,本人技术栈是 Vue,所以列出的都是 Vue 相关的组件库,React 也有很多优秀的组件库,有兴趣可以自己去看一下
在 ElementUI 中实现的比较简单,loading
状态下是一个 div
,等到图片渲染的时候就会替换成 img
标签,而 vue-lazyload 作为一个插件,考虑的更加周到,功能也更全面,它是通过提供一个自定义的全局指令 v-lazy
来实现的。但是他们的核心原理都是一样的,就是通过给带有滚动属性的容器加一个滚动事件的监听,只渲染可视区域内的图片,从而实现图片懒加载功能。
准备工作
看完了别人的功能我们来自己实现一个,这里我是参照了 vue-lazyload 的源码尽量实现一个简洁的插件。
首先准备几张图片,这里我就直接使用了 ElementUI 的图片,将图片放入列表中待会展示
简易的 HTML 模板
<div id="app">
<div class="box">
<li v-for="(img, index) in imgs" :key="index">
<img v-lazy="img" alt="" />
</li>
</div>
</div>
OK,准备工作完成了,接下来直接就进入我们的正题了。
install 方法
开发 Vue 的插件必须提供一个 install
方法,这个方法的第一个参数是 Vue 构造器,第二个参数是一个可选的选项对象。然后我们通过 Vue.use
来使用它,install
方法被同一个插件多次调用时,只会被安装一次,Vue 在内部做了一个缓存优化,避免重复安装。
借用官网的一个例子:
MyPlugin.install = function (Vue, options) {
// 1. 添加全局方法或 property
Vue.myGlobalMethod = function () {
// 逻辑...
}
// 2. 添加全局资源
Vue.directive('my-directive', {
bind (el, binding, vnode, oldVnode) {
// 逻辑...
}
...
})
// 3. 注入组件选项
Vue.mixin({
created: function () {
// 逻辑...
}
...
})
// 4. 添加实例方法
Vue.prototype.$myMethod = function (methodOptions) {
// 逻辑...
}
}
当我们调用 Vue.use(MyPlugin)
的时候,Vue 内部就会自动执行 install
方法,并根据内部的合并策略自动合并到组件实例上去,届时我们就可以直接使用插件提供的功能了。
那说完了怎么开发插件,现在就动手来实现我们自己的一个插件。
我们给插件起一个名字叫 VueLazyload,然后向外导出:
const VueLazyload = {
install: function() {
// ...
}
}
export default VueLazyload
在 install
方法里我们要做的事就是处理用户传入的 options
并给 Vue 实例上添加一个全局指令 v-lazy
install: function (Vue) {
// 保存用户传入的 options
const options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}
// 函数柯里化,将每一个的功能都封装到一个类里,如果功能需要扩展的话直接添加一个新的类即可
const LazyClass = Lazy(Vue)
const lazy = new LazyClass(options)
Vue.directive('lazy', {
bind: lazy.add.bind(lazy), // 将 this 绑定到 lazy 上
})
}
首先 install
方法的第一个参数是 Vue 构造函数,这是在使用 Vue.use()
时就会默认传的,就是为了让插件与 Vue 不会强关联上。假设没有传入这个 Vue 构造函数,插件内部使用 Vue 就必须显式引入,这样就会导致和用户使用的版本不一致时发生不可预见的异常。
options
可以直接通过 arguments
来取,这个不用多说。重点是生成一个 lazy
实例,这里我也是看了源码之后好长时间才理解的,使用函数柯里化将 Vue 作为参数传入到一个专门生成类的函数中,我们可以将每一个功能都封装到一个类里面,然后通过 Lazy
函数返回,vue-lazyload 的功能非常全,感兴趣的可以去了解一下他的源码。
接下来就是自定义一个全局指令,使用的是 Vue.directive()
,不熟悉的同学可以去文档上看一下使用说明,使用它提供的一个初始化钩子函数 bind
将指令绑定至元素上。然后将 lazy
实例上的 add
方法赋给了这个钩子函数,注意 this
的指向,需要使用 bind
方法显式指定 this
为 lazy
Lazy 方法
首先要明白的是这个方法是用来返回一个类的,然后用这个类生成实例:
const Lazy = Vue => {
return class LazyClass {
constructor() {
}
add() {
}
}
}
这样我们基本的框架就已经完成了,接下来要做的事就是处理自定义指令。
这个 add
方法就是将指令绑定到 img
元素上的,它的参数就是 bind
函数的参数,即 el
、bindings
和 vnode
,在这里面我们要处理的就是整个图片懒加载功能的核心原理:
- 找到图片的带有滚动的父级容器,然后给它添加上监听事件
- 判断元素是否在这个容器的可视区域内,如果不在那就不需要加载它
根据这个原理,我们一步步地去实现它。
添加监听事件
由于要找到指令绑定元素的父级元素,就必须要等到元素被插入到父元素时才能够获取到,一种方法是直接使用指令的 inserted
钩子函数,但是这里我们使用了 bind
似乎是没有办法获取到父级元素。别急,Vue 中 DOM 更新是使用的异步队列,我们可以通过 Vue.nextTick()
来获取更新后的 DOM,也就是说需要将逻辑写到 nextTick
的回调函数里。
那我们写一个方法来获取绑定元素的带有滚动属性的父元素:
const getScrollParent = el => {
let parent = el.parentNode
while (parent) {
// 判断父级元素是不是可以滚动的
if (/scroll|auto/.test(getComputedStyle(parent)['overflow'])) {
return parent
}
parent = parent.parentNode
}
return window
}
很简单,就是通过遍历父元素的 overflow
属性,如果是 scroll
或者 auto
就返回。
拿到这个父级元素之后,我们判断一下它有没有绑定滚动事件,如果没有,就给它绑定一个滚动事件。
let scrollParent = getScrollParent(el)
// 如果父级元素存在并且没有绑定 `scroll` 事件
if (scrollParent && !this.bindHandle) {
this.bindHandle = true
scrollParent.addEventListener('scroll', throttle(this.handleLazyLoad.bind(this), 200))
}
滚动事件一般都需要加上一个函数节流来优化,这个节流函数你可以自己写,也可以使用 throttle-debounce
或者 lodash 提供的 _.throttle
。
记得要销毁事件
初次渲染
接下来就需要去判断这个元素是否在容器的可视区域内,这里我们定义一个类,用来生成每一张被监听的图片的实例,由于我们前面使用了函数柯里化来生成 lazy
实例,那现在就可以直接在 Lazy
方法中添加一个类:
// 每一个图片元素都构造成一个类的实例,方便扩展
class ReactiveListener {
constructor({ el, src, options, elRender }) {
this.el = el
this.src = src
this.options = options
this.state = {
loading: false, // 是否加载过
error: false, // 是否出错
}
this.elRender = elRender
}
// 检测当前图片是否在可是区域内
checkInView() {
// ...
}
// 加载这个图片
load() {
// ...
}
}
根据我们前面所说的,如果图片在可视区域内,就需要加载这张图片,所以这两个方法后面都是必要的。
再回到 add
方法里,添加完滚动事件后,就需要把绑定的图片元素推到队列里,从队列里遍历,如果没有加载过并且在可视区域内,那么就调用图片的 load
方法加载它,下面把这两个方法的完整代码放出来:
add(el, bindings, vnode) {
Vue.nextTick(() => {
let scrollParent = getScrollParent(el)
if (scrollParent && !this.bindHandle) {
this.bindHandle = true
scrollParent.addEventListener('scroll', throttle(this.handleLazyLoad.bind(this), 2z00))
}
// 判断这个元素是否在容器的可视区域内,如果不在就不需要渲染
const listener = new ReactiveListener({
el,
src: bindings.value,
options: this.options,
elRender: this.elRender.bind(this),
})
// 把每一个图片实例都放进数组里
this.listenerQueue.push(listener)
this.handleLazyLoad()
})
}
handleLazyLoad() {
// 遍历所有的图片,如果没有加载过并且在可视区域内就加载图片
this.listenerQueue.forEach(listener => {
if (!listener.state.loading) {
let catIn = listener.checkInView()
catIn && listener.load()
}
})
}
检测图片和加载图片也比较简单,我就直接写出来:
// 检测当前图片是否在可是区域内
checkInView() {
let { top } = this.el.getBoundingClientRect()
// 相对的是窗口的高度
return top < window.innerHeight * (this.options.preload || 1.3)
}
// 加载这个图片
load() {
// 先加载 loading 图片
// 加载成功则替换成正常的图片
this.elRender(this, 'loading')
console.log(this.src)
loadImageAsync(
this.src,
() => {
setTimeout(() => {
this.state.loading = true
this.elRender(this, 'finish')
}, 1000)
},
() => {
this.state.error = true
this.elRender(this, 'error')
}
)
}
需要注意的是
getBoundingClientRect
是获取元素的大小以及相对于视口的位置的,在 vue-lazyload 中还使用了一种新的 API 叫 Intersection Observer API,这个 API 能够让你以异步回调的方式监听元素的可见性,感兴趣可以直接去 MDN 了解
这个 loadImageAsync
方法就是创建一个 Image
对象,图片加载成功了会走第一个回调,加载失败会走第二个回调,这里加上 setTimeout
是为了开发的时候更容易看到效果,实际使用的时候是不需要的。这个方法你也可以将它改造成一个 Promise
,变成真正的异步加载图片。
const loadImageAsync = (src, resolve, reject) => {
let image = new Image()
image.src = src
image.onload = resolve
image.onerror = reject
}
elRender
ok,到这里基本上就已经写完了核心代码,最后我们需要一个方法根据不同的状态来改变图片的 src
路径,这个方法就是 lazy
实例上的 elRender
方法:
// 渲染方法
elRender(listener, state) {
let el = listener.el
let src = ''
switch (state) {
case 'loading':
src = listener.options.loading || '' // 用户传进来的 loading
break
case 'error':
src = listener.options.error || '' // 用户传进来的 error
break
default:
src = listener.src // finish
break
}
el.setAttribute('src', src)
}
如果图片是正在加载中,就显示 loading 图,加载失败了就显示 error 图,加载成功了那就显示原图片。
效果图
最后我们来使用一下自己的图片懒加载插件:
Vue.use(VueLazyLoad, {
preload: 1,
loading: 'http://images.h-ui.net/icon/detail/loading/075.gif',
})
这里我设置了默认加载一屏,并且传入了 loading 图
从图片中可以看出前四张都已经渲染完成了,第五张图片还没有 src
地址,证明我们的懒加载已经实现了,放上几张效果图:
这是后话
当然,源码中的实现有很多细节值得我们学习的,这里只是简单的带大家实现了一个图片懒加载插件,有很多情况都没有考虑到,我还是推荐大家去看一下 vue-lazyload 的源码。
最近这段时间刚入职了新公司,全程都在搞公司里的项目,也没有多少时间看其他的东西,而且公司的项目使用的是 antdv 组件库,所以看 antdv 的源码会多一些,导致我没有多余的时间再去写 ElementUI 的源码文章,不过有时间的话我还是会把 ElementUI 系列写下去,因为它的源码看起来要比 antdv 的舒服。
源码
最后附上源码,别忘了来一波关注和点赞~
/**
* 函数节流
* @param {*} action 回调函数
* @param {*} delay 延迟的时间
*/
function throttle(action, delay) {
var timeout = null
var lastRun = 0
return function () {
if (timeout) {
return
}
var elapsed = Date.now() - lastRun
var context = this
var args = arguments
var runCallback = function runCallback() {
lastRun = Date.now()
timeout = false
action.apply(context, args)
}
if (elapsed >= delay) {
runCallback()
} else {
timeout = setTimeout(runCallback, delay)
}
}
}
// 获取元素带有滚动的父级元素
const getScrollParent = el => {
let parent = el.parentNode
while (parent) {
// 判断父级元素是不是可以滚动的
if (/scroll|auto/.test(getComputedStyle(parent)['overflow'])) {
return parent
}
parent = parent.parentNode
}
return window
}
const loadImageAsync = (src, resolve, reject) => {
let image = new Image()
image.src = src
image.onload = resolve
image.onerror = reject
}
const Lazy = Vue => {
// 每一个图片元素都构造成一个类的实例,方便扩展
class ReactiveListener {
constructor({ el, src, options, elRender }) {
this.el = el
this.src = src
this.options = options
this.state = {
loading: false, // 是否加载过
error: false, // 是否出错
}
this.elRender = elRender
}
// 检测当前图片是否在可是区域内
checkInView() {
let { top } = this.el.getBoundingClientRect()
// 相对的是窗口的高度
return top < window.innerHeight * (this.options.preload || 1.3)
}
// 加载这个图片
load() {
// 先加载 loading 图片
// 加载成功则替换成正常的图片
this.elRender(this, 'loading')
console.log(this.src)
loadImageAsync(
this.src,
() => {
setTimeout(() => {
this.state.loading = true
this.elRender(this, 'finish')
}, 1000)
},
() => {
this.state.error = true
this.elRender(this, 'error')
}
)
}
}
class LazyClass {
constructor(options) {
this.options = options
this.bindHandle = false // 是否已经绑定 scroll 事件
this.listenerQueue = []
}
add(el, bindings, vnode) {
Vue.nextTick(() => {
let scrollParent = getScrollParent(el)
if (scrollParent && !this.bindHandle) {
this.bindHandle = true
scrollParent.addEventListener('scroll', throttle(this.handleLazyLoad.bind(this), 200))
}
// 判断这个元素是否在容器的可视区域内,如果不在就不需要渲染
const listener = new ReactiveListener({
el,
src: bindings.value,
options: this.options,
elRender: this.elRender.bind(this),
})
// 把每一个图片实例都放进数组里
this.listenerQueue.push(listener)
this.handleLazyLoad()
})
}
handleLazyLoad() {
// 遍历所有的图片,如果没有加载过并且在可视区域内就加载图片
this.listenerQueue.forEach(listener => {
if (!listener.state.loading) {
let catIn = listener.checkInView()
catIn && listener.load()
}
})
}
// 渲染方法
elRender(listener, state) {
let el = listener.el
let src = ''
switch (state) {
case 'loading':
src = listener.options.loading || '' // 用户传进来的 loading
break
case 'error':
src = listener.options.error || '' // 用户传进来的 error
break
default:
src = listener.src
break
}
el.setAttribute('src', src)
}
}
return LazyClass
}
const VueLazyLoad = {
install: function (Vue) {
// 保存用户传入的 options
const options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}
// 函数柯里化,将每一个的功能都封装到一个类里,如果功能需要扩展的话直接添加一个新的类即可
const LazyClass = Lazy(Vue)
const lazy = new LazyClass(options)
Vue.directive('lazy', {
bind: lazy.add.bind(lazy), // 将 this 绑定到 lazy 上
})
},
}
export default VueLazyLoad