React 代码分割与懒加载
全打包成一个 JS,首屏加载几 MB?代码分割按路由/组件拆成小 chunk,用到才加载。本文讲清 React.lazy + Suspense 的用法、Next.js 自动分割、路由级 vs 组件级分割、预加载策略,以及分割的度(太多 chunk 反而慢)。
本文要点
- 主题聚焦:Next.js、React
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
你打开 DevTools 的 Network 面板,刷新一下首屏,看到一个 index-abc123.js,2.4MB。再细看,这个文件里塞着首页、设置页、那个只有点开才用得到的富文本编辑器、整套图表库、还有三四个你叫不出名字的依赖。用户打开你的首页,浏览器得先把这 2.4MB 全下下来,再解析、编译、执行一遍,然后才渲染。这中间用户看到的是白屏,或者一个转圈的骨架。
这就是"不分割"的代价。问题不只在下载慢——更大的问题是 JS 是单线程的,浏览器解析和执行这坨 bundle 的时间,往往比下载还长,在低端机上尤其明显。代码分割(code splitting)解决的就是这件事:把一个大 bundle 拆成按需加载的小 chunk,首屏只加载首屏真正需要的代码,其余的等用到再加载。本文讲清楚分割的标准动作、Next.js 16 的自动分割、预加载的真实行为,以及一个常被忽略的事实——chunk 不是拆得越多越好。
一、先搞清楚"bundle 大"到底贵在哪:不只是下载,更是解析与执行
很多人对"为什么 bundle 大就慢"的理解停留在"下载慢"。但一个 2MB 的 JS 在光纤下也就几百毫秒下完,真正卡的是后面三步:
- 解析(parse):浏览器把文本解析成 AST;
- 编译(compile):JIT 编译成可执行字节码;
- 执行(execute):跑一遍顶层语句、注册组件。
这三步都在主线程上,执行期间页面是完全卡死的——输入没响应、动画掉帧。所以"首屏 JS 体积"这个指标,本质衡量的是主线程被占用多久。这正是分割的核心动机:砍掉首屏用不到的代码,就是在直接缩短主线程被占住的时间。一个朴素的目标:首屏 JS 体积控制在 200KB 以内(理想),最大的单个 chunk 别超过 500KB。
二、React.lazy + Suspense:Client Component 按需加载的标准动作
代码分割的底层机制就一个:动态 import()。静态 import 会被打包进同一个 bundle,而 import() 返回一个 Promise,打包工具(webpack/Turbopack)会把它单独切成一个 chunk,运行时按需拉取。React 提供的标准化封装就是 React.lazy + Suspense:
import { lazy, Suspense } from "react";
// 动态 import → 这个组件被切成独立 chunk,用到才加载
const HeavyEditor = lazy(() => import("./HeavyEditor"));
export default function Page() {
const [editing, setEditing] = useState(false);
return (
<div>
<button onClick={() => setEditing(true)}>写文章</button>
{/* 没点之前,编辑器那坨代码根本不会下载 */}
{editing && (
<Suspense fallback={<div>编辑器加载中…</div>}>
<HeavyEditor />
</Suspense>
)}
</div>
);
}
几个关键点:lazy 接收的函数里必须是静态字符串路径(() => import('./HeavyEditor')),不能用变量或模板字符串拼,否则打包工具分析不出切点。Suspense 的 fallback 是 chunk 加载期间显示的占位,给个骨架比白屏强得多。这个模式适合条件渲染的重型 Client Component——弹窗里的编辑器、点击展开的图表、登录后才出现的看板。它解决的是"组件级"的按需加载。
三、Next.js App Router:你什么都没做,它已经帮你分割好了
如果是 Next.js App Router,事情更省心。App Router 的每个 page.tsx / layout.tsx 天然就是按路由分割的——每个路由是一个独立 chunk,访问 /dashboard 不会加载 /settings 的代码。更关键的是:Server Component 默认就是代码分割的,服务端渲染完把 HTML 流式(streaming)发给浏览器,客户端不需要为它下载额外的组件 JS。
所以一个常见的认知错位:很多从纯 SPA(CRA/Vite)过来的同学,到了 Next.js 还在到处手写 React.lazy,其实路由级的分割框架已经替你做了。你需要手动 lazy 的,只剩那些「重型 Client Component」——带 'use client'、体积大、又不是首屏必需的。在 Next.js 里更地道的写法是 next/dynamic,它是 React.lazy + Suspense 的封装,还多了一个 ssr: false 选项:
"use client"; // 注意:ssr: false 只能在 Client Component 里用
import dynamic from "next/dynamic";
// 只在客户端渲染(依赖 window 之类),且懒加载
const Map = dynamic(() => import("./Map"), {
ssr: false,
loading: () => <div>地图加载中…</div>,
});
注意一个 Next.js 16 的硬限制:ssr: false 只能在 Client Component 里用,放进 Server Component 会直接报错。需要的话,在外面包一层 'use client' 的壳。
四、分割的三种粒度:路由级、组件级、第三方库
实战中分割就三个层次,从粗到细:
1. 路由级(最常用,收益最大)。 每个路由一个 chunk,首屏只加载当前路由的代码。Next.js App Router 默认就做了;纯 SPA 里则靠路由级 lazy 包裹路由组件,比如 const Home = lazy(() => import('./pages/Home'))。这一层几乎无脑做,收益最大、副作用最小。
2. 组件级(针对重型组件)。 编辑器(monaco、tiptap)、图表(recharts、echarts)、地图、Markdown 渲染器——这类单个组件就几百 KB,但只在特定交互下才出现,用 lazy / dynamic 包成独立 chunk,只在用的时候拉。
3. 第三方库(大依赖单独切)。 像编辑器、图表这类大依赖,可以直接在事件回调里动态 import 整个库,只在用户真的触发时才下载:
"use client";
export default function Search() {
return (
<input
onChange={async (e) => {
// 用户真的开始搜了,才下载 fuse.js
const Fuse = (await import("fuse.js")).default;
const fuse = new Fuse(list);
setResults(fuse.search(e.target.value));
}}
/>
);
}
这一层的判断标准是:这个依赖是不是首屏必需? 不是就懒加载。另外有个 Next.js 配置值得常备——optimizePackageImports,专治"图标库 / 工具库导出几百个东西"的场景(比如 lucide、antd 这类),它会让 import { X } from 'big-lib' 只打包你真正用到的那个,不用手动按路径引入:
// next.config.js
const nextConfig = {
experimental: {
optimizePackageImports: ["lucide-react", "antd"],
},
};
五、预加载:Next.js 默认是"进视口就预取",不是"hover 才预取"
这是个被广泛讲错的点(也是从老版本 Next.js 沿用下来的误解)。在 Next.js 16 的生产环境里,<Link> 的默认预取行为是:链接一进入视口就预取,而不是等用户 hover。它的调度器还会按优先级排队——视口里的链接、有交互意图(hover/touch)的链接优先,滚出视口的会被丢弃。这样设计的目的是:等你点击的时候,目标路由的 chunk 早就躺在缓存里了,跳转几乎是瞬时的。
几个需要记牢的事实:
- 预取只在 production 生效,dev 模式下不预取,别在本地测。
- 默认值是
prefetch不写(等价于"auto"):静态路由预取整页,动态路由预取到最近的loading.js边界。 prefetch={false}:完全关闭视口预取(但点击时仍会按需加载)。长列表(无限滚动表格)里几十个 Link 一起进视口,就该关掉预取,否则白白下载一堆用户根本不会点的页面。
// 列表项这种密集场景:关掉预取,省流量
<Link href={`/blog/${post.id}`} prefetch={false}>
{post.title}
</Link>
如果你只想要 hover 预取(折中方案:既省流量又保留即时跳转的体验),Next.js 给了官方写法——默认关掉,鼠标移上去再开:
"use client";
import Link from "next/link";
import { useState } from "react";
export function HoverPrefetchLink({ href, children }) {
const [active, setActive] = useState(false);
return (
<Link
href={href}
prefetch={active ? null : false} // hover 后才启用默认预取
onMouseEnter={() => setActive(true)}
>
{children}
</Link>
);
}
需要更精细的控制(埋点、滚动、预测用户意图),用 useRouter().prefetch('/path') 手动预热,比如在用户停留某区域时预取它很可能去的下一个页面。
六、分割的度:chunk 不是拆得越多越好
到这里你可能会想:那我把每个组件、每个函数都切成单独 chunk 岂不是最极致?错。 分割是有代价的:
- 每个 chunk 都有 HTTP 请求开销(连接、TLS、请求头),HTTP/2 虽能多路复用,但请求调度、解压、执行的串行成本还在。
- chunk 之间有依赖关系,加载一个 chunk 可能要先加载它的依赖,形成 waterfall——A 等 B,B 等 C,串起来的延迟比一个稍大的 bundle 还高。
- 太碎的 chunk 反而难命中缓存:随便改一行代码,hash 一变,缓存全失效。
经验上:路由级 + 少数几个重型组件级 chunk,通常就够了。一个页面首屏加载 3-5 个 chunk 是健康的,几十个就过头了。Next.js / webpack 的默认 splitChunks 策略已经做了不错的平衡(把 node_modules 抽成 vendor chunk、共享代码提成 common chunk),多数项目不需要手动调 splitChunks 配置——你更需要做的是找出"意外塞进来"的大依赖,而不是追求更多 chunk。
七、用分析器找最大的那块:next experimental-analyze / @next/bundle-analyzer
不测量就优化是耍流氓。分割完得看 bundle 到底长什么样。Next.js 16 起内置了基于 Turbopack 的 Bundle Analyzer(experimental),直接出交互式模块图,还能看完整 import 链——一个包是被谁、从哪里引进来的,一目了然:
# 交互式分析(浏览器里看 treemap)
pnpm next experimental-analyze
# 把结果写到磁盘,方便前后对比 / 团队共享
pnpm next experimental-analyze --output
# 产物在 .next/diagnostics/analyze
如果是 webpack 项目,用 @next/bundle-analyzer:
// next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({ /* 你的配置 */ });
ANALYZE=true pnpm build # 跑完会开几个浏览器 tab 展示 client/server bundle
除此之外,别忽略 next build 命令本身输出的大小表——它会列出每条路由的 First Load JS,这才是用户访问该路由时真正下载的量。盯住这个数字,比盯运行时性能更直接地反映首屏体验。
八、最被低估的免费路径:把重活挪到 Server Component
最后讲一条"不用分割就赢麻了"的路径,很多同学没意识到。代码分割解决的是"客户端要不要下载这段 JS",但最彻底的优化是让这段 JS 压根不进客户端 bundle。在 Next.js App Router 里,Server Component 跑在服务端,它的依赖不会被打包进客户端 JS。
一个经典场景:代码高亮。如果你在 Client Component 里用 prism-react-renderer 高亮代码,整个高亮库(及它的分词逻辑)都会进客户端 bundle——但高亮结果其实是一段静态 HTML,根本不需要在浏览器里算。把这部分挪到 Server Component,用 shiki 在服务端渲染好,客户端只收到渲染完的 <pre><code> 标记,高亮库一个字节都不进客户端:
// app/blog/[slug]/page.tsx —— Server Component,shiki 只在服务端跑
import { codeToHtml } from "shiki";
export default async function Page() {
const html = await codeToHtml(codeSnippet, { lang: "tsx", theme: "github-dark" });
return <pre dangerouslySetInnerHTML={{ __html: html }} />;
}
语法高亮、Markdown 转 HTML、数据格式化这类"输入是数据、输出是静态 UI"的活儿,都适合这么处理。这是比 lazy 更进一步的优化——分割只是延后加载,Server Component 是完全不加载。 在动手 lazy 一个重型组件之前,先问自己一句:这玩意儿真的需要在浏览器里跑吗?
小结
- bundle 大的代价不只是下载,更是主线程上的解析、编译、执行。首屏 JS 体积本质上衡量的是"主线程被占多久",砍 bundle 直接缩短白屏和卡顿。目标:首屏 JS < 200KB(理想),最大 chunk < 500KB。
- 分割的底层是动态
import(),标准封装是React.lazy+Suspense(条件渲染的重型 Client Component),Next.js 里用next/dynamic,还能配ssr: false(仅限 Client Component)。 - Next.js App Router 的路由级分割是默认行为,Server Component 也默认代码分割 + 流式渲染。你只需手动
lazy那些重型 Client Component,别到处包lazy。 - 预取是"进视口就预取",不是"hover 才预取"(且仅 production 生效)。密集列表用
prefetch={false}关掉;想只要 hover 预取就用官方的HoverPrefetchLink模式;精细控制交给router.prefetch()。 - chunk 不是越多越好。请求开销和依赖 waterfall 会反噬,路由级 + 少数重型组件级就够;别盲目手调 splitChunks。先用
next experimental-analyze(Turbopack)或@next/bundle-analyzer(webpack)看 bundle 图,找意外的大依赖,而不是追求更多 chunk。 - 最优解是让代码不进客户端:把高亮、Markdown 解析这类"数据 → 静态 UI"的活儿挪到 Server Component,依赖留在服务端,客户端零下载。这是比
lazy更彻底的优化。
金句:分割是"延后加载",Server Component 才是"不加载"——动手 lazy 一个重型组件之前,先问它是不是根本不需要在浏览器里跑。
Related