陌上人如玉
公子世无双

5.6 代码分割与懒加载

5.6 代码分割与懒加载

一句话:代码分割(Code Splitting)用「动态 import()」把 bundle 拆成多个 chunk,按路由或按组件的交互时机加载——这是改善首屏加载最有效的手段之一。

一、为什么要代码分割

问题:单 bundle 的代价

① 首屏必须下载「整个应用」的代码(包括「设置页」「富文本编辑器」这些暂时用不到的)
② 主包越大 → 下载、解析、执行时间越长 → 首屏越慢
③ 高 TTI(可交互时间)——JS 执行完才能交互

例子:
  · 一个后台系统 200 个页面 + 富文本编辑器 + 图表库
  · 打包成一个 3MB 的 bundle
  · 用户只是想看「登录页」,却要下载 3MB
代码分割的收益:
① 首屏只加载「当前路由/当前视图」所需代码 → 包更小 → 更快
② 其余代码「按需」加载(用户点进去才下载)
③ 配合预加载(hover 时先下载)→ 既快又不浪费

⚠️ 注意:分割不是「免费的」——
  · chunk 太多会导致「请求数过多」(HTTP/2 下影响较小)
  · 分割不当会导致「懒加载的 chunk 里又引入公共大依赖」

二、React.lazy + Suspense

import { lazy, Suspense } from 'react';

// ① 定义懒加载组件(动态 import)
const HeavyEditor = lazy(() => import('./HeavyEditor'));
// ⚠️ 要求:默认导出(或写成 lazy(() => import('./X').then((m) => ({ default: m.Named }))))

// ② 用 Suspense 包裹(提供加载时的 fallback)
function App() {
  return (
    <Suspense fallback={<Skeleton />}>
      <HeavyEditor />
    </Suspense>
  );
}
// 命名导出的写法
const Editor = lazy(() =>
  import('./Editor').then((m) => ({ default: m.Editor }))
);
// ⚠️ 常见错误:忘了 Suspense → 报错
function App() {
  return <HeavyEditor />;                  // ❌ 报错:A component suspended while responding to synchronous input
  // ✅
  return (
    <Suspense fallback={<Skeleton />}>
      <HeavyEditor />
    </Suspense>
  );
}
关键点 说明
必须动态 import() 静态 import 会被打包进主 chunk
组件要求 默认导出,或转换成 { default: ... }
必须有 Suspense 否则加载期间会报错
SSR React.lazy 在 SSR 中的支持有限(用框架的 dynamic / RSC 代替)
chunk 命名 可加注释 /* webpackChunkName */(Webpack)或用打包工具的自动命名

三、路由级分割(最划算)

// ✅ 路由级分割是「收益最大、成本最低」的分割方式
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Settings = lazy(() => import('./pages/Settings'));

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <Home /> },
      { path: 'about', element: <About /> },
      { path: 'settings', element: <Settings /> },
    ],
  },
]);

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      {/* ⭐ Suspense 放在「布局内、路由外」,这样切换时只有内容区显示骨架 */}
      <RouterProvider router={router} />
    </Suspense>
  );
}
⚠️ Suspense 的放置位置很关键:

❌ 放在最外层(包住整个 App)
   → 路由切换时【整个页面】变成 fallback(导航栏也没了)→ 视觉闪烁

✅ 放在「内容区」
   → 只有内容区显示骨架,导航栏保持不变

✅ 或者用「每个路由元素单独包」+ 外层一个兜底
// ✅ 更好的写法:在布局里包(只影响内容区)
function Layout() {
  return (
    <div className="layout">
      <TopNav />
      <main>
        <Suspense fallback={<ContentSkeleton />}>
          <Outlet />                  {/* ⭐ 只有这里在切换时显示骨架 */}
        </Suspense>
      </main>
    </div>
  );
}
// 数据路由(createBrowserRouter)下可以用懒加载配置
const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, lazy: () => import('./pages/Home') },   // ⭐ 只要导出 Component
      { path: 'settings', lazy: () => import('./pages/Settings') },
    ],
  },
]);
// Home.tsx 里导出:export function Component() { ... }
// 还可以同时导出 loader / action

