FEATURED · 精选文章

IntersectionObserver实战:告别滚动监听,实现高性能懒加载

发布时间 / 2026/9/16 16:40:57
来源 / 创域科博编辑部
栏目 / 资讯中心
IntersectionObserver实战:告别滚动监听,实现高性能懒加载 1. 先聊聊为什么那么多团队还在用“笨办法”做懒加载1.1 “笨办法”到底笨在哪我刚接手一个老项目的时候打开页面代码满屏的addEventListener(scroll, throttledHandler)配合getBoundingClientRect()写一段位置判断逻辑。图片、列表、评论区全部用这一套。说实话这套方案在功能上完全能用甚至不难写很多前端从接触懒加载的第一天起学的就是它。面试八股里你也能看到一堆“如何实现懒加载”的标准答案基本都是滚动监听加高度计算。但问题就出在这里。这套方案每一次滚动都要在主线程上跑一段 JavaScript做一整套布局读取和位置计算。浏览器滚动本身是合成器线程的工作你硬要插一脚结果就是用户手指一滑页面出现肉眼可见的掉帧。尤其当页面上有成百上千个待加载节点、每个节点都要做一次getBoundingClientRect()的时候这种计算开销会被放大得非常明显。再叠加一个节流函数也只是把问题从“每帧都算”变成“每 200 毫秒算一次”本质上并没有解决主线程被高频占用的问题。我见过更极端的写法有些团队直接在scroll回调里做offsetTop层层读 DOM 属性不做缓存、不节流。这种写法在低端安卓机上页面基本是滑一帧卡一下体验极其糟糕。可你要说完全不能用吧也不是功能没毛病图片确实到视口附近才会加载。1.2 为啥明明有更优解大家还是不换你可能想问浏览器早就推出了IntersectionObserver为什么还有这么多人选择手写滚动监听我自己总结下来原因就三个字惯性。同时背后还有几个非常现实的因素。第一是认知偏差。很多前端只知道IntersectionObserver叫“交叉观察器”能观察元素进入视口但真让他在项目里替换掉旧方案他又不确定边界情况怎么处理页面里多个图片要不要一个元素开一个 observer加载完要不要 remove和虚拟滚动怎么配合一旦心里没底就不敢动了。第二是旧代码多。老项目里懒加载逻辑往往散落在各个组件里有的是 jQuery 时代留下来的有的是早期封装的一个工具函数。替换成本并不是“改一个工具函数”那么简单要动多个业务组件还要回归测试。在“能跑就不动”的共识下负责人一般不愿意为了性能优化去折腾已经稳定的代码。第三是兼容性顾虑。IE 浏览器不支持IntersectionObserver虽然现在很多项目已经明确不兼容 IE但确实有部分 B 端项目还在支持。前端选方案的时候一旦聊到兼容性问题沟通成本就上去了最后往往又走回老路。还有一个特别容易被忽略的原因就是不少前端对这个 API 的理解停留在“知道它存在”的层面并没有真的意识到它和传统方案之间有一条性能鸿沟。接下来我从原理层面把这条鸿沟掰开讲清楚。1.3 懒加载到底想解决什么问题在深入技术之前得先明确一件事懒加载本质上是在解决三个问题。第一个问题是流量成本。图片、视频、组件脚本这些东西如果一次性全部加载用户在没看到它们之前就白费了带宽。移动端场景下流量是钱加载时间是命必须按需加载。第二个问题是首屏速度。一个页面首屏只占整个文档很小一部分把首屏以外的内容全部提前下载会阻塞 JS 执行和关键资源加载直接拉低 LCP、FCP 这类性能指标。第三个问题是渲染压力。即使资源已经加载了如果一次性把几千个 DOM 节点全部铺到页面上浏览器要一次性完成布局和绘制主线程瞬间被打满。懒加载配合分批渲染可以让浏览器以更平滑的节奏处理内容。明白了这几个目标再看IntersectionObserver就更有针对性了它正是在“元素什么时候进入视口”这个关键节点上交给了浏览器一个更聪明的答案。2. IntersectionObserver 到底聪明在哪2.1 一句话理解这个 APIIntersectionObserver是浏览器原生提供的一个异步观察 API它的核心能力是判断目标元素与祖先元素或者顶级视口之间的交叉状态。用人话讲它能告诉你“某个 DOM 元素现在是否出现在可视区域里”以及“它出现在可视区域里的比例是多少”。这个 API 不是让你自己去监听 scroll 事件、自己去算位置而是由浏览器内部去计算这些几何关系。计算好了之后以异步回调的方式把结果通知给你。const observer new IntersectionObserver(callback, options); observer.observe(targetElement);就这么简单。创建一个观察器实例传入一个回调函数然后告诉它“你帮我盯着这个元素”。后面的事情浏览器全包了。2.2 核心设计原理异步回调是怎么工作的要真正理解IntersectionObserver关键在于理解“异步”这两个字。传统方案里你在scroll事件里写的代码是同步执行的而且每个事件回调都会阻塞一下主线程。叠加页面上多个监听逻辑主线程负担越来越大。IntersectionObserver的设计思路完全不同它把“元素几何位置变化”这件事交给浏览器内部去计算计算完成之后再以异步队列的方式批量派发结果。浏览器会在两个时机触发回调。第一个是观察开始时也就是你调用observe()之后会立即触发一次回调告诉你目标元素的初始状态。第二个是目标元素与根元素的交叉状态发生实际变化的时候比如从不可见变成可见、交叉比例从 0 变成 0.3。这在底层是怎么实现的简单说浏览器的渲染引擎会维护一个交叉观察器列表在每次做样式计算和布局更新的阶段自动去计算这些目标元素与根元素之间的相交情况。计算结果会生成一批IntersectionObserverEntry对象然后通过微任务或者渲染后的异步任务队列统一派发到你注册的回调里。这里面有个很重要的细节回调是异步的不会阻塞主线程上的布局计算。你页面上再多的图片、再多的懒加载目标都只是让浏览器在内部做一次整体的交叉计算而不是每滚动一像素就执行一次 JavaScript。2.3 和传统方案对比差在哪我用一个实际的例子对比一下两套方案的开销差异。假设页面上有 100 张图片需要懒加载。用传统方案你要监听scroll当然也会监听窗口变化每次触发回调都去遍历这 100 个 DOM 元素调用getBoundingClientRect()获取它们的位置再判断是否进入了视口区域。这里每一次getBoundingClientRect()都会强制浏览器进行一次同步布局计算在滚动过程中这就等于你在持续做重活。用IntersectionObserver你只需要创建observer并observe这 100 个元素。浏览器会在渲染管线的适当阶段自动计算所有目标元素的交叉状态然后通过一次回调集中把“谁可见、谁不可见”的通知发给你。整套过程是批量化和异步化的。双端的数据相差有多大官方没有给过一个精确的数字但我在 Chrome DevTools 的 Performance 面板里实测过一个页面旧方案滚动过程中主线程长时间处于满负荷状态任务条几乎全红换成IntersectionObserver之后滚动期间主线程基本都是空闲的只有每次交叉状态变化时才出现几个微小的任务。另外还有一个工程上的细节传统方案为了不卡顿你必须自己实现节流或者防抖。用IntersectionObserver这个需求天然不存在。浏览器已经帮你控制了触发频次你只管处理结果就好。3. 从零实现一个真正可用的懒加载3.1 最简单版本图片懒加载先写一个最基础的图片懒加载。原理非常直接把img的真实地址放在>img classlazy>const images document.querySelectorAll(.lazy); const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { const img entry.target; const realSrc img.dataset.src; if (realSrc) { img.src realSrc; img.removeAttribute(data-src); } observer.unobserve(img); } }); }); images.forEach((img) observer.observe(img));几个关键点第一一定要判断entry.isIntersecting。交叉回调不只会在元素进入视口时触发还会在完全离开视口时触发这时候isIntersecting是false。没这个判断图片可能还没进入视口就把真实地址加载了。第二加载完之后记得unobserve。图片地址已经赋值后面的观察已经没意义了不取消会让浏览器一直维护这个元素的交叉计算。我见过有些项目忘了这一步页面滚动起来之后观察器还在持续工作白白增加开销。第三observer实例只需要创建一次多个图片都观察这一个实例即可。不要每一个图片都new一个自己的观察器那个开销比例比懒加载省下来的还大。这个版本是最直接能跑的但实际业务里光有它还不够。图片懒加载做多了以后你会发现真正的麻烦在于各种边界场景。3.2 加入加载状态和错误处理真实项目中的图片懒加载需要考虑的不只是“替换 src”。图片加载失败怎么办加载过程中要不要给一个骨架屏或者 loading 状态滚动很快的时候用户已经划过去了图片还要不要加载下面的代码我加上了这几个维度的处理class LazyLoader { constructor(options {}) { this.loadingClass options.loadingClass || is-loading; this.errorClass options.errorClass || is-error; this.observer new IntersectionObserver((entries) { entries.forEach((entry) { if (!entry.isIntersecting) return; const img entry.target; this.loadImage(img); this.observer.unobserve(img); }); }, { rootMargin: options.rootMargin || 100px 0px, threshold: options.threshold || 0.01 }); } observe(img) { this.observer.observe(img); } loadImage(img) { const realSrc img.dataset.src; if (!realSrc) return; const wasLoading img.classList.contains(this.loadingClass); img.classList.add(this.loadingClass); img.addEventListener(load, () { img.classList.remove(this.loadingClass); }, { once: true }); img.addEventListener(error, () { img.classList.remove(this.loadingClass); img.classList.add(this.errorClass); img.removeAttribute(src); }, { once: true }); img.src realSrc; img.removeAttribute(data-src); } } const loader new LazyLoader({ rootMargin: 200px 0px }); document.querySelectorAll(.lazy).forEach((img) loader.observe(img));这里我把rootMargin设成了200px 0px意思是图片还没真正进入视口、还差 200 像素的时候就开始加载。这个策略叫“预加载缓冲”在移动端尤其重要。因为用户的滚动操作往往很迅速如果等到图片完全进入视口再加载会出现明显的白屏等待图片下载耗时短的还好耗时长就会出现先看到空位、后看到图片的情况。有了预加载缓冲图片往往在进入视口之前就已经下载完成视觉上会连贯很多。threshold我设了0.01意思是只要有 1% 的面积进入视口就算相交。这个阈值对图片懒加载来说足够灵敏。你也可以试试写多个阈值比例的写法后面我会讲它更有意思的用法。3.3 两个高级参数threshold 和 rootMargin 怎么选rootMargin前面已经提到了它本质上在放大或者缩小“根元素”的判定范围。默认情况下根元素就是浏览器视口。你设置rootMargin: 200px 0px后相当于把视口往外扩了 200 像素元素在距离视口边缘还有 200 像素时就会触发交叉回调。threshold则需要理解得更细一点。它可以是单个数字也可以是一个数组。阈值的含义是当目标元素与根元素的交叉面积达到这个比例时触发一次回调。我举一个例子你在观察一个“回到顶部”按钮的显示时机。如果阈值设为0那么只要按钮哪怕只有一像素在视口内就会触发回调。如果设为[0, 0.5, 1]那么交叉比例每经过 0、0.5、1 其中一个节点都会触发一次回调。在实际业务里threshold: 0.01通常够用但你在做曝光埋点时会希望精确统计用户看到了内容的多少这时可以用数组来区分元素只露出 10% 算一次曝光露出 50% 算深度曝光完整露出算完成曝光。这里有一个经验值可以参考场景建议配置理由图片懒加载threshold: 0.01, rootMargin: 200px 0px提前加载避免白屏微小比例触发足够无限滚动threshold: 0.5底部占位元素露出一半才加载下一页手感更稳曝光埋点threshold: [0, 0.5, 1]区分轻度浏览、半屏浏览、完全浏览视频自动播放threshold: 0.5画面过一半再播放体验更好3.4 工程化封装Vue3 里的自定义指令如果你用的是 Vue3懒加载最好封装成自定义指令业务代码里一行v-lazy就能搞定。这个方案在维护性上比在每个业务组件里手动写观察逻辑好得多。// lazy-directive.js let observer; function getObserver() { if (!observer) { observer new IntersectionObserver((entries) { entries.forEach((entry) { if (!entry.isIntersecting) return; const img entry.target; const realSrc img.dataset.src; if (realSrc) { img.src realSrc; img.removeAttribute(data-src); } observer.unobserve(img); }); }, { rootMargin: 200px 0px, threshold: 0.01 }); } return observer; } export default { mounted(el) { getObserver().observe(el); }, unmounted(el) { getObserver().unobserve(el); } };在main.js里注册import lazyDirective from ./directives/lazy-directive; app.directive(lazy, lazyDirective);模板里的用法img v-lazy>const sentinel document.querySelector(#sentinel); const observer new IntersectionObserver((entries) { if (entries[0].isIntersecting) { // 加载下一页数据 loadNextPage(); } }, { rootMargin: 100px 0px, threshold: 0 }); observer.observe(sentinel);在这个场景里rootMargin底部设置一个正向偏移量比如100px可以让触发时机提前到距离底部还有 100 像素时。这样用户还没真正到底下一批数据已经开始请求等用户滚到底部时内容已经渲染好体验非常顺滑。4.2 曝光埋点统计前端还有个很常见的需求统计某个广告位、某条推荐内容是否被用户看到了。旧方案还是在滚动事件里做计算并且还要考虑在页面刷新之前把曝光数据上报上去非常麻烦。IntersectionObserver天然适合这个场景。它能在元素进入视口的那一刻告诉你而且还能精确到交叉比例。const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (!entry.isIntersecting) return; const exposureRatio entry.intersectionRatio; reportExposure({ elementId: entry.target.dataset.exposeId, ratio: exposureRatio }); // 只上报一次上报完立即停止观察 observer.unobserve(entry.target); }); }, { threshold: [0, 0.25, 0.5, 0.75, 1] });如果你的业务需要统计“用户是否看完了整篇文章”可以观察文章底部的某个元素等它进入视口时上报“文章阅读完成”。统计“某个组件在页面停留了多少时间”则可以在元素进入视口时标记时间戳离开时计算差值。这里有一个容易踩的坑曝光数据如果不做去重用户在列表页快速滑动时同一个元素可能会触发多次回调。虽然IntersectionObserver在正常情况下不会像 scroll 那样高频触发但元素在视口边缘来回穿插时仍有可能出现重复曝光。解决办法就是上面代码里写的曝光一次就unobserve。如果要用多个阈值记录不同深度那可以采用“第一次触发就上报首曝存储最大交叉比例最后统一上报”的策略。4.3 懒加载组件和异步脚本图片之外IntersectionObserver还能用来做组件级别的懒加载。比如一个页面底部有评论区用户很可能根本不到达那里。那这个评论区组件就可以等到它快进入视口时再动态 import。const commentSection document.querySelector(#comment-section); const observer new IntersectionObserver((entries) { if (entries[0].isIntersecting) { import(./components/CommentList.vue).then((module) { // 挂载组件 }); observer.unobserve(entries[0].target); } }, { rootMargin: 500px 0px }); observer.observe(commentSection);这个思路和图片懒加载一样都是把“用户暂时看不到的东西延后加载”。它还可以用在《埋点脚本只有在用户首次交互或滚动到页面中部时再加载》、《第三方广告 SDK 延迟注入》、《动态导入路由对应的页面 chunk》。很多人一听到代码分割、懒加载首先想到的是 webpack 的 dynamic import再配合路由级按需加载。但是路由级懒加载的粒度通常比较大一个页面就是一个 chunk。用IntersectionObserver可以把懒加载的粒度进一步细化到区块级首屏只加载首屏用到的内容其他区块等用户快看到了再加载。这在移动端低配置设备上对首屏性能的提升是立竿见影的。5. 实战项目里踩过的坑和排查技巧5.1 回调一直不触发怎么办这是初学者问得最多的一个问题。observe一个元素以后回调函数怎么一直没动静第一确认你的根元素设置是否正确。默认根是浏览器视口如果你设置了root为一个overflow: hidden的容器目标元素必须在这个容器内部并且容器必须真的是目标元素的祖先节点。如果root选错了交叉状态永远算不出来。第二确认目标元素是不是display: none或者visibility: hidden。观察一个不可见的元素它和视口就没有交叉区域自然不会触发回调。这个坑在条件渲染的场景里特别容易踩比如v-if控制的弹窗。第三确认threshold设置是否合理。如果你设了threshold: 1意思是元素 100% 进入视口才算相交。但目标元素本身就比视口大那它永远不可能 100% 出现在视口里回调就不会触发。第四如果目标元素在observe的时候还没有被插入到 DOM 树中也会有问题。React 的useEffect或 Vue 的mounted阶段通常没问题但如果在数据异步返回之前就执行了observe目标元素压根不存在自然观察不到。这个场景要在数据返回、DOM 更新之后再去observe。5.2 兼容性问题的取舍IntersectionObserver的兼容性在现代浏览器里已经非常好了。Chrome、Firefox、Safari、Edge 均有完整支持移动端 WebView 也基本没有障碍。唯一的问题出在 IE 和小众老版本内核的浏览器。如果项目确实需要兼容 IE有两个选择。第一个是引入官方 polyfill社区有一个intersection-observerpolyfill体积不大能模拟出类似回调。使用时在入口文件最前面加载 polyfill如果浏览器不支持原生 API就用 polyfill 版本。第二个是降级方案做一个环境检测function createLoader(callback, options) { if (IntersectionObserver in window) { return new IntersectionObserver(callback, options); } // 降级直接执行 callback把所有元素视为已进入视口 callback([{ isIntersecting: true }], null); return null; }这个降级方案的核心逻辑是不支持的浏览器干脆不做懒加载直接把所有资源全部加载。虽然牺牲了优化效果但保证了功能完整性这也是一种合理的取舍。5.3 不要把所有东西都往 IntersectionObserver 里塞讲了很多适用场景但也得说说它不适合的场景。一是需要精确控制加载位置、例如要精确到具体像素位置时IntersectionObserver提供的信息粒度不够你需要IntersectionObserverEntry.boundingClientRect自己算。二是高频变化的动画状态比如你希望元素在视口边缘来回移动时立即同步视觉效果IntersectionObserver的回调是异步批量的会有轻微延迟。三是跟 Canvas 或 WebGL 场景里的碰撞检测那是游戏逻辑不是 DOM 层面的事这个 API 根本不参与。我自己的经验是用IntersectionObserver做一个默认方案遇到上面这些特殊需求时再退回手动计算。这才是工程师思维——用合适的工具去解决合适的问题而不是追着新 API 到处套。5.4 排查技巧速查表现象可能原因解决办法回调完全不触发root 设置错误、目标元素隐藏、threshold 过高检查 root 是否为祖先且可见调低 threshold图片闪一下才出现没有设置 rootMargin 预加载加 200px 左右的预加载缓冲回调触发次数过多没有在加载后 unobserve处理完后立即取消观察观察失效导致内存泄露组件销毁时未 unobserve在 unmounted/useEffect cleanup 中取消观察无限滚动重复加载加载下一页后没有移动 sentinel每次加载完成后再创建新的 sentinel 并重新 observe我个人在实际开发中的习惯是每个项目都会封装一个统一的useIntersectionObserverHook 或者一个LazyLoader类把创建观察器、观察、卸载、取消观察的逻辑全部收敛到一处。这样团队成员写业务时只需要调用接口不容易出细节问题。6. 顺手聊一下和虚拟列表、防抖节流的关系6.1 有一类场景不能替代滚动监听有朋友看完前面的内容可能会产生一个误会是不是只要涉及滚动位置判断全部换成IntersectionObserver就万事大吉不是的。虚拟列表就是一个典型的反例。虚拟列表的核心需求是根据滚动位置动态计算当前应该渲染哪些行、哪些行需要卸载。它需要的是每一帧都非常精确的滚动位置信息而不是一个“元素是否可见”的异步通知。所以虚拟列表通常还是要用scroll事件或者直接使用框架层面已经优化过的方案。如果你在虚拟列表的容器里塞了很多IntersectionObserver观察目标你会发现回调触发的时机很怪因为虚拟列表中的行是被反复创建和销毁的观察目标本身都不稳定。这种复杂度叠加会让排查变得很痛苦。我目前的经验总结是这样的IntersectionObserver最适合的是“判断某个静态存在的元素与视口的关系”这类场景。虚拟列表、拖拽排序、实时碰撞检测这些需要精确位置和连续反馈的场景老老实实走scroll或requestAnimationFrame循环就够了。6.2 和防抖节流组合使用的正确姿势IntersectionObserver本身不需要防抖节流这一点前面说过了。但你在回调里做的事情可能需要。比如无限滚动加载数据时回调触发后你发起网络请求如果用户滚动的速度非常快sentinel 在很短的时间内一进一出你又在上一次请求还没返回时再次触发了回调就会发出多个重复请求。虽然接口通常会有去重但体验上还是会有抖动的风险。我的做法是在回调里加一个简单的loading标志位let isLoading false; const observer new IntersectionObserver(async (entries) { if (!entries[0].isIntersecting || isLoading) return; isLoading true; try { await loadNextPage(); // 重新调整 sentinel 位置 } finally { isLoading false; } }, { rootMargin: 100px 0px });这个标志位本质上是一种业务层面的节流和IntersectionObserver自带的频率控制是两个维度的东西。一个管浏览器层面的回调频率一个管业务层面的请求并发。把这两层区分开写出来的代码会清晰很多。7. 面试官问懒加载时你该怎么答热词里相关的前端面试题一直很多懒加载基本属于必考题。面试官如果问“让你实现一个图片懒加载你怎么做”你直接答“用 IntersectionObserver”是可以的但想拿高分最好多走两步。第一步说明IntersectionObserver的基本用法和参数。你能讲清楚rootMargin做什么、threshold做什么、unobserve什么时候用面试官就能确定你是真的用过不是背的。第二步对比旧方案解释为什么要用它。提到旧方案需要在 scroll 事件里做getBoundingClientRect计算会阻塞主线程还要自己做节流而IntersectionObserver是浏览器原生异步计算不阻塞主线程、不需要节流这两句话一出基础考核就过了。第三步主动聊边界条件和缺陷。比如兼容性怎么处理、虚拟列表里能不能用、如果观察的元素是动态渲染出来的要如何处理observe时机。能够主动暴露问题并给出解决方案会让面试官觉得你有真实项目的沉淀。顺带提一句如果你应聘的公司技术栈是 Vue3可以顺手说过封装了一个v-lazy指令如果技术栈是 React可以提一下useRef配合IntersectionObserver的封装思路。这个细节看似小但很能体现工程化意识。“为什么绝大多数前端仍在用‘笨办法’做懒加载”我觉得本质上不是这个 API 有多高级别人不会用而是很多前端在接触一个新知识的时候只停留在了“知道”的层面没有真正把它变成项目里的标准方案。你手上的项目如果还用 scroll 监听来做懒加载找一个周末抽个半小时把那套逻辑换成IntersectionObserver。实测一把你就知道原来滚动可以这么安静。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