1. 从监听滚动的“野路子”说起
Intersection Observer 这个名字,直译过来是“交叉观察器”,听起来挺绕口,但在前端圈混久了,你会发现它就是解决“判断某个元素到底有没有出现在视口里”这个问题的标准答案。
在它出现之前,判断元素是否可见,基本只能靠 scroll 事件监听配合getBoundingClientRect()硬算。每次用户滚动,需要遍历所有目标元素,手动比较getBoundingClientRect()返回的 top、bottom 与窗口内部高度,再考虑各种边界情况,例如window.innerHeight、document.documentElement.clientHeight在不同浏览器里的差异。写得多了就会觉得,这套逻辑的重复性非常高,而且主线程的负担也不小,尤其是一次要检测几十上百个元素时。更坑的是,滚动事件触发频率极高,每次触发都去强行触发回流,性能损耗在所难免。
Intersection Observer 的出现,本质上是用浏览器底层的能力替代了 JS 里一次次的手动计算。它把元素与根容器(或者说视口)相交状态的变化,做成了异步回调通知。这意味着我不用再关心滚动性能问题,也不需要再去手动监听 scroll 和 resize,只要告诉浏览器“你帮我盯着这个元素,状态变了喊我一声就行”。
这篇文章会围绕这个 API,讲清楚它的核心原理、关键参数、实际场景的落地方式,以及我在真实项目里踩过的坑和总结出来的写法。无论是做图片懒加载、无限滚动列表,还是做埋点曝光上报,这套东西都值得你重新捋一遍。
2. Intersection Observer 核心思路拆解
2.1 你只需要理解“交叉”这件事
所谓交叉(Intersection),在页面上其实就是两个矩形区域之间的重叠关系。目标元素自身是一个矩形,视口或者说祖先元素的内容区域,是另一个矩形。当这两个矩形有交集时,观察器就会认为目标元素处于可见状态,或者部分可见状态。
关键点在于,浏览器会对两个矩形的相交面积进行实时计算。传统的做法是自己写测量逻辑,而 Intersection Observer 则是把这些测量工作全部下沉到了浏览器本身,页面只需要注册观察器,当一个目标元素的可见状态发生变化时,观察器会返回一个包含详细信息的对象列表。
我在初次接触这个 API 时会有一个误解,觉得它只在完全进入或完全离开视口时才触发。实际上不是这样。默认情况下,只要目标元素哪怕只有 1 像素进入视口,或者从有交集变成完全无交集,回调就会触发。而具体到一个元素露出多少像素才触发回调,这个是可以自己配置的,这就是 threshold 参数的作用。
2.2 为什么用浏览器原生 API 比 JS 手动计算优秀
手动监听滚动的经典写法是这样的:
window.addEventListener('scroll', () => { const rect = element.getBoundingClientRect(); const windowHeight = window.innerHeight || document.documentElement.clientHeight; if (rect.top < windowHeight && rect.bottom > 0) { // 元素能看到 } });这段逻辑看起来简单,但如果页面里同时存在几十个监听目标,性能问题就会立刻暴露。getBoundingClientRect()每次调用都会强制触发浏览器的重排,因为在返回坐标信息之前,浏览器必须保证布局信息是最新的。滚动事件又在一秒内触发几十次,不断重排的结果就是页面掉帧、滚动卡顿。
Intersection Observer 完全绕开了这一系列问题。它会采用异步回调机制,以帧为单位批量处理所有目标元素的相交状态计算,然后统一派发结果。也就是说,滚动一帧内,不管页面里有多少个被观察元素,浏览器也只需要在这一帧的合适时机统一计算一次,回调用一个数组把所有变化了状态的目标元素一次性全部丢给我。这种设计既减少了主线程的负担,也大大简化了业务逻辑的复杂度。
从开发心智上来看,Intersection Observer 也明显更友好。以前我不得不维护一堆滚动事件的监听和清理逻辑,还要区分不同元素各自的状态。现在只需要在初始化时观察所有目标元素,在回调里统一处理,维护成本骤降。
3. 参数详解与配置选择
3.1 root、threshold、rootMargin 各自是什么
Intersection Observer 构造函数接收两个参数:第一个是回调函数,第二个是可选的配置对象。配置对象里三个核心字段最值得关注,分别是root、threshold、rootMargin。
root决定观察的参照物。如果不传,默认就是浏览器视口。也可以传一个祖先元素,比如一个设置了overflow: auto的可滚动容器,此时目标元素是否可见,是相对于这个容器来判断的。这个属性在实现自定义滚动容器内的懒加载时很有用。
threshold是触发器。它接收一个数值或者数值数组,数值范围是 0 到 1,表示目标元素有多大的比例出现在 root 内时才触发回调。默认值是 0,也就是只要有一个像素露出就触发。如果传0.5,则必须有一半以上的元素可见才触发。如果传[0, 0.25, 0.75, 1],那么目标元素每跨过这些比例边界中的某一个,回调就会触发一次。
rootMargin则是扩张或收缩 root 区域的边界。它的写法和 margin 完全一样,比如'20px 0px'、'-10% 0px'。正值会扩大 root 区域的范围,负值则缩小。可以简单理解成,在判断相交之前,先把根容器四周各往外扩或者往里缩一圈,再拿扩大后的范围去判断,这就会非常方便地实现“提前加载”或“延迟加载”的效果。
3.2 threshold 不同取值的实战意义对比
threshold 在实际使用时,取值不同带来的体验差异非常明显。下面列一个简单的对照表来说明:
| threshold 值 | 触发时机 | 常见应用场景 |
|---|---|---|
| 0 | 目标元素只要有 1px 进入 root 区域 | 懒加载、曝光统计 |
| 0.25 | 目标元素露出约 1/4 | 滚动视差、部分区块渲染 |
| 0.5 | 目标元素一半可见 | 信息流卡片加载、播放器自动播放 |
| 1 | 目标元素完全可见 | 计数动画、元素全屏进入后再触发 |
在图片懒加载场景里,用 threshold 0 是比较合理的,传统做法是图片底部一进入视口就开始加载,这样能保证用户滚到时已经加载完成或正在加载中。而像播放器自动播放这类场景,如果还有一像素露出来就自动播放,会很突兀,一般要求元素一半以上可见时才触发播放,这个阈值就要往上调。
3.3 rootMargin 的应用与边界扩展
rootMargin 是我个人最常用的参数之一,尤其是在做加载延迟预判时。比如我做图片懒加载的时候,会希望图片在下一次滚动之前就提前开始请求,让用户真正滚动到图片位置时,资源已经处于 ready 状态。此时直接修改 threshold 到 1 并不能达成这个效果,因为 threshold 说的是元素本身露出多少,而不是说元素离视野边缘多远。
正确做法是给 rootMargin 设置一个向下的扩展值,例如rootMargin: '0px 0px 200px 0px',意思是把根容器底部边界往外扩 200 像素,相当于判断区域比屏幕实际可视区域多了 200 像素的提前量。这样目标元素还在屏幕下方 200 像素以内时,观察器就已经判定发生了相交并触发回调。
我遇到过有人纠结使用 threshold 还是 rootMargin 做预加载。两者的本质区别在于:threshold 是基于目标元素在 root 区域内露出面积的百分比来判断的,rootMargin 则是直接修改观察区域范围。懒加载预判场景下,rootMargin 能更直观地控制提前量的大小。
4. 从零上手:基础观察器实操
4.1 初始化一个最简单的观察器
下面是最常见的写法,观察 id 为target的元素,当它进入或离开视口时打印通知:
const target = document.getElementById('target'); const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { console.log('元素进入视口'); } else { console.log('元素离开视口'); } }); }, { threshold: 0, }); observer.observe(target);回调参数entries是一个数组,每个元素对应一个被观察目标的状态变化。之所以是数组,是因为浏览器会批量处理同一帧内状态发生变化的所有目标元素。处理数组里的每一个 entry,从中读取isIntersecting布尔值,就能判断出元素当前是否处于相交状态。
这里需要留意的是,isIntersecting和intersectionRatio的区别。isIntersecting是一个布尔简化值,等价于intersectionRatio > 0。如果想知道具体露出了多大的比例,读取intersectionRatio会更精确,它返回一个 0 到 1 之间的小数。
4.2 回调里拿到的 entry 对象里到底有什么
每次回调都会给一个 IntersectionObserverEntry 对象,里面包含以下常用字段:
| 字段名 | 含义 |
|---|---|
| target | 当前触发的目标 DOM 元素 |
| isIntersecting | 当前是否与 root 相交 |
| intersectionRatio | 相交面积占目标元素面积的比例 |
| intersectionRect | 目标元素与 root 相交的矩形信息 |
| boundingClientRect | 目标元素的矩形信息 |
| rootBounds | root 区域的矩形信息 |
| time | 状态发生变化时的时间戳 |
实际开发中,isIntersecting和target是两个最常用的字段。target字段在观察多个元素时尤其好用,我能直接定位到究竟哪一个元素触发了回调,不需要再通过闭包变量单独去绑定每个元素。
4.3 暂停观察与销毁观察器的最佳实践
有一些场景下,目标元素的状态只需要触发一次,比如“进入视口后播放数字滚动动画”,之后无论怎么滚动都不需要再触发。此时最合适的做法是在回调里拿到目标元素后立即停止观察,避免后续的无谓判断开销。
标准写法是调用unobserve方法:
const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const target = entry.target; // 执行动画逻辑 // ... observer.unobserve(target); } }); });页面销毁时,还要记得手动断开观察器。虽然现代浏览器在页面销毁时会自动释放资源,但如果你是在单页应用中频繁切换路由,旧的观察器不及时清理,可能导致回调仍然在触发,从而造成内存泄漏或者多次执行导致的性能浪费。使用观察器的页面或组件卸载时直接调用observer.disconnect()把它干掉,会省心不少。
5. 高频场景实战:从图片懒加载到曝光埋点
5.1 图片懒加载的完整实现方案
懒加载是 Intersection Observer 最经典的应用场景。我做一个相对完整的版本,图片懒加载要求优先保障两个目标:一是初始不加载可视区以上图片,二是滚动接近时提前发起请求,避免用户看到空白区域。
HTML 部分
<img>const images = document.querySelectorAll('img[data-src]'); const loader = new IntersectionObserver((entries, observer) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; img.addEventListener('load', () => { img.classList.add('loaded'); }); observer.unobserve(img); } }); }, { rootMargin: '0px 0px 300px 0px', threshold: 0 }); images.forEach((image) => { loader.observe(image); });rootMargin 里的 300px 是提前量。用户还在图片上方 300 像素以外时,观察器就把图片识别为进入区域,触发了真实资源加载。这样等用户真正滚动到图片位置时,图片已经处于加载中,甚至已经加载完成,体感上视觉不会出现明显的加载闪烁。
需要特别注意的是><div id="sentinel"></div>
对应的逻辑就是
const sentinel = document.getElementById('sentinel'); const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { loadMoreData(); } }); }); observer.observe(sentinel);无限滚动的坑通常出现在数据加载期间。如果数据加载速度很快,哨兵元素被不断触发,就会造成连续请求,引起数据叠加混乱。我常用的做法是加一个isLoading布尔值作为锁,回调触发时如果已经在加载中,则直接忽略本次触发,等数据追加完成后把锁解开:
let isLoading = false; const observer = new IntersectionObserver(async (entries) => { if (isLoading) return; const entry = entries[0]; if (entry.isIntersecting) { isLoading = true; await loadMoreData(); isLoading = false; } });如果用框架开发,还有一个细节需要注意。组件卸载的时候,记得调用observer.disconnect(),否则哨兵元素的观察回调可能会在组件已经销毁后仍然被触发,并且某些旧框架或浏览器环境中还可能存在对已卸载 DOM 元素的引用问题。及时释放观察器是防止内存泄漏的重要手段。
5.3 曝光埋点的实现与数据上报细节
曝光埋点指的是只有当用户真正看到某个广告位、文章卡片、商品区块时,才上报数据给统计服务。以前很多人用鼠标事件或滚动事件估算,误差比较大,用 Intersection Observer 来统计曝光才算是准确的方案。
埋点场景有一个特点:一次曝光通常只需要上报一次,不需要重复上报。结合unobserve就能非常优雅地实现:
const trackItems = document.querySelectorAll('.track-item'); const tracker = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const item = entry.target; const itemId = item.dataset.id; reportExposure(itemId); tracker.unobserve(item); } }); }, { threshold: 0.5 }); trackItems.forEach((item) => { tracker.observe(item); });曝光埋点的 threshold 我建议至少设为 0.5。如果设成默认的 0,用户在页面上快速划过时,一个元素可能仅仅露出一像素就被上报,实际用户根本没看清楚内容。把阈值提高到 0.5,才能保证用户至少看到了元素的一半,曝光统计的准确率更高。
上报数据的时机也需要斟酌。如果是在isIntersecting的瞬间立即上报,用户只是在快速滚动时无意扫过,可能造成误报。有些场景会加上定时器延迟,例如元素进入视口后 2 秒内一直保持可见才上报,这个可以根据实际业务需求来定。
5.4 列表动画与视觉反馈技巧
除了懒加载和埋点,Intersection Observer 也非常适合列表项目的入场动画。以往列表动画往往是在页面加载后统一播放,但用户根本没有滚动到那个位置,动画就已经放完了。用 Intersection Observer 可以做到元素进入视口的瞬间才触发动效。
实现的核心思想是,给目标元素先设置一个opacity: 0初始状态,在回调里判断进入视口后,给目标元素添加一个包含真正显示状态的类名,并设置transition过渡效果:
const cards = document.querySelectorAll('.card'); const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.classList.add('card--visible'); observer.unobserve(entry.target); } }); }, { threshold: 0.2 }); cards.forEach((card) => { observer.observe(card); });这种方案不仅流畅,而且还能结合rootMargin做提前量,让元素在接近视口但还未完全可见时就开始动效,视觉上会更自然。
6. 常见问题与排查技巧实录
6.1 root 容器设置不生效怎么办
在使用root参数时,我经常遇到的一个问题是:明明指定了一个祖先元素作为 root,但回调完全没有任何反应。最常见的排查方向是确认 root 元素本身是否创建了新的格式化上下文。Intersection Observer 的root只支持目标元素的祖先节点,而且这个祖先元素必须是可产生布局区域的容器。如果 root 是一个 inline 元素或显示区域为 0 的空元素,那当然不可能与其他元素产生任何交叉。
另外就是overflow: hidden的影响。当指定非视口的 root 元素时,浏览器会把 root 的内部布局区域视为判断区域。如果 root 元素自身尺寸计算有误,比如内容溢出但 root 没有显式设置高度,那么实际判断区域可能是 0,导致永远不触发。建议在调试时用 DevTools 检查 root 元素的盒模型信息,确认其视觉可见尺寸是否符合预期。
还有一种容易被忽略的情况:目标元素不能是 root 元素自身,也不可以是 root 的后代之外的元素。如果目标元素在 DOM 结构上和 root 没关系,浏览器会直接以 undefined 处理 root,观察器拿视口来当参照。
6.2 回调不触发或无限触发的排查思路
回调不触发的原因,通常可以归结为三个维度:判断区域、目标区域、阈值配置。
判断区域缩小了或者不存在,比如 rootMargin 设置了较大的负值,导致 root 判断区域变成负宽度,就无法触发。目标区域不可见或被隐藏,比如目标元素是display: none,或者宽高为 0,它与任何区域都不会产生有效交集。阈值大于 1 了,threshold 取值范围是 0 到 1 的闭区间,超过 1 的值浏览器会直接按异常值忽略,可能导致回调永远不触发。
反过来,无限触发通常是因为回调中执行的操作改变了布局或滚动位置,导致观察器计算出的相交状态反复变化。比如加载更多数据后,列表高度变化,哨兵元素不断重新计算位置,就会反复触发回调。解决办法是上文提到的isLoading锁,或者用unobserve停止观察。
还有一个容易被坑的点,在一些较旧的移动浏览器中,intersection observer 回调可能不会在用户停止滚动之后立刻触发,可能会延迟一两帧。所以如果你的逻辑里有关键数据加载,要注意补一个兜底定时器或者做降级处理。
6.3 兼容性与降级处理策略
Intersection Observer 在主流浏览器中的支持情况已经不错,尤其是 Chrome、Firefox、Safari 在较新版本中都原生支持,但在部分旧版浏览器和 WebView 中,这个 API 可能不存在。
有两种降级方案可以推荐:
| 降级方案 | 适用场景 |
|---|---|
| 判断 API 是否存在,不存在时直接加载所有资源 | 懒加载场景,降级后虽然失去了懒加载效果,但功能可用 |
| 使用滚动事件加 getBoundingClientRect 的兼容方案 | 需要精确判断曝光埋点且依赖旧环境 |
我的经验是,兼容代码别写太复杂,否则维护成本会反超收益。如果项目的主力用户使用现代浏览器,直接判断'IntersectionObserver' in window,不存在时把懒加载元素全部转为立即加载即可。这样可以确保功能不缺失,并且代码体积保持可控。
6.4 性能优化要点
虽然 Intersection Observer 的异步批量处理已经比手动监听事件性能高很多,但使用方式不当依然会造成性能问题。
第一个优化点是减少观察器的数量。一个页面中最好只创建一个观察器,同时观察多个目标元素,而不是给每个元素分别创建各自的观察器。因为浏览器会对多个观察器做多次独立计算,虽然总体计算比手动方式轻,但数量多了依然有损耗。
第二个优化点是控制回调内部的复杂度。它是在空闲或特定帧阶段触发的,回调里如果执行大计算量的任务,依然会阻塞主线程。合理的做法是,在回调里只做轻量状态变更,把复杂任务放到requestIdleCallback或下一帧中执行。
第三个优化点是使用rootMargin配合少量偏移做提前量。提前量并不是越大越好。举个例子,如果你的页面有长列表,rootMargin 提前 1000px 意味着每次滚动都会让大量尚未进入可视区的元素早早加载,反而可能发送多余的请求或执行多余操作。根据图片实际体积和网络情况调整合理的提前量,有时候 200px 到 300px 远比 800px 到 1000px 体验好。
7. 观察多个目标的工程化实践
7.1 生产环境中的几个设计模式
在真实的工程项目里,我会把 Intersection Observer 封装成一个小工具模块,避免在业务代码里重复创建观察器。比较常用的设计思路有两种:一种是订阅/发布模式,一种是具名目标映射模式。
具名目标映射的方式是这样的:观察器内部维护一个 Map,把 DOM 元素和回调函数对应起来。当元素相交状态变化时,调用对应元素注册的回调函数。
class VisibilityWatcher { constructor(options = {}) { this.callbacks = new Map(); this.observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { const callback = this.callbacks.get(entry.target); if (callback) { callback(entry); } }); }, options); } add(element, callback) { this.callbacks.set(element, callback); this.observer.observe(element); } remove(element) { this.callbacks.delete(element); this.observer.unobserve(element); } destroy() { this.callbacks.clear(); this.observer.disconnect(); } }这样设计有几个明显优势:观察器统一管理,方便批量清理;回调注册独立,业务侧写起来非常干净;扩展起来也比较简单,比如可以在类里统一加日志、统一处理节流。
7.2 框架集成的注意事项与 hooks 封装
在 Vue 和 React 中,组件生命周期内的观察器创建和销毁需要格外留意。如果在 Vue 组件中使用了created或mounted中创建观察器,那就必须在beforeUnmount或unmounted阶段调用disconnect()。在 React 中,useEffect的清理函数同样承担这个职责。
在 React 中封装一个useInView的 Hook 是常见的做法:
funct useInView(options = {}) { const ref = useRef(null); const [inView, setInView] = useState(false); useEffect(() => { const element = ref.current; if (!element) return; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { setInView(entry.isIntersecting); }); }, options); observer.observe(element); return () => observer.disconnect(); }, [options.rootMargin, options.threshold]); return [ref, inView]; }这样的封装能直接把 Intersection Observer 的能力带到任意组件中,且能利用 React 的响应式状态驱动 UI 变化。需要注意的是 options 中的参数如果每次渲染都发生变化,会影响 effect 重跑,所以请务必使用 useMemo 或直接传稳定的配置对象。
在 Vue 3 中,也可以类似地封装一个useInViewcomposable,核心逻辑一致,区别仅在于清理时机写在了onUnmounted中。
8. 被忽略的边缘技巧:动态内容、Cross-origin iframe 与方向判断
8.1 观察动态渲染的目标元素
在 SPA 应用中,很多目标是异步渲染出来的。如果观察器的注册发生在元素渲染之前,那就观察不到任何东西。因此,需要确保在元素真的挂载到 DOM 之后再调用observe方法。
比较可靠的方案是,把观察器注册的时机放在数据获取的 then 方法之后,或者使用 nextTick。例如:
async function loadList() { await fetchData(); // 此时 DOM 已经更新 requestAnimationFrame(() => { listItems.forEach((item) => observer.observe(item)); }); }有些时候,目标元素是分页渲染的,这种情况下建议重复调用observer.observe方法,随时把新生成的元素追加进观察范围。
8.2 滚动方向判断与页面元素行为联动
Intersection Observer 的 entry 还隐含了滚动方向信息吗?严格来说它没有直接提供方向值,但可以通过比较boundingClientRect.top的变化来判断。保存上一次触发时的 top 值,在下一次回调中比较当前值,就能知道页面是向上滚动还是向下滚动。
比如,我想实现一个“向下滚动时显示返回顶部按钮,向上滚动时隐藏返回顶部按钮”的交互,可以用这个方法判断:
let lastY = 0; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { const currentY = entry.boundingClientRect.top; if (currentY < lastY) { // 向下滚动 backToTopBtn.classList.add('show'); } else { // 向上滚动 backToTopBtn.classList.remove('show'); } lastY = currentY; }); }); observer.observe(sentinel);这里的原理很简单,如果页面向下滚动,目标元素相对视口的位置会持续向上移动,boundingClientRect.top会逐渐变小。
8.3 处理跨 iframe 的可见性判断
现代页面中,广告位或嵌入的小应用常常处在一个 iframe 里。Intersection Observer 在这种情况下依然能准确判断 iframe 内的元素相对视口是否可见,但前提是 iframe 和父页面同源或允许跨源脚本权限。如果 iframe 内的内容与父页面跨源,并且设置了严格的权限限制,那么 iframe 内的元素可能无法直接获得父页面滚动状态,观察器的判断会受限。
在这种情况下,我建议把 Intersection Observer 的观察目标设在父页面的 iframe 元素上,通过判断 iframe 本身的可见性,间接推导 iframe 内部内容的可见状态。虽然这种方案并不是绝对精确,但实际项目中已经完全够用了。
9. 深入原理:浏览器是如何计算相交状态的
新接触 Intersection Observer 的读者,可能会比较好奇浏览器内部到底是怎么算出相交状态的。整个计算过程大致可以概括为这几步:
浏览器每隔一段时间或在滚动、布局变化时,会为每个被观察元素执行绘制前的相交测试。它会读取目标元素的布局边界,再读取 root 区域的边界,然后计算两者交集区域的范围。计算完成后,浏览器会对比当前状态与上一次状态。只有当状态发生了切换,比如从不相交变成相交,或者相交比例跨过了一个新配置的 threshold 边界,它才会把对应的 entry 加入回调队列。
重点在于,整个过程中目标元素和 root 区域的边界信息会从布局引擎中直接获取,绕过了传统的 JavaScript 计算调用链。这意味着浏览器可以在内部以极低成本快速完成批量计算,并在合适的时机(通常是在组合结束后)异步派发结果。
还有一个细节值得单独说明:visibility 属性对观察器的影响。visibility: hidden的元素会被观察器认定为不可见,因为布局信息虽然仍然存在,但渲染层已经把它隐藏了。不过opacity: 0和transform: translateX(9999px)这类视觉隐藏方式,元素仍然占据布局空间且具备被绘制条件,观察器依然会认为它在视口内。如果你希望隐藏的元素不被统计曝光,记得使用display: none或visibility: hidden。
4. 个人实战中的一点经验总结(收尾)
之前有一个项目,需要在同一个页面里同时处理整页图片懒加载、长列表无限滚动和广告位曝光埋点这三类需求。起初我图省事,给每个需求都单独创建了一个观察器实例,结果发现页面主线程的长时间运行耗时明显上升,虽然不至于卡死,但确实能感觉到滚动跟手程度下降了。后来把所有逻辑收敛到一个共享观察器里,将目标元素和对应的处理回调放入一个 Map 中统一调度,性能明显改善。
Intersection Observer 的学习曲线其实很短,但真正想用好它,核心在于理解它解决的核心问题和浏览器的设计意图:用极低成本感知元素可见性变化,替代 JavaScript 中繁琐的滚动事件计算。场景上提前预判和曝光埋点是最有价值的两个方向,建议在实际项目中优先尝试。
最后再分享一个我自己常用的小技巧:如果你在做图片懒加载,设置 rootMargin 提前量的时候建议结合当前设备的屏幕高度和图片的实际体积来决定,不要一味调大,否则只是在把流量浪费在用户根本不会看过的图片上。换算下来,通常 0.5 倍屏幕高度左右的提前量,已经足够保证不错的使用体验了。