四、组件级分割(按交互时机)

// ✅ 场景一:重型组件(富文本编辑器、图表、地图),用户不一定打开
const RichEditor = lazy(() => import('./RichEditor'));

function ArticlePage() {
  const [editing, setEditing] = useState(false);

  return (
    <>
      <button onClick={() => setEditing(true)}>编辑</button>
      {editing && (
        <Suspense fallback={<EditorSkeleton />}>
          <RichEditor />
        </Suspense>
      )}
    </>
  );
}
// 好处:用户不点「编辑」就永远不会下载编辑器代码
// ✅ 场景二:弹窗/抽屉(经常含大量代码)
const UserDetailModal = lazy(() => import('./UserDetailModal'));

function UserList() {
  const [openId, setOpenId] = useState<string | null>(null);

  return (
    <>
      <List onSelect={setOpenId} />
      {openId && (
        <Suspense fallback={<ModalSkeleton />}>
          <UserDetailModal userId={openId} onClose={() => setOpenId(null)} />
        </Suspense>
      )}
    </>
  );
}
// ✅ 场景三:条件依赖(如按平台/权限/语言加载不同实现)
const Chart = lazy(() =>
  import(/* @vite-ignore */ `./charts/${platform}.tsx`)
);
// ⚠️ 这种「动态路径」会让打包工具无法静态分析 → 需要显式处理(否则会生成很多 chunk)
分割位置 收益 注意
路由级 最大(天然按页面拆分) Suspense 放在内容区
重型组件(编辑器/图表/地图) 大(可能几百 KB) 按交互时机加载
弹窗/抽屉 中 打开时才加载
低频功能(导出、打印、设置) 中 按钮触发时才加载
小组件(按钮、图标) 小 不值得(增加请求数)

五、预加载策略(让懒加载「无感」)

// ① 路由切换前预加载(hover / focus 时)
const SettingsLazy = lazy(() => import('./pages/Settings'));

// 单独提取「加载函数」,供预取使用
const loadSettings = () => import('./pages/Settings');

function NavLinkWithPreload() {
  return (
    <Link
      to="/settings"
      onMouseEnter={() => loadSettings()}       // ⭐ 悬停时先下载
      onFocus={() => loadSettings()}
    >
      设置
    </Link>
  );
}
// ② 空闲时预加载(首屏加载完成后)
useEffect(() => {
  const id = requestIdleCallback(() => {
    import('./pages/Settings');                 // 空闲时预取
  });
  return () => cancelIdleCallback(id);
}, []);
// ③ 路由级预加载(进入某个路由后预取「可能的下一步」)
function HomePage() {
  useEffect(() => {
    // 用户大概率会点进详情页 → 空闲时预取
    const id = requestIdleCallback(() => import('./pages/Detail'));
    return () => cancelIdleCallback(id);
  }, []);
}
// ④ 用「提前声明」的方式预加载(Webpack 的魔法注释 / Vite 的 preload)
import { lazy } from 'react';

const Home = lazy(() => import(/* webpackPrefetch: true */ './pages/Home'));
// webpackPrefetch:浏览器空闲时预取(优先级最低)
// webpackPreload:与主 chunk 并行加载(谨慎使用)
预加载的三种策略:

① hover/focus 预取(最实用)
   用户「表现出意图」时开始下载 → 点击时通常已下载完 → 无感

② 空闲时预取(requestIdleCallback)
   首屏完成后预取「最可能的下一步」

③ 依赖声明式预取(打包工具注释)
   交给打包工具与浏览器调度

⚠️ 不要「全部预取」——那等于回到了「一个大 bundle」的问题。

六、分析产物(先看再分)

