用懒加载、滚动揭示动画、无限滚动三个实战,讲清IntersectionObserver的配置项与回调在主线程、防抖等关键注意事项。
Intersection Observer API 实战:高性能滚动触发动画与懒加载
Intersection Observer API 实战:高性能滚动触发动画与懒加载
你有没有遇到过这样的场景:页面里有一长串图片列表,用户刚打开页面,浏览器就把几兆的图片一口气全请求了,首屏卡得半死;或者想做一个"滚动到哪、动画就播到哪"的炫酷效果,结果一通scroll事件监听下来,页面直接掉帧,滚动时像在翻幻灯片。这两个问题,十个前端里有八个都踩过坑。
我是前端哥。今天这篇不讲虚的,直接上浏览器原生给出的标准答案——IntersectionObserver 交叉观察器。它是浏览器内置的一个异步 API,专门用来"盯着"某个元素,看它到底进没进视口、和视口交叠了多少。用它来替代传统的 scroll 事件轮询,几乎所有"滚动相关"的需求都能写得更短、更稳、更省电。下面我按"原理 → 配置 → 三个实战 → 注意事项"的顺序,带你把这套 API 彻底吃透,每个环节都配能直接跑的代码。
先把话说在前面:你以前那些window.addEventListener('scroll', ...)里写满getBoundingClientRect() 的写法,看完这篇基本可以退休了。不是不能用,而是它既费主线程又容易写出 bug,而浏览器已经把更优的解法递到你手边了。话不多说,开整。
一、为什么传统的 scroll 监听会卡:先搞懂痛点
要理解 Intersection Observer 为什么香,得先明白它被发明出来是为了解决什么。
早年我们要判断"一个元素有没有滚进可视区域",唯一能用的就是scroll事件。思路大概是这样:监听滚动,每次触发就去读目标元素的getBoundingClientRect().top,再拿它和window.innerHeight比一比,小于零就说明进来了。逻辑没错,但致命问题有三个。
第一,触发频率失控。scroll 事件在用户滚动时会以极高的频率连续触发,有时候一秒能跑几十次甚至上百次。如果你在回调里还做了读取布局、操作 DOM 这类"强制同步布局"的动作,浏览器被迫在主线程上反复计算,掉帧是必然的。
第二,主线程被死死占住。滚动是用户的连续操作,本该保持顺滑,可你的监听回调和页面渲染抢的是同一个线程。回调一重,滚动就顿,这是最影响体验的地方。
第三,节流防抖写起来啰嗦还容易错。为了缓解上面的问题,你得手动加throttle(节流)或debounce(防抖),用定时器人为地降低触发频率。这又引入了新的复杂度:节流间隔设多少合适?防抖会不会漏掉关键帧?无限滚动场景下定时器凑不齐怎么办?一堆边角问题。
IntersectionObserver的聪明之处就在于:它把这些脏活累活收进了浏览器底层,用异步、批量的方式在"合适的时机"通知你,整个过程不阻塞主线程、不需要你算坐标、不需要你自己节流。scroll能做的视口检测,它基本都能更优雅地做掉。
二、Intersection Observer 是什么:一句话说清本质
用一句人话概括:IntersectionObserver 是一个"异步观察器",你告诉它"盯住谁、拿什么当参照、用什么比例算相交",它就在目标元素和参照区域(默认是浏览器视口)的交叉状态发生变化时,异步回调通知你。
这里有几个关键词值得拆开讲。
"异步"——它的回调不在你注册的那一刻立即同步执行,也不在每一帧强迫执行,而是由浏览器在合适的时机(通常是一次渲染循环里)批量汇总后调用。这意味着你拿到的永远是稳定、最终的相交状态,而不是滚动中途的噪声。
"交叉状态"——英文是 intersection,指的是目标元素和参照根(root)之间重叠的面积比例。它用一个 0到1 的数字表示:0 表示完全没碰到,1 表示完全进了参照区域。这个连续比例正是它能做"渐入动画""懒加载阈值"的基础。
"参照根"——默认就是浏览器的视口(viewport)。但你也可以指定页面里的某个容器当根,比如一个可滚动的div,让孩子元素相对于这个容器来判断是否可见。这个能力在做"内部滚动区里的懒加载"时特别有用。
理解了这三句话,剩下的就都是 API 调用细节了。
三、基础用法:先跑通一个最小例子
任何 API 的入门,都是先把骨架搭起来。IntersectionObserver 的使用分三步:第一,创建一个 observer 实例,传入回调函数和配置;第二,调用 observe()把要观察的目标元素交给它;第三,当交叉状态变化时,回调里会拿到一个entries数组,每个entry描述一个被观察元素的最新状态。
看下面这段最基础的代码,它实现的功能是:当某个元素进入视口时,在控制台打印一条日志。
js// 第一步:创建观察器,传入回调函数和配置对象const observer = new IntersectionObserver( (entries) => { // entries 是一个数组,包含这一次"状态有变化"的所有被观察元素 entries.forEach((entry) => { // isIntersecting 为 true 表示元素至少有一部分和视口重叠了 if (entry.isIntersecting) { console.log('元素进入视口了!'); } else { console.log('元素离开视口了'); } }); }, { // threshold 设为 0,表示只要有一点像素进视口就触发(后面会细讲) threshold: 0, });// 第二步:拿到页面上要观察的目标,告诉观察器去盯着它const target = document.querySelector('#box');observer.observe(target);

