前端懒加载(Lazy Loading)是现代 Web 性能优化里最划算的一招:它让图片、组件和路由级别的资源只在真正需要时才加载,从而缩短首屏时间、节省带宽、降低主线程压力。本文从原生 loading 属性讲到 IntersectionObserver,再延伸到组件与路由级的代码分割,手把手带你把”按需加载”落到生产环境。
一、懒加载到底在”懒”什么
传统页面会一次性请求所有资源:首屏以下的图片、暂不展示的弹窗组件、用户还没点进去的二级路由,全都在打开页面时排队下载。对带宽和首屏都是浪费。懒加载的核心思想是延迟非关键资源的获取,等到资源即将进入视口、或被用户触发时才真正发起请求。它的收益非常直接:
- 首屏关键请求数下降,Largest Contentful Paint(LCP) 明显改善;
- 总传输体积下降,移动端弱网体验更好;
- 主线程编译执行负担降低,交互更跟手。
下面我们从三个层级逐个拆解:图片、组件、路由。
二、图片懒加载:从原生属性到 IntersectionObserver
2.1 一行代码搞定:loading=”lazy”
现代浏览器原生支持图片懒加载,只需给 <img> 加一个属性,浏览器会自动在图片接近视口时才下载:
<img
src="/hero.jpg"
alt="产品主视觉"
loading="lazy"
width="800" height="450">
loading="lazy" 由浏览器底层调度,零 JS、零依赖,兼容性已覆盖主流版本。注意两点:一是务必写死 width/height,否则图片加载后撑开布局会引发累计布局偏移(CLS);二是它只适合”首屏以下”的图片,首屏关键图反而应该 eager 加载。
2.2 更可控:IntersectionObserver 自定义加载
当需要在”即将进入视口前”预取、或加载前先展示占位图、或做淡入动画时,原生属性就不够用了。IntersectionObserver 能在元素进入视口阈值时回调,是前端懒加载的标准方案:
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src; // 真实地址存在 data-src
img.addEventListener("load", () => img.classList.add("is-loaded"));
io.unobserve(img); // 加载一次后停止观察
}
}, { rootMargin: "200px" }); // 提前 200px 预加载
document.querySelectorAll("img[data-src]").forEach((el) => io.observe(el));
rootMargin: "200px" 表示元素距离视口还有 200px 时就开始加载,避免用户滚到才”闪一下”。配合 data-src 占位模式,首屏 HTML 里只放轻量占位,真实大图延迟拉取。
2.3 响应式图片也能懒加载
懒加载和响应式布局并不冲突。把 srcset 与 loading="lazy" 组合,既能按设备选图,又能延迟下载:
<img
src="photo-480.jpg"
srcset="photo-480.jpg 480w, photo-960.jpg 960w, photo-1440.jpg 1440w"
sizes="(max-width: 600px) 480px, 960px"
alt="风景照"
loading="lazy"
decoding="async">
| 方案 | 是否需要 JS | 可控性 | 适用场景 |
|---|---|---|---|
| loading=”lazy” | 否 | 低 | 普通首屏下图片 |
| IntersectionObserver | 是 | 高 | 占位/动画/预取 |
| 第三方库(lazysizes) | 是 | 中 | 老旧浏览器兼容 |
三、组件懒加载:动态 import 与异步组件
图片解决了”看得见”的资源,但 JS 里的重组件(图表、富文本编辑器、弹窗)同样可以按需加载。关键能力是 ES 的动态 import():它在运行时才去拉取并执行对应模块,配合打包工具的代码分割自动产出独立 chunk。
3.1 React:React.lazy + Suspense
import { lazy, Suspense } from "react";
const HeavyChart = lazy(() => import("./HeavyChart"));
function Dashboard() {
return (
<Suspense fallback={<Spinner />}>
<HeavyChart />
</Suspense>
);
}
lazy(() => import("./HeavyChart")) 让图表组件被单独打包,只有 Dashboard 真正渲染它时才下载该 chunk。Suspense 的 fallback 用来展示加载占位,避免白屏。
3.2 Vue:defineAsyncComponent
import { defineAsyncComponent } from "vue";
const HeavyEditor = defineAsyncComponent({
loader: () => import("./HeavyEditor.vue"),
loadingComponent: Spinner,
delay: 200,
errorComponent: ErrorTip,
});
在 Vue3 组合式 API 项目里,defineAsyncComponent 提供了 loading/delay/error 全套钩子,比 React 的 Suspense 更细粒度,非常适合把富交互组件从主包里拆出去。
四、路由级懒加载:整页按需切分
组件懒加载向上再抽象一层,就是路由懒加载:每个页面变成一个独立 chunk,用户访问哪个路由才加载哪页。这是单页应用(SPA)瘦身的主力手段。
4.1 Vue Router
const routes = [
{ path: "/", component: () => import("./views/Home.vue") },
{ path: "/report", component: () => import("./views/Report.vue") },
];
4.2 React Router
import { lazy } from "react";
import { Routes, Route } from "react-router-dom";
const Report = lazy(() => import("./Report"));
<Routes>
<Route path="/report" element={<Report />} />
</Routes>
打包后每个路由会生成独立 JS 文件。你可以用 Vite 构建流水线 的 rollup-plugin-visualizer 直观看到 chunk 拆分效果,再结合 包体积优化 把公共依赖抽到 vendor chunk。
五、懒加载的边界与常见坑
5.1 首屏关键资源别懒
懒加载是”延迟非关键资源”,不是”全部延迟”。首屏主图、首屏核心 JS 如果被误设为 lazy,反而会让 LCP 变差。经验法则:折叠线(above the fold)之上的一律 eager。
5.2 用占位与骨架屏守住 CLS
图片懒加载后,若容器没有预留尺寸,资源到位时会把下方内容”挤”下去,造成布局抖动(CLS,影响 Lighthouse 评分)。务必给 <img> 写死宽高,或用 aspect-ratio 占位。
5.3 关键下一页用 prefetch 提前加载
懒加载的副作用是”点了才等”。可以用 <link rel="prefetch"> 在浏览器空闲时预取最可能访问的下一页资源,兼顾首屏与跳转速度。Vite 生态里 vite-plugin-prefetch 能按路由自动注入。
| 维度 | 触发时机 | 典型手段 | 收益 |
|---|---|---|---|
| 图片 | 进入视口 | loading=lazy / IO | 省带宽、降 CLS |
| 组件 | 被渲染 | 动态 import | 减主包体积 |
| 路由 | 访问页面 | 路由懒加载 | 首屏更快 |
六、在构建与线上验证懒加载是否生效
写完代码只是第一步,确认懒加载真的”懒”下来才算闭环。本地构建侧和线上运行时侧各有一套观察方法。
6.1 构建产物看 chunk 拆分
路由与组件级懒加载的前提,是打包工具把动态 import() 拆成了独立文件。用 Vite 构建后,在 dist/assets 目录里应该能看到按页面或组件命名的独立 JS,而不是全部挤进一个 main chunk:
$ npm run build
dist/assets/index-a1b2.js # 主包(含框架与公共依赖)
dist/assets/Home-9f3c.js # 首页 chunk
dist/assets/Report-7d2e.js # 报表页 chunk(仅访问 /report 才下载)
dist/assets/HeavyChart-c4a1.js # 图表组件 chunk
如果发现所有代码仍合并在一个文件里,大概率是动态 import 被静态分析吃掉,或路由没有用返回 Promise 的写法。配合 Vite 构建流水线 的可视化插件,能一眼看出哪个 chunk 最大、是否还需要继续拆。
6.2 线上用 DevTools 看网络时机
打开浏览器 DevTools 的 Network 面板,勾选 “Img” 与 “JS” 过滤,刷新页面后向下滚动:懒加载正常的表现是——首屏下方的图片和路由 chunk 在初始请求里不出现,而是随着滚动或点击才进入请求列表,且带有一段延迟。如果发现图片一打开就全部请求,优先检查 loading 是否写错、IntersectionObserver 的 rootMargin 是否过大,或图片被 CSS 提前撑进了视口。
另一组值得盯的指标是 Core Web Vitals:懒加载见效后,LCP 与总请求数应同步下降。把它们和 Lighthouse 评分一起纳入持续观测,才能判断”懒”得是否到位。
七、小结
懒加载不是某个单一 API,而是一套”按需加载”的思路:图片层用原生 loading 或 IntersectionObserver,组件层用动态 import + Suspense / defineAsyncComponent,路由层用懒加载拆分 chunk。落地时记住三条铁律——首屏关键资源不懒、占位守住 CLS、关键下一页用 prefetch 兜底。把这三层一起用上,配合构建工具的分析插件持续观测,你的页面首屏和交互流畅度都会有肉眼可见的提升。




