Performance Engineering

Code Splitting
代码拆分

不要把整本百科全书塞进一页纸 ——只给用户当前需要的那一章

📦 没有 Code Splitting

去餐厅吃饭,服务员把整本菜谱(包括所有季节菜单、酒水单、厨师笔记) 全部端给你 —— 你只想点一份炒饭,却要等 10 分钟翻完整本菜谱。

首屏 JS: 487KB gzipped

🍽️ 有 Code Splitting

服务员只递上一页今日推荐,你需要哪道菜就点哪道 —— 后厨接到单后现做。想看更多?下一页按需翻阅

首屏 JS: 98KB gzipped(↓ 80%)
Why Code Splitting

为什么你的 SPA 需要它?

白屏时间过长

用户在 3G 网络下,一个 487KB 的 JS bundle 解析 + 执行需要 3.2 秒。 Google 数据显示:加载每延迟 1 秒,转化率下降 20%

带宽浪费严重

统计显示,用户平均只访问一个网站的 3~4 个页面。 但全量打包意味着用户下载了 100% 的代码,实际只用到 ~30%。 剩余 70% 是带宽浪费

解析成本不可忽略

JS 不仅要下载,还要解析 + 编译 + 执行。 V8 引擎解析 1MB JS 约需 150~300ms(桌面端), 移动端可达 1 秒+。 拆分 = 减少单次解析量。

实测性能对比 — 电商首页(200+ 组件,47 个 npm 依赖)

指标❌ 无拆分⚡ 路由拆分🚀 精细拆分 + 预加载
初始 JS 总量 (gzip)487KB142KB ↓71%98KB ↓80%
首次内容绘制 (FCP)3.2s1.4s1.2s
最大内容绘制 (LCP)4.8s2.1s1.8s
可交互时间 (TTI)6.1s2.8s2.3s
后续路由导航0ms (已全加载)200~400ms~0ms (已预加载)
Core Mechanism

import() 到底做了什么?

动态 import() 并不是魔法 —— 它是一个返回 Promise 的函数表达式,告诉打包器: "这个模块别打包进来,运行时我再来取。"

编译器处理 import() 的完整流程

① 源码编写

await import('./Chart')

② 打包器分析

识别为拆分点(split point)

③ 生成独立 Chunk

384.chunk.js (67KB)

运行时(浏览器)

④ 执行到 import()

发起 HTTP 请求加载 chunk 文件

⑤ Chunk 下载完成

Promise resolve,返回模块对象

打包器内部:import() 的编译产物(简化版)javascript
// 引擎层面:动态 import() 被编译器如何处理

// 1️⃣ 编译阶段 — Webpack 识别到 import() 表达式
const module = await import('./heavy-module')

// 2️⃣ Webpack 将其转换为运行时调用 (简化版)
const module = __webpack_require__.e(/* import() */ "heavy-module-chunk")
  .then(__webpack_require__.bind(null, './heavy-module'))

