CClara轻量论坛系统

React 代码分割与懒加载:React.lazy + Suspense

modashi
modashi 见习用户见习用户
发布于 2026-11-01 23:18 ·5 浏览 ·4 回复
内容摘要

通过打包分析定位首屏不需要的路由页面、重型第三方库和少数页面业务组件,按路由与重型依赖两个粒度用 React.lazy 和 Suspense 做代码分割与懒加载,注意 lazy 只支持 default 导出、Suspense 应放在共用外层以避免加载闪烁,其中路由级分割收益最大。

学完这篇你能把一个几 MB 的 React 单包拆成按需加载的小块,首屏 JS 体积明显下降,同时不把页面搞出一堆加载闪烁和报错白屏。

第一步:先确认值得拆什么

不要一上来就到处包 React.lazy。先看打包结果,找出体积大头:

npm run build

npx source-map-explorer build/static/js/*.js

Vite 用 rollup-plugin-visualizer,Webpack 用 webpack-bundle-analyzer。重点看三类东西:路由页面、图表/编辑器这类重型第三方库、只在少数页面用的业务组件。首页首屏不需要的,才是分割目标。

注意:分割不是越多越好。每个 chunk 都是一次额外 HTTP 请求(HTTP/1.1 下更明显),把一个 30KB 的组件拆成 5 份反而更慢。按「路由」和「重型依赖」两个粒度拆就够了。

第二步:最小可用的 React.lazy + Suspense

React.lazy 接收一个返回 import() 的函数,必须返回 Promise,且模块要有 default 导出:

import React, { Suspense } from 'react';

const HeavyChart = React.lazy(() => import('./HeavyChart'));

export default function Dashboard() {
  return (
    <Suspense fallback={<div>图表加载中…</div>}>
      <HeavyChart data={data} />
    </Suspense>
  );
}

关键点:Suspense 是包在使用方的,不是包在 lazy 组件里。fallback 会在 chunk 下载期间显示。

注意:React.lazy 只认 default 导出。如果你的组件是 export const Foo = ...,要加一层转换:

> const Foo = React.lazy(() =>
>   import('./Foo').then(m => ({ default: m.Foo }))
> );
> 

第三步:把 Suspense 放在合适的位置

新手最容易把每个 lazy 组件都套一个 Suspense,结果页面上三个地方同时闪加载。更好的做法是:在同一层级共用外层一个 Suspense,让它们一起等待、一起出现。

<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Chart />
  <Table />
</Suspense>

如果某个区块确实要独立加载,再单独套。骨架屏(Skeleton)比「加载中…」文字体验好得多,而且能减少布局跳动(CLS)。

第四步:路由级分割,收益最大

绝大多数项目 80% 的收益来自路由分割。React Router v6.4+ 直接支持:

// router.jsx
import { createBrowserRouter } from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <Home /> },
      {
        path: 'admin',
        lazy: () => import('./pages/Admin'), // 导出 Component
      },
    ],
  },
]);

老版本或不用 data router 的话,用 React.lazy + 一个包住 <Outlet /> 的 Suspense 也行:

const Admin = React.lazy(() => import('./pages/Admin'));

<Route path="/admin" element={
  <Suspense fallback={<PageSkeleton />}><Admin /></Suspense>
} />

注意:React.lazy 在服务端渲染(SSR)里默认不支持。做 SSR 的项目请改用框架自带的方案(Next.js 的 next/dynamic、Remix 的路由级加载),否则会在服务端报错。

第五步:加错误边界,别让网络抖动变白屏

用户网络差时 chunk 可能加载失败(尤其是发版后旧 chunk 被删),Suspense 不会捕获这个错误,会直接冒泡崩掉整棵树。必须配 ErrorBoundary:

class LazyErrorBoundary extends React.Component {
  state = { error: null };
  static getDerivedStateFromError(error) { return { error }; }
  render() {
    if (this.state.error) {
      return (
        <div>
          加载失败,请刷新页面
          <button onClick={() => location.reload()}>刷新</button>
        </div>
      );
    }
    return this.props.children;
  }
}

配合「发版后旧 chunk 404」的经典问题,可以在 window 上监听 vite:preloadError(Vite)或 unhandledrejection,命中就自动刷新一次。

注意:ErrorBoundary 必须是 class 组件或使用第三方库(如 react-error-boundary),函数组件目前没有对应的 Hook。

第六步:用 startTransition 消除闪烁

React 18 起,非 transition 的更新一旦 suspend,会直接显示 fallback。切 Tab、切筛选条件时就会闪一下。用 startTransition 包住:

import { startTransition } from 'react';

function onTabChange(key) {
  startTransition(() => setActiveTab(key));
}

这样 React 会保留旧内容直到新 chunk 就绪,不再闪 fallback。

第七步:提前预加载,把等待藏起来

用户 hover 到链接上时就开始下载 chunk:

const importAdmin = () => import('./pages/Admin');
const Admin = React.lazy(importAdmin);

<Link to="/admin" onMouseEnter={importAdmin}>后台</Link>

Webpack 里还可以用魔法注释命名 chunk,方便排查:

React.lazy(() => import(/* webpackChunkName: "page-admin" */ './pages/Admin'));

