视口之外不渲染:IntersectionObserver 懒加载与组件卸载回收
一、长页面首屏之痛:全量加载的隐性代价
某内容聚合平台做过一次复盘。首页图文流加载 47 张图,首屏 LCP 5.8 秒,移动端跳出率 38%。定位时发现:47 张图全部在 DOM 加载阶段发起请求,浏览器并发上限是 6,剩下的 41 张在队列里排队,把首屏 hero 图也堵在后面。用户最关心的头图反而最晚出现。
这事我见过太多团队栽进去------把图片列表当数组渲染,img 标签一次性全挂上。每多挂一张,浏览器就要分配解码任务、占内存、抢网络槽位。屏幕外的图占了首屏资源,屏幕内的图反而拿不到。
更隐蔽的是重型组件的成本。某运营后台首页挂了 8 个图表组件,每个都跑 ECharts init。哪怕用户只看了前两个,后六个还是全量初始化、绑定 resize 监听、占内存。低端机直接 OOM。
按需加载是这些问题的工程化解法。IntersectionObserver 把渲染时机从 DOM 挂载,推迟到进入视口。让首屏只承担首屏的代价。但它的配置项与生命周期管理有不少坑,本文把这些坑讲清楚。
二、视口观察机制:root、rootMargin 与 threshold 的语义
IntersectionObserver 的核心是异步观察。它跟踪目标元素与根容器的相交状态。浏览器在内部维护一个观察列表,只在布局变化时计算相交矩形,把回调放进微任务队列。这比 scroll 事件里手写位置计算高效得多。后者每次滚动都强制同步布局,是经典的布局抖动来源。
三个关键配置项决定触发时机。root 指定参考容器,默认是浏览器视口。指定为某个滚动容器时,观察范围就局限在该容器内。rootMargin 是参考矩形的向外扩张量,类似 CSS margin,常用 200px 来"提前加载"。目标距离视口 200 像素时就触发,避免用户滚到了才加载的卡顿感。threshold 是相交比例阈值。0 表示刚进入就触发,1 表示完全进入才触发,常用于渐进式动画。
懒加载组件的卸载回收也至关重要。重型组件(如图表、视频播放器)滚出视口后,应主动 unobserve 并销毁实例,释放内存。某监控大屏曾未做卸载回收,用户滚了 20 屏后内存涨到 1.2GB,浏览器直接卡死。
但卸载也有代价:用户来回滚动会触发反复加载。要配合"加载后稳定"策略。一旦加载过就保持挂载,仅对极重型组件做卸载回收。
与虚拟列表的配合是另一个工程细节。虚拟列表通过 DOM 复用减少节点数,IntersectionObserver 在这种场景下要小心。列表项会被复用,observe 的目标元素会变化。正确做法是在列表项的 effect 里挂载观察,并在 cleanup 里 unobserve,避免观察悬空到旧 DOM。
SSR 场景必须兜底。服务端没有 IntersectionObserver,直接调用会报错。要在 hydration 完成后再创建观察器,并对不支持 IO 的旧浏览器降级到直接加载。
三、生产级代码:通用懒加载组件(含 SSR 兜底)
下面给出一个 React 通用懒加载组件。它兼容 SSR,降级旧浏览器,并支持离屏卸载回收。
typescript
import { useEffect, useRef, useState, type ReactNode } from 'react';
interface LazyRenderProps {
// 支持函数形式,避免未加载也构造重型组件实例
children: ReactNode | (() => ReactNode);
fallback?: ReactNode; // 占位 UI
rootMargin?: string; // 提前加载距离,默认 200px
threshold?: number; // 相交比例阈值
unmountWhenOut?: boolean; // 滚出视口是否卸载(重型组件用)
placeholderHeight?: number; // 占位高度,防止布局抖动
}
// 通用懒加载组件:兼容 SSR,降级旧浏览器,支持离屏卸载
export function LazyRender({
children, fallback = null, rootMargin = '200px',
threshold = 0, unmountWhenOut = false, placeholderHeight = 200,
}: LazyRenderProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
// 离屏观察器:仅在 unmountWhenOut 时启用
const unobserveRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
// SSR 与旧浏览器兜底:直接加载,避免白屏
if (typeof IntersectionObserver === 'undefined') {
setVisible(true);
return;
}
const io = new IntersectionObserver(
(entries) => {
// 仅处理目标元素自身的相交变化,过滤回调中的其他 entries
const entry = entries.find((e) => e.target === el);
if (!entry) return;
if (entry.isIntersecting) {
setVisible(true);
// 加载后停止观察,避免重复触发
io.unobserve(el);
// 注册离屏观察:用于重型组件的卸载回收
if (unmountWhenOut) {
unobserveRef.current = new IntersectionObserver(
(outEntries) => {
const out = outEntries.find((e) => e.target === el);
if (!out) return;
if (!out.isIntersecting) {
setVisible(false); // 卸载真实组件,释放内存
} else {
setVisible(true); // 重新进入视口重新加载
}
},
{ rootMargin: '0px', threshold: 0 }
);
unobserveRef.current.observe(el);
}
}
},
{ rootMargin, threshold }
);
io.observe(el);
return () => {
// cleanup 必须显式断开两个观察器,防止悬空到复用 DOM
io.disconnect();
unobserveRef.current?.disconnect();
unobserveRef.current = null;
};
}, [rootMargin, threshold, unmountWhenOut]);
return (
<div ref={ref} style={{ minHeight: placeholderHeight }}>
{/* 未加载时返回占位,避免重型组件在未可见时也构造实例 */}
{visible
? (typeof children === 'function' ? (children as () => ReactNode)() : children)
: fallback}
</div>
);
}
使用示例:
typescript
// 图片懒加载:保持挂载,不启用离屏卸载
<LazyRender placeholderHeight={300} fallback={<div className="skeleton" />}>
<img src="/heavy.jpg" alt="" loading="lazy" />
</LazyRender>
// 重型图表组件:懒加载 + 离屏卸载回收
<LazyRender
unmountWhenOut
placeholderHeight={400}
fallback={<ChartSkeleton />}
rootMargin="100px"
>
{() => <HeavyChart data={chartData} />}
</LazyRender>
关键点三处。其一,children 支持函数形式,未加载时不构造重型组件实例。其二,SSR 与旧浏览器降级到直接加载,保证可访问性。其三,离屏卸载仅对极重型组件启用,普通组件保持挂载避免反复加载。
四、边界分析:占位高度的代价与离屏卸载的反噬
懒加载不是无损。占位高度会改变页面布局,影响滚动条位置与文档总高度。若占位高度与实际高度差距大,用户滚动时会出现"跳页"现象。某商品流曾用 0 占位高度,结果滚到中部时整个页面突然向上跳 300 像素。用户以为是 bug 反馈了一周。
rootMargin 的提前量也要克制。200 像素看似合理,但在长页面下意味着同时有 10 到 20 个目标处于"即将进入"状态。全部触发加载会让首屏资源再次被抢。对图片密集场景,rootMargin 应压到 50 到 100 像素。
离屏卸载有反噬。用户快速滚动时,组件会反复加载卸载,每次都重新初始化。某图表组件因此每次重入视口都跑 200 毫秒 init,反而比保持挂载更卡。离屏卸载只适合内存占用极大(大于 50MB)的组件,且要配合"加载一次后保持挂载 N 秒"的稳定窗口。
与虚拟列表配合时要警惕观察悬空。虚拟列表复用 DOM 节点,IntersectionObserver 绑定的元素会被换给其他数据项。必须在 effect cleanup 里显式 unobserve,否则会出现"数据项 A 进入视口却触发了数据项 B 的加载"的错乱。
threshold 配置也容易踩坑。配 1 时,若目标元素比 root 还高,永远不会完全进入视口,回调永远不触发。配 0.5 时,目标大于两倍视口也会卡住。经验是默认用 0,只在确有渐进动画需求时才调高。
适用边界:长页面、图片密集、重型组件分散的场景收益最高。短页面、首屏即全部可见的场景,懒加载反而是负优化。多一层观察器开销,无任何收益。
五、总结
懒加载的核心是把渲染时机与 DOM 挂载解耦,让首屏只承担首屏的代价。落地建议:第一,rootMargin 控制在 50 到 200 像素,提前量过大会让首屏资源再次被抢。第二,占位高度必须接近真实高度,避免滚动跳页。第三,重型组件支持离屏卸载,但要配稳定窗口避免反复加载。第四,与虚拟列表配合时,effect cleanup 必须显式 unobserve。防止观察悬空。第五,SSR 与旧浏览器降级到直接加载,保证可访问性。最终在首屏性能、内存占用与滚动稳定性之间取得平衡。这条路在百屏级长页面下能跑通,回报是值得的。