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 一句话理解这个 API
IntersectionObserver是浏览器原生提供的一个异步观察 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 class="lazy">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。实测一把你就知道,原来滚动可以这么安静。