前言
总所周知,图片懒加载是前端优化性能的最基本功法,但是只会使用工具是不够的,在这个越来越卷的圈子里,造轮子才是正道。
首先来看一下我们的使用方式,引入插件,使用插件。
<script src="./5-懒加载.js"></script>
Vue.use(VueLazyLoad, {
preload: 1.3,
loading,
error,
})
好了,现在模版可以使用v-lazy指令了,就是这么简单方便。
<li v-for="img in imglist" :key="img.id">
<img v-lazy="img.src" alt="">
</li>
接下来就根据我们的使用方式去考虑怎么实现插件吧。
实现思路
把所有需要懒加载的对象添加到一个容器里面,然后给最近的可滚动的父类盒子添加滚动事件,当父类盒子滚动时,遍历刚才的容器,判断对象是否在可视区域内,如果在可视区域内则加载图片。
实现过程
创建插件
把所有懒加载相关的逻辑写到一个类里面,方便扩展功能。
const VueLazyLoad = {
install(Vue, options){
// 把懒加载的逻辑封装在一个类里面
const lazyClass = Lazy(Vue);
const lazy = new lazyClass(options);
}
}
const Lazy = (Vue) => {
// 这里可以继续扩展其他功能...
// 封装懒加载的逻辑
return class LazyClass{
constructor(options){
this.options = options;
}
}
}
v-lazy指令
添加v-lazy指令时是在bind阶段执行我们的逻辑,而这个时候还不能获取到html元素el,所以需要使用Vue.nextTick()来处理一下,Vue.nextTick()会在页面渲染完毕后执行我们的逻辑,这和Vue的异步渲染有关,不熟悉的话推荐看前面vue源码的文章。第一次调用v-lazy指令时需要对父元素添加滚动事件。另外每次在img标签内使用v-lazy指令时,我们都为它创建一个ReactiveListener对象,表示图片相关。
const VueLazyLoad = {
install(Vue, options){
// 把懒加载的逻辑封装在一个类里面
const lazyClass = Lazy(Vue);
const lazy = new lazyClass(options);
Vue.directive('lazy', {
bind: lazy.add.bind(lazy),
})
}
}
const Lazy = (Vue) => {
// 这里可以继续扩展其他功能...
// 封装懒加载的逻辑
return class LazyClass{
constructor(options){
this.options = options;
this.hasAddScrollListener = false;
this.queueListener = [];
}
add(el, bindings, vnode, oldVnode){
// 找到父元素,给父元素监听滚动事件,自定义指令在bind的时候还获取不到el,所以要用到nextTick
Vue.nextTick(() => {
const parentNode = getScrollParent(el);
if(parentNode && !this.hasAddScrollListener){
this.hasAddScrollListener = true;
parentNode.addEventListener('scroll', debounce(this.scrollHandler.bind(this), 100));
}
// 把每张图片添加到队列中
const listener = new ReactiveListener({
el: el,
src: bindings.value,
options: this.options,
elRender: this.elRender,
});
this.queueListener.push(listener);
// 首次判断一下图片是否在可视区域
this.scrollHandler()
})
}
}
}
ReactiveListener对象
class ReactiveListener{
constructor({el, src, options, elRender}){
this.el = el; // 图片html元素
this.src = src; // 图片src
this.options = options;
this.status = {loaded: false}; // 记录图片加载的状态
this.elRender = elRender;
}
// 判断是否在可加载区域
checkInView(){
let bcr = this.el.getBoundingClientRect();
return bcr.top < window.innerHeight*(this.options.preload || 1.3);
}
// 加载图片
load(){
this.elRender(this, 'loading');
loadAsyncImg(this.src, () => {
this.elRender(this, 'finish');
}, () => {
this.elRender(this, 'error');
})
this.status.loaded = true;
}
}
滚动事件
获取元素最近的可滚动父元素,给它添加滚动事件监听。滚动的时候,如果元素进入了可视区域并且没有加载过,那么加载图片。在监听滚动事件时,使用防抖进行优化。
const getScrollParent = (el) => {
let parentNode = el.parentNode;
while(parentNode){
if(/(scroll)|(auto)/.test(getComputedStyle(parentNode)['overflow'])){
return parentNode;
}
parentNode = parentNode.parentNode;
}
return parentNode;
}
scrollHandler(e){
// 滚动的时候判断每张图片是否进入可视区域
this.queueListener.forEach(listener => {
let catIn = listener.checkInView();
catIn && !listener.status.loaded && listener.load();
})
}
parentNode.addEventListener('scroll', debounce(this.scrollHandler.bind(this), 100));
// 防抖
function debounce(func, wait=0) {
if (typeof func !== 'function') {
throw new TypeError('need a function arguments')
}
let timeid = null;
let result;
return function() {
let context = this;
let args = arguments;
if (timeid) {
clearTimeout(timeid);
}
timeid = setTimeout(function() {
result = func.apply(context, args);
}, wait);
return result;
}
}
图片加载渲染
加载图片的核心是创建一个image对象,如果图片加载成功的话则显示图片,如果加载失败则显示失败图片。
class ReactiveListener{
constructor({el, src, options, elRender}){
this.el = el; // 图片html元素
this.src = src; // 图片src
this.options = options;
this.status = {loaded: false}; // 记录图片加载的状态
this.elRender = elRender;
}
// 加载图片
load(){
this.elRender(this, 'loading');
loadAsyncImg(this.src, () => {
this.elRender(this, 'finish');
}, () => {
this.elRender(this, 'error');
})
this.status.loaded = true;
}
}
function loadAsyncImg(src, resolve, reject){
let img = new Image();
img.src = src;
img.onload = resolve;
img.onerror = reject;
}
elRender(listener, status){
let el = listener.el;
let src = '';
switch(status){
case 'loading':
src = this.options.loading || '';
break;
case 'error':
src = this.options.error || '';
break;
default:
src = listener.src;
break;
}
el.setAttribute('src', src);
}
完整代码
<ol class="box">
<li v-for="img in imglist" :key="img.id">
<img v-lazy="img.src" alt="">
</li>
</ol>
const loading = "./image/loading.gif";
const error = './image/error.png';
Vue.use(VueLazyLoad, {
preload: 1.3,
loading,
error,
})
const getScrollParent = (el) => {
let parentNode = el.parentNode;
while(parentNode){
if(/(scroll)|(auto)/.test(getComputedStyle(parentNode)['overflow'])){
return parentNode;
}
parentNode = parentNode.parentNode;
}
return parentNode;
}
function loadAsyncImg(src, resolve, reject){
let img = new Image();
img.src = src;
img.onload = resolve;
img.onerror = reject;
}
// 防抖
function debounce(func, wait=0) {
if (typeof func !== 'function') {
throw new TypeError('need a function arguments')
}
let timeid = null;
let result;
return function() {
let context = this;
let args = arguments;
if (timeid) {
clearTimeout(timeid);
}
timeid = setTimeout(function() {
result = func.apply(context, args);
}, wait);
return result;
}
}
const Lazy = (Vue) => {
// 把每张图片当作的一个对象来处理
class ReactiveListener{
constructor({el, src, options, elRender}){
this.el = el; // 图片html元素
this.src = src; // 图片src
this.options = options;
this.status = {loaded: false}; // 记录图片加载的状态
this.elRender = elRender;
}
// 判断是否在可加载区域
checkInView(){
let bcr = this.el.getBoundingClientRect();
return bcr.top < window.innerHeight*(this.options.preload || 1.3);
}
// 加载图片
load(){
this.elRender(this, 'loading');
loadAsyncImg(this.src, () => {
this.elRender(this, 'finish');
}, () => {
this.elRender(this, 'error');
})
this.status.loaded = true;
}
}
// 封装懒加载的逻辑
return class LazyClass{
constructor(options){
this.options = options;
this.hasAddScrollListener = false;
this.queueListener = [];
}
scrollHandler(e){
console.log('scroll')
// 滚动的时候判断每张图片是否进入可视区域
this.queueListener.forEach(listener => {
let catIn = listener.checkInView();
catIn && !listener.status.loaded && listener.load();
})
}
add(el, bindings, vnode, oldVnode){
// 找到父元素,给父元素监听滚动事件,自定义指令在bind的时候还获取不到el,所以要用到nextTick
Vue.nextTick(() => {
const parentNode = getScrollParent(el);
if(parentNode && !this.hasAddScrollListener){
this.hasAddScrollListener = true;
parentNode.addEventListener('scroll', debounce(this.scrollHandler.bind(this), 100));
}
// 把每张图片添加到队列中
const listener = new ReactiveListener({
el: el,
src: bindings.value,
options: this.options,
elRender: this.elRender,
});
this.queueListener.push(listener);
// 首次判断一下图片是否在可视区域
this.scrollHandler()
})
}
elRender(listener, status){
let el = listener.el;
let src = '';
switch(status){
case 'loading':
src = this.options.loading || '';
break;
case 'error':
src = this.options.error || '';
break;
default:
src = listener.src;
break;
}
el.setAttribute('src', src);
}
}
}
const VueLazyLoad = {
install(Vue, options){
// 把懒加载的逻辑封装在一个类里面
const lazyClass = Lazy(Vue);
const lazy = new lazyClass(options);
Vue.directive('lazy', {
bind: lazy.add.bind(lazy),
})
}
}