那就写一个vue图片懒加载的插件吧

1,177 阅读4分钟

前言

总所周知,图片懒加载是前端优化性能的最基本功法,但是只会使用工具是不够的,在这个越来越卷的圈子里,造轮子才是正道。
首先来看一下我们的使用方式,引入插件,使用插件。

<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),
        })
    }
}

效果

lazy-load-show.gif