- 发布日期
第 26 讲:代码分割、动态导入与 next/dynamic
代码分割策略、动态导入(next/dynamic)与客户端 chunk 加载机制
第 24 讲讲了 webpack 配置如何把 React/ReactDOM 切到独立
frameworkchunk。但 client bundle 的拆分远不止这一刀——还有 page chunk、shared chunk、async chunk、lazy chunk、CSS chunk。本讲拆 Next.js 的代码分割策略和next/dynamic/React.lazy的实现。
学习目标
读完本讲,你能:
- 解释 Next.js client bundle 的 5 层切片:framework / commons / shared / page / async。
- 看懂
next/dynamic的实现(基于react-loadable),以及它和React.lazy的关系。 - 决定何时用
next/dynamic、何时直接import()、何时用 React 19use(import(...))。 - 配合 React
Suspense控制 lazy 组件的 loading state。 - 实战优化大型项目的 First Load JS。
本讲对应代码:
packages/next/src/shared/lib/dynamic.tsx(next/dynamic)packages/next/src/shared/lib/loadable.shared-runtime.tspackages/next/src/build/webpack-config.ts: getSplitChunksConfig(splitChunks 配置)packages/next/src/build/webpack/plugins/react-loadable-plugin.ts
一、为什么需要代码分割
一个大型电商站,所有 page 加起来 5MB JS。用户第一次访问首页,加载 5MB JS 之后才能 hydrate:
- 4G 网络下载 5MB ~ 8s
- 解析 + 执行 ~ 3s
- TTI 11s+
如果做了 split:
- 首页只加载首页相关的 chunk(200KB)+ framework chunk(90KB)+ shared chunk(150KB)≈ 440KB
- 同一 chunk 在 CDN 缓存,访问第二个页面只多加载该页特有的 chunk
效果:TTI 从 11s 降到 1-2s。
二、Next.js 的五层切片
client bundle 切片
├─ framework.js (React + ReactDOM) ~90KB gzipped
├─ main.js (Next.js client runtime) ~30KB
├─ commons.js (多 page 共用 module ≥ 2 次) ~50-150KB(看项目)
├─ webpack-runtime.js (chunk loader) ~5KB
├─ <page>.js (每 page 一个) 5-100KB
└─ async chunks (dynamic import) 按需加载
切片策略由 webpack 的 optimization.splitChunks 配置控制:
// 简化
splitChunks: {
cacheGroups: {
framework: {
chunks: 'all',
test: /node_modules\/(react|react-dom|scheduler|...)/,
name: 'framework',
priority: 40,
enforce: true,
},
commons: {
name: 'commons',
minChunks: 2, // 至少被 2 个 page 引用才提取
priority: 20,
},
// ...
}
}
注意 enforce: true:即使 framework chunk 小于 minSize 阈值也要单独切片,保证 CDN cache 友好。
三、Page Chunk:每个 page 一个
App Router 下每个 page 编译产物对应 static/chunks/app/<route>/page.js。访问 /blog/[slug] 时:
- 浏览器加载
static/chunks/framework.js(CDN cache 命中率高) - 加载
static/chunks/main.js、webpack-runtime.js - 加载
static/chunks/app/blog/[slug]/page.js(本页特有) - 加载本页所有 Client Component 对应的 async chunk(按 ClientReferenceManifest)
URL 形式:
/_next/static/chunks/app/blog/[slug]/page-abc123.js
/_next/static/chunks/framework-def456.js
后面那段 hash 是 webpack chunk hash + buildId,部署新版本时 URL 变化,旧 client 不会拿到不兼容 chunk。
四、async chunk:dynamic import 的产物
const Modal = dynamic(() => import("./Modal"));
// 或
const { default: lib } = await import("huge-lib");
webpack 看到 import() 调用,把 ./Modal 单独打成一个 chunk:
static/chunks/Modal-xyz789.js
第一次渲染 <Modal> 时浏览器才 fetch 这个 chunk,避免初次加载就把 Modal 代码下载。
webpack 默认 chunk 名是数字 ID(如 321.js)。优化时可加 webpack 注释:
const Modal = dynamic(() => import(/* webpackChunkName: "modal" */ "./Modal"), {
ssr: false,
});
五、next/dynamic 的内部实现
export default function dynamic<P = {}>(
dynamicOptions: DynamicOptions<P> | Loader<P>,
options?: DynamicOptions<P>
): React.ComponentType<P> {
import Loadable from './loadable.shared-runtime'
let loadableFn = Loadable as LoadableFn<P>
let loadableOptions: LoadableOptions<P> = {
loading: ({ error, isLoading, pastDelay }) => { ... },
// ...
}
next/dynamic 是 react-loadable 风格的封装:
- 接收
loader函数(返回Promise<Component>) - 接收
loading组件(lazy 加载时显示) - 接收
ssr标志(控制 server-side render 行为) - 返回一个 React component
内部本质是 React.lazy + <Suspense> + 额外的 SSR 适配。
convertModule:兼容 default export
function convertModule<P>(mod: React.ComponentType<P> | ComponentModule<P>) {
return { default: (mod as ComponentModule<P>)?.default || mod }
}
React.lazy 要求 loader 返回 { default: Component },但 next/dynamic 允许 loader 直接 return component。这个 helper 把后者包装成前者。
ssr: false 的逻辑
export function noSSR<P = {}>(
LoadableInitializer: LoadableFn<P>,
loadableOptions: DynamicOptions<P>
): React.ComponentType<P> {
delete loadableOptions.webpack
delete loadableOptions.modules
if (!isServerSide) {
return LoadableInitializer(loadableOptions)
}
const Loading = loadableOptions.loading!
return () => (
<Loading error={null} isLoading pastDelay={false} timedOut={false} />
)
}
ssr: false 时:
- 服务端:不渲染真实组件,直接渲染
<Loading> - 客户端:渲染
Loadable,触发 lazy 加载
这是为什么 ssr: false 适合那种"只在浏览器有意义"的组件(用 window / document 的 chart library)。
App Router 重要变化:
next/dynamic在 App Router 下行为略有调整——'use client'boundary 已经隐含了"server 不执行 client component",所以 ssr: false 主要用来进一步避免 SSR render 阶段(连 fallback 都不渲染真组件代码)。
六、React.lazy + Suspense:原生方案
App Router 鼓励直接用 React lazy:
"use client";
import { lazy, Suspense } from "react";
const Chart = lazy(() => import("./Chart"));
export default function Dashboard() {
return (
<Suspense fallback={<Skeleton />}>
<Chart />
</Suspense>
);
}
效果和 next/dynamic 几乎一样,但更原生、更易理解。
何时仍用 next/dynamic:
- 需要
ssr: false - 需要更复杂的 loading state(pastDelay、timedOut、retry)
- 旧代码迁移成本
七、React 19 use(import(...))
React 19 引入 use() hook,可以读 Promise:
import { use } from "react";
function Component() {
const mod = use(import("huge-lib"));
return <mod.default />;
}
它比 lazy 灵活——可以在组件内部动态加载,且 Promise 自动被 Suspense 抓取。
八、splitChunks:webpack 的核心
// 简化版
splitChunks: {
chunks: 'all',
cacheGroups: {
default: false,
vendors: false,
framework: {
chunks: 'all',
name: 'framework',
test: matchesFrameworkPath,
priority: 40,
enforce: true,
},
lib: {
test(module) {
return module.size() > 160000 && /node_modules/.test(module.identifier())
},
name(module) {
const hash = crypto.createHash('sha1')
hash.update(module.identifier())
return hash.digest('hex').substring(0, 8)
},
priority: 30,
minChunks: 1,
reuseExistingChunk: true,
},
commons: {
name: 'commons',
minChunks: totalPages, // 所有 page 都共用才提取
priority: 20,
},
shared: {
name(module, chunks) {
return crypto.createHash('sha1')
.update(chunks.reduce((acc, c) => acc + c.name, ''))
.digest('hex')
+ (isModuleCSS(module) ? '_CSS' : '')
},
priority: 10,
minChunks: 2,
reuseExistingChunk: true,
},
},
maxInitialRequests: 25,
minSize: 20000,
}
读懂几条:
framework:React 等核心库,强制切片,优先级最高lib:单个 module > 160KB 时单独切片(大库不被合并)commons:所有 page 都用到的代码(如 utils)shared:被 ≥ 2 个 chunk 共用的代码maxInitialRequests:单 page 首次最多发起 25 个请求(HTTP/2 下没大问题)
九、Tree-shaking 与 optimizePackageImports
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ["lodash-es", "date-fns", "@mui/icons-material"],
},
};
很多库有 barrel file(index.ts re-export 所有 named exports):
// lodash-es/index.ts
export { default as debounce } from "./debounce";
export { default as throttle } from "./throttle";
// ... 几百个
使用任一 named export 时,webpack 默认会把整个 barrel file 拉进 bundle(虽然 tree-shake 会删除未使用代码,但很多场景失效)。
optimizePackageImports 通过 SWC transform 把:
import { debounce } from "lodash-es";
改写成:
import debounce from "lodash-es/debounce";
直接 import 子模块,绕过 barrel file,bundle 减少 50%+。
业务示例:用
@mui/icons-material时不加这个配置,icons 直接 import 把整库(500KB+)打进去;加了之后只打用到的几个 icon(5KB)。
十、Prefetch 与 Preload
webpack 支持 import() 的 magic comment:
const Modal = dynamic(() => import(/* webpackPrefetch: true */ "./Modal"));
webpackPrefetch: true→ 浏览器 idle 时低优 fetch(不阻塞 main 加载)webpackPreload: true→ 与父 chunk 并行 fetch(高优)
Next.js 的 <Link prefetch> 内部用类似机制 prefetch 整个路由的 page chunk。
十一、CSS 切片
App Router 的 CSS 切片由 CssChunkingPlugin 处理:
- 每个
'use client'component 的 CSS import 跟随其 chunk globals.css单独切片,注入到<head>- 单 page CSS 在 hydration 前同步加载,避免闪烁
输出形式:
static/css/page-abc.css # 某 page 的 CSS
static/css/framework-def.css # framework chunk 的 CSS(罕见)
十二、Bundle 分析
ANALYZE=true pnpm build
# 配合 @next/bundle-analyzer 插件,生成交互式 treemap
或用最朴素的方式看:
ls -lhS .next/static/chunks/ # 按大小排序看 chunk
ls -lhS .next/server/chunks/
Next.js 自己 build log 末尾的 tree view(第 23 讲讲过)显示每个 page 的 First Load JS,是最快的 health check。
十三、生产排障实战清单
| 现象 | 排查 |
|---|---|
| First Load JS 800KB+ | 检查 barrel imports、加 optimizePackageImports |
| 单 chunk > 500KB | 找元凶(lib 切片名 = 哈希);用 dynamic import 拆 |
| Modal 第一次点要等 200ms 才显示 | 加 webpackPrefetch: true |
dynamic import 报 ChunkLoadError | 部署后旧 buildId 的 client 来加载新 chunk URL 404;用 router.refresh() 兜底,或自定义错误处理 |
ssr: false 组件 hydration mismatch | 服务端渲染了 fallback、客户端是真组件,DOM 不一致;用 <Suspense> 包裹 |
| 改了组件 chunk hash 没变 | webpack cache 错乱;删 .next/cache |
| chunk URL 跨域 CORS | 配 next.config.js 的 assetPrefix + CORS header |
| 第三方 lib 重复打包 2 次 | 不同入口 import 时机不同;用 webpack.optimization.runtimeChunk: 'single' 让 module 共享 |
| import('huge-lib') 卡 5s | 大 lib 解析时间长;考虑拆 chunk、使用 web worker、或换 lib |
十四、配套 fixture:观察 chunk 拆分
fixtures/lecture-26/ 提供:
- 1 个 page,import 一个"重"的本地 mock lib
- 1 个
next/dynamic加载的 Modal - 1 个
React.lazy+ Suspense 加载的 Chart - 1 个
import()内联调用
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-26
pnpm install
pnpm build
# 看 chunk 拆分
ls -lh .next/static/chunks/
ls .next/static/chunks/app/
# build log 末尾的 tree view 显示每个 page 的 First Load JS
十五、本讲小结
- Next.js client bundle 五层切片:framework / commons / shared / page / async,由 splitChunks 控制。
next/dynamic是react-loadable风格的封装;底层是React.lazy+ Suspense + SSR 适配。- App Router 鼓励直接用
React.lazy+Suspense,更简单;React 19use(import(...))更灵活。 optimizePackageImports绕过 barrel file,能让 lodash-es、material UI 这类库的 bundle 锐减 50%+。- webpack magic comment
prefetch/preload控制 chunk 的加载时机。
下讲预告
第 27 讲《CSS、字体、图片资源的处理》。CSS Modules / Tailwind / global / Sass、next/font 的预加载与子集化、next/image 的图像优化管道、static asset 的 fingerprint 与 immutable cache。