vue-lazyload的局限

1,374 阅读7分钟

vue-lazyload 原理简述

实现的基本功能是:可视区域的图片加载,其他的图片可以暂时有一个占位loading图,等滚动它们到可视区域时再去请求真实图片并且替换。

企业微信截图_b9980626-50e9-475d-b38c-ab2e794db643.png

原理简述:

  1. vue-lazyload是通过指令的方式实现的,定义的指令是v-lazy指令,在index.js中,通过vue的插件(install)方式,注册自定义指令v-lazy
  2. 指令被bind时会创建一个listener,listener只负责状态的控制,在不同状态执行不同的业务。此时会加载loading图,如果在filter中有提供渐进式图片方式,则会优先加载缩略图。
  3. 上一步创建的listener,会将其添加到listener queue里面, 并且搜索target dom节点,即当前dom最近的overflow为scroll或auto的parent节点,没有则为window,为其注册dom事件(listenEvents事件,如scroll事件)。
  4. 上面的dom事件回调中,会遍历 listener queue里的listener,判断此listener绑定的dom是否处于页面中perload的位置,如果处于则异步加载loadImageAsync()当前图片的资源。
  5. 同时listener会在当前图片加载的过程的loading,loaded,error三种状态触发当前dom渲染的函数,分别渲染三种状态下dom的内容,会将data-src中的值,赋予到img的src中。

绝大多数场景下使用vue-lazyload都能很好的满足业务需求。不过笔者遇到一种场景,居然没法很好的工作。

失效场景简述

笔者开发的H5页面是内嵌到app的webview中,有ios和android,位置在一级tab下的某个二级tab。 客户端为了提高该页面的用户体验,都分别使用了预加载,ios是在点击该一级tab时,就预加载该H5页面,而android则是打开app就预加载了该页面。

一次首屏优化中,为了提高该组件的性能,剥离掉组件没用到的代码,并将listenEvents事件范围缩小。

const IMG_REG = /cdn1/Vue.use(VueLazyload, {
    preLoad: 1.3,
    attempt: 3,
    error: errorImg,
    // 首屏刚好有个轮播图,切换轮播图,会触发transitionend事件
    listenEvents: ['scroll', 'transitionend'],
    // 图片监听过滤,每张图片加载时都会执行一次。
    filter: {
        progressive (listener, options) { // 实现渐近式加载图片(先加载模糊的图)
            const { src } = listener;            
            if (IMG_REG.test(src)) {
                listener.el.setAttribute('lazy-progressive', 'true');
                // 先加载模糊的图
                let loadingUrl = listener.src + '/thumb1';
                if (options.supportWebp) {
                    loadingUrl += '?imageMogr2/format/webp';
                }
                listener.loading = loadingUrl;
                // 最后加载清晰一点的图
                listener.src += '/thumb2';
            }
        },
        webp (listener, options) { // 加载 webp 图
            if (!options.supportWebp) return
            const { src } = listener;
            if (IMG_REG.test(src)) {
                listener.src += '?imageMogr2/format/webp';
            }
        }
    }
})

测试环境和预发布环境验证无误,并成功上线。

本以为告一段落,直到几天后,有同事反馈,ios线上环境,等预加载完成后,第一次进入到该tab,图片没有加载出来,只有滚动或刷新该页面,才能将图片加载出来。

此时的我满脸黑人问号。android线上验证没有问题,为什么ios会有问题呢?

快速解决

影响到用户体验的线上问题,为紧急重要的事情。因此要优先处理该问题。

简单分析了下,在后台预加载该页面,不在视口内,listener的checkInView()为false,因此图片没有被加载,等到点击到该二级tab时,触发某一个事件,事件监听到时,执行lazyLoadHandler的load()方法,加载图片

于是,我放开监听事件的范围,也就是使用默认的事件,快速修复了该问题。

刨根问底

但是为什么呢?为什么放开监听事件就能加载出图片,没放开的时候,android为什么能够加载出来,还有前几天,为什么都没有这些问题?为什么测试环境没问题,线上环境有问题呢?

为了方便在移动端调试,加上vconsole-webpack-plugin插件来辅助分析。仔细阅读了vue-lazyload的源码后,在_lazyLoadHandler()中,加上事件类型和是否在视图内的console.log。

经过亿点点探索后,终于解开了谜底。

一、放开监听事件前

1、为什么android可以加载出图片而iOS不可以?

在后台预加载该H5页面时,getBoundingClientRect()方法在android环境下,能够正确获取到dom元素的相关值,因此checkInView()为true,图片在后台就被加载出来;而ios环境下,getBoundingClientRect()方法得到dom元素的相关值为0,因此checkInView()为false,图片在后台没有被加载出来。

