在现代单页应用中,所有 JavaScript 代码通常会被打包成一个或多个 bundle。如果不做任何处理,用户首次访问时会下载整个应用的代码,导致首屏加载缓慢,尤其在应用体积较大时更为明显。路由级代码分割就是将不同路由对应的组件拆分成独立的代码块(chunk),仅当用户访问该路由时才动态加载对应的代码。
React 提供了 lazy 函数和 Suspense 组件,使得组件级别的代码分割变得异常简单,无需手动配置复杂的打包逻辑。
基本用法:搭配 React Router
假设有一个包含首页、关于页和用户页的应用,我们可以按路由对页面组件进行懒加载:
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// 使用 dynamic import 语法进行懒加载
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const User = lazy(() => import('./pages/User'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/user/:id" element={<User />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
当用户首次访问 / 时,只有 Home 组件的代码会被加载;切换到 /about 时,浏览器才会去下载 About 组件的对应 chunk。Suspense 的 fallback 属性用于指定在组件加载过程中显示的占位界面(例如一个加载动画)。
对于使用旧版 react-router-dom v5 的用户,也可以结合 Switch 和 Route 使用:
<Switch>
<Suspense fallback={<div>Loading...</div>}>
<Route exact path="/" component={Home} />
<Route path="/about" component={About} />
</Suspense>
</Switch>
如何处理加载失败
lazy 组件在加载过程中如果网络出现问题或者 chunk 加载失败,会导致组件渲染错误。我们可以使用 Error Boundary 包裹 Suspense 来统一处理这类异常:
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <div>页面加载失败,请刷新重试。</div>;
}
return this.props.children;
}
}
function App() {
return (
<ErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<Routes>...</Routes>
</Suspense>
</ErrorBoundary>
);
}
命名 chunk 便于调试
默认情况下,打包工具(如 Vite 或 Webpack)会为懒加载的 chunk 生成数字或哈希文件名,难以识别。可以在 import() 中使用魔法注释指定 chunk 名称:
const About = lazy(() => import(/* webpackChunkName: "about" */ './pages/About'));
使用 Vite 时,可以直接利用动态导入,Vite 会自动根据文件路径生成有意义的名称,一般不需要手动干预。
加载指示器的用户体验优化
路由切换时的 loading 状态如果只是简单显示“Loading...”,体验仍显生硬。通常我们可以:
- 使用骨架屏(Skeleton)代替简单的文字
- 使用顶部的进度条(如 NProgress)
- 利用
Suspense的边界做更细粒度的控制
例如,在页面布局中,可以仅在内容区域显示加载状态,而保持 Header 和 Sidebar 始终可见:
function App() {
return (
<div className="layout">
<Header />
<Sidebar />
<main>
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Suspense>
</main>
</div>
);
}
延迟加载的时机与预加载
React.lazy 是按需加载,只有组件真正开始渲染时才会发起网络请求。这可能导致用户点击路由后仍然需要等待 chunk 下载,造成短暂的延迟。对于某些用户大概率会访问的页面,可以提前预加载(Prefetch)对应的 chunk:
// 鼠标悬停时预加载
<Link
to="/about"
onMouseEnter={() => import('./pages/About')}
>
关于
</Link>
当用户鼠标悬停到链接上时,浏览器会在后台下载 About 页面的代码,点击后几乎可以瞬间渲染。
Webpack 和 Vite 的注意事项
- Webpack:需确保
@babel/plugin-syntax-dynamic-import已配置(通常 Create React App 和多数脚手架已默认启用)。 - Vite:天然支持
import()动态导入,无需额外配置,但注意在开发环境下lazy组件仍会触发网络请求,生产构建后才会真正分割。
避免过度分割
路由级代码分割虽然能减小初始包体积,但也不宜分割过细。如果一个路由页面的子组件也被 lazy 拆分,可能会导致页面上出现多次 loading 闪烁。合理的粒度通常是以路由为单元进行分割,对特别大的页面内部组件(如重型图表、富文本编辑器)可以再单独再懒加载。
通过 React.lazy 和 Suspense,路由级代码分割几乎零配置即可引入,能显著提升大型应用的首次加载速度和用户体验,是 React 项目性能优化的必选项之一。