陌上人如玉
公子世无双

6.8 路由懒加载

6.8 路由懒加载

一句话:路由级懒加载是「代码分割收益最大的一步」——每个页面单独打包,用户只下载当前页面的代码;声明式路由用 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'));   // 其它页懒加载

八、面试延伸

  1. 「路由级懒加载怎么做?」
    两种方式:① 声明式路由——用 React.lazy(() => import('./pages/Home')) 定义组件,并用 <Suspense fallback={...}> 包住 Routes(或者更好:包在「布局的内容区」);② 数据路由——直接在配置里写 lazy: () => import('./pages/Home'),模块里按约定导出 Component(必需)以及可选的 loader/action/ErrorBoundary,由 Router 自动管理加载状态(不用手写 Suspense)。

  2. 「为什么路由级分割收益最大?」
    因为:① 它是天然的「页面边界」,每个路由对应一块功能,分割时几乎不会做错(相比「组件级」需要判断交互时机);② 用户一次只访问一个页面——不需要为其它页面付出下载与解析成本;③ 页面之间的依赖差异大(某个页面用图表库、某个页面用富文本编辑器),分割后这些大依赖只在需要时才加载;④ 路由是「必然发生」的导航动作,预取也很自然(hover 时预加载)。

  3. 「Suspense 应该放在哪里?」
    关键是「让 fallback 的影响范围尽量小」:不要放在最外层(包住 Routes/RouterProvider)——那样路由切换时整个页面包括导航栏都会变成 fallback(明显的闪烁)。正确做法是放在「布局的内容区」——如 Layout 组件的 <main> 里包住 <Outlet />,这样导航栏保持稳定、只有内容区显示骨架。另外 fallback 要有确定的高度(避免内容出现时的布局跳动 CLS)。

  4. 「React.lazy 和路由级 lazy 有什么区别?」
    ① React.lazy 只懒加载「组件代码」,需要自己配 Suspense,可用于任何组件(不限于路由);② 路由级 lazy(数据路由)会「同时加载该路由的 Component + loader + action + ErrorBoundary」,并且由 Router 管理加载状态(不需要手写 Suspense),但只在数据路由下可用。所以数据路由项目里优先用路由级 lazy,两者不要混用。

  5. 「怎么让懒加载『无感』?」
    用预加载:① hover/focus 预取(最实用)——把 import() 提取成一个函数,在 NavLink 的 onMouseEnter/onFocus 里调用(注意「预取函数」与「lazy 工厂函数」要指向同一个模块,否则可能下载两次);② 空闲时预取——requestIdleCallback 里预取「用户大概率要去的下一步」(如首页预取详情页);③ 打包工具声明(webpackPrefetch: true)。不要全量预取——那就等于取消了分割。

  6. 「懒加载的 chunk 加载失败怎么办?」
    两个层次:① UI 兜底——用 <ErrorBoundary><Suspense> 包裹(ErrorBoundary 在外),加载失败时显示「加载失败 + 重试」而不是永远 loading;② 自动恢复——SPA 部署后「旧 chunk 被删」会导致 ChunkLoadError(经典问题),可以在 vite:preloadError / webpack 的 chunk 错误里自动刷新一次页面(用 sessionStorage 记录上次刷新时间,防止刷新循环)。根治办法是「部署时保留上一版本的静态资源」。

  7. 「懒加载有什么代价?」
    三点:① 请求数增加——chunk 多了,网络往返变多(HTTP/2 下影响较小,但仍需注意);② 瀑布式加载——如果「懒加载的 chunk 里又依赖别的 lazy chunk」(如嵌套路由懒加载),会形成「加载完父才知道要加载子」的串行瀑布,反而更慢(解法:把「薄的布局层」合并或同步加载);③ 需要处理加载态——fallback 的设计(高度、骨架形状)、错误兜底、以及「首屏页面不该懒加载」(否则首屏多一次请求、白屏更久)。

  8. 「哪些页面不该懒加载?」
    ① 首屏页面(首页 / 登录后的默认页)——它必须最快可见,懒加载会让首屏多一次请求;② 极小的页面(只有几十行 JSX)——分割的收益小于「多一个请求」的成本;③ 页面加载后会立刻用到的公共依赖(如果多个懒加载页面都依赖同一个大库,应该把它提取到「公共 chunk」,否则每个页面 chunk 都会带一份)。判断标准是「这个页面的加载是否发生在『用户已经在等待交互』的关键路径上」。

一句话速记

路由级懒加载是代码分割收益最大的一步(天然的页面边界 + 用户一次只访问一个页面);声明式路由用 React.lazy + Suspense(命名导出要 .then(m => ({ default: m.X }))),数据路由用 lazy: () => import(...)(模块导出 Component,可带 loader/ErrorBoundary);Suspense 放在「布局的内容区」而不是最外层(否则整页闪),fallback 要有确定高度(避免 CLS);首屏页面不要懒加载;用 hover/空闲预取让懒加载无感(但别全量);懒加载失败要 ErrorBoundary 兜底 + 检测 ChunkLoadError 自动刷新一次(防刷新循环);嵌套懒加载会造成串行瀑布,薄布局层建议合并或同步。

赞(0) 打赏
未经允许不得转载:陌上寒 » 6.8 路由懒加载

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