
一句话:路由级懒加载是「代码分割收益最大的一步」——每个页面单独打包,用户只下载当前页面的代码;声明式路由用
React.lazy+Suspense,数据路由直接用lazy: () => import(...)。
一、为什么路由级分割最划算
① 天然的「按页面拆分」边界——每个路由对应一块功能
② 用户「一次只访问一个页面」——不需要下载其它页面的代码
③ 首屏只需「首屏路由」的代码 → 主包大幅缩小
④ 页面之间「依赖差异大」(图表库只被某个页面用到)→ 分割收益更明显
对比「组件级分割」:
· 组件级需要判断「什么时候加载」(交互时机),容易漏
· 路由级是「必然的边界」,几乎不会做错
二、声明式路由的懒加载
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// ① 懒加载页面组件
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Settings = lazy(() => import('./pages/Settings'));
const UserDetail = lazy(() => import('./pages/UserDetail'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="settings" element={<Settings />} />
<Route path="users/:id" element={<UserDetail />} />
</Route>
</Routes>
</Suspense>
);
}
// ⚠️ 命名导出需要转换
const Settings = lazy(() =>
import('./pages/Settings').then((m) => ({ default: m.Settings }))
);
三、Suspense 的位置(关键细节)
// ❌ 放在最外层 → 路由切换时「整页」变成 fallback(导航栏也没了)
function App() {
return (
<Suspense fallback={<FullPageLoading />}>
<Routes>
<Route path="/" element={<Layout />}>
{/* ... */}
</Route>
</Routes>
</Suspense>
);
}
// 现象:点导航 → 整个页面(含导航栏)被替换成 loading → 视觉闪烁
// ✅ 放在「布局的内容区」→ 只有内容区显示骨架
function Layout() {
return (
<div className="layout">
<TopNav /> {/* ⭐ 导航栏保持稳定 */}
<main>
<Suspense fallback={<ContentSkeleton />}>
<Outlet /> {/* ⭐ 只有这里在切换时显示骨架 */}
</Suspense>
</main>
</div>
);
}
// ✅ 或者「逐路由包」(更细的粒度)
<Route
path="settings"
element={
<Suspense fallback={<SettingsSkeleton />}>
<Settings />
</Suspense>
}
/>
// 好处:每个页面可以有自己的骨架(更贴合页面结构)
// 缺点:模板冗长 → 可以封装一个 helper
function lazyElement(Component: React.LazyExoticComponent<() => JSX.Element>, fallback: React.ReactNode) {
return (
<Suspense fallback={fallback}>
<Component />
</Suspense>
);
}
三条 Suspense 放置原则:
① 不要把 Suspense 放在「会包含导航栏」的层级
② fallback 要有「确定的高度」(避免 CLS/布局跳动)
③ 骨架结构尽量贴合真实内容(体验更好)
四、数据路由的懒加载
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ index: true, lazy: () => import('./pages/Home') },
{ path: 'settings', lazy: () => import('./pages/Settings') },
{
path: 'users/:id',
lazy: () => import('./pages/UserDetail'),
// ⭐ 可以同时提供 loader / action
},
],
},
]);
// pages/Settings.tsx —— 按约定导出
export function Component() {
const data = useLoaderData() as Settings;
return <div>{data.theme}</div>;
}
export async function loader() {
return fetchSettings();
}
export function ErrorBoundary() { {/* ⭐ 路由级错误边界也能懒加载 */}
return <div>设置页出错了</div>;
}
⚠️ 两种懒加载方式的区别:
`React.lazy`:
· 只懒加载「组件代码」
· 需要自己配 Suspense
· 与路由无关(可用于任何组件)
路由级 `lazy`:
· 同时加载「组件 + loader + action + ErrorBoundary」
· 由 Router 管理加载状态(不用手写 Suspense)
· 只在「数据路由」下可用
· ⚠️ 加载期间显示的是「上层」的 fallback(或框架默认的 HydrateFallback)
// 嵌套路由的懒加载(父布局 + 子页面分别懒加载)
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />, // 同步加载(首屏需要)
children: [
{
path: 'admin',
lazy: () => import('./layouts/AdminLayout'), // ⭐ 懒加载父布局
children: [
{ index: true, lazy: () => import('./pages/admin/Home') },
{ path: 'users', lazy: () => import('./pages/admin/Users') },
],
},
],
},
]);
// ⚠️ 嵌套懒加载会形成「串行请求」:先加载 AdminLayout,再加载子页面
// → 如果父布局只是「薄薄一层」,考虑把它合并进子页面或同步加载
五、预加载(让懒加载无感)
// ① hover/focus 时预取(最实用)
const loadSettings = () => import('./pages/Settings');
const Settings = lazy(loadSettings);
function NavWithPreload() {
return (
<NavLink
to="/settings"
onMouseEnter={loadSettings} // ⭐ 悬停就开始下载
onFocus={loadSettings}
>
设置
</NavLink>
);
}
// ⚠️ 注意:lazy 的「工厂函数」与「预取函数」必须【返回同一个模块】,
// 否则会下载两次(Vite/Webpack 会按模块路径去重,所以只要路径相同就行)
// ② 空闲时预取(首屏加载完成后)
function Layout() {
const location = useLocation();
useEffect(() => {
// 在首页时,空闲预取「用户大概率要去的下一步」
if (location.pathname !== '/') return;
const id = window.requestIdleCallback(() => {
import('./pages/UserDetail');
import('./pages/Settings');
});
return () => window.cancelIdleCallback(id);
}, [location.pathname]);
return <Outlet />;
}
// ③ 数据路由下用 link 的 preload(React Router 的命令式 API)
import { useNavigate, Link } from 'react-router-dom';
// React Router v6.4+ 提供 preload(对 lazy 路由)
// 也可以在 hover 时用「命令式导航的预取」思路,或直接 import()
// ④ 打包工具声明的预取(Webpack)
const Settings = lazy(() => import(/* webpackPrefetch: true */ './pages/Settings'));
// webpackPrefetch:浏览器空闲时预取(低优先级)
// webpackPreload:与主 chunk 并行加载(谨慎,会抢带宽)
六、加载失败的处理
// ⚠️ 常见问题:部署后「旧 chunk 被删」→ 用户停留在旧页面 → 点别的页面报 ChunkLoadError
// ✅ 方案一:ErrorBoundary 兜底 + 重试
function lazyElement(Component: React.ComponentType) {
return (
<ErrorBoundary
fallback={
<div className="load-error">
页面加载失败,请检查网络后重试
<button onClick={() => window.location.reload()}>刷新页面</button>
</div>
}
>
<Suspense fallback={<ContentSkeleton />}>
<Component />
</Suspense>
</ErrorBoundary>
);
}
// ✅ 方案二:自动检测「chunk 加载失败」并刷新一次(对付「发版后旧 chunk 404」)
// Vite
window.addEventListener('vite:preloadError', (event) => {
const KEY = 'chunk-reload-at';
const last = Number(sessionStorage.getItem(KEY) ?? '0');
if (Date.now() - last > 10_000) { // ⭐ 防刷新循环(10 秒内只刷一次)
sessionStorage.setItem(KEY, String(Date.now()));
event.preventDefault();
window.location.reload();
}
});
// Webpack(react-scripts / CRA)
window.addEventListener('error', (e) => {
if (/Loading chunk \d+ failed/.test(e.message)) {
window.location.reload();
}
});
⚠️ 「发版后 chunk 404」是 SPA 的经典问题:
用户加载了旧版本的 index.html(引用旧 chunk 文件名),
部署新版本后旧 chunk 被删除 → 用户点击懒加载页面时请求 404。
解法:
① 部署时「保留上一版本的静态资源」(如 Nginx 里保留旧 hash 文件一段时间)
② 前端「检测到 chunk 加载失败 → 提示刷新或自动刷新一次」
③ 用「Service Worker 做版本管理」
七、常见坑
// ① 忘了 Suspense(React.lazy 报错)
<LazyPage /> // ❌
// ② Suspense 放在最外层(整页闪)
// ③ 命名导出没转换
lazy(() => import('./Page')) // ⚠️ 只有命名导出时出错
lazy(() => import('./Page').then((m) => ({ default: m.Page }))) // ✅
// ④ 数据路由下「混用 React.lazy 与路由 lazy」(重复且容易出问题)
// ⑤ 懒加载「首屏路由」(应该同步加载)
const Home = lazy(() => import('./pages/Home')); // ⚠️ 首屏多一次请求 → 白屏更久
// ✅ 首屏页面同步导入
// ⑥ 嵌套懒加载导致「串行瀑布」(先父后子)
// ⑦ 预取函数与 lazy 工厂函数的模块不一致(重复下载)
// ⑧ fallback 没有高度 → 内容出现时布局跳动(CLS)
// ⑨ 忘了处理「chunk 加载失败」(一直 loading 或直接白屏)
// ⑩ 分割过细(每个小组件都 lazy)→ 请求数暴增、瀑布变多
// ⑪ 用 React.lazy 加载「SSR 页面」(支持有限)
// ⑫ 忘记「区分开发/生产」——开发环境懒加载会让 HMR 与调试变慢
// ⑬ Suspense 的 fallback 用了「会闪烁的 spinner」(体验差)
// ⑭ 在懒加载组件里「重复请求数据」(每次挂载都请求一次,应该用缓存/loader)
// ⑤ 的正确做法
import Home from './pages/Home'; // ⭐ 首屏同步
const Settings = lazy(() => import('./pages/Settings')); // 其它页懒加载
八、面试延伸
- 「路由级懒加载怎么做?」
两种方式:① 声明式路由——用React.lazy(() => import('./pages/Home'))定义组件,并用<Suspense fallback={...}>包住Routes(或者更好:包在「布局的内容区」);② 数据路由——直接在配置里写lazy: () => import('./pages/Home'),模块里按约定导出Component(必需)以及可选的loader/action/ErrorBoundary,由 Router 自动管理加载状态(不用手写Suspense)。 -
「为什么路由级分割收益最大?」
因为:① 它是天然的「页面边界」,每个路由对应一块功能,分割时几乎不会做错(相比「组件级」需要判断交互时机);② 用户一次只访问一个页面——不需要为其它页面付出下载与解析成本;③ 页面之间的依赖差异大(某个页面用图表库、某个页面用富文本编辑器),分割后这些大依赖只在需要时才加载;④ 路由是「必然发生」的导航动作,预取也很自然(hover 时预加载)。 -
「
Suspense应该放在哪里?」
关键是「让 fallback 的影响范围尽量小」:不要放在最外层(包住Routes/RouterProvider)——那样路由切换时整个页面包括导航栏都会变成 fallback(明显的闪烁)。正确做法是放在「布局的内容区」——如Layout组件的<main>里包住<Outlet />,这样导航栏保持稳定、只有内容区显示骨架。另外 fallback 要有确定的高度(避免内容出现时的布局跳动 CLS)。 -
「
React.lazy和路由级lazy有什么区别?」
①React.lazy只懒加载「组件代码」,需要自己配Suspense,可用于任何组件(不限于路由);② 路由级lazy(数据路由)会「同时加载该路由的Component+loader+action+ErrorBoundary」,并且由 Router 管理加载状态(不需要手写Suspense),但只在数据路由下可用。所以数据路由项目里优先用路由级lazy,两者不要混用。 -
「怎么让懒加载『无感』?」
用预加载:① hover/focus 预取(最实用)——把import()提取成一个函数,在NavLink的onMouseEnter/onFocus里调用(注意「预取函数」与「lazy 工厂函数」要指向同一个模块,否则可能下载两次);② 空闲时预取——requestIdleCallback里预取「用户大概率要去的下一步」(如首页预取详情页);③ 打包工具声明(webpackPrefetch: true)。不要全量预取——那就等于取消了分割。 -
「懒加载的 chunk 加载失败怎么办?」
两个层次:① UI 兜底——用<ErrorBoundary><Suspense>包裹(ErrorBoundary 在外),加载失败时显示「加载失败 + 重试」而不是永远 loading;② 自动恢复——SPA 部署后「旧 chunk 被删」会导致ChunkLoadError(经典问题),可以在vite:preloadError/webpack的 chunk 错误里自动刷新一次页面(用sessionStorage记录上次刷新时间,防止刷新循环)。根治办法是「部署时保留上一版本的静态资源」。 -
「懒加载有什么代价?」
三点:① 请求数增加——chunk 多了,网络往返变多(HTTP/2 下影响较小,但仍需注意);② 瀑布式加载——如果「懒加载的 chunk 里又依赖别的 lazy chunk」(如嵌套路由懒加载),会形成「加载完父才知道要加载子」的串行瀑布,反而更慢(解法:把「薄的布局层」合并或同步加载);③ 需要处理加载态——fallback 的设计(高度、骨架形状)、错误兜底、以及「首屏页面不该懒加载」(否则首屏多一次请求、白屏更久)。 -
「哪些页面不该懒加载?」
① 首屏页面(首页 / 登录后的默认页)——它必须最快可见,懒加载会让首屏多一次请求;② 极小的页面(只有几十行 JSX)——分割的收益小于「多一个请求」的成本;③ 页面加载后会立刻用到的公共依赖(如果多个懒加载页面都依赖同一个大库,应该把它提取到「公共 chunk」,否则每个页面 chunk 都会带一份)。判断标准是「这个页面的加载是否发生在『用户已经在等待交互』的关键路径上」。
一句话速记
路由级懒加载是代码分割收益最大的一步(天然的页面边界 + 用户一次只访问一个页面);声明式路由用
React.lazy+Suspense(命名导出要.then(m => ({ default: m.X }))),数据路由用lazy: () => import(...)(模块导出Component,可带loader/ErrorBoundary);Suspense放在「布局的内容区」而不是最外层(否则整页闪),fallback 要有确定高度(避免 CLS);首屏页面不要懒加载;用 hover/空闲预取让懒加载无感(但别全量);懒加载失败要 ErrorBoundary 兜底 + 检测ChunkLoadError自动刷新一次(防刷新循环);嵌套懒加载会造成串行瀑布,薄布局层建议合并或同步。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!