前叙

本来想要研究mint-ui组件库的Lazy load组件,没想到翻看它的源码,发现它完全引用的vue-lazyload项目,直接引用,没有丝毫修改。
因此转而研究vue-lazyload,代码并不多,几百行吧,有兴趣的可以读一下。

简单接入示例

html代码:

<div id="app">
    <li v-for="img in imgList">
        <img v-lazy="img">
    </li>
</div>

js代码:

<!-- 先引入 Vue -->
<script src="../js/vue.js"></script>
<!-- 引入组件库 -->
<script src="../js/index.js"></script>
<script>
    Vue.use(Lazyload);
    new Vue({
        el: '#app',
        data: {
            imgList: ['img url', 'img url', 'img url']
        }
    });
</script>

官方文档和示例:
mint-ui Lazyload文档
vue-lazyload github文档

原理剖析

首先是我总结的一个lazyload的主要流程的流程图
这里写图片描述
原理简述:

  1. vue-lazyload是通过指令的方式实现的,定义的指令是v-lazy指令
  2. 指令被bind时会创建一个listener,并将其添加到listener queue里面, 并且搜索target dom节点,为其注册dom事件(如scroll事件)
  3. 上面的dom事件回调中,会遍历 listener queue里的listener,判断此listener绑定的dom是否处于页面中perload的位置,如果处于则加载异步加载当前图片的资源
  4. 同时listener会在当前图片加载的过程的loading,loaded,error三种状态触发当前dom渲染的函数,分别渲染三种状态下dom的内容

