你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。
老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。
解决方案:三条技术路线,你全踩了一遍
1. 最偷懒:原生 loading="lazy"
一行代码就能跑,浏览器帮你搞定。
1<img
2 src="https://cdn.xxx.com/poster1.jpg"
3 loading="lazy"
4 decoding="async"
5 width="800" height="450"
6/>
🔍 关键决策点
loading="lazy"
2020 年后现代浏览器全覆盖,IE 全军覆没。- 必须写死
width/height
,否则 CLS 会抖成 PPT。
适用场景:内部系统、用户浏览器可控,且图片域名已开启 Accept-Ranges: bytes
(支持分段加载)。
2. 最稳妥:scroll 节流 + getBoundingClientRect
老项目里还有 5% 的 IE11 用户,我们只能回到石器时代。
1
2const lazyImgs = [...document.querySelectorAll('[data-src]')];
3let ticking = false;
4
5const loadIfNeeded = () => {
6 if (ticking) return;
7 ticking = true;
8 requestAnimationFrame(() => {
9 lazyImgs.forEach((img, idx) => {
10 const { top } = img.getBoundingClientRect();
11 if (top < window.innerHeight + 200) {
12 img.src = img.dataset.src;
13 lazyImgs.splice(idx, 1);
14 }
15 });
16 ticking = false;
17 });
18};
19
20window.addEventListener('scroll', loadIfNeeded, { passive: true });
🔍 关键决策点
- 用
requestAnimationFrame
把 30 ms 的节流降到 16 ms,肉眼不再掉帧。 - 预加载阈值 200 px,实测 4G 网络滑动不白屏。
缺点:滚动密集时 CPU 占用仍高,列表越长越卡。
3. 最优雅:IntersectionObserver 精准观测
新项目直接上 Vue3 + TypeScript,我们用 IntersectionObserver
做统一调度。
1
2export const useLazyLoad = (selector = '.lazy') => {
3 onMounted(() => {
4 const imgs = document.querySelectorAll<HTMLImageElement>(selector);
5 const io = new IntersectionObserver(
6 (entries) => {
7 entries.forEach((e) => {
8 if (e.isIntersecting) {
9 const img = e.target as HTMLImageElement;
10 img.src = img.dataset.src!;
11 img.classList.add('fade-in');
12 io.unobserve(img);
13 }
14 });
15 },
16 { rootMargin: '100px', threshold: 0.01 }
17 );
18 imgs.forEach((img) => io.observe(img));
19 });
20};
- 浏览器合成线程把「目标元素与视口交叉状态」异步推送到主线程。
- 主线程回调里只做一件事:把
data-src
搬到src
,然后unobserve
。 - 整个滚动期间,零事件监听,CPU 占用 < 1%。
原理剖析:从「事件驱动」到「观测驱动」
维度 | scroll + 节流 | IntersectionObserver |
---|---|---|
触发时机 | 高频事件(~30 ms) | 浏览器内部合成帧后回调 |
计算量 | 每帧遍历 N 个元素 | 仅通知交叉元素 |
线程占用 | 主线程 | 合成线程 → 主线程 |
兼容性 | IE9+ | Edge79+(可 polyfill) |
代码体积 | 0.5 KB | 0.3 KB(含 polyfill 2 KB) |
一句话总结:把「我每隔 16 ms 问一次」变成「浏览器你告诉我啥时候到」。
应用扩展:把懒加载做成通用指令
在 Vue3 项目里,我们干脆封装成 v-lazy
指令,任何元素都能用。
1
2const lazyDirective = {
3 mounted(el: HTMLImageElement, binding) {
4 const io = new IntersectionObserver(
5 ([entry]) => {
6 if (entry.isIntersecting) {
7 el.src = binding.value;
8 io.disconnect();
9 }
10 },
11 { rootMargin: '50px 0px' }
12 );
13 io.observe(el);
14 },
15};
16
17app.directive('lazy', lazyDirective);
模板里直接写:
1<img v-lazy="item.url" :alt="item.title" />
举一反三:三个变体场景思路
-
无限滚动列表
把IntersectionObserver
绑在「加载更多」占位节点上,触底即请求下一页,再把新节点继续observe
,形成递归观测链。 -
广告曝光统计
广告位 50% 像素可见且持续 1 s 才算一次曝光。设置threshold: 0.5
并在回调里用setTimeout
延迟 1 s 上报,离开视口时clearTimeout
。 -
背景图懒加载
背景图没有src
,可以把真实地址塞在style="--bg: url(...)"
,交叉时把background-image
设成var(--bg)
,同样零回流。
小结
- 浏览器新特性能救命的,就别再卷节流函数了。
- 写死尺寸、加过渡、及时
unobserve
,是懒加载不翻车的三件套。 - 把观测器做成指令/组合式函数,后续业务直接零成本接入。
现在你的「企业风采」首屏降到 1.2 s,老板滑得开心,运营继续传 8K 图,世界和平。
个人笔记记录 2021 ~ 2025