首屏 6 秒变 1.5 秒,核心武器正是懒加载。
用户打开一个页面,只看到首屏那一小块区域。而图片、JS、组件,你通常一次性全加载了。那些藏在折叠线下面的内容,那些用户根本不会滚动到的代码,都在跟首屏抢带宽。
懒加载不是"延迟加载",而是只加载用户当前需要的东西。本文从图片、路由、组件三个维度,给出可直接落地的方案。

一、图片懒加载:最直观的性能提升
1. 原生 loading="lazy"(推荐)
现代浏览器原生支持,一行代码搞定:
html
<img src="hero.jpg" loading="lazy" alt="产品图" />
浏览器会自动判断图片是否接近可视区域,接近时才开始加载。Chrome 77+、Edge 79+、Firefox 75+ 均支持。兼容老旧浏览器时,作为渐进增强的手段,不用担心不支持的浏览器会崩溃,它们只会忽略这个属性并立即加载图片。

2. IntersectionObserver 手动实现
需要更细粒度的控制(如自定义加载阈值、动态背景图、懒加载视频)时,可用 IntersectionObserver:
javascript
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // 将真实图片地址赋给 src
observer.unobserve(img); // 加载完成后停止观察
}
});
}, { rootMargin: '200px' }); // 提前 200px 开始加载,用户无感知
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
对应 HTML:
html
<img data-src="large-photo.jpg" src="placeholder.png" alt="产品图" />
占位图 placeholder.png 可以是极小的灰色方块或模糊缩略图,保证页面布局稳定。
3. 避免布局偏移(CLS)
懒加载最大的隐患是图片加载后撑开页面导致内容跳动。修复方式很简单------给图片容器预留占位空间:
css
.img-wrapper {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 比例 */
}
.img-wrapper img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
或者在 <img> 上直接设置 width 和 height 属性,现代浏览器会根据这两个属性自动计算宽高比并保留空间。
二、路由懒加载:SPA 首屏体积的"瘦身刀"
单页应用首屏加载慢,很大程度是因为 import 会把所有页面组件打包进一个 bundle。通过动态 import() 实现代码分割,让用户访问哪个页面时才加载哪个页面对应的 JS。
1. React Router v6
tsx
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
function App() {
return (
<Suspense fallback={<Spin size="large" />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}
React.lazy 配合 Suspense,在组件加载期间展示 fallback(骨架屏或 loading 动画)。Webpack 和 Vite 在构建时识别到 import(),自动将这三个页面拆成独立的 chunk。首屏 bundle 体积通常能下降 40%-60%。
2. Vue Router
javascript
const Dashboard = () => import('./views/Dashboard.vue');
const Settings = () => import('./views/Settings.vue');
const router = createRouter({
routes: [
{ path: '/dashboard', component: Dashboard },
{ path: '/settings', component: Settings },
]
});
Vue 会自动将每个动态导入的组件拆成独立 chunk,无需额外配置。
3. 路由懒加载与预加载的配合
懒加载的副作用是切换路由时有短暂的加载延迟。对于用户极大概率会访问的页面(比如首页→列表页),可在当前页面空闲时预加载下一个页面的 chunk:
tsx
// 在 Dashboard 页面中,预加载 Settings 页面
const Settings = lazy(() => import('./pages/Settings'));
function Dashboard() {
useEffect(() => {
// 页面渲染完成后,浏览器空闲时预加载
const preload = () => import('./pages/Settings');
if ('requestIdleCallback' in window) {
requestIdleCallback(preload);
} else {
setTimeout(preload, 1000);
}
}, []);
}
或者在 <link> 标签中声明:
html
<link rel="prefetch" href="/assets/Settings.chunk.js" as="script">
Webpack 魔法注释同样可以触发 prefetch:
javascript
const Settings = lazy(() => import(/* webpackPrefetch: true */ './pages/Settings'));
三、组件懒加载:非首屏可见的重型组件"延迟到场"
页面中有些组件体积庞大(图表、富文本编辑器、视频播放器、地图),但用户不一定滚动到它们所在的位置。
1. React 示例:图表组件懒加载
tsx
import { lazy, Suspense, useState } from 'react';
import { useInView } from 'react-intersection-observer';
const HeavyChart = lazy(() => import('./HeavyChart'));
function Dashboard() {
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: '200px' });
const [showChart, setShowChart] = useState(false);
useEffect(() => {
if (inView && !showChart) setShowChart(true);
}, [inView]);
return (
<div ref={ref} style={{ minHeight: 400 }}>
{showChart ? (
<Suspense fallback={<div style={{ height: 400, background: '#f0f0f0' }}>加载图表中...</div>}>
<HeavyChart />
</Suspense>
) : (
<div style={{ height: 400, background: '#f0f0f0' }}>滚动至此加载图表</div>
)}
</div>
);
}
关键点:
useInView(来自react-intersection-observer)监听组件是否进入视口。triggerOnce: true保证只触发一次,加载后不再卸载。rootMargin: '200px'提前加载,用户无感知。- 占位 div 预留高度,防止布局跳动。
2. Vue 示例:地图组件懒加载
vue
<template>
<div ref="container" style="min-height: 400px;">
<div v-if="!loaded" style="height: 400px; background: #f0f0f0;">
滚动至此加载地图
</div>
<MapComponent v-if="loaded" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const loaded = ref(false);
const container = ref(null);
onMounted(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
loaded.value = true;
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(container.value);
});
</script>
3. 哪些组件应该懒加载?
- 图表库(ECharts、Highcharts 等)
- 富文本编辑器(Quill、TinyMCE、Monaco Editor)
- 地图 SDK(高德、百度、Mapbox)
- 视频/音频播放器
- 复杂表单(多步骤向导)
四、三种懒加载的配合:一个完整页面的加载策略
假设一个后台管理首页包含:
- 顶部数据卡片(首屏可见)
- 折线图(第二屏)
- 用户列表(第三屏,3000+ 数据)
完整加载策略如下:
css
页面打开(首屏)
├─ 数据卡片组件 ------ 立即渲染
├─ 图表占位 div ------ 展示骨架屏(400px 灰色区块)
├─ 列表占位 div ------ 展示骨架屏(600px 灰色区块)
├─ 底部图片 ------ 不加载,保持空白
用户开始滚动 ↓
图表区域进入视口
→ 动态加载 echarts 并渲染图表
列表区域进入视口
→ 动态加载列表组件 + 只渲染可视行(虚拟滚动)
底部图片进入视口(提前 200px)
→ 将 data-src 替换为真实图片地址
用户感知到的首屏时间大幅缩短,而后续内容的加载在滚动中自然完成。
五、懒加载常见踩坑与解决方案
1. SEO 问题
搜索引擎爬虫通常不会执行 JS,懒加载的图片和内容可能无法被索引。解决 :首屏关键图片不使用懒加载;重要页面考虑 SSR/SSG,或为图片添加 <noscript> 降级。
2. 打印时图片缺失
用户打印页面时,懒加载的图片可能还没加载。解决 :在 @media print 中强制显示所有图片。
3. 用户快速滚动时的加载延迟
如果用户快速滚动到底部,多个懒加载组件同时触发,可能导致短时间大量请求。解决 :使用 IntersectionObserver 的 rootMargin 提前触发,配合请求优先级控制。
4. 骨架屏与 CLS
懒加载组件在加载期间如果没有占位空间,会导致后续内容频繁跳动,累积布局偏移(CLS)扣分。解决:始终为懒加载的组件预留最小高度,使用骨架屏或灰色占位块。
六、总结
| 类型 | 核心 API | 适用场景 |
|---|---|---|
| 图片懒加载 | loading="lazy" 或 IntersectionObserver |
所有非首屏图片 |
| 路由懒加载 | React.lazy / dynamic import |
SPA 的各页面 |
| 组件懒加载 | IntersectionObserver + lazy |
图表、编辑器、地图等重型组件 |
懒加载的本质不是"延迟",而是用空间换时间 ------在关键路径上削减一切非必要资源,让用户更快看到有意义的内容,然后悄悄地把其他东西加载好。你不需要一次性把所有优化手段都加上,但至少从今天开始,每写一个 <img> 就多想一秒:"这个图片,用户现在真的需要吗?"