# Vite / Rollup
npm i -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({ open: true, gzipSize: true, brotliSize: true }),
  ],
});
看产物的三个目标:
① 找出「最大的几个 chunk」——它们是否是首屏必需的?
② 找出「被多个 chunk 引用的公共依赖」——是否被重复打包?
③ 找出「不该在首屏的大依赖」——如 moment、lodash 全量、图表库、富文本编辑器
   → 按需引入 / 换更轻的库 / 懒加载
// 常见的「大依赖」优化
// ❌ 全量引入
import _ from 'lodash';                     // ~70KB
import moment from 'moment';                // ~70KB + locale

// ✅ 按需引入
import debounce from 'lodash/debounce';
// ✅ 或换更轻的替代
import { format } from 'date-fns';          // 支持 tree-shaking
// ✅ 或用平台能力
new Intl.DateTimeFormat('zh-CN').format(date);

七、常见坑

// ① 忘了 Suspense → 报错
<LazyComp />                                // ❌

// ② Suspense 放在最外层 → 路由切换时整页闪骨架
<Suspense fallback={<FullPageLoading />}>
  <RouterProvider router={router} />         {/* ⚠️ 导航栏也没了 */}
</Suspense>

// ③ 动态 import 的组件用「命名导出」却没转换
lazy(() => import('./X'));                   // ⚠️ X 只有命名导出时会报错
lazy(() => import('./X').then((m) => ({ default: m.X })));   // ✅

// ④ 分割过细 → 请求数过多、每个 chunk 都带一份公共代码
// ⑤ 在同一路由内的多个组件各自 lazy → 多次瀑布式加载
//    ✅ 应该「合并成一个懒加载入口」或让它们并行
// ⑥ 懒加载组件「在首屏可见区域」→ 反而拖慢(应该预加载)
// ⑦ 忘了给懒加载的 fallback 一个「合理的高度」(导致布局抖动 CLS)
<Suspense fallback={null}>                   // ⚠️ 内容出现时页面会跳
<Suspense fallback={<Skeleton height={400} />}>   // ✅

// ⑧ 用 lazy 加载「很小的组件」(不值得)
// ⑨ 没有分析产物就盲目分割(可能分了个「本来就不大」的模块)
// ⑩ 预加载「所有路由」→ 等于没分割
// ⑪ SSR 中用 React.lazy(支持有限)→ 用框架的 dynamic/RSC
// ⑫ 懒加载 chunk 报错时没有兜底(网络失败 → 一直 loading)
//    ✅ 用 ErrorBoundary + 重试按钮
// ⑦ 与 ⑫ 的完整示例
function LazySection() {
  return (
    <ErrorBoundary
      fallback={
        <div className="error-state">
          内容加载失败
          <button onClick={() => window.location.reload()}>重试</button>
        </div>
      }
    >
      <Suspense fallback={<Skeleton height={400} />}>
        <HeavyComponent />
      </Suspense>
    </ErrorBoundary>
  );
}
// ⭐ ErrorBoundary 在外、Suspense 在内
//    → chunk 加载失败时显示错误兜底(而不是永远 loading)

