2

前言

ES6 注意!!

最近在优化个人博客前端,翻看到了自己图片懒加载/预加载的远古代码(通过watch监听实现的),虽说实际效果勉强还行,但总觉得不够 “Vue”,功能上也有所不足。
考虑到现有的 vue-lazyload 插件将懒加载指令化了,于是我想能不能自己也写一个。搜索一波后发现是可行的,并且还挺简单的,这篇文章给了我很多参考,在此之上我又优化并增添了额外功能,最后也不超一百行代码,奥利给,造TA就完了

问:为什么不用现成的插件?
答:代码还是自己撸的爽,vue-lazyload 据说不支持分别指定占位图,而我实现了

图片懒加载

这里给小白的简单介绍一下,熟知此概念的大佬可以跳过,还是看不懂的小白转百度或谷歌

图片的懒加载是指对于首屏加载后未在视野的图片容器 / 视野之外新增的图片容器,先给容器一个缩略图或者默认占位图(size 很小);待容器进入或即将进入视野时在后台下载原图,原图准备好后才替换。效果如下,左右分别是懒加载完成前后的效果

懒加载效果图

这样用户在原图到来之前至少还有缩略图看看,一定程度减缓其不耐烦的心情从而优化用户体验;同时按需加载的特性能够节省流量,这对服务器和用户都是一件好事

所以对于小水管服务器和面对网络拥堵时候,懒加载就显得特别有用

实现关键

首先给数组原型加两个自定义方法,后面会用上,这段代码放在指令调用前即可

// 移除数组指定的元素
if (!Array.prototype.remove){
    Array.prototype.remove = function (item) {
        if (!this.length) return;
        let index = this.indexOf(item);
        if (index > -1) {
            this.splice(index,1);
            return this;
        }
    }
}
// 推入数组当且仅当该数组没有该元素(针对string)
if (!Array.prototype.pushIfNew){
    Array.prototype.pushIfNew = function (...item) {
        for (let i of item)
            if (this.indexOf(i)===-1)
                this.push(i);
        return this
    }
}

位置判断

懒加载关键的之一就是判断该图片容器是否在视野之内,这里要用到节点的 getBoundingClientRect() 方法,返回值是 DOMRect 对象,包含该元素块边框相对于视野左上角的距离,各属性如下

rect.png

如果视野高度为 screenHeight,结合以上属性,我们很容易判断元素是否在视野之内

let top  = el.getBoundingClientRect().top;
let screenHeight = window.innerHeight || document.documentElement.clientHeight;
if (top < screenHeight + 50 && top > -50){
// 不一定要严格地进入视野,可以适当“扩大”视野,能够判断“即将进入”的情况,更符合实际要求
}

后台加载

懒加载关键的之二是后台加载原图,实现起来很简单,当 img 元素的 src 属性被赋值时,加载就会发生,加载成功后执行其 onload 方法,失败时执行 onerror 方法。利用这个特性,当目标进入视野时,可以创建一个临时 img(不用插入document),定义其加载成功和失败的行为,然后给他的 src 赋值即可

let img = new Image();
img.onload = ()=>{
// 成功后替换缩略图
//...
}
img.onerror = ()=>{
// 失败后可以显示 error 图片
// 或什么都不做维持之前的缩略图
// ...
}
img.src = 'original imgSrc'

监听追踪

关键之三就是对目标的监听和追踪了,可以定义两个数组,listenList 存放追踪目标,imgCacheList 存放已加载(已缓存)图片的 src。

当一个 img 元素被新插入文档后,以下操作按序三选一

  • 如果其原图在 imgCacheList 中,直接 src 赋值为原图
  • 如果该 img 在视野之内,开始触发后台加载,加载成功后其 src 加入 imgCacheList
  • 如果该 img 在视野之外,将其加入 listenList 中进行监听

对于 listenList, 我们会绑定全局滚动事件,窗口一滚动就对 listenList 中的所有目标进行位置判断

  • 如果在视野内,触发后台加载,加载成功后其 src 加入 imgCacheList,同时将目标从 listenList 中移除
  • 如果在视野外,什么都不干

当然直接绑定滚动时间会超频繁的触发函数,这里可以对函数做防抖处理

被监听的 img 如果被移除(如页面跳转),listenList 中相应的监听目标要移除

指令注册

因为要对 listenListimgCacheList 进行共享和管理,所以不能简单地进行全局指令注册 Vue.directive(),而是要在其之外开辟一个区域存放这些共享的数据,这就要以插件形式进行指令的注册了

同时指令有多个钩子函数,考虑到 img 要插入文档后才能通过 getBoundingClientRect() 获取位置信息,这里选择 inserted 钩子函数

inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)

虽然跟要求有偏差,但这是最接近要求的钩子函数了,实际使用上又没什么问题,就选他吧。