2、一开始iOS为什么可以加载出图片,为什么后来不行?

根据上面的分析,理论上前几天也是加载不出来的。但为什么没问题呢?

根据源码可知,在刚开始绑定dom元素,生成newListener的时候,执行filter()方法,上面配置项中的progressive和webp方法都会被依次执行。此时img加载loading占位图,而在progressive中,已经将loading图设置为缩略图,于是线上环境下看到的是xxx.png/thumb1?imageMogr2/format/webp缩略图。

理论上线上环境还是有问题的,只是被缩略图占位,看不出来。

后来运营同学在新管理后台上更新了页面内容,线上存量内容都是在老管理后台上配置的,其中上传图片的接口不同,返回图片url的cdn不同。于是配置项中的if (IMG_REG.test(src))为false,无法将loading图设置为缩略图,因此线上环境会看不到图片。

正确的解决方式应该为const IMG_REG = /(cdn1|cdn2)/,此时就都能看到缩略图占位。

二、放开监听事件后

ios线上环境为什么会恢复正常?

这个问题我探索了好久,迟迟得不到答案。vue-lazyload的默认监听事件为'scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend', 'touchmove',到底是触发了哪个监听事件?

前情提要

在重写vue-lazyload,去掉不要的代码时,节流函数throttle我使用了本地一直在用的工具函数,如下:

const throttle = (fn, delay, atleast) => {
    let timer = null;
    let previous = null;
    return function () {
        let now = +new Date();
        if (!previous) previous = now;
        if (atleast && now - previous > atleast) {
            fn();
            previous = now;
            clearTimeout(timer);
        } else {
            clearTimeout(timer);
            timer = setTimeout(function () {
                fn();
                previous = null;
            }, delay);
        }
    };
};

而官方节流函数为:

function throttle (action, delay) {
    let timeout = null
    let lastRun = 0
    return function () {
        if (timeout) {
            return
        }
        let elapsed = Date.now() - lastRun
        let context = this
        let args = arguments
        let runCallback = function () {
            lastRun = Date.now()
            timeout = false
            action.apply(context, args)
        }
        if (elapsed >= delay) {
            runCallback()
        } else {
            timeout = setTimeout(runCallback, delay)
        }
    }
}

乍看之下,两种差不多,但问题就出在这里。

乌云蔽日

理论上,放开监听事件后,预加载完成第一次切换到该tab,我应该能够在vconsole中知道触发了哪个事件,然后事情解决。

在测试环境下,我将工具函数换回官方的,包括节流函数,然后放开监听事件。奔溃的事情来了,预加载完成后,第一次切换到该tab,图片依然没有加载出来。

那线上为什么放开就好了呢???

于是迷途越走越远。。。

初见端倪

仔细研究了下两个节流函数的实现方式,发现官方的节流函数会立即执行,而我个人用的节流函数,因为atleast没有传参,所以一开始并没有立即执行。

水落石出

lazyLoadHandler是放在节流函数中,第一次进入tab时,触发了某个事件后,如果节流函数有立即执行,此时页面从后台切换到前台的瞬间,checkInView()为false,因此,图片无法加载出来;延迟一段时间,等页面从后台切换到前台后,再执行回调函数,此时checkInView()为true,图片也就能加载出来。

于是将测试环境节流函数换回自己的,并做了调整,主要是为了透传参数,如下:

const throttle = (fn, delay, atleast) => {
    let timer = null;
    let previous = null;
    return function (...args) {
        let now = +new Date();
        const context = this;
        if (!previous) previous = now;
        if (atleast && now - previous > atleast) {
            fn.apply(context, args); // 透传参数
            previous = now;
            clearTimeout(timer);
        } else {
            clearTimeout(timer);
            timer = setTimeout(function () {
                fn.apply(context, args); // 透传参数
                previous = null;
            }, delay);
        }
    };
};

然后在vconsole中顺利的得到答案:放开监听事件,预加载后,第一次进入该tab,iOS会触发resize事件,android不会。延迟200ms后执行了lazyLoadHandler(),此时页面已经切换到前台,checkInView()为true,图片就加载了出来。

总结

  1. 通过vue-lazyload源码可知,lazy.js负责和dom相关的处理,包括为dom创建listener,为target注册dom事件,渲染dom;而listener.js只负责状态的控制,在不同状态执行不同的业务。
  2. 官方的节流函数throttle(),在绝大多数场景下,立即执行是没什么问题的,但是在笔者的这种场景下,是有问题的。