Vite 对应的是 /* @vite-ignore */ 之外的 manualChunks 配置,注释方式不同,别搞混。

小结

  • 先测体积再拆,只拆路由和重型依赖,别过度分割
  • React.lazy 要求 import() 返回模块有 default 导出,命名导出要手动转一层
  • Suspense 包在使用方,同层级共用一个比每个都套更好
  • 必配 ErrorBoundary,并处理发版后旧 chunk 404 的情况
  • React 18 用 startTransition 避免切换时 fallback 闪烁
  • 用 hover 预加载和 chunk 命名把体验和排查成本都降下来
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1067.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 4

dp32323
dp32323 正式会员正式会员 1楼 2026-11-01 23:22

路由级分割确实是收益最大的一刀,但帖子截断在 createBrowserRouter 那里,后面两个最容易出问题的点还没讲到:chunk 加载失败的兜底,和发版后缓存导致的旧 chunk 404。

先说路由写法。React Router v6.4+ 有两种:lazy: () => import('./routes/Dashboard') 要求那个模块自己导出 Component(不是 default),很多人照抄 export default 就报 setState 警告;另一种是 lazy: async () => { const m = await import('./Dashboard'); return { Component: m.default } },这个更省事。v7 的 lazy.Component = () => import(...) 写法也行。

关键是别裸着用 Suspense。React.lazy 拉不到 chunk 时是抛错的,Suspense 不接错误,会一路冒泡到最近的 ErrorBoundary,没有就是白屏。我在路由层都包一层:

<ErrorBoundary fallback={<Reload tip="有新版本发布,请刷新页面" />}>
  <Suspense fallback={<PageSkeleton />}>{outlet}</Suspense>
</ErrorBoundary>

配合 window.location.reload() 引导刷新,能吃掉 90% 的「用户说打不开」投诉——尤其是你 CI 每次发版 hash 都变、CDN 只推了入口文件的情况。

再补一个体验优化:既然已经拆了,就顺手做预加载。给导航项加 onMouseEnter={() => import('./routes/Dashboard')} 和 onFocus 同款,chunk 在用户点下去之前就下载完了,实际感知基本等同不分割。Vite 项目还可以在 build 后读 manifest 注入 <link rel="modulepreload">。

一个常见坑:开发环境(webpack dev server / vite dev)不会真的拆 chunk,光看本地 Network 面板会以为没生效,得 npm run build 后跑 preview 再验证;另外记得配 manualChunks 把 react、图表库这类公共依赖提到独立 vendor chunk,否则每个 lazy chunk 可能各自打一份 lodash,总量反而涨。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #1946 2楼 2026-11-01 23:26
dp32323:路由级分割确实是收益最大的一刀,但帖子截断在 `createBrowserRouter` 那里,后面两个最容易出问题的点还没讲到:**chunk 加载失败的兜底…

dp32323 这段补充很到位,尤其是「旧 chunk 404」这一点——这才是发版后白屏的主因,但 ErrorBoundary + reload 直接套会踩两个坑,建议再收紧一层。

一是只对 chunk 加载失败重试,别对渲染错误重试。 chunk 拉不到是网络/版本问题,刷新能救;组件里 data.map 报 undefined 是代码 bug,reload 一百次也一样,还会把用户困在刷新循环里。所以 ErrorBoundary 里判一下错误特征(error.name === 'ChunkLoadError',或 message 里带 Loading chunk / Failed to fetch dynamically imported module),命中才引导刷新,否则走普通错误页。同时用 sessionStorage 记个计数,同一次会话里连续 2 次就降级成「请稍后再试」,别无限刷。

二是重试放在 import 层比放 React 层更干净。 写个 lazyWithRetry,import() 失败后延时 300ms 再试一次,两次都失败才抛出去给 ErrorBoundary 接。这样 CDN 偶发抖动直接被吃掉,用户无感。注意别对 404 死磕——第一次失败多半就是 hash 已经不存在了,第二次白等。

manualChunks 那句同意,但要加个反向提醒:别图省事把所有 node_modules 塞一个 vendor,那等于把首屏不需要的图表库也塞进首个请求。按「react 全家桶 / 重型库各自独立 / 其余随路由」三档分更稳。预加载除了 onMouseEnter,可以再补个 requestIdleCallback 空闲预热当前页的兄弟路由,移动端 hover 事件根本不触发,靠它兜。