// 3️⃣ __webpack_require__.e 的核心逻辑
function ensure(chunkId) {
  return installedChunks[chunkId] = new Promise((resolve, reject) => {
    // 动态创建 <script> 标签
    var script = document.createElement('script');
    script.src = __webpack_require__.p + chunkId + ".chunk.js";
    // 注册回调,chunk 加载完毕后 resolve
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
}
Webpack vs Vite

两大打包器的分包策略

Webpack 和 Vite(生产构建基于 Rollup)的分包哲学截然不同: Webpack 走的是"自动拆 + 可配置", Vite 走的是"语义化 + 手动控制"

Webpack — splitChunks

cacheGroups 精细控制:按依赖路径、模块大小、引用次数分组
chunks: 'all' 同时拆分同步和异步模块
magic comments 在 import() 内联配置 chunk 名称和策略
配置复杂,cacheGroups 的 priority 字段容易踩坑
默认数字 ID 命名 chunk,调试困难(需 namedChunks: true

Vite — Rollup manualChunks

默认语义化文件名(含内容 hash),开箱即用
manualChunks 对象式 / 函数式两种模式,简单直接
Rollup 的 tree-shaking 更激进,产物通常更小 5~15%
没有 cacheGroups 那样的自动分组,需要手动列出依赖
manualChunks 函数模式中访问的 id 是绝对路径,需用 includes 判断
webpack.config.jsjavascript
// webpack.config.js — Webpack 分包策略
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',               // 对同步 + 异步模块都生效
      maxSize: 244000,              // 单 chunk 不超过 244KB
      cacheGroups: {
        // 🔹 策略一:提取 node_modules 为 vendor chunk
        vendor: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          priority: 10,
          chunks: 'all',
          enforce: true,            // 即使很小也要拆
        },
        // 🔹 策略二:提取公共模块为 common chunk
        common: {
          minChunks: 2,             // 被 2+ 个 chunk 引用时提取
          priority: 5,
          reuseExistingChunk: true,
        },
        // 🔹 策略三:特定大库单独拆包
        chart: {
          test: /[\/]node_modules[\/](chart\.js|d3)/,
          name: 'chart-vendor',
          priority: 20,             // 优先级高于 vendor
          chunks: 'all',
        },
      },
    },
  },
};
vite.config.tstypescript
// vite.config.ts — Vite 分包策略 (基于 Rollup)
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 🔹 手动分包映射 — 精确控制每个 chunk
        manualChunks: {
          // React 生态 → 单独 vendor chunk
          'vendor-react': [
            'react',
            'react-dom',
            'react-router-dom',
          ],
          // 图表库 → 独立 chunk(按需加载时不会拖慢首屏)
          'vendor-charts': [
            'chart.js',
            'react-chartjs-2',
          ],
          // 工具库 → 公共 chunk
          'vendor-utils': [
            'lodash-es',
            'date-fns',
          ],
        },
        // 🔹 也可以用函数式手动分包(更灵活)
        // manualChunks(id) {
        //   if (id.includes('node_modules/react')) return 'vendor-react';
        //   if (id.includes('node_modules/chart')) return 'vendor-charts';
        //   if (id.includes('node_modules')) return 'vendor-other';
        // },
      },
    },
    // Vite 默认 chunk 大小警告阈值 (500KB)
    chunkSizeWarningLimit: 500,
  },
});
编译产物结构对比bash
// 📊 编译产物对比 — 同一项目,两种打包器

// Webpack 产物结构 (production)
dist/
├── main.4f2a8b1c.js          (89KB gzipped)   ← 入口 chunk
├── vendors.e5d7c3b2.js       (142KB gzipped)  ← node_modules
├── charts.7a9f1e4d.chunk.js  (67KB gzipped)   ← 按需加载的图表
├── dashboard.a1b2c3d4.chunk.js (23KB gzipped) ← 路由 chunk
└── 883.chunk.js               (12KB gzipped)   ← Webpack 自动命名

// Vite 产物结构 (production)
dist/
├── assets/main-bK4x2d.js         (82KB gzipped)  ← 入口 chunk
├── assets/vendor-react-P3x1.js   (45KB gzipped)  ← React 独立
├── assets/vendor-charts-mN7k.js  (64KB gzipped)  ← 图表独立
├── assets/dashboard-Qr8w.js     (21KB gzipped)  ← 路由 chunk
└── assets/vendor-utils-fL2p.js   (18KB gzipped)  ← 工具库

// 🔑 关键差异:
// 1. Webpack 默认用数字 ID 命名 chunk,Vite 用语义化命名
// 2. Vite 生产构建基于 Rollup,tree-shaking 通常更彻底
// 3. Webpack 的 splitChunks.cacheGroups 更灵活,Vite 用 manualChunks
Route-Level Splitting

Next.js 路由级拆分实战

Next.js 14+ App Router 已经自动对每个路由进行代码拆分 —— 每个 page.tsx 及其依赖成为一个独立 chunk。 但真正的工程化还需要 组件级懒加载 + 预加载策略

app/dashboard/page.tsx — Next.js 自动路由拆分tsx
// app/dashboard/page.tsx — Next.js App Router 路由级代码拆分
// Next.js 14+ 自动对每个路由进行代码拆分 ✅

// 📦 此文件及依赖 → 自动成为独立 chunk
// 用户未访问 /dashboard 时,此代码不会被加载