上面这几行就是 Intersection Observer 的全部骨架,没有坐标计算、没有事件节流、没有 getBoundingClientRect。你只管observe,剩下的交给浏览器。回调里的entry.isIntersecting是个布尔值,配合entry.intersectionRatio(当前相交比例)就能写出各种丰富的逻辑。
补充一个新手容易忽略的点:observe 可以多次调用,一个 observer 实例能同时盯住很多个元素,不必为每个元素都 new一个观察者。当你确定某个元素再也不需要观察时(比如懒加载的图片已经显示过了),记得调用observer.unobserve(target)把它摘掉,或者用observer.disconnect()一次性断开整个观察者,避免内存泄漏。这在长列表、无限滚动里尤其重要——观察的对象只增不减,迟早把性能拖垮。
四、核心配置项:threshold、rootMargin、root 三件套
光会observe还不够,真正决定"什么时候触发"的,是创建观察者时传入的配置对象(options)。这里面有三个关键字段,吃透它们,你就能精确控制相交判定的时机和范围。
1. threshold:触发阈值(最常用)
threshold决定了"相交比例到达什么程度"才回调。它可以是单个数字,也可以是一组数字。
- 传
0:只要元素哪怕 1 像素露出来就触发。适合做懒加载——图片刚冒头就开始加载。 - 传
1:必须整块元素百分百进入视口才触发。适合做"整屏入场动画"。 - 传
[0, 0.25, 0.5, 0.75, 1]:每当相交比例跨过这些档位(25%、50%、75%)就回调一次。适合做"滚动进度条"或"视差渐变"。
js// 演示不同的 threshold 写法const lazyObserver = new IntersectionObserver(onChange, { threshold: 0, // 单值:露头即触发,懒加载首选});const animObserver = new IntersectionObserver(onChange, { threshold: 0.6, // 元素 60% 进视口才触发,动画更稳更有"仪式感"});const progressObserver = new IntersectionObserver(onChange, { // 数组:比例跨过每个档位都回调一次,可做精细进度 threshold: [0, 0.25, 0.5, 0.75, 1],});
2. rootMargin:根边距(提前/延后触发的利器)
rootMargin 相当于给参照根"外扩"或"内缩"一圈边距,语法和 CSS 的 margin一样,支持'10px'、'0px 0px -100px 0px'这种写法。它是做"预加载"的神器。
比如说,你想让图片在"距离视口底部还有 200 像素"的时候就开始加载,而不是等它真正露头才动,那就把底部边距设为负值 -200px,意思是"把判定区域往上提 200 像素",元素提前触发。反过来,想让元素真正完全进来后才算,可以把顶部内缩。
js// 用 rootMargin 提前 200px 触发懒加载,用户还没看到图,图已经备好了const preloadObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { loadImage(entry.target); // 提前加载,消除白屏等待 preloadObserver.unobserve(entry.target); } }); }, { root: null, // null 表示以浏览器视口为根 rootMargin: '0px 0px 200px 0px', // 底部外扩 200px,提前触发 threshold: 0, });
3. root:自定义参照根
root默认是null,也就是浏览器视口。但如果你要观察的元素是放在一个可滚动的弹窗、侧边栏、横向滚动容器里,那它相对于"整个页面视口"其实一直都是可见的,这时就得把root设成那个滚动容器本身,让判定以容器为基准。
js// 当目标元素处于某个可滚动容器内时,把容器设为 rootconst container = document.querySelector('#scroll-box');const innerObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { entry.target.classList.toggle('in-view', entry.isIntersecting); }); }, { root: container, // 关键:以这个滚动容器作为参照根 rootMargin: '0px', threshold: 0.5, });
划重点:这三个配置项最好一起理解——root决定"跟谁比",rootMargin决定"比的时候边界往哪儿扩",threshold决定"重叠到什么程度算数"。把它们组合好,你能覆盖几乎全部滚动场景,而完全不用碰scroll事件。
五、实战一:图片懒加载(最经典、最该上 IO 的场景)
懒加载是 Intersection Observer 最出圈的用法,没有之一。它的诉求很朴素:图片很多,但首屏只显示几张,那就让不在视口里的图片先别加载,等快滚到它了再加载,省流量、提首屏速度。
实现思路分三步。第一,HTML 里给 img不直接写src,而是把真实地址放到data-src 自定义属性上,并给一个轻量的占位图或纯色背景。第二,用 observer 盯着这些图片,当它们进入视口,就把 data-src的值赋给src,真正发起请求。第三,加载完立刻unobserve摘掉观察,避免重复触发。
讲到图片加载优化,如果你还想进一步把加载过的图片缓存到本地、让用户断网也能看图,那套思路和PWA 离线应用实战:Service Worker 缓存策略与 manifest 配置 里讲的 Service Worker 缓存策略是天然搭档——IO 负责"何时加载",缓存负责"加载过的不用再拉",两者一结合,弱网体验直接起飞。
html<!-- 结构:真实地址放在 data-src,src 先留空或放占位图 --><img class="lazy" data-src="https://example.com/photo1.jpg" alt="示例图1"><img class="lazy" data-src="https://example.com/photo2.jpg" alt="示例图2"><img class="lazy" data-src="https://example.com/photo3.jpg" alt="示例图3">
js// 懒加载核心逻辑const lazyImages = document.querySelectorAll('img.lazy');const lazyObserver = new IntersectionObserver( (entries, observer) => { entries.forEach((entry) => { // 元素进入视口 if (entry.isIntersecting) { const img = entry.target; // 把 data-src 的真实地址交给 src,真正发起图片请求 img.src = img.dataset.src; // 加载完成后淡入,体验更顺滑 img.onload = () => img.classList.add('loaded'); // 加载过就摘掉观察,避免重复回调 observer.unobserve(img); } }); }, { // 提前一屏加载,用户滚到时图已就绪,消除白屏 rootMargin: '0px 0px 200px 0px', threshold: 0, });// 逐个把图片交给观察器lazyImages.forEach((img) => lazyObserver.observe(img));
cssimg.lazy { opacity: 0; transition: opacity 0.4s ease; background: #f0f0f0; }img.lazy.loaded { opacity: 1;}
这里有个实战细节必须提醒你:懒加载的占位高度一定要提前定好(比如给img设aspect-ratio 或固定宽高),否则图片没加载时元素高度是 0,加载后突然撑开,页面会"跳一下",体验很糟。配合 aspect-ratio: 16 / 9这类属性锁定比例,是专业做法。
六、实战二:滚动进入视口触发动画(告别 scroll 轮询)
第二个高频需求是"滚动揭示动画"——元素滚进视口时淡入、上移、缩放,滚出去再回去又重新播。以前靠scroll 加节流硬算坐标,现在用 IO 一个回调就搞定,而且天然流畅。
思路是:给所有要做入场动画的元素一个初始的"隐藏态"(比如opacity:0; transform: translateY(40px)),当 IO 判定它进入视口,就给它加一个 visible 类,触发 CSS 过渡。想让它"滚出去再回来重新播",就不 unobserve,靠isIntersecting 的真假反复切换 class 即可。
这类"进入视口就变色、变主题"的效果,如果你还想把它和全站换肤联动,可以复用CSS 变量(Custom Properties)实战:一行 var() 搞定全局主题切换 里的方案——动画负责"入场演出",CSS 变量负责"主题切换",两者各管一摊、互不打架。
js// 滚动揭示动画:元素进视口加 visible,离开则移除const revealEls = document.querySelectorAll('.reveal');const revealObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { // 进入视口 → 加类,触发 CSS 过渡 if (entry.isIntersecting) { entry.target.classList.add('visible'); } else { // 离开视口 → 移除类,下次进来能重新播放 entry.target.classList.remove('visible'); } }); }, { threshold: 0.2, // 露出 20% 就播,节奏更自然 });revealEls.forEach((el) => revealObserver.observe(el));
css.reveal { opacity: 0; transform: translateY(40px); transition: opacity 0.6s ease, transform 0.6s ease;}.reveal.visible { opacity: 1; transform: translateY(0);}
进阶玩法:利用entry.intersectionRatio还能做"滚动视差"或"进度联动"。比如一个进度条的宽度跟着相交比例走:bar.style.width = (entry.intersectionRatio * 100) + '%',元素越往里滚,条越长,效果非常丝滑。再配合不同的threshold数组,你能做出"几段式"的阶梯动画,比单一淡入高级得多。
七、实战三:无限滚动 / 加载更多(最考验 observer 用法的场景)
无限滚动——也就是"列表滚到底部自动加载下一页"——是 IO 另一个杀手级应用。传统做法是在 scroll里反复算scrollTop + clientHeight >= scrollHeight - 阈值,既繁琐又容易在快速滚动时漏触发。用 IO,只要盯住列表末尾那个"哨兵元素"(sentinel),它一进视口就加载下一页,逻辑干净利落。
js// 无限滚动:盯住列表底部的哨兵元素let page = 1;let loading = false; // 用标志位防止重复请求,等价于"防抖"const sentinel = document.querySelector('#sentinel');const list = document.querySelector('#list');const loadMoreObserver = new IntersectionObserver( async (entries) => { // 哨兵进入视口,且当前没有正在进行的请求 if (entries[0].isIntersecting && !loading) { loading = true; const data = await fetchNextPage(page); // 拉取下一页数据 renderItems(list, data); // 渲染到列表 page += 1; loading = false; } }, { rootMargin: '0px 0px 150px 0px', // 提前 150px 触发,滚到底前就开始加载 threshold: 0, });loadMoreObserver.observe(sentinel);
这里我特意加了一个loading 布尔标志位,这是无限滚动里必写的防抖逻辑。原因在下一节会细说:IO 的回调是异步的,而且哨兵在视口里停留期间可能反复触发,如果没有这个"锁",你会在一瞬间发出一堆重复请求。这个标志位本质上就是你手写防抖时那个 timer的替代物,只是更直观。
如果你做的是图片瀑布流或网格相册,配合无限滚动时,布局强烈建议用CSS Grid 网格布局完全指南:从网格线到 auto-fit 响应式里的grid-template-columns: repeat(auto-fit, minmax(...)),让新加载的卡片自动回流排布,不用你手动算列宽。IO 管"何时加载",Grid 管"怎么排",分工明确。
八、注意事项:这些坑不填,上线就翻车
Intersection Observer 虽好,但不是"无脑用就完事"。这几条是我踩过、也帮别人救过火后总结的硬经验,务必看完。
坑一:回调跑在主线程,别在里头干重活
虽然 IO 的"判定"是浏览器异步批量做的,不阻塞滚动,但你的回调函数本身仍然是在主线程执行的 JavaScript。如果你在回调里同步做大量 DOM 操作、复杂计算、或者又去读 offsetHeight触发强制重排,那该卡还是卡。正确姿势是:回调里只做"轻量的类切换"或"发起异步请求",重计算挪到requestAnimationFrame或Web Worker 里。记住一句话——IO 帮你省掉的是"监听和判定"的成本,不是"回调逻辑"的成本。
坑二:异步回调需要防抖/加锁
上面无限滚动的loading 标志位就是典型例子。IO 是异步的,意味着从"你发起请求"到"请求回来"之间,哨兵可能还在视口里,于是下一次回调又来了,如果不加锁就会重复加载。懒加载虽然用了 unobserve摘观察来天然防重,但无限滚动的哨兵是长期存在的,必须靠标志位或AbortController 这类手段自己防抖。这也是为什么我说它"替代了 scroll 轮询,但防抖意识不能丢"。
坑三:首屏已可见的元素也会回调一次
很多新手以为"元素一开始就在视口里,observer 不会理它"。错。元素在初始观察时如果已经和 root 相交,IO 会在第一次观察时立即回调一次,告诉你"它现在是可见的"。这其实是好事——意味着你不用单独写"首屏元素直接展示"的特例,IO 会自动把初始状态同步给你。但要注意:如果你的回调副作用是"只触发一次"的(比如打点统计曝光),记得在回调里及时 unobserve,否则重复曝光统计会失真。
坑四:浏览器兼容性要兜底
现代浏览器(Chrome、Edge、Firefox、Safari 12.1+)都原生支持 IntersectionObserver,但如果你要兼容非常老的环境(比如旧版 IE),需要引入官方 polyfill(W3C 的 intersection-observer垫片)。一个稳妥的写法是:先if ('IntersectionObserver' in window) 判断能力,有就用 IO,没有就降级成"直接显示"或经典的 scroll + 节流方案,保证功能不丢。
坑五:rootMargin 不支持百分比
rootMargin 的写法和 CSS margin 类似,但只能用长度值(px、rem、em 等),不能用百分比。想做"提前半屏加载",写'0px 0px 50% 0px'是会报错的,得换算成具体的像素,比如'0px 0px 400px 0px'。这个坑特别隐蔽,写错了控制台可能只给个含糊的报错,排错时记得先查这里。
讲到性能调优,IO 其实和"响应式原理"有异曲同工之妙——都是把"频繁的变化"收敛成"一次性的通知"。如果你想更深入理解"观察者模式"在前端的底层运用,可以看 Proxy 与 Reflect 元编程:从拦截器到 Vue3 响应式原理,里面讲的依赖收集,和 IO 把滚动变化收集成回调的思想是一脉相承的。
九、它和 MutationObserver、ResizeObserver 是什么关系
顺手帮你把前端几个"Observer"家族理一理,免得你以后用混。
IntersectionObserver:观察"元素和视口(或容器)的交叉状态",今天的主角,专治滚动。ResizeObserver:观察"元素自身尺寸的变化",比如容器宽高被改了就通知你,常用来做响应式重算。MutationObserver:观察"DOM 树的变化",比如某个节点被增删、属性被改,用来做微前端沙箱、脏检查替代。
它们都属于浏览器原生的"异步观察者"体系,设计哲学一致:把高频、琐碎的变化收拢成批量的异步回调,既不阻塞主线程也不让你手写节流。理解了一个,另外两个上手极快。
另外,如果你做的组件库想把这些能力封装成可复用的自定义元素(比如一个<lazy-img>标签天生自带懒加载),思路和Web Components 实战:用原生 API 封装可复用自定义元素(Shadow DOM 与插槽全解析) 里讲的 Shadow DOM 封装如出一辙——把 IO 的观察逻辑关进组件内部,对外只暴露干净的属性接口,业务方无感知就能用上高性能懒加载。
十、性能对比:为什么 IO 一定比 scroll 快
最后用一句话把性能账算明白。传统的scroll方案,是"用户每滚一像素,你就被叫去算一次坐标",计算量和滚动距离线性相关,且全挤在主线程;而IntersectionObserver是"浏览器在底层用更高效的机制(通常是合成线程的几何计算)统一判定,只在交叉状态真正变化的那一帧通知你一次"。换句话说,前者是你主动去轮询,后者是浏览器主动来通知。一个"拉"(pull),一个"推"(push),在高频率、长列表的场景下,差距会被放大到肉眼可感。
这也是为什么主流框架和库(从原生实现到各类图片懒加载插件)都纷纷把底层换成 IO。它不是"更时髦",而是"在正确的抽象层解决了正确问题"。
十一、一个能直接跑的完整 Demo
光讲不练假把式。下面把今天三个实战——懒加载、滚动揭示、无限滚动——揉进一个 HTML 文件。你新建 io-demo.html,把整段粘进去,浏览器打开后向下滚动,就能看到图片懒加载、卡片淡入、滚到底自动加载的效果,全程零依赖。
html<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>IntersectionObserver 整合 Demo</title><style> body { font-family: system-ui; margin: 0; padding: 24px; line-height: 1.7; } .lazy { width: 100%; max-width: 400px; aspect-ratio: 16/9; background: #eee; opacity: 0; transition: opacity .4s; } .lazy.loaded { opacity: 1; } .reveal { opacity: 0; transform: translateY(40px); transition: .6s ease; margin: 24px 0; padding: 16px; border: 1px solid #ddd; border-radius: 12px; } .reveal.visible { opacity: 1; transform: none; } #sentinel { height: 1px; }</style></head><body> <h2>向下滚动看效果 ↓</h2> <!-- 实战一:懒加载图片 --> <img class="lazy" data-src="https://picsum.photos/400/225?1" alt="图1"> <img class="lazy" data-src="https://picsum.photos/400/225?2" alt="图2"> <!-- 实战二:滚动揭示卡片 --> <div class="reveal">我是滚动揭示卡片,进视口就淡入上移。</div> <div class="reveal">第二张卡片,同样的效果。</div> <!-- 列表容器 + 哨兵 --> <ul id="list"></ul> <div id="sentinel"></div><script>// 实战一:懒加载const lazyObserver = new IntersectionObserver((entries, obs) => { entries.forEach(e => { if (e.isIntersecting) { const img = e.target; img.src = img.dataset.src; // 真正加载 img.onload = () => img.classList.add('loaded'); obs.unobserve(img); // 摘掉观察防重复 } });}, { rootMargin: '0px 0px 200px 0px' });document.querySelectorAll('img.lazy').forEach(img => lazyObserver.observe(img));// 实战二:滚动揭示const revealObserver = new IntersectionObserver((entries) => { entries.forEach(e => e.target.classList.toggle('visible', e.isIntersecting));}, { threshold: 0.2 });document.querySelectorAll('.reveal').forEach(el => revealObserver.observe(el));// 实战三:无限滚动let page = 1, loading = false;const list = document.getElementById('list');const loadMore = new IntersectionObserver(async (entries) => { if (entries[0].isIntersecting && !loading) { loading = true; for (let i = 0; i < 5; i++) { const li = document.createElement('li'); li.textContent = `第 ${page} 页 - 第 ${i + 1} 条`; list.appendChild(li); } page++; loading = false; }}, { rootMargin: '0px 0px 150px 0px' });loadMore.observe(document.getElementById('sentinel'));</script></body></html>
把这段代码存成io-demo.html双击打开,依次滚动观察三类效果。建议你故意把rootMargin改成0px、把threshold调到1,亲眼看看触发时机怎么变,印象才深。调试时如果某个元素死活不触发,先确认它不是display:none(不可见元素 IO 不会判定相交),再检查 root是不是设错成了某个隐藏的父容器。
十二、一句话收尾与延伸阅读
以上就是 Intersection Observer 的全部干货。记住它的核心心智模型:你不用再去轮询滚动,只要告诉浏览器"盯住谁、怎么算相交",剩下它推消息给你。懒加载、滚动动画、无限滚动,本质上都是这同一个 API 的不同配置组合。下次再写滚动相关需求,先把 scroll监听放一放,试试IntersectionObserver,你会回来谢我的。
相关阅读(站内):
- 图片懒加载想进一步做离线缓存,看 PWA 离线应用实战那篇
- 想让滚动动画和全站换肤联动,看 CSS 变量实战那篇
- 想深入观察者模式的底层原理,看 Proxy 与 Reflect 元编程那篇

发表评论发布评论后审核成功,即可显示在列表中...