Vue-lazyload原理详解之源码解析

3 篇文章 0 订阅

前叙

本来想要研究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的主要流程的流程图
在这里插入图片描述

原理简述:

  • vue-lazyload是通过指令的方式实现的,定义的指令是v-lazy指令
  • 指令被bind时会创建一个listener,并将其添加到listener queue里面, 并且搜索target dom节点,为其注册dom事件(如scroll事件)
  • 上面的dom事件回调中,会遍历 listener queue里的listener,判断此listener绑定的dom是否处于页面中perload的位置,如果处于则加载异步加载当前图片的资源
  • 同时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只负责状态的控制,在不同状态执行不同的业务。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值