import { AnalyticsChart } from './analytics-chart';
import { RecentOrders } from './recent-orders';

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* 组件内再拆分重量级子组件 */}
      <AnalyticsChart />   {/* ← 这个组件自身也是独立 chunk */}
      <RecentOrders />
    </div>
  );
}
app/dashboard/analytics-chart.tsx — 组件级动态导入tsx
// app/dashboard/analytics-chart.tsx
'use client';  // ← Client Component 自动成为可拆分边界

import dynamic from 'next/dynamic';

// 🚀 进一步拆分:Chart.js 整体延迟加载
const Chart = dynamic(() => import('./HeavyChart'), {
  loading: () => <ChartSkeleton />,       // 加载占位符
  ssr: false,                              // 禁止 SSR(纯客户端库)
});

// 💡 对比:如果用普通 import,Chart.js (250KB) 会在首屏加载
// import { Chart } from './HeavyChart'  // ❌ 增大初始 bundle

export function AnalyticsChart() {
  return (
    <div className="p-6 border-2 border-[var(--foreground)] rounded-2xl">
      <h2>流量趋势</h2>
      <Chart data={useAnalyticsData()} />
    </div>
  );
}

拆分层级金字塔

L3: 组件级 lazy()

重型子组件按需加载 → 一个页面内再拆

L2: 路由级拆分 (Next.js 自动)

每个 page.tsx → 独立 chunk → 访问时加载

L1: splitChunks / manualChunks (打包器配置)

vendor / common / 特定库 → 全局共享 chunk

Preloading Strategies

预加载 —— 让拆分的代价趋近于零

代码拆分的最大痛点是"冷启动" —— 用户点击后等待 chunk 下载。 预加载通过提前获取将这个延迟从 300~600ms 降至 ~0ms

三种预加载策略实战tsx
// 🔮 预加载策略 — 在用户"快要"需要时提前加载

// 方案一:Hover 预加载(最常用)
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
  const router = useRouter();

  const handleMouseEnter = () => {
    // 鼠标悬停时预加载目标路由的 chunk
    router.prefetch(href);  // ← Next.js 内置
  };

  return (
    <a
      href={href}
      onMouseEnter={handleMouseEnter}
      className="hover:underline"
    >
      {children}
    </a>
  );
}

// 方案二:基于视口的预加载
function useViewportPreload(href: string) {
  const ref = useRef<HTMLElement>(null);
  const router = useRouter();

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          router.prefetch(href);  // ← 元素进入视口时预加载
          observer.disconnect();
        }
      },
      { rootMargin: '200px' }     // 提前 200px 开始加载
    );
    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, [href, router]);

  return ref;
}

// 方案三:空闲时预加载(非关键路由)
function useIdlePreload(href: string) {
  const router = useRouter();
  useEffect(() => {
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => router.prefetch(href));  // ← 浏览器空闲时
    } else {
      setTimeout(() => router.prefetch(href), 2000);     // 降级方案
    }
  }, [href, router]);
}

Hover 预加载

用户鼠标悬停导航链接时触发。命中率 ~60%(用户确实会点击), 实现最简单。配合 onMouseEnter 即可。

视口预加载

当链接元素进入可视区域(或提前 200px)时触发。命中率 ~80%。 用 IntersectionObserver 实现, 适合长列表中的下一页入口。

空闲预加载

在浏览器空闲时预加载所有高频路由。命中率 ~100%(全量下载), 适合小型应用。用 requestIdleCallback 实现, 不阻塞主线程。

Interactive Playground

🧪 Bundle 拆分模拟器

实时观察拆分策略对加载时间的影响

全量打包(无 Code Splitting)

main.js
487KB — 包含所有页面、图表、PDF、Markdown...

⚠️ 首屏加载全部代码,FCP 3.2s

代码拆分 + 懒加载

main.js
45KB
首屏核心
vendor-react.js
42KB
React 运行时
dashboard.chunk.js
23KB
路由懒加载
charts.chunk.js
67KB
图表库按需

✅ 首屏仅加载 87KB,其余按需获取,FCP 1.2s

⏱️ 用户点击 /dashboard 后的时间线

0ms

页面已在内存中

0ms

渲染 /dashboard

~0ms

导航完成(但首屏被全量 JS 阻塞了 3.2s)

0ms

用户 hover 时已预加载 chunk

~5ms

chunk 已在浏览器缓存中

~50ms

渲染 /dashboard(无额外网络请求)

Anti-Patterns & Pitfalls

⚠️ 常见陷阱与反模式

