性能优化之懒加载 - 基于观察者模式和单例模式的实现

一、引入

在前端性能优化中,关于图片/视频等内容的懒加载一直都是优化利器。当用户看到对应的视图模块时,才去请求加载对应的图像。 原理也很简单,通过浏览器提供的 IntersectionObserver - Web API 接口参考 | MDN (mozilla.org),观察"哪个元素和视口交叉",从而进行懒加载。

这个API具有很好的性能,因为它的监听是异步的,不会影响JS的主线程,所以比传统的"监听页面滚动"更佳。关于API的使用,这里就不做过多说明了,主要操作如下:

TypeScript 复制代码
const DOM = document.querySelector('img')
const io = new IntersectionObserver((entries) => {
    entries.forEach((k) => {
        //回调函数,可以利用 k.target 是否和我们要监听的DOM元素相等,来判断当前是否是我们要监听的目标元素
        if(k.target === DOM){ /* 做懒加载的操作 */}
    });
}, {/*一些配置,详见MDN文档*/});
io.observe(DOM) //添加监听

二、可优化的点

值得注意的是,一个observer实例,可以监听多个DOM元素。如果我们需要封装一个图片组件,并实现它的懒加载,那么"每个组件都创建一个IntersectionObserver实例" 显然是不划算的,如果页面上有上百个图片,就会创建出上百个实例。

针对这种情况,并且不想破坏组件的封装性,于是考虑把实例提升到全局 ,封装一个hook,从而每个组件都能自行添加入该实例的观察对象中。但是,监听的回调函数是创建实例的时候就决定的,后续添加进入的DOM元素,在回调函数中无法判断"是否轮到自己"了

三、观察者模式

有什么办法能够让DOM元素动态的进入回调函数呢? 我们可以利用对象引用地址不变的特性,动态的往对象里添加数据,这样在回调函数触发时,就能够取出正确的数据了

