利用发布订阅模式封装一个IntersectionObserver
前言
用于判断当前元素是否出现在视口区域,以此来实现懒加载已经是日常业务必须的东西了,但是如何去判断这个元素是否出现在视口中,是一个非常头疼的地方。
很久以前,我们通过dom.offsetTop
的方式,不断的累加自身和父级元素的offsetTop
值,得到元素具体文档顶部的距离,然后判断这个距离是否小于等于当前视窗的高度+滚动条scrollTop,如果是的话,说明用户已经滚动到了,或者滚过去了,这个时候就得触发图片懒加载。
但是这种方式十分痛苦,需要递归计算offsetTop的值,而且性能不是很好。
后来浏览器又提供了getBoundClientRect
的方法,这个方法会返回当前元素距离视窗的四个方位的数据,由于是浏览器提供,所以性能较好,通过判断方位数据也能判定元素是否在视口中,但是这个是dom的方法,我们不得不监听浏览器的scroll事件,在事件的回调里一次次遍历dom数组,然后不断调用getBoundClientRect
方法获取方位数据,然后判断。
不得不说这个比offsetTop
值判断好了太多,但是也没有很方便。
有没有可能,让浏览器主动通知我们这个元素出现在视口或者离开视口呢?这样的话就不用一次次的遍历,一次次的计算了,毕竟scroll事件如果太多的话是会造成浏览器卡顿的。
现在这个东西就来了,它就是IntersectionObserver
,它提供了一种异步观察目标元素与祖先元素或者顶级文档的视口交叉状态的方法!
说人话就是它可以监听元素是否出现在祖先元素中或者文档视口中,通过回调的方式。
MDN文档:IntersectionObserver
但是这个Api它有个缺陷,就是callback
回调函数只能在new的时候传入,这样的话就没法针对不同的dom使用不同的回调处理,但是它的实例对象却又可以调用observe
方法不断的去监听dom元素。
所以为了解决这个短板,我采用发布订阅模式,通过订阅使得监听可以支持不同的回调函数。
源码
/** 监听dom参数 */
export type ObserverTarget = Element | Array<Element>;
/** 监听dom的回调 */
export type ObserverCallback = (entries: IntersectionObserverEntry) => void;
/** 事件存储对象 */
export type EventMapValue = {
/** 回调数组 */
callback: ObserverCallback;
/** this指向 */
that?: any;
};
export type EventMap = Map<ObserverTarget, Array<EventMapValue>>;
export class Observer {
/** bom提供的监听Api实例 */
private observer: IntersectionObserver;
/** 事件存储对象 */
private eventMap: EventMap = new Map();
constructor(options?: IntersectionObserverInit) {
this.observer = new IntersectionObserver(this.watchCallback.bind(this), options);
}
/** 监听回调函数 */
private watchCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
const { target } = entry;
const events = this.eventMap.get(target);
if (!events) return;
events.forEach((item) => {
const { callback, that } = item;
callback.call(that, entry);
});
});
};
/**
* @description: 监听传入的dom元素
* @param {ObserverTarget} target 监听的dom元素
* @param {ObserverCallback} callback 回调函数
* @param {any} that 回调函数的this指向
* @Date: 2023-03-20 19:09:20
* @Author: mulingyuer
*/
public observe(target: ObserverTarget, callback: ObserverCallback, that?: any) {
if (Array.isArray(target)) {
target.forEach((item) => {
this.subscribe(item, callback, that);
this.observer.observe(item);
});
} else {
this.subscribe(target, callback, that);
this.observer.observe(target);
}
}
/**
* @description: 取消监听
* @param {ObserverTarget} target 监听的dom元素
* @param {ObserverCallback} callback 回调函数
* @param {any} that 回调函数的this指向
* @Date: 2023-03-20 19:09:34
* @Author: mulingyuer
*/
public unobserve(target: ObserverTarget, callback: ObserverCallback, that?: any) {
if (Array.isArray(target)) {
target.forEach((item) => {
this.unsubscribe(item, callback, that);
this.observer.unobserve(item);
});
} else {
this.unsubscribe(target, callback, that);
this.observer.unobserve(target);
}
}
/**
* @description: 清空所有监听
* @Date: 2023-03-20 19:09:48
* @Author: mulingyuer
*/
public disconnect() {
this.observer.disconnect();
this.eventMap.clear();
}
/**
* @description: 事件订阅
* @param {ObserverTarget} target 监听的dom元素
* @param {ObserverCallback} callback 回调函数
* @param {any} that 回调函数的this指向
* @Date: 2023-03-20 19:09:57
* @Author: mulingyuer
*/
private subscribe(target: ObserverTarget, callback: ObserverCallback, that?: any) {
let events = this.eventMap.get(target);
if (!events) {
events = [];
this.eventMap.set(target, events);
}
//判断是否重复订阅
const findIndex = this.findSubscribeIndex(target, callback, that);
if (findIndex !== -1) return console.warn("重复订阅");
//引用类型,不需要map重新赋值
events.push({ callback, that });
}
/**
* @description: 事件取消订阅
* @param {ObserverTarget} target 监听的dom元素
* @param {ObserverCallback} callback 回调函数
* @param {any} that 回调函数的this指向
* @Date: 2023-03-20 19:10:19
* @Author: mulingyuer
*/
private unsubscribe(target: ObserverTarget, callback: ObserverCallback, that?: any) {
let events = this.eventMap.get(target);
if (!events) return;
const findIndex = this.findSubscribeIndex(target, callback, that);
if (findIndex === -1) return;
events.splice(findIndex, 1);
if (events.length <= 0) this.eventMap.delete(target);
}
/**
* @description: 查询是否已经存在订阅,返回存在的下标
* @param {ObserverTarget} target 监听的dom元素
* @param {ObserverCallback} callback 回调函数
* @param {any} that 回调函数的this指向
* @Date: 2023-03-20 19:10:32
* @Author: mulingyuer
*/
private findSubscribeIndex(target: ObserverTarget, callback: ObserverCallback, that?: any): number {
let events = this.eventMap.get(target);
if (!events) return -1;
const findIndex = events.findIndex((item) => {
const { callback: itemCallback, that: itemThat } = item;
let flag = false;
const isCallback = callback === itemCallback;
if (that) {
flag = isCallback && that === itemThat;
} else {
flag = isCallback && !itemThat;
}
return flag;
});
return findIndex;
}
}
使用
Observer
的实例对外抛出三个接口:
observe
监听unobserve
取消监听disconnect
清空所有监听
通过一个通用的watchCallback
回调函数来做区分,在内部触发订阅的事件,它就是emit
方法。
import { Observer } from "@/utils/observer";
const observer = new Observer();
observer.observe(document.querySelector(".box"), (entry: IntersectionObserverEntry) => {
console.log(entry.isIntersecting); //是否出现在视口中
})
options配置
IntersectionObserver
本身是支持传配置参数的,常用的有两个参数:
root
指定它的祖先元素,这样就不是默认基于视口了,而是相对于指定的元素。rootMargin
交叉判断时的偏移量
rootMargin可以很方便的实现一个功能,就是提前加载,由于默认IntersectionObserver的回调触发,是元素出现在视口的时候,但是有时候我们需要提前一定的距离,这样在用户还没有滚动到当前元素时,就已经触发了加载,这个就是提前加载。
比如我们的列表,可以在滚动快要到底部的时候就触发,通过配置rootMargin。
import { Observer } from "@/utils/observer";
//利用配置提前100px触发
const observerOptions: IntersectionObserverInit = {
rootMargin: "0px 0px 100px 0px",
};
const observer = new Observer(observerOptions);
observer.observe(document.querySelector(".box"), (entry: IntersectionObserverEntry) => {
console.log(entry.isIntersecting); //是否出现在视口中
})
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据