/*-------lazyload.js-------*/
export default (Vue,options)=>{
    let listenList = [];
    let imgCacheList = [];
    //.....
    Vue.directive('lazyload',{
        inserted:(el,binding)=>{
        
        },
        unbind:(el)=>{
        
        }
    }
}
/*-------main.js-------*/
import LazyLoad from './lazyload';
Vue.use(LazyLoad)

使用

了解了这几个关键点我想最终实现也应该有个大概了,剩下一些细节以注释给出,详看下面的完整代码

// ----lazyload.js----
// 防抖
function throttle(func, wait) {
    let context, args;
    let previous = 0;
    return function() {
        let now = +new Date();
        context = this;
        args = arguments;
        if (now - previous > wait) {
            func.apply(context, args);
            previous = now;
        }
    }
}
export default (Vue,options={})=>{
    //默认设置,可以传入options覆盖
    //preloadClass: 占位状态(原图未加载进来)的 class,可以利用他配合 css 加模糊效果
    //loadErrorClass: 图片加载失败后赋予的 class
    //default: 默认占位图透明
    //error: 出错后显示的图片默认透明,要启用错误处理才生效
    let init = {
        preloadClass:'lazyload-preload',
        loadErrorClass:'lazyload-status-fail',
        default:'data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==',
        error:`data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==`,
        ...options
    };
    let listenList = [];
    let imgCacheList = [];

    // 判断图片是否已经缓存
    const isAlreadyLoad = (imgSrc)=>{
        return imgCacheList.indexOf(imgSrc) > -1;
    };

    // 如果在视野内,触发后台加载返回true,否则返回false
    const tryLoad = (item)=>{
        let {el,src} = item;
        let top  = el.getBoundingClientRect().top;
        let screenHeight = window.innerHeight || document.documentElement.clientHeight;
        if (top < screenHeight + 50 && top > -50){
            let img = new Image();
            //后台加载完:进行替换,加入缓存,移除监听,更新class
            img.onload = ()=>{
                el.src = src;
                el.classList.remove(init.preloadClass);
                imgCacheList.pushIfNew(src);
                listenList.remove(item);
            };
            //如果出错:更新class,移除监听
            img.onerror = ()=>{
                if (item.errorHandle){
                    el.src = init.error;
                    el.style.objectFit = 'none';
                }
                el.classList.remove(init.preloadClass);
                el.classList.add(init.loadErrorClass);
                listenList.remove(item);
            };
            //出发后台加载
            img.src = src;
            return true;
        }else{
            return false;
        }
    };

    //用于标记监听状态,确保只会 addEventListener(由第一张插入的图片触发)
    let listenStatus = false;
    const listenScroll = ()=>{
        if (!listenStatus){
            window.addEventListener('scroll',throttle(()=>{
                let len = listenList.length;
                for (let i = 0; i < len; i++){
                    tryLoad(listenList[i])
                }
            },200));
            listenStatus = true;
        }
    };

    Vue.directive('lazyload',{
        inserted:(el,{value,modifiers})=>{
            let imgSrc,placeholder;
            // 两种方式传参数
            if (typeof value==='string'){
                imgSrc = value;
                placeholder = init.default;
            }else{
                imgSrc = value[0];
                placeholder = value[1]||init.default;
            }
            // 如果已经有缓存,直接使用
            if (isAlreadyLoad(imgSrc)){
                el.src = imgSrc;
                return false;
            }
            let item = {
                el:el,
                src:imgSrc,
                errorHandle:!!modifiers.rude //是否开启错误处理
            };
            // 先给占位图和占位 class
            el.src = placeholder;
            el.classList.add(init.preloadClass);
            if (tryLoad(item)){
                return;
            }
            // 如果在视野外,加入监听
            listenList.pushIfNew(item);
            // 第一张插入的图片负责 addEventListener
            !listenStatus && listenScroll();
        },
        //被监听的图片被移除,取消对其监听
        unbind:(el)=>{
          for(let item of listenList)
            if (item.el===el){
              listenList.remove(item);
              //console.log('remove')
            }
        }
    })
}

使用上和 Vue 装插件一样

/*-------main.js-------*/

import LazyLoad from './lazyload';
Vue.use(LazyLoad)

/*-------xxx.vue-------*/
// 两种方式传参数,指定原图和占位图/只指定原图,占位图默认
<img v-lazyload="[originSrc,thumbnailSrc]">
<img v-lazyload="originSrc">
// 启用错误处理
<img v-lazyload.rude="[originSrc,thumbnailSrc]">

其他

目前该指令只支持 img 标签的懒加载,对于 background-image 这种背景图并未支持(因为自己博客用得少),但我想实现起来也不难 “通过指令的修饰区别两种情况,改一下 tryLoad 函数……” 应该就行了

同时也不支持动态响应的参数(我不知道这样说对不对),也就是如果传入指令的 imgSrc 发生变动,被绑定的元素并不会更新。所以目前该指令只适用于插入一次后不再变更的元素

目前想到的问题就上面两个,如果有什么实用的功能也可以提出来,正好我也想把这个指令做得更精一些

以上


忍野忍
209 声望187 粉丝

自分の世界を変えるのは自分。 —— モルガナ