八、面试延伸

  1. 「什么是代码分割?它解决什么问题?」
    代码分割(Code Splitting)是把「一个大的 bundle」拆成多个 chunk,按需(路由切换、交互触发)加载的技术。它解决的是「首屏加载慢」的问题——用户不需要为「暂时不访问的页面」或「不打开的编辑器」付出下载、解析与执行的代价。实现方式是「动态 import()」,在 React 里配合 React.lazy + Suspense。

  2. 「React.lazy 怎么用?有什么限制?」
    用法:const Comp = lazy(() => import('./Comp')),然后用 <Suspense fallback={...}> 包住它。四个限制:① 必须用动态 import()(静态导入会被打进主 chunk);② 组件必须是默认导出(命名导出需要 .then(m => ({ default: m.X })) 转换);③ 必须有 Suspense,否则加载期间会报错;④ 在 SSR 中支持有限(应使用框架提供的 dynamic / RSC 方案)。

  3. 「哪些地方适合做代码分割?」
    按收益排序:① 路由级(收益最大、最自然,每个页面一个 chunk);② 重型组件(富文本编辑器、图表库、地图、PDF 预览,可能几百 KB);③ 弹窗/抽屉(含表单或详情页代码);④ 低频功能(导出、打印、设置页)。不适合:小组件(按钮、图标)——分割反而增加请求数,收益为负。

  4. 「Suspense 应该放在哪里?」
    关键是「让 fallback 的影响范围尽量小」。不要放在最外层(包住整个 App / RouterProvider)——那样路由切换时整个页面(包括导航栏)都会变成 fallback,产生明显的视觉闪烁。正确做法是放在「内容区域」(如布局组件的 <main> 里包住 <Outlet />),这样只有内容区显示骨架,导航栏保持稳定。如果要更细的体验,可以「每个路由元素单独包 + 外层一个兜底」。

  5. 「怎么让懒加载『无感』?」
    用预加载:① hover/focus 预取(最实用)——用户把鼠标移到链接上就开始下载,点击时通常已经下载完;② 空闲时预取——requestIdleCallback 里预取「最可能的下一步」(如首页预取详情页);③ 打包工具声明的预取(webpackPrefetch 会在浏览器空闲时预取)。注意不要「全部预取」——那等于把分割的收益抵消了。

  6. 「代码分割有什么代价?」
    三个:① 请求数增加——chunk 太多会导致网络请求变多(HTTP/2 下影响较小,但仍有开销);② 瀑布式加载——如果懒加载的 chunk 里又依赖别的 lazy chunk,会形成「加载完了才知道要再加载」的串行瀑布,反而更慢;③ 加载状态与错误处理——需要 fallback UI(注意设定高度避免 CLS),还要处理「chunk 加载失败」(用 ErrorBoundary + 重试)。所以不是「分得越细越好」。

  7. 「怎么决定分割哪些模块?」
    先分析产物(rollup-plugin-visualizer / webpack-bundle-analyzer),然后按三条标准决策:① 体积大的(超过 50KB 的优先考虑);② 首屏不需要的(如设置页、编辑器、图表);③ 被多个 chunk 共享的(提取到公共 chunk,避免重复打包)。同时要检查「大依赖的按需引入」——lodash 全量、moment、图标库全量这类是常见的「隐形大头」。

  8. 「懒加载的 chunk 加载失败怎么办?」
    三个层次:① UI 兜底——用 ErrorBoundary 包住 Suspense(注意顺序:<ErrorBoundary><Suspense>),加载失败时显示「加载失败 + 重试」而不是一直 loading;② 重试机制——React.lazy 的 import() 失败后可以「重新 import()」(某些打包工具会缓存失败结果,可以加时间戳参数强制重试);③ 版本更新导致的 404——用户停留在旧版本页面、部署后旧 chunk 被删除会拿到 404,常见做法是「提示刷新」或「自动 reload」(配合 vite:preloadError / webpack 的 chunkLoadError 监听)。

一句话速记

代码分割 = 用动态 import() 把 bundle 按需拆分,解决首屏加载;在 React 里用 React.lazy + Suspense(必须默认导出、必须有 Suspense,SSR 支持有限);优先按路由分(收益最大),再按「重型组件/弹窗/低频功能」分;Suspense 要放在「内容区」而不是最外层(否则整页闪骨架),fallback 要有确定高度避免 CLS;用 hover/focus 或 requestIdleCallback 预加载让懒加载无感(但别全量预取);先用 rollup-plugin-visualizer 分析产物再决定分割点;懒加载要配 ErrorBoundary 兜底(chunk 加载失败不能一直 loading)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.6 代码分割与懒加载

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