Vue指令图片懒加载插件学习笔记

常永长
2023-12-01

文章链接:https://mp.weixin.qq.com/s/OO7jVd2kIlkRtNNaRjGLuQ

插件

方便复用,封装成一个插件:

// lazy.js
export const lazyPlugin = {
    install (app, options = {}) {
        const lazy = new Lazy(options)

        app.directive('lazy', {
            inserted: lazy.add.bind(lazy)
        })
    }
}

使用bind方法返回一个新的函数,且该函数内部的this指向Lazy实例,全局只有一个Lazy实例

Vue3 使用mounted钩子函数,Vue2使用inserted

main.js中引入

import Vue from 'vue'
import App from './App.vue'
import { lazyPlugin } from './/plugins/ImageLoader'

Vue.config.productionTip = false
Vue.use(lazyPlugin)

new Vue({
  render: h => h(App),
}).$mount('#app')

ImageManager 类

构建图片处理类,用于控制图片加载

// 状态常量,用于指示图片的加载状态
export const State = {
    loading: 0,
    loaded: 1,
    error: 2
}

export class ImageManager {
    constructor(options) {
        this.el = options.el	// 图片DOM,img元素
        this.src = options.src	// 图片地址
        this.state = State.loading	// 初始为“未加载”
        this.loading = options.loading	// 用于占位的Loading图片
        this.error = options.error
        // 初始化指向一张空白图
        this.render(this.loading)
    }

    render (src) {
        this.el.setAttribute('src', src)
    }

    load(next) {
        if (this.state > State.loading) {
            // 如果已经加载完图片
            return
        }
        this.renderSrc(next)
    }

    renderSrc(next) {
        loadImage(this.src).then(() => {
        	// 加载完之后,修改当前图片处理器实例的状态
            this.state = State.loaded
            // 重新设置新的真正加载的图片
            this.render(this.src)
            next && next()
        }).catch(e => {
            this.state = State.error
            console.log('加载图片失败:', this.src, e)
            next && next()
        })
    }
}

加载图片的方法:

export function loadImage (src) {
	// 使用一个Promise异步
	// 等待图片加载完成,onload 事件执行,Promise也就执行了
    return new Promise((resolve, reject) => {
        const image = new Image()
        image.onload = () => {
            resolve()
            dispose()
        }

        image.onerror = () => {
            reject()
            dispose()
        }

        image.src = src

        function dispose () {
            image.onload = image.onerror = null
        }
    })
}

Lazy类

const DEFAULT_URL = 'https://www.bing.com/images/search?q=%e5%93%88%e5%a3%ab%e5%a5%87&id=7EA1C608564AA243D3767286CF84AEE79F5081F6&FORM=IQFRBA'

class Lazy {
    constructor (options) {
    	// 图片处理类实例的队列
        this.managerQueue = []
        // 添加观察者
        this.initIntersectionObserver()

        this.loading = options.loading || DEFAULT_URL
        this.error = options.error || DEFAULT_URL
    }

    add (el, binding) {
    	// value 即为绑定命令的值
        const src = binding.value

        const manager = new ImageManager({
            el,
            src,
            loading: this.loading,
            error: this.error
        })

        this.managerQueue.push(manager)

        this.observer.observe(el)
    }

    initIntersectionObserver () {
        this.observer = new IntersectionObserver(entries => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    // 已经交叉
                    const manager = this.managerQueue.find(manager => manager.el === entry.target)
                    if (manager) {
                        if (manager.state === State.loaded) {
                            this.removeManager(manager)
                            return
                        }
                        manager.load()
                    }
                }
            })
        }, {
            rootMargin: '0px',
            threshold: 0, // 交叉的阈值
        })
    }

    removeManager (manager) {
        const idx = this.managerQueue.indexOf(manager)
        if (idx > -1) {
            this.managerQueue.splice(idx, 1)
        }
        if (this.observer) {
            this.observer.unobserve(manager.el)
        }
    }
}