这里我的灵感其实来源于Vue3的响应式原理, 收集依赖 --> 监听 --> 触发依赖 。(Vue3是多对多的发布-订阅模式 , 这里是 一对多的观察者模式

TypeScript 复制代码
/**回调函数的类型*/
type ObserverCallback = (entryData: IntersectionObserverEntry) => void
/** 键是DOM元素,值是该元素的回调函数Set (考虑到可能一个元素会有多个回调) */ 
const watchMap = new WeakMap<Element, Set<ObserverCallback>>()
const io = new IntersectionObserver((entries) => {
    entries.forEach((k) => {
        const set = watchMap.get(k.target)
        if(set){
            set.forEach((fn) => fn(k)) //从weakMap中取出对应的监听事件触发
        } 
    });
}, {/*一些配置,详见MDN文档*/}); 

剩下要做的就是"依赖收集"了。基于面向对象的思想 (可以创建多个实例,多处复用,互不干扰)。

当有DOM元素需要被监听时,添加进weakMap中;需要取消监听时,移除; observer触发回调时,取出对应的元素的依赖,执行回调函数

手写过观察者模式或者发布订阅模式的小伙伴,应该对下面的代码构造很熟悉。

TypeScript 复制代码
    /**视口监听器 - 观察者模式 */
    export class ViewportObserverWatcher {
        /**IntersectionObserver 实例 */
        io: IntersectionObserver
        /**当前正在监听的元素的weakMap */
        watchMap = new WeakMap<Element, Set<ObserverCallback>>()
        constructor(options?: IntersectionObserverInit) {
            this.io = new IntersectionObserver((entries) => {
                entries.forEach((k) => {
                    this.watchMap.get(k.target)?.forEach((fn) => fn(k)) //从weakMap中取出对应的监听事件触发
                });
            }, options);
        }
        /**添加对元素的一个监听回调,可以选择触发条件
         * @param target 目标元素
         * @param callback 回调函数
         * @param condition 触发回调条件 `true | false | undefined` 分别对应 `与视口边界交叉 | 不与视口交叉 | 都`
         */
        addWatch = (target: Element, callback: ObserverCallback, condition?: boolean) => {
            const _callback: ObserverCallback = (k) => {
                if (condition == undefined) { }//无论如何都触发 
                else if ((condition !== k.isIntersecting)) return //当触发条件和实际情况不相同时,不触发 
                callback(k)
            }
            if (this.watchMap.has(target)) {
                this.watchMap.get(target)!.add(_callback)
            } else {
                this.io.observe(target)
                this.watchMap.set(target, new Set([_callback]))
            }
        }
        /**取消对元素的某个回调 */
        removeWatch = (target: Element, callback: ObserverCallback) => {
            const set = this.watchMap.get(target)
            if (set) {
                set.delete(callback)
                if (set.size === 0) {
                    this.watchMap.delete(target)
                    this.io.unobserve(target)
                }
            }
        }
        /**取消对该元素的全部回调 */
        cancelWatch = (target: Element) => {
            this.watchMap.delete(target)
            this.io.unobserve(target)
        }
    }

四、写个Hook吧

  1. 元素创建时,加入io的监听;

  2. 触发懒加载之后,取消对该元素的监听。

  3. 依赖项变化后,重复前面的逻辑。

  4. 只要是元素,都能进行监听,不只是图片/视频。有需要使用到该功能的元素都能使用。

TypeScript 复制代码
    import { DependencyList, RefObject, useEffect, useRef } from "react";

    /**视口监听器 - 单例模式 */
    const viewportObserver = new ViewportObserverWatcher() //注:如果你是NextJs, 在NextJS build的时候,不能直接实例化IntersectionObserver,否则会报错 (因为在走服务端代码) 可以先设置为null,后续给这个变量赋值


    /**懒加载Hook。懒加载触发后,将会取消监听
     * @param watchRef 要监听的DOM元素
     * @param onEntering 元素进入视口的回调函数
     * @param onDestroy useEffect的return中要做的事
     * @param deps useEffect的依赖数组 (当什么变化时,需要重新开始懒加载流程)
     */
    const useLazyLoad = (watchRef: RefObject<HTMLElement>, onEntering: ObserverCallback, onDestroy?: () => void, deps: DependencyList = []) => {
        /**是否完成懒加载 */
        const isLazySuccess = useRef(false);
        useEffect(() => {
            if (!watchRef.current) return; 
            const callback: ObserverCallback = (k) => {
                //因为只要和视口在交叉,就会不断触发这个函数,故需要使用一个标识符来限制 
                if (isLazySuccess.current === false) {
                    onEntering(k)
                    isLazySuccess.current = true;
                    viewportObserver!.removeWatch(watchRef.current!, callback) //加载完成就取消监听
                    onEntering(k)
                }
            }
            viewportObserver.addWatch(watchRef.current, callback, true)
            return () => {
                if (watchRef.current && viewportObserver) viewportObserver.removeWatch(watchRef.current, callback); //卸载时也要取消监听 
                isLazySuccess.current = false;
                onDestroy && onDestroy()
            };
        }, deps)
    }

使用方法: 核心思想:到了视口才赋值真实路径,其它时候使用占位符

TypeScript 复制代码
    /**视频组件 */
    export default function Video({ src, className, otherProps }: VideoProps) {
      const outRef = useRef<HTMLDivElement>(null); //被监听的元素
      const [realSrc, setRealSrc] = useState<string>(); //存放展示的src,如果还没到视口就不展示
      useLazyLoad(outRef, () => setRealSrc(src));

      return (
        <div className={cn(className, "rounded")} ref={outRef}>
          {/* 其它逻辑.... */}

          {/* 正常展示视频 */}
          {realSrc && <video src={realSrc} {...otherProps} />}

          {/* 其它逻辑.... */}
        </div>
      );
    }

五、使用效果

结合前面文章写的的瀑布流组件,实现以下效果:

(图片链接来源于 岁月小筑随机图片API接口-随机背景图片-随机图片API (xjh.me)

相关推荐
m0_748247552 小时前
Web 应用项目开发全流程解析与实战经验分享
开发语言·前端·php
m0_748255022 小时前
前端常用算法集合
前端·算法
真的很上进2 小时前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
web130933203982 小时前
vue elementUI form组件动态添加el-form-item并且动态添加rules必填项校验方法
前端·vue.js·elementui
NiNg_1_2343 小时前
Echarts连接数据库,实时绘制图表详解
前端·数据库·echarts
如若1233 小时前
对文件内的文件名生成目录,方便查阅
java·前端·python
滚雪球~4 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语4 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport4 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
brrdg_sefg4 小时前
WEB 漏洞 - 文件包含漏洞深度解析
前端·网络·安全