常见错误 → 正确做法对比typescript
// 🚫 Anti-Pattern 1: 动态路径导致全目录打包
// ❌ 错误 — 变量路径无法在编译时静态分析
const modules = await import(`./modules/${name}.js`);
// Webpack 警告: Critical dependency: the request of a dependency is an expression
// 结果: ./modules/ 目录下所有文件都被打包

// ✅ 正确 — 使用 webpack 魔法注释限制范围
const modules = await import(
  /* webpackInclude: /\.[jt]sx?$/ */   // 只包含 JS/TS
  /* webpackExclude: /\.test\./ */    // 排除测试文件
  /* webpackMode: "lazy-once" */       // 所有匹配模块打成一个 chunk
  `./modules/${name}.js`
);

// 🚫 Anti-Pattern 2: 过度拆分导致瀑布请求
// ❌ 每个小组件都拆成独立 chunk
const Button = lazy(() => import('./Button'));      // 2KB
const Input = lazy(() => import('./Input'));        // 3KB
const Badge = lazy(() => import('./Badge'));         // 1.5KB
// → 用户看到 3 次 loading 状态,总请求数膨胀

// ✅ 正确 — 按功能/路由聚合拆分
const AdminPanel = lazy(() => import('./admin/AdminPanel'));
// → AdminPanel 内部包含 Button、Input、Badge,一次加载

陷阱:动态路径 + 变量

import(`./${name}`) 中的变量路径让打包器无法静态分析,Webpack 会将整个目录打包。 用 webpackInclude/Exclude 注释限制范围,或改用显式映射表

陷阱:过度拆分的瀑布请求

拆分粒度过细(每个 2KB 组件都 lazy import)会导致: ① HTTP 请求数爆炸 ② 用户多次看到 loading 态 ③ 浏览器并行限制(6个域名)导致排队。经验法则:单 chunk 不低于 20KB,按功能聚合。

陷阱:SSR + 动态导入的兼容

React.lazy() 在服务端渲染时会报错 —— SSR 没有 import() 的运行时。 Next.js 的 dynamic() 已内置 SSR 降级, 但自定义 SSR 方案需要手动处理 loadable-components

💡 最佳实践:Suspense + 骨架屏

永远不要让用户看到空白 —— 配合 <Suspense fallback=>提供骨架屏(Skeleton)。骨架屏让感知加载时间降低 30~50%(Nielsen Norman Group 研究数据)。

Cheat Sheet

📋 Code Splitting 速查清单

import() 动态导入

运行时按需加载模块,返回 Promise

const m = await import('./module')

React.lazy()

React 原生的组件懒加载,配合 Suspense

const Comp = lazy(() => import('./Comp'))

Next.js dynamic()

支持 SSR 降级、loading 组件、禁用 SSR

const D = dynamic(() => import('./D'), { ssr: false })

router.prefetch()

Next.js 预加载路由 chunk

router.prefetch('/dashboard')

Webpack splitChunks

cacheGroups 配置 vendor / common 拆分

splitChunks: { chunks: 'all', cacheGroups: {...} }

Vite manualChunks

Rollup 手动分包,对象式或函数式

manualChunks: { 'vendor-react': ['react'] }

webpackMagicComment

在 import() 内注入打包指令

import(/* webpackChunkName: 'chart' */ './chart')

Suspense + Skeleton

加载态骨架屏,降低感知延迟 30~50%

<Suspense fallback={<Skeleton />} />

IntersectionObserver

视口内预加载,命中率 ~80%

new IntersectionObserver(cb, { rootMargin: '200px' })

拆分决策流程图 — 该不该拆?

1

这个模块 > 100KB 吗?

YES → 必须拆分(vendor / 大库单独 chunk)

2

用户首屏是否需要它?

YES → 放入 main chunk

NO → 路由级 / 组件级 lazy import

3

被 2+ 个页面共用?

YES → 提取为 shared chunk (splitChunks / manualChunks)

NO → 放入对应路由 chunk

4

用户有 60%+ 概率访问?

YES → 启用预加载 (prefetch / preload)

NO → 纯懒加载即可

Code Splitting = 按需加载 + 预加载 + 缓存策略

拆分是手段,不是目的。目标始终是:更少的初始 JS + 更快的可交互时间。