浏览器里那些监听DOM变化的玩意儿,MutationObserver和IntersectionObserver,原生用起来步骤多得让人头皮发麻。每次都要先new一个实例,再写个回调函数处理那些entries,最后还得调用observe方法盯住某个元素。想断开监听?还得记着disconnect或者unobserve,一不小心就内存泄漏。其实完全可以像封装ResizeObserver那样,搞一个超好用的工具函数,把那些啰嗦的模板代码全部藏起来,只暴露出最核心的回调逻辑。下面直接上硬核改造流程,手把手把这两个观察者API变成真香的轻量调用。
摘要
原生MutationObserver和IntersectionObserver的使用流程偏繁琐,需要手动创建实例、定义回调、调用观察方法,还要处理断开和记录残留。通过封装一个高阶函数,把内部样板代码统一收敛,对外提供回调模式和自定义事件模式两种简洁调用方式。封装后只需要传入目标节点和配置项,就能自动管理观察器生命周期,连takeRecords这种偏门方法都一并处理好,代码量直接砍掉三分之二。
MutationObserver和IntersectionObserver用着太绕,怎么封装成顺手的工具函数?
原生写法啥样
MutationObserver和IntersectionObserver是浏览器提供的两个观察器接口。前者用来监听DOM节点的属性变化、子节点增删、文本内容改动;后者则盯着某个元素是否进入视口或根元素的可视区域。原生调用方式有多啰嗦?看一段MutationObserver的典型操作。
// 先建一个观察器实例
const targetNode = document.querySelector('#sidebar');
const config = { attributes: true, childList: true, subtree: true };
const callback = function(mutationsList) {
for(let mutation of mutationsList) {
console.log('变动的类型:', mutation.type);
}
};
const observer = new MutationObserver(callback);
observer.observe(targetNode, config);
// 想断开?还得单独调
observer.disconnect();IntersectionObserver也差不多,只是把配置项挪到了构造函数第二个参数。
const box = document.querySelector('#ad');
const options = { threshold: 0.5 };
const ioCallback = (entries) => {
entries.forEach(entry => {
if(entry.isIntersecting) console.log('冒出来了');
});
};
const io = new IntersectionObserver(ioCallback, options);
io.observe(box);每次都要重复new、写回调、调observe这一套连招,代码里到处散落着observer实例,看着就烦。更别提MutationObserver的takeRecords方法——99%的人根本不知道它在哪用。
突变观察封装
封装MutationObserver的关键点在于把observe步骤合并到初始化里,同时提供两种结果接收方式:回调函数或者自定义事件。下面这个buildMutWatcher函数直接搞定一切。
function buildMutWatcher(elem, settings = {}) {
const watcher = new MutationObserver(handleMutations);
const { callback, ...observeConfig } = settings;
watcher.observe(elem, observeConfig);
function handleMutations(records) {
for (const record of records) {
if (settings.callback) {
settings.callback({ record, records, watcher });
} else {
elem.dispatchEvent(
new CustomEvent('domChanged', {
detail: { record, records, watcher }
})
);
}
}
}
return {
cutOff() {
const leftover = watcher.takeRecords();
watcher.disconnect();
if (leftover.length > 0) handleMutations(leftover);
}
};
}调用时只需要传入节点和配置,完全不用手写new MutationObserver。配置项里的callback会被自动提取出来,剩下的参数直接丢给observe方法。注意那个cutOff函数——断开前先用takeRecords把还没处理的变动记录捞出来,再丢进回调跑一遍,避免丢失最后一次变化。很多网上的封装都漏了这一步,导致断开瞬间产生的记录直接蒸发。
const panel = document.querySelector('.panel');
const watcher = buildMutWatcher(panel, {
childList: true,
subtree: false,
callback({ record }) {
console.log('子节点有变化', record.addedNodes);
}
});
// 过一会不想监听了
watcher.cutOff();如果不想写回调,也可以监听自定义事件domChanged,同样能拿到变动详情。这种模式尤其适合多个地方需要响应同一个DOM变化的情况,直接panel.addEventListener('domChanged', handler)就能解耦。
交叉观察封装
IntersectionObserver的封装套路几乎一模一样,唯一的坑在于配置项必须传给构造函数而不是observe方法。写一个buildIntersectWatcher来搞定。
function buildIntersectWatcher(elem, settings = {}) {
const { callback, ...observerOptions } = settings;
const watcher = new IntersectionObserver(handleIntersect, observerOptions);
watcher.observe(elem);
function handleIntersect(entries) {
for (const entry of entries) {
if (settings.callback) {
settings.callback({ entry, entries, watcher });
} else {
elem.dispatchEvent(
new CustomEvent('viewHit', {
detail: { entry, entries, watcher }
})
);
}
}
}
return {
stopWatch(node) {
watcher.unobserve(node);
},
killAll() {
const pending = watcher.takeRecords();
watcher.disconnect();
if (pending.length > 0) handleIntersect(pending);
},
pullRecords() {
return watcher.takeRecords();
}
};
}调用的时候直接把节点和配置塞进去,连threshold、rootMargin这些参数都写在同一个对象里。封装内部自动把它们拆开:callback单独抽出来,剩下的全扔给IntersectionObserver的构造函数。
const adBanner = document.querySelector('#floatAd');
const intersectWatcher = buildIntersectWatcher(adBanner, {
threshold: [0, 0.25, 0.5, 0.75, 1],
rootMargin: '0px 0px -50px 0px',
callback({ entry }) {
if (entry.isIntersecting) {
console.log(`曝光比例 ${entry.intersectionRatio}`);
}
}
});
// 只停止观察这一个元素
intersectWatcher.stopWatch(adBanner);
// 或者彻底断开全部
intersectWatcher.killAll();注意到返回对象里多了一个pullRecords方法。这玩意儿能直接拿到还没触发的所有记录,比如在页面滚动停止后一次性批量处理曝光数据,比一条一条回调收着更高效。另外killAll同样复用了takeRecords的逻辑,确保断开前把队列里的残渣清理干净。
两种模式咋选
回调模式和自定义事件模式各有各的适用场景。回调模式适合单一操作,比如某个弹窗组件只需要在自己内部监听广告露出,直接在callback里写逻辑最省事。事件模式适合多处监听,例如一个评论区需要同时被统计模块、自动播放模块、懒加载模块关注交叉状态,用addEventListener就能挂上多个处理器,完全不用修改封装本身。
// 回调模式:简单直接
const quickWatch = buildIntersectWatcher(img, {
callback({ entry }) { entry.target.classList.add('seen'); }
});
// 事件模式:灵活解耦
const eventWatch = buildIntersectWatcher(video, { threshold: 0.5 });
document.querySelector('#stats').addEventListener('viewHit', (e) => {
sendAnalytics(e.detail.entry.target.id);
});
document.querySelector('#autoplay').addEventListener('viewHit', (e) => {
e.detail.entry.target.play();
});两种模式在同一个封装里共存,调用时选带callback字段就触发回调模式,不传就默认走事件模式,不用改任何内部代码。这种设计思路就像外卖柜——既可以直接开箱取餐(回调),也可以等短信通知再来拿(事件),怎么舒服怎么来。