主要是使用了IntersectionObserverAPI创建DOM观察者,当观察的元素进入可视区的比例大于threshold,就会执行回调函数,entries是所有被观察的DOM元素,entry是单个元素在可视区的交叉信息,isIntersectingtrue表示已经进入可视区,且达到阈值(threshold

然后加载图片,如该图片已经加载完成,便从managerQueue删除该图片管理类实例,并取消该元素的观察

优化

1. 当元素从页面卸载后,也需要执行一些清理的操作

class Lazy {
	...
	remove(el) {
        const manager = this.managerQueue.find((manager) => {
            return manager.el === el
        })
        if (manager) {
            this.removeManager(manager)
        }
    }
}

export const lazyPlugin = {
    install(app, options = {}) {
        const lazy = new Lazy(options)

        app.directive('lazy', {
            inserted: lazy.add.bind(lazy),
            unbind: lazy.remove.bind(lazy),
        })
    }
}

unbind:只调用一次,指令与元素解绑时调用
Vue3对应的是remove钩子函数

2. 当v-lazy的绑定的图片地址动态改变时,应该重新设置src

class ImageManager {
	...
	update (src) {
        const curSrc = this.src
        if (curSrc !== src) {
            this.src = src
            this.state = State.loading
        }
    }
}
constructor(options) {
		...
		// 添加一个队列,用于记录所有ImageManager实例
        this._staticQueue = []
        ...
    }

    add(el, binding) {
        ...
        this._staticQueue.push(manager)
        ...
    }

    update (el, binding) {
        const src = binding.value
        const manager = this._staticQueue.find(manager => manager.el === el)
        if (manager.state > 0) {
            // 已经加载完
            manager.update(src)
            // 再添加进队列,并监听
            this.managerQueue.push(manager)
            this.observer.observe(el)
        } else {
            // 还未加载,直接修改
            manager.update(src)
        }
    }

    remove(el) {
        const manager = this.managerQueue.find((manager) => {
            return manager.el === el
        })
        if (manager) {
        	// 解除绑定时,同样需要删除记录
            this._staticQueue = this._staticQueue.filter(ins => ins === manager)
            this.removeManager(manager)
        }
    }

3. 使用URL缓存优化

- 在`Lazy`中建立的集合类型作为缓存,创建图片管理实例时传入
- 在`ImageManager`的`load`方法中,判断是否存在缓存,存在:修改该图片管理实例的加载状态;不存在:正常加载
- 加载完图片后,添加缓存
class Lazy {
    constructor(options) {
        ...
        this.cache = new Set()
        ...
    }

	add(el, binding) {
        const src = binding.value

        const manager = new ImageManager({
            el,
            src,
            loading: this.loading,
            error: this.error,
            cache: this.cache,
        })
        this._staticQueue.push(manager)
        this.managerQueue.push(manager)
        this.observer.observe(el)
    }
}
class ImageManager {
	...
    load(next) {
        if (this.state > State.loading) {
            // 如果已经加载完图片
            return
        }
        // 先查看是否有缓存
        if (this.cache.has(this.src)) {
            // 缓存中存在,修改图片加载状态
            this.state = State.loaded
            // 直接加载
            this.render(this.src)
        }
        this.renderSrc(next)
    }

    renderSrc(next) {
        loadImage(this.src).then(() => {
            this.state = State.loaded
            // 重新设置新的真正加载的图片
            this.render(this.src)
            // 加载完后,添加进缓存
            this.cache.add(this.src)
            next && next()
        }).catch(e => {
            this.state = State.error
            this.cache.add(this.src)
            this.render(this.error)
            console.log('加载图片失败:', this.src, e)
            next && next()
        })
    }
}

4. 无IntersectionObserverAPI适配

  • 主要就是通过监听滚动事件,判断图片元素是否进入了可视区
// Lazy.js
function throttle(callback, delay) {
  let previous;
  return (...args) => {
      // previous 第一次为 undefined
      if(!previous || Date.now() - previous > delay){
        callback.apply(null,args)
        previous = Date.now()
      }
  }
}

class Lazy {
    constructor(options) {
        this.managerQueue = []
        this._staticQueue = []
        this.cache = new Set()
        this.__windowHeight = document.documentElement.clientHeight

        this.loading = options.loading || DEFAULT_URL
        this.error = options.error || DEFAULT_URL
        // 添加全局的滚动事件
        this.initListenerScroll()
    }


    add(el, binding) {
        const src = binding.value

        const manager = new ImageManager({
            el,
            src,
            loading: this.loading,
            error: this.error,
            cache: this.cache,
        })
        this._staticQueue.push(manager)
        this.managerQueue.push(manager)
        this.load()
    }

    load () {
      // 过滤掉已经加载完成的、加载错误的
      this.managerQueue = this.managerQueue.filter(manager => manager.state === State.loading)
      this.managerQueue.forEach(manager => this.judgeIsIntersecting(manager.el) && manager.load())
    }

    judgeIsIntersecting (el) {
      const top = el.getBoundingClientRect().top
      const bottom = el.getBoundingClientRect().bottom
      let IsIntersecting = false
      // 三种情况
      if (bottom > 0 && top < 0) {
        // 1. 从上往下划进可视区,部分交叉
        IsIntersecting = true
      } else if (top > 0 && bottom <= this.__windowHeight) {
        // 2. 完全进入可视区
        IsIntersecting = true
      } else if (top < this.__windowHeight && bottom > this.__windowHeight) {
        // 3. 从下往上划进可视区,部分交叉
        IsIntersecting = true
      }
      return IsIntersecting
    }

    initListenerScroll() {
      const throttleLoad = throttle(this.load.bind(this), 100)
      window.addEventListener('scroll', () => {
        throttleLoad()
      })
    }

    update (el, binding) {
        const src = binding.value
        const manager = this._staticQueue.find(manager => manager.el === el)
        if (manager.state > 0) {
            // 已经加载完
            manager.update(src)
            // 再添加进队列,并监听
            this.managerQueue.push(manager)
        } else {
            // 还未加载,直接修改
            manager.update(src)
        }
    }

    remove(el) {
        const manager = this.managerQueue.find((manager) => {
            return manager.el === el
        })
        if (manager) {
            this._staticQueue = this._staticQueue.filter(ins => ins === manager)
        }
    }
}
 类似资料: