懒加载与代码分割
概述
当用户打开一个网页时,浏览器需要下载、解析和执行所有的 JavaScript 代码。这个过程消耗的时间和资源直接影响用户何时能够开始与页面交互。如果一个 Web 应用把所有的代码都打包到一个巨大的 bundle 中,用户可能需要等待数十秒才能看到页面内容——即使他们只使用了其中一小部分功能。
懒加载(Lazy Loading)和代码分割(Code Splitting)是解决这个问题的核心技术。懒加载的核心思想是「延迟加载」——不要一次加载所有内容,而是按需加载。代码分割则是将代码分解为多个独立的 chunk(块),每个 chunk 可以独立下载和缓存。
想象一下电商网站的结账流程:用户浏览商品、加入购物车、最后进入结算页面。大多数用户可能只浏览而不购买,但结账功能的代码却被发送到每一个用户。代码分割允许你将结账相关代码单独打包,只有真正进入结算页面的用户才会下载它。
本章将深入讲解现代前端开发中的懒加载和代码分割技术。我们会从 ES 动态导入语法开始,探讨 Webpack 和 Vite 等构建工具如何支持代码分割,然后学习 React 中的组件懒加载方案,最后讨论预加载和预获取策略来优化用户体验。
目标
- 掌握动态 import() 语法与构建工具的代码分割机制
- 熟练使用 React.lazy 和 Suspense 实现组件级懒加载
- 理解路由级和组件级代码分割的最佳实践
- 掌握预加载(Preload)和预获取(Prefetch)策略以优化用户体验
知识体系
1. 动态 import() 基础
ES6 引入了 import 语法用于静态导入,这种导入方式在代码执行前就会解析,所有的导入模块都会被包含在初始 bundle 中。动态 import() 则不同,它返回的是一个 Promise,允许我们在运行时决定加载哪个模块。
ES 动态导入
动态导入使用 import() 语法,接受一个模块路径作为参数,返回一个 Promise。当模块加载完成后,Promise 会被 resolve,then 参数中接收的就是完整的模块对象。
// 静态导入 — 构建时确定,包含在主 bundle 中
import { heavyFunction } from './heavy-module';
// 动态导入 — 运行时加载,自动拆分为独立 chunk
const module = await import('./heavy-module');
module.heavyFunction();
// 条件加载
async function loadAnalytics() {
if (userConsented) {
const { init } = await import('./analytics');
init();
}
}
// 使用 webpackChunkName 自定义 chunk 名称
const EditorModule = () => import(
/* webpackChunkName: "editor" */
/* webpackPrefetch: true */
'./components/Editor'
);
Webpack 在处理动态导入时会自动创建一个新的 chunk。默认情况下,生成的 chunk 文件名是数字 ID,不够友好。你可以使用 webpack 特定的注释(magic comments)来自定义 chunk 名称或添加预获取指令。
Webpack 的 magic comments 包括:
webpackChunkName:指定 chunk 的名称webpackPrefetch:预获取,告诉 Webpack 在主 chunk 加载完成后闲时获取webpackPreload:预加载,与主 chunk 并行获取
Vite 的代码分割
Vite 基于 Rollup 实现代码分割,提供了 manualChunks 配置选项来自定义分割策略。相比 Webpack,Vite 的配置更加简洁。
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// 将 React 相关库打包到 vendor chunk
'react-vendor': ['react', 'react-dom'],
// 将 UI 库单独打包
'ui-vendor': ['@radix-ui/react-dialog', '@radix-ui/react-popover'],
// 将工具库单独打包
'utils': ['date-fns', 'lodash-es'],
},
},
},
},
});
// 更灵活的分割策略
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react')) return 'react-vendor';
if (id.includes('@radix-ui')) return 'ui-vendor';
return 'vendor';
}
},
},
},
},
});
manualChunks 的配置非常灵活,可以按模块路径来分组。将频繁更新的业务代码和几乎不变化的第三方库分开打包,可以优化缓存效率——当业务代码更新时,用户不需要重新下载没有变化的第三方库。
2. React 组件懒加载
在 React 应用中,懒加载通常应用于整个页面或重量级组件。React 提供了 React.lazy 和 Suspense 两个 API 来简化组件级懒加载的实现。
React.lazy + Suspense
React.lazy 接受一个返回动态 import 的函数,返回一个 React 组件。当组件首次渲染时,懒加载的模块才会被请求。Suspense 组件则用于包裹懒加载组件,并提供加载状态的 UI。
import { lazy, Suspense } from 'react';
// 懒加载组件
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
// 带重试逻辑的懒加载
function lazyWithRetry(importFn, retries = 3) {
return lazy(async () => {
for (let i = 0; i < retries; i++) {
try {
return await importFn();
} catch (error) {
if (i === retries - 1) throw error;
// 等待后重试,可能是网络临时故障
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
}
}
throw new Error('Failed to load module');
});
}
const HeavyChart = lazyWithRetry(() => import('./components/HeavyChart'));
// 使用 Suspense 包裹
function App() {
return (
<Suspense fallback={<LoadingSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}
lazyWithRetry 是一个实用的增强函数,它在网络故障时自动重试。在不稳定的网络环境下,这可以显著提高用户体验。网络请求失败往往是暂时的,简单重试通常就能成功。
fallback prop 接受任何 React 元素,通常是一个骨架屏(Skeleton)组件。骨架屏相比传统的 loading spinner 体验更好,因为它提前展示了内容的结构,让用户对加载内容有预期。
命名导出的懒加载
React.lazy 默认只支持 default export 的懒加载。对于 named export,需要一些额外的处理。
// React.lazy 默认只支持 default export
// 对于 named export,使用以下模式
// 方式一:中间模块重导出
// HeavyChart.lazy.js
export { HeavyChart as default } from './HeavyChart';
const HeavyChart = lazy(() => import('./HeavyChart.lazy'));
// 方式二:在 import 中转换
const HeavyChart = lazy(() =>
import('./components').then((module) => ({
default: module.HeavyChart,
}))
);
在实际项目中,通常建议对需要懒加载的组件使用 default export,这样可以简化懒加载的实现。
3. 路由级代码分割
路由级代码分割是最常见也是最有效的代码分割策略。它基于路由来划分代码边界,每个路由对应一个代码 chunk,用户在访问路由时才加载对应的代码。
// 路由级分割是最高效的分割策略
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const Products = lazy(() => import('./pages/Products'));
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
const Cart = lazy(() => import('./pages/Cart'));
const Checkout = lazy(() => import('./pages/Checkout'));
const router = createBrowserRouter([
{
path: '/',
element: (
<Suspense fallback={<PageSkeleton />}>
<Home />
</Suspense>
),
},
{
path: '/products',
element: (
<Suspense fallback={<PageSkeleton />}>
<Products />
</Suspense>
),
children: [
{
path: ':id',
element: (
<Suspense fallback={<ProductSkeleton />}>
<ProductDetail />
</Suspense>
),
},
],
},
{
path: '/checkout',
element: (
<Suspense fallback={<PageSkeleton />}>
<Checkout />
</Suspense>
),
},
]);
function App() {
return <RouterProvider router={router} />;
}
每个路由组件被包裹在自己的 Suspense 中,这意味着只有在用户导航到特定路由时,才会触发该路由组件的加载。这种模式在路由数量较多的大型应用中效果显著——用户只需要下载访问过的路由代码。
4. 预加载与预获取策略
懒加载的一个潜在问题是首次访问某个路由时的加载延迟。当用户点击导航到新路由时,需要等待代码下载和执行才能看到内容。为了改善这种体验,可以使用预加载和预获取策略。
交互预加载
在用户鼠标悬停时就开始预加载,可以显著减少导航到该路由时的等待时间。
// 鼠标悬停时预加载
function NavLink({ to, children, importFn }) {
const handleMouseEnter = () => {
// 触发 chunk 预加载
importFn();
};
return (
<Link to={to} onMouseEnter={handleMouseEnter}>
{children}
</Link>
);
}
// 使用
const settingsImport = () => import('./pages/Settings');
const Settings = lazy(settingsImport);
<NavLink to="/settings" importFn={settingsImport}>
Settings
</NavLink>
这个技术的原理是:当用户鼠标悬停在导航链接上时,代码 chunk 就在后台开始下载。当用户实际点击链接时,代码可能已经下载完成,用户可以直接看到页面内容,而不是等待加载状态。
Intersection Observer 预加载
对于页面底部的内容或组件,可以在它们接近可视区域时提前预加载。
function usePrefetchOnVisible(importFn) {
const ref = useRef(null);
const prefetched = useRef(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !prefetched.current) {
prefetched.current = true;
importFn();
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(element);
return () => observer.disconnect();
}, [importFn]);
return ref;
}
// 当用户滚动到附近时自动预加载
function ProductSection() {
const chartImport = () => import('./HeavyChart');
const ref = usePrefetchOnVisible(chartImport);
return (
<section ref={ref}>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
</section>
);
}
rootMargin: '200px' 设置了一个 200px 的缓冲区,意味着元素在距离可视区域 200px 时就开始被观察。这给了预加载一个提前量,确保在元素真正进入可视区域时已经加载完成。
Link Prefetch
浏览器原生的 <link rel="prefetch"> 标签可以在 HTML 中声明资源的预获取,让浏览器在闲时下载这些资源。
<!-- 浏览器空闲时预获取 -->
<link rel="prefetch" href="/static/js/settings.chunk.js" />
<!-- 高优先级预加载当前页面需要的资源 -->
<link rel="preload" href="/static/js/critical.chunk.js" as="script" />
prefetch 和 preload 的区别在于时机和优先级。preload 是高优先级的,会在当前页面加载完成后立即开始下载。prefetch 是低优先级的,浏览器会在闲时(不影响主页面加载)才下载。
5. 资源懒加载
代码分割只是懒加载的一种形式。图片、脚本、视频等资源同样适用懒加载策略。
图片懒加载
现代浏览器原生支持图片懒加载,只需添加 loading="lazy" 属性即可。
<!-- 原生 loading="lazy" -->
<img src="image.jpg" loading="lazy" alt="..." width="800" height="600" />
<!-- 首屏图片不应使用懒加载 -->
<img src="hero.jpg" loading="eager" fetchpriority="high" alt="..." />
关键是要区分首屏图片和次屏图片。首屏图片(LCP 候选元素)不应该使用懒加载,因为它们需要尽快加载以优化 LCP 指标。fetchpriority="high" 属性可以进一步提升关键图片的加载优先级。
第三方脚本懒加载
第三方脚本(如分析、监控、广告)通常不是首屏必需的,可以延迟加载。
// 延迟加载非关键第三方脚本
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
}
// 页面空闲时加载分析脚本
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
loadScript('https://analytics.example.com/tracker.js');
});
} else {
setTimeout(() => {
loadScript('https://analytics.example.com/tracker.js');
}, 3000);
}
requestIdleCallback API 允许你在浏览器空闲时执行低优先级任务。相比 setTimeout,它能更好地响应用户的交互——如果用户正在活跃地与页面交互,requestIdleCallback 会推迟执行。
6. 分割策略选择
代码分割需要权衡。分割越细,缓存粒度越好,但 initial bundle 越小;如果分割太粗,所有代码在一个大文件中,缓存效率低。选择合适的分割策略需要考虑多个因素。
代码分割决策树:
┌──────────────────────┐
│ 该代码是首屏必需的吗? │
└──────┬───────────────┘
│
Yes ──┤── No
│ │
│ ▼
│ ┌──────────────────────┐
│ │ 是否按路由自然分离? │
│ └──────┬───────────────┘
│ Yes ──┤── No
│ │ │
│ │ ▼
│ │ ┌──────────────────────┐
│ │ │ 组件体积 > 30KB? │
│ │ └──────┬───────────────┘
│ │ Yes ──┤── No
│ │ │ │
│ │ │ ▼
│ │ │ 保留在主 bundle
│ │ ▼
│ │ 组件级懒加载
│ ▼
│ 路由级代码分割
▼
主 bundle(保持精简)
判断是否需要分割的经验法则:
- 首屏必需的代码留在主 bundle,确保最快的首次内容绘制
- 按路由分离的代码使用路由级分割,用户只下载访问路由的代码
- 体积超过 30KB 的非首屏组件考虑懒加载,过小的组件分割收益不明显
- 第三方库单独打包,优化长期缓存
实战练习
练习 1:路由懒加载改造
将一个全量打包的 SPA 改造为路由级代码分割。使用 Webpack Bundle Analyzer 或 Vite 的 build analyzer 观察改造前后的 bundle 组成变化,使用 Lighthouse 测量页面加载时间的改善。
练习 2:智能预加载
实现一个基于用户行为预测的智能预加载系统:跟踪用户的导航模式,当检测到用户即将访问某个路由时提前预加载代码。实现一个简单的马尔可夫链模型来预测下一个可能访问的页面。
练习 3:Loading 状态优化
设计优雅的 Suspense fallback 组件,包括骨架屏和过渡动画。实现内容渐显动画,让用户感受到加载是「有进度」的,而不是无反馈地等待。
延展阅读
- React.lazy 官方文档 — React 懒加载的官方 API 文档
- Webpack Code Splitting — Webpack 官方代码分割指南
- Route-based Code Splitting — Google 官方的路由级代码分割指南
- Prefetch 与 Preload 详解 — Google 官方对预加载和预获取的深度讲解
关键术语
| 术语 | 解释 |
|---|---|
| Code Splitting | 代码分割,将 bundle 拆分为按需加载的 chunk 的技术 |
| Lazy Loading | 懒加载,延迟加载非关键资源,只在需要时加载 |
| Dynamic Import | 动态导入,通过 import() 语法在运行时加载模块 |
| Chunk | 代码分割后的独立文件单元,可以独立下载和缓存 |
| Prefetch | 预获取,在浏览器空闲时提前下载资源 |
| Preload | 预加载,高优先级提前下载当前页面需要的资源 |
| Suspense | React 用于处理异步加载状态的组件,配合 lazy 使用 |
| Skeleton | 骨架屏,内容加载前的占位 UI,提升加载感知 |