发布日期

第 26 讲:代码分割、动态导入与 next/dynamic

代码分割策略、动态导入(next/dynamic)与客户端 chunk 加载机制

第 24 讲讲了 webpack 配置如何把 React/ReactDOM 切到独立 framework chunk。但 client bundle 的拆分远不止这一刀——还有 page chunk、shared chunk、async chunk、lazy chunk、CSS chunk。本讲拆 Next.js 的代码分割策略和 next/dynamic / React.lazy 的实现。

学习目标

读完本讲,你能:

  1. 解释 Next.js client bundle 的 5 层切片:framework / commons / shared / page / async。
  2. 看懂 next/dynamic 的实现(基于 react-loadable),以及它和 React.lazy 的关系。
  3. 决定何时用 next/dynamic、何时直接 import()、何时用 React 19 use(import(...))
  4. 配合 React Suspense 控制 lazy 组件的 loading state。
  5. 实战优化大型项目的 First Load JS。

本讲对应代码:

  • packages/next/src/shared/lib/dynamic.tsx(next/dynamic)
  • packages/next/src/shared/lib/loadable.shared-runtime.ts
  • packages/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] 时:

  1. 浏览器加载 static/chunks/framework.js(CDN cache 命中率高)
  2. 加载 static/chunks/main.jswebpack-runtime.js
  3. 加载 static/chunks/app/blog/[slug]/page.js(本页特有)
  4. 加载本页所有 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 的内部实现

82:packages/next/src/shared/lib/dynamic.tsx
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/dynamicreact-loadable 风格的封装:

  1. 接收 loader 函数(返回 Promise<Component>
  2. 接收 loading 组件(lazy 加载时显示)
  3. 接收 ssr 标志(控制 server-side render 行为)
  4. 返回一个 React component

内部本质是 React.lazy + <Suspense> + 额外的 SSR 适配。

convertModule:兼容 default export

36:packages/next/src/shared/lib/dynamic.tsx
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 的逻辑

71:packages/next/src/shared/lib/dynamic.tsx
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 跨域 CORSnext.config.jsassetPrefix + 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

十五、本讲小结

  1. Next.js client bundle 五层切片:framework / commons / shared / page / async,由 splitChunks 控制。
  2. next/dynamicreact-loadable 风格的封装;底层是 React.lazy + Suspense + SSR 适配。
  3. App Router 鼓励直接用 React.lazy + Suspense,更简单;React 19 use(import(...)) 更灵活。
  4. optimizePackageImports 绕过 barrel file,能让 lodash-es、material UI 这类库的 bundle 锐减 50%+。
  5. webpack magic comment prefetch / preload 控制 chunk 的加载时机。

下讲预告

第 27 讲《CSS、字体、图片资源的处理》。CSS Modules / Tailwind / global / Sass、next/font 的预加载与子集化、next/image 的图像优化管道、static asset 的 fingerprint 与 immutable cache。