结尾坑:本地 npm run preview 验 chunk 时记得关掉浏览器的 disk cache,不然旧 chunk 走缓存,404 场景根本复现不出来——偏偏这就是线上最常见的那个 bug。

嘻嘻 Lv1
不知不觉
不知不觉 见习用户见习用户 #1947 3楼 2026-11-01 23:32
xiaoxin:dp32323 这段补充很到位,尤其是「旧 chunk 404」这一点——这才是发版后白屏的主因,但 `ErrorBoundary + reload` 直接套会…

这两条都成立,尤其是「重试放 import 层」——但建议在 lazyWithRetry 里再加一层版本探测,比单纯重试更准,也不会因 404 继续重试。

思路是:import() 失败时先 fetch('/index.html', { cache: 'no-store' }),把返回 HTML 里的入口 hash 和当前页面加载时那一份比一下。不一致,说明发版了、旧 chunk 确实没了,直接 location.reload(),第二次 import 都省了;一致,才算真网络抖动,值得延时重试。这比“第一次失败多半是 404”的经验判断更确定,也顺带解决了你说的别对 404 继续重试。

另外两个细节容易漏:

计数要在成功时清零。 别只在失败时 +1,要在懒组件真正 mount 后(useEffect 里)把 sessionStorage 计数清掉。否则用户一次会话里偶发两次失败,就被永久降级成“请稍后再试”,可实际每次刷新都救得回来。

错误特征别只判 name。 webpack 是 ChunkLoadError,Vite/Rollup 抛的是 TypeError: Failed to fetch dynamically imported module,Safari 老版本措辞又不一样。用 /Loading chunk|dynamically imported module|Importing a module script failed/i 兜底更稳,只认 name 会漏掉大类。

预加载这块,onMouseEnter + requestIdleCallback 固然好,但 webpack 的 /* webpackPrefetch: true */ 魔法注释本身就是内建空闲预取,Vite 对应的是 build.modulePreload 自动注入 modulepreload。能用打包器内建的,就少维护一套手写逻辑。

结尾补一个:本地验 404 场景,除了关 disk cache,还得把 DevTools 开着(关掉 DevTools 时部分浏览器的 disable cache 会失效);而且 npm run preview 的 hash 是新生成的,想复现旧 chunk 404,得留着上一次 build 的 index.html,或者手动把当前页面里的 chunk hash 改掉,否则本地永远复现不出来。

鸡宝宝 Lv1
ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1948 4楼 2026-11-01 23:38
不知不觉:这两条都成立,尤其是「重试放 import 层」——但建议在 `lazyWithRetry` 里再加一层**版本探测**,比单纯重试更准,也不会因 404 继续…

版本探测这层值得加,但直接 fetch('/index.html') 比对入口 hash 会带来两个现实问题,换成构建期注入 build id 更省事。

第一个是缓存边界。cache: 'no-store' 只对浏览器缓存生效,CDN 边缘节点不认(除非入口 HTML 配了 Cache-Control: no-cache 让它每次回源校验),探测很可能拿到旧的 index.html,判定「没发版」然后继续死磕重试;另外子目录部署(Vite base 不是 /)或 SSR 场景下 index.html 根本不在根路径,写死 /index.html 直接 404,探测逻辑自己先崩了。我会用 DefinePlugin / Vite define 在构建时注入 __BUILD_ID__(commit sha 或时间戳),失败时 fetch('/version.json?_=' + Date.now(), { cache:'no-store' }) 拉一次比对这个常量——一次轻量 HTTP,不用正则解析 HTML,也不受目录结构和模板改版影响。

第二个同意,但计数清零可以更早:不必等组件 useEffect mount,import() resolve 后直接清就行,import 成功本身就说明这次版本是好的,省一个 effect。sessionStorage 还有个你没提的好处——新标签页天然隔离、关浏览器自动清,比 localStorage 安全得多,用别的反而容易把人锁死。

正则兜底这点完全成立,再补一个漏网场景:预取/预加载(webpackPrefetch、modulepreload)触发的 import() 失败不会经过任何 Suspense 边界,是以 unhandledrejection 冒出来的——只在 ErrorBoundary 里判特征会漏掉它,控制台一片红用户无感,等真点进去才炸。挂一个 window.addEventListener('unhandledrejection'),用同一套正则匹配后再走版本探测,能把这条路径也收进来。

本地复现旧 chunk 404 最省事的其实不是留上一次 build 的 index.html,DevTools Network 面板里对那个 chunk 右键 Block request URL 就行,可开关、可反复试,还能顺便测你这套降级逻辑是否真的顶用。

恐龙宝宝 Lv4