源码剖析

  • 首先组件安装的函数 install函数解析:

    install (Vue, options = {}) {
        const LazyClass = Lazy(Vue)
        const lazy = new LazyClass(options) // 核心函数
        const isVueNext = Vue.version.split('.')[0] === '2' // 判断当前vue的版本
    
        Vue.prototype.$Lazyload = lazy
        // 如果支持 lazyload 组件,则定义一个 lazy-component的全局组件
        if (options.lazyComponent) {
            Vue.component('lazy-component', LazyComponent(lazy))
        }
    
        if (isVueNext) { // 2.0版本 自定义指令方式
            Vue.directive('lazy', {
                bind: lazy.add.bind(lazy),
                update: lazy.update.bind(lazy),
                componentUpdated: lazy.lazyLoadHandler.bind(lazy),
                unbind : lazy.remove.bind(lazy)
            })
        } else { // 1.0 版本自定义指令的方式
            Vue.directive('lazy', {
                bind: lazy.lazyLoadHandler.bind(lazy),
                update (newValue, oldValue) {
                    assign(this.vm.$refs, this.vm.$els)
                    lazy.add(this.el, {
                        modifiers: this.modifiers || {},
                        arg: this.arg,
                        value: newValue,
                        oldValue: oldValue
                    }, {
                        context: this.vm
                    })
                },
                unbind () {
                    lazy.remove(this.el)
                }
            })
        }
    }
  • 下面分析LazyClass核心函数,源码如下

    function (Vue) {
        return class Lazy {};
    }

    上面返回了一个class对象,然后在install函数创建了一个class实例,下面首先看看它的构造函数

    constructor ({ preLoad, error, preLoadTop, loading, attempt, silent, scale, listenEvents, hasbind, filter, adapter }) {
            this.ListenerQueue = []
            this.TargetIndex = 0
            this.TargetQueue = []
            this.options = {
                silent: silent || true,
                preLoad: preLoad || 1.3, // 0.3的距离是 当前dom距离页面底部的高度时就开始加载图片了
                preLoadTop: preLoadTop || 0, // dom的底部距离页面顶部多少距离还是加载
                error: error || DEFAULT_URL, // 加载失败显示的图片
                loading: loading || DEFAULT_URL, // 加载中显示的图片
                attempt: attempt || 3, // 图片加载失败,最多重试的次数
                scale: scale || getDPR(scale),
                ListenEvents: listenEvents || DEFAULT_EVENTS, // 给dom注册dom的事件,在这些事件回调中会触发加载图片的方法
                hasbind: false,
                supportWebp: supportWebp(),
                filter: filter || {},
                adapter: adapter || {} // 状态变化的回调监听,同时也可以使用lazyload的$on()函数(注意不是vue的)来监听状态变化的回调函数
            }
            this.initEvent() // 初始化事件处理器 (实现同理 vue的事件机制)
            // 使用了节流函数
            this.lazyLoadHandler = throttle(() => {
                let catIn = false
                this.ListenerQueue.forEach(listener => {
                    if (listener.state.loaded) return
                    catIn = listener.checkInView() // 判断当前dom是否处于可以preload的位置
                    catIn && listener.load() // 处于preload的位置, 执行图片加载的操作
                })
            }, 200)
        }

    关于options配置项可以参考vue-lazyload的github官网的说明。但是我还是将重要的配置在上面做了中文说明。
    lazyLoadHandler()函数是一个很重要的函数,它触发图片加载的入口函数,并且此函数是图片加载的入口。它的核心处理函数经过了节流函数的处理了,关于节流函数,我在之前的mint-ui 的inifite-scroll组件做了说明,如果想了解,请移步。

  • 下面对constructor中调用的initEvent()函数,初始化事件处理器函数的代码进行说明。

    initEvent () {
            this.Event = {
                listeners: {
                    loading: [],
                    loaded: [],
                    error: []
                }
            }
    
            this.$on = (event, func) => {
                this.Event.listeners[event].push(func)
            }
    
            this.$once = (event, func) => {
                const vm = this
                function on () {
                    vm.$off(event, on)
                    func.apply(vm, arguments)
                }
                this.$on(event, on)
            }
    
            this.$off = (event, func) => {
                if (!func) {
                    this.Event.listeners[event] = []
                    return
                }
                remove(this.Event.listeners[event], func)
            }
    
            this.$emit = (event, context, inCache) => {
                this.Event.listeners[event].forEach(func => func(context, inCache))
            }
        }

    实现方式很简单,代码大家应该都很容易读懂,我就不加注释说明了,并且vue中的事件处理也是这样实现,代码基本相同,相信读过vue源码的同学应该有感触。

  • 下面是v-lazy指令 bind时触发的lazy 的 add函数,源码如下

    add (el, binding, vnode) {
            if (some(this.ListenerQueue, item => item.el === el)) { // 判断当前监听队列里面是否含有当前dom的监听事件
                //如果已经含有,执行它的update函数,更新即可,无需创建
                this.update(el, binding)
                return Vue.nextTick(this.lazyLoadHandler)
            }
    
            let { src, loading, error } = this.valueFormatter(binding.value)
    
            Vue.nextTick(() => {
                src = getBestSelectionFromSrcset(el, this.options.scale) || src
    
                const container = Object.keys(binding.modifiers)[0]
                let $parent
    
                // 如果使用了container 修饰符, 那么查找我们定义的contianer; 如果没有使用当前dom所在最近的滚动parent
                // 这个contianer是用于 设置监听dom事件的dom对象, 他的事件触发回调会触发图片的加载操作
                if (container) {
                    $parent = vnode.context.$refs[container]
                    // if there is container passed in, try ref first, then fallback to getElementById to support the original usage
                    $parent = $parent ? $parent.$el || $parent : document.getElementById(container)
                }
    
                if (!$parent) {
                    $parent = scrollParent(el)
                }
    
                // 在当前dom绑定到vdom中, 为当前dom创建一个监听事件(此事件用于触发当前dom在不同时期的不同处理操作), 并将事件添加到事件队列里面
                const newListener = new ReactiveListener({
                    bindType: binding.arg, // 要绑定的属性
                    $parent,
                    el,
                    loading,
                    error,
                    src,
                    elRenderer: this.elRenderer.bind(this),
                    options: this.options
                })
    
                this.ListenerQueue.push(newListener)
                if (inBrowser) {
                    this._addListenerTarget(window)
                    this._addListenerTarget($parent)
                }
    
                this.lazyLoadHandler()
                Vue.nextTick(() => this.lazyLoadHandler())
            })
        }

    主要操作:找到对应的target(用于注册dom事件的dom节点;比如:页面滚动的dom节点),为其注册dom事件;为当前dom创建Listenr并添加到listener queue中。最后代用lazyLoadHandler()函数,加载图片

  • 下面,我们回过头来看lazyLoadHandler()的实现,其实前面已经简单解析过。

        this.lazyLoadHandler = throttle(() => {
            let catIn = false
            this.ListenerQueue.forEach(listener => {
                if (listener.state.loaded) return
                catIn = listener.checkInView()
                catIn && listener.load()
            })
        }, 200)

    下面继续看checkInView()是怎么实现,简单当前dom是否位于preload的位置

    checkInView () { 
        this.getRect() // 调用dom的getBoundingClientRect()
        return (this.rect.top < window.innerHeight * this.options.preLoad,  && this.rect.bottom > this.options.preLoadTop) &&
            (this.rect.left < window.innerWidth * this.options.preLoad && this.rect.right > 0)
    }

    首先看y轴方向的判断:this.rect.top < window.innerHeight * this.options.preLoad, 是dom的顶部是否到了preload的位置;this.rect.bottom > this.options.preLoadTop 判断dom的底部是否到达了preload的位置
    关于x轴方向就不做解析了,实现同y轴。

    然后是load()异步加载图片的核心函数

    load () {
        // 如果当前尝试加载图片的次数大于指定的次数, 并且当前状态还是错误的, 停止加载动作
        if ((this.attempt > this.options.attempt - 1) && this.state.error) {
            if (!this.options.silent) console.log('error end')
            return
        }
    
        if (this.state.loaded || imageCache[this.src]) {
            return this.render('loaded', true) // 使用缓存渲染图片
        }
    
        this.render('loading', false) // 调用lazy中的 elRender()函数, 用户切换img的src显示数据,并触发相应的状态的回调函数
    
        this.attempt++ // 尝试次数累加
    
        this.record('loadStart') // 记录当前状态的时间
    
        // 异步记载图片, 使用Image对象实现
        loadImageAsync({
            src: this.src
        }, data => {
            this.naturalHeight = data.naturalHeight
            this.naturalWidth = data.naturalWidth
            this.state.loaded = true
            this.state.error = false
            this.record('loadEnd')
            this.render('loaded', false) // 渲染 loaded状态的 dom的内容
            imageCache[this.src] = 1 // 当前图片缓存在浏览器里面了
        }, err => {
            this.state.error = true
            this.state.loaded = false
            this.render('error', false)
        })
    }

    紧接着是loadImageAsync()异步加载图片的函数

    const loadImageAsync = (item, resolve, reject) => {
        let image = new Image()
        image.src = item.src
    
        image.onload = function () {
            resolve({
                naturalHeight: image.naturalHeight, // 图片的 实际高度
                naturalWidth: image.naturalWidth,
                src: image.src
            })
        }
    
        image.onerror = function (e) {
            reject(e)
        }
    }

    实现很简单,就是使用的Image对象实现的网络请求。

  • 下面来看看渲染图片不同状态的render函数的实现,首先是listener中的render()函数

    render (state, cache) {
        this.elRenderer(this, state, cache) // 指向的是lazy class中的 elRenderer函数
    }

    下面来看elRenderer函数实现

    elRenderer (listener, state, cache) {
            if (!listener.el) return
            const { el, bindType } = listener
    
            let src
            // 根据不同状态加载不同的图片资源
            switch (state) {
                case 'loading':
                    src = listener.loading
                    break
                case 'error':
                    src = listener.error
                    break
                default:
                    src = listener.src
                    break
            }
    
            if (bindType) { // v-lazy: 后面的内容, 代表绑定的是这个属性
                el.style[bindType] = 'url(' + src + ')'  // 用于lazy load 背景图片
            } else if (el.getAttribute('src') !== src) {
                el.setAttribute('src', src)  // 普通lazyload image
            }
    
            el.setAttribute('lazy', state) // 自定义属性 lazy,用于给用于 根据此进行class搜索,设置指定状态的样式
    
            this.$emit(state, listener, cache) // 触发当前状态的回调函数
            // 触发adapter中的回调函数
            this.options.adapter[state] && this.options.adapter[state](listener, this.options)
        }

    上面将lazy load实现主要过程做了解析,下面对指令的update回调和lazy-component组件进行解析。

  • 从指令创建时传递的配置可知update指向的lazy class的update()函数,也就是v-lazy指令绑定的数据发生改变的时候出发的回调函数。

        update (el, binding) { // 获取当前dom绑定的 图片src的数据, 如果当前dom执行过load过程, 重置当前dom的图片数据和状态
            let { src, loading, error } = this.valueFormatter(binding.value) // 当前绑定的value是 obj, 从中选取{src, loading, error}; 是string, 则用作src
            // 找到当前dom绑定的listener
            const exist = find(this.ListenerQueue, item => item.el === el)
            // 更新listener的状态和状态对应的图片资源
            exist && exist.update({
                src,
                loading,
                error
            })
            this.lazyLoadHandler()
            Vue.nextTick(() => this.lazyLoadHandler())
        }

    上面代码很简单,逻辑通过注释基本能看懂。

  • lazy-component组件
    我们看到注册全局的lazy-component组件的时候,创建组件实例是通过一个方法创建的,方法原型如下:

    export default (lazy) => { // 将lazy class的实例作为参数传入
        return {
        }
    }

    下面再来看看props,data和render。

        props: {
            tag: { // 当前组件渲染出来的外层的container的tag
                type: String,
                default: 'div' 
            }
        },
        render (h) {
            // 如果当前组件内的内容是隐藏状态, 只渲染外层 container
            if (this.show === false) {
                return h(this.tag)
            }
            // 变为显示状态, 渲染组件内的slot内容,也就要显示的主体内容
            return h(this.tag, null, this.$slots.default)
        },
        data () {
            return {
                state: { // 当前组件内容的状态
                    loaded: false
                },
                rect: {}, // 当前组件的dom getBoundingClientRect()内容
                show: false // 当前组件内的内容的显示状态
            }
        }  

    然后是mounted()回调函数,在当前组件挂载上的时候的回调。

        mounted () {
            lazy.addLazyBox(this)
            lazy.lazyLoadHandler()
        }

    内部触发了lazy的addLazyBox()函数和lazyLoadHandler()函数。关于lazyLoadHandler()函数上面已经说过好多了,不在赘述。下面对addLazyBox()进行解析。

        addLazyBox (vm) {
            this.ListenerQueue.push(vm) // 将当前vue实例以Listener的方式传入到listener queue队列中;当前vue实例就是起到listener的作用
            if (inBrowser) {
                this._addListenerTarget(window)
                if (vm.$el && vm.$el.parentNode) { // 为当前组件的dom 父节点注册相应的dom事件
                    this._addListenerTarget(vm.$el.parentNode)
                }
            }
        }

    通过上面的代码可知,当前组件的vue实例起到和我们上面提到的listener相同的作用,那么它可能也会有listener对应的核心的api 函数。是的,这些都在组件的methods中注册了。

        methods: {
            getRect () {
                this.rect = this.$el.getBoundingClientRect()
            },
            checkInView () {
                this.getRect()
                return inBrowser &&
                    (this.rect.top < window.innerHeight * lazy.options.preLoad && this.rect.bottom > 0) &&
                    (this.rect.left < window.innerWidth * lazy.options.preLoad && this.rect.right > 0)
            },
            load () { // 执行到dom的时候,就没有网络请求了,直接将dom的内容显示出来了
                this.show = true
                this.state.loaded = true
                this.$emit('show', this) // 注意: 这里的触发的回调事件是vue发出的,只能vue才能拦截
            }
        }

    上面的代码量很少,也很简单,不再赘述。但是大家有没有注意到load()方法,这里没有显示调用render()函数去渲染不同状态的内容,和listener不同。那是因为vue的mvvm数据绑定机制。data建立了observer,当里面的数据发生变化的时候,会触发update()回调,然后触发render()渲染函数。关于vue怎么实现的mvvm,可以通过阅读vue的源码得知。

总结

通过阅读源码我们学到了什么。

  • lazy load的实现原理
  • 作者代码结构的设计,我们可以看到Lazy load模块和listener模块他们的业务职责分工明确。lazy负责和dom相关的处理,包括为dom创建listener,为target注册dom事件,渲染dom;而listener只负责状态的控制,在不同状态执行不同的业务。
Logo

前往低代码交流专区

更多推荐