⚡ 性能优化决策与落地

本章记录 lhx-kit 构建产物优化的完整思考过程。 读完之后你应该能:

  • 📐 理解 HTTP/2 下 chunk 大小的物理公理
  • 🧪 看懂 Rollup manualChunks 是如何工作的
  • ❌ 明白为什么不能把 react-dom 拆成更小的 chunk
  • ✅ 自己回答"这个项目的 chunk 该不该拆"
为什么要单独写这一章

性能优化是一个反直觉很重的领域。"拆得越细越快"听起来对,实际经常是错的。本章的每一个结论都伴随数据原理,让你能独立判断、而不是记住几条 recipe。


一、🎯 问题起点

examples/rmpa(React MPA,3 个页面)第一版构建产物:

pnpm --filter rmpa build(优化前)
dist/settings/assets/settings-B9HRk1Nj.js             0.56 kB
dist/home/assets/HomeAbout-CXjmzz56.js                0.57 kB
dist/shared/assets/preload-helper-D7HrI6pR.js         1.02 kB
dist/home/assets/home-zbQ6W3LI.js                     1.07 kB
dist/dashboard/assets/dashboard-Cyz9swpE.js           1.10 kB
dist/shared/assets/schema-zod-DJC6O4JY.js             1.12 kB
dist/dashboard/assets/DashboardLanding-BqwLkcZq.js    1.28 kB
dist/home/assets/HomeLanding-B5Lz-QKO.js              1.29 kB
dist/shared/assets/bootstrap-HbFA4b1I.js              3.68 kB
dist/shared/assets/Paragraph-CqF_hI2l.js             10.62 kB
dist/shared/assets/vendor-react-router-UdACNAMy.js   20.30 kB
dist/shared/assets/vendor-zod-BLheilQr.js            54.54 kB
dist/shared/assets/vendor-react-elAOjG93.js         192.05 kB

13 个 JS chunk,其中 8 个小于 2KB

这是一个值得优化的信号

我们的直觉是"小 chunk 太多浪费"。但"直觉"不能作为决策依据——必须先建立可量化的判断标准


二、🧠 底层原理:HTTP 传输的物理公理

很多优化讨论混淆两个概念:"拆多了慢""拆少了慢"。先把边界定下来。

2.1 TCP + TLS 的启动成本

一次 HTTPS 请求的典型 timeline (4G):
├─ DNS lookup         ~20ms
├─ TCP handshake      ~50ms  (1 RTT)
├─ TLS handshake      ~100ms (2 RTT)
├─ HTTP 请求发送       ~0ms
├─ TTFB (wait)         ~50ms (1 RTT)
└─ 内容传输

首次打开一个域名至少 3 个 RTT ≈ 200ms。对第二个资源如果走同一连接,省掉 DNS + TCP + TLS,只剩 TTFB ≈ 50ms

2.2 TCP 慢启动(initcwnd)

Linux 内核默认
initcwnd = 10         // 初始拥塞窗口 = 10 个 MSS
MSS      ≈ 1.46 KB    // 典型值

意味着首轮 RTT 最多发 ~14.6KB。如果资源 ≤ 14.6KB,传输时间 ≈ 1 RTT;如果 > 14.6KB,需要多轮 RTT。

什么意思?

低于 14KB 的 chunk,拆分带来的"并行下载"优势根本没机会发挥——因为 initcwnd 还没打开,带宽还没被用上,就传完了。

2.3 HTTP/2 多路复用

HTTP/2 一条 TCP 连接上:
┌─ stream 1: chunk-A.js
├─ stream 2: chunk-B.js     ← 逻辑上"并行"
├─ stream 3: chunk-C.js
└─ stream 4: chunk-D.js

但底层物理带宽是同一条管道。
真实传输时间 ≈ 总字节数 / 带宽

拆 chunk 在 HTTP/2 下的真正收益不是并行下载(物理上不可能),而是:

  • 🎯 缓存粒度:换 React 版本不作废业务 chunk
  • 🔀 解析并行:多个 chunk 可以同时 parse(V8 有多核 parser)
  • 📥 HTTP/2 优先级:关键 chunk 可以优先到达

2.4 甜蜜区

chunk size    0       10KB      30KB      100KB      500KB
─────────────────────────────────────────────────────────────
拆分收益     ──        0    ↑↑      ↑↑↑      ↑↑        ↓
拆分代价     ↑↑↑      ↑     ~        ↓        ↓↓        ↓↓↓
净收益       ↓↓↓      ~    ↑↑      ↑↑↑        ↑         ↓
                     ^^^^              ^^^^^^^^^^^^
                  合并为王            拆分为王
区间 策略 原因
< 10KB 一定要合 拆了净亏:RTT 开销 > 传输节省
10–30KB 看场景 灰色地带
30–500KB 应该拆 并行解析 + 缓存粒度收益明显
> 500KB 不拆但压 再拆会破坏内部优化

这是所有后续决策的基础公理


三、📋 候选方案清单

列出理论上能解决这个问题的所有方案,逐一评估:

方案 A:手写 manualChunks 映射表

rollupOptions.output.manualChunks
manualChunks(id) {
  if (id.includes('react-router')) return 'vendor-react-router';
  if (id.includes('react'))        return 'vendor-react';
  if (id.includes('zod'))          return 'vendor-zod';
  return undefined;
}
评价
完全可控
需要项目方维护包名列表
对用户代码(HomeAboutbootstrap)无能为力
通用插件硬编码包名不合理

方案 B:Rollup experimentalMinChunkSize

Rollup 官方 flag:任何独立 chunk 如果小于阈值,就把它合并进静态 importer

评价
对 user code 和 node_modules 都生效
零配置——只要一个阈值数字
内部做"安全性检查":不会破坏循环依赖 / 多 importer chunk
不会合并真正需要延迟加载的 chunk(lazy route)
🟡 名字叫 "experimental",但 Vite 从 4.x 就在内部依赖它

方案 C:社区插件 vite-plugin-chunk-split

评价
引入第三方依赖增加供应链风险
本质是 manualChunks 的封装,等价于方案 A
对 Vite 7 / Rollup 4 的兼容性不保证

方案 D:强拆 React/Vue 这种大单块

直觉说"拆了更快",但物理上拆不开

详见 第四节

方案 E:换更小的框架

  • Preact + compat:runtime ≈ 10KB
  • Solid / Svelte:runtime < 8KB

产品层决策,不是构建层优化。lhx-kit 的 aliasGlobals + initScript 就是为这条路留的接口(见 🌐 CDN 外挂)。


四、❌ react-dom 为什么不能再拆

这是最反直觉的点,值得详细论证。

4.1 物理结构:React 19 包内真实情况

Terminal
$ ls node_modules/.pnpm/react-dom@19.2.5_*/node_modules/react-dom/cjs/
1react-dom-client.development.js        1066 KB
2react-dom-client.production.js          536 KB   ← 整个 reconciler 闭包
3react-dom-profiling.development.js     1082 KB
4react-dom-server.browser.production.js  268 KB
react-dom-client.production.js(结构示意)
'use strict';
(function () {
  // ... 10000+ 行 fiber 调度器
  // ... 事件系统
  // ... commit phase
  function createRoot(container) { /* ... */ }
  function hydrateRoot(container, element) { /* ... */ }
  // ...
  module.exports = {createRoot, hydrateRoot, flushSync, /* ... */};
})();
核心事实

react-dom-client.production.jsMeta 的 release bundler 预先打好的一整个 CJS 闭包。到了你的 pnpm 安装时,它在 Rollup 眼里就是一个 module

4.2 Rollup 的视角

Rollup manualChunks 的约束
manualChunks: N 个 module → M 个 chunk

约束:N ≥ M

对 react-dom:

  • N = 1(只有一个 CJS 文件)
  • M 最多 1(其他桶都是空的,Rollup 会忽略)

物理上没有拆分空间——不是懒,是输入粒度就是不可再分的原子。

4.3 "那我自己写插件切成 4 份呢?"

假想实现:按字节切
transform(code, id) {
  if (id.includes('react-dom-client.production.js')) {
    const quarter = code.length / 4;
    return {
      code: [
        code.slice(0, quarter),
        code.slice(quarter, quarter * 2),
        code.slice(quarter * 2, quarter * 3),
        code.slice(quarter * 3)
      ]
    };
  }
}

会发生什么?

运行结果
❌ ReferenceError: fiberRootTag is not defined
❌ ReferenceError: scheduleUpdateOnFiber is not defined
...
为什么必炸

那个 IIFE 闭包内部变量互相引用。切两半后:

  • 前半段定义的 fiberRootTag 在后半段不可见
  • 后半段的 scheduleUpdateOnFiber 反过来也一样

要做语义切分需要理解 fiber 内部数据流——这相当于自己维护一份 React 分发版本。Meta 10 年都没自己做,投入产出比极低

4.4 Vue 3 稍好但也有限

Vue 3 的 ESM 构建是多个 module:

@vue/runtime-core       (reactivity 接口 + render)
@vue/runtime-dom        (DOM 端适配)
@vue/reactivity         (ref / reactive)
@vue/shared             (工具函数)

但这几个互相调用极其频繁——reactivity 的 ref 会在 runtime-core 里被 patch,runtime-dom 会回调 runtime-core 的 render……

分到 4 个 chunk 后每个 chunk 都得 import 另外 3 个——浏览器还是要全部下载完才能执行,且多了 3 个 HTTP 请求


五、🧮 假设能拆:收益分析

即使我们有办法拆,性能就更好吗?用数据说话。

5.1 方案 A:1 个 192KB chunk(现状)

HTTP/2 单连接:
  t = 0ms    请求发起
  t = 50ms   首字节到达 (TTFB)
  t = 250ms  传输完成 (192KB / ~1MB/s)
  t = 250ms  开始执行

5.2 方案 B:假设能拆成 4 个 48KB chunks

1HTTP/2 单连接:
2  t = 0ms    4 个请求同时发起(modulepreload 并行)
3  t = 50ms   首字节到达(共享同一 TCP 连接)
4  t = ???    受限于带宽,总下载量仍是 192KB
5             4 个 chunk 共享同一 TCP 管道
6             下载时间 ≈ 195KB / 带宽 ≈ 200ms
7  t = 200ms  最后一个 chunk 到
8  t = 200ms  开始执行(必须等全部到齐)

5.3 对比表

指标 方案 A 方案 B 差异
字节总量 192KB 195KB +3KB(闭包头 + HPACK)
请求数 1 4 +3
首字节 50ms 50ms 0
传输完成 250ms 250ms 0
执行开始 250ms 200ms -50ms(理论)
缓存失效代价 192KB 48KB × 4 实际相同
结论

方案 B 在最理想情况下只是和 A 持平;考虑闭包头 / HPACK / TCP 拥塞控制,实际略差

5.4 真正能根治 192KB 的只有三条路

┌──────────────────────────────────────────────┐
│ 1. 换框架(Preact/Solid)    → 产品决策        │
│ 2. CDN 外挂                  → 浏览器缓存命中 │
│ 3. 路由级懒加载              → MPA 无此空间    │
└──────────────────────────────────────────────┘

六、✅ 最终落地

6.1 代码

packages/vite-plugin/src/plugin.ts showLineNumbers
1rollupOptions: {
2  input,
3  output: {
4    // 第一层策略:按包路径分 chunk,家族归并
5    manualChunks: nodeModulesPerPackageChunker,
6
7    // 第二层策略:10KB 以下自动折叠进父 chunk
8    // Merge any chunk below the threshold into its static importer.
9    // Rollup only considers this for chunks that are safe to inline
10    // (no cycles, single importer). Sub-10KB chunks are dominated by
11    // HTTP request overhead — TCP+TLS+HTTP/2 framing is ~1–2 round-trips
12    // regardless of payload, so a 0.5KB chunk costs the same as a 10KB
13    // one. This auto-folds tiny route views / helper chunks without us
14    // having to hand-tune per-project.
15    experimentalMinChunkSize: 10 * 1024
16  }
17}

6.2 家族分组规则

packages/vite-plugin/src/plugin.ts
const FAMILY_GROUPS = {
  // React 全家桶:reconciler 核心 + cooperative scheduler
  react: ['react', 'react-dom', 'scheduler'],

  // React Router 全家桶:router + dom bindings + 底层状态机
  'react-router': ['react-router', 'react-router-dom', 'remix-run-router'],

  // Vue 3 全家桶:核心 + vue-demi 兼容层(Pinia 依赖)
  vue: ['vue', 'vue-demi'],

  // Pinia + Vue Router:Vue 的规范数据层 + 路由
  'vue-state': ['pinia', 'vue-router']
};

两层策略叠加

1. FAMILY_GROUPS:    node_modules 按家族归并
2. minChunkSize=10K: 所有 chunk(含 user code)自动合并

6.3 实测结果

pnpm --filter rmpa build(优化后)
Initially, there are 11 chunks, of which 10 are below minChunkSize.
After merging chunks, there are 7 chunks, of which 6 are below minChunkSize.
优化后产物
dist/settings/index.html                              0.58 kB
dist/home/index.html                                  0.67 kB
dist/dashboard/index.html                             0.67 kB
dist/settings/assets/settings-CNYq4wvg.js             0.56 kB
dist/shared/assets/schema-zod-DJC6O4JY.js             1.12 kB
dist/dashboard/assets/DashboardLanding-Bs4LgNti.js    1.23 kB
dist/home/assets/HomeLanding-D_jatks2.js              1.23 kB
dist/dashboard/assets/dashboard-DCvf0Gnk.js           1.36 kB
dist/home/assets/home-OcQ0rEhC.js                     1.44 kB
dist/shared/assets/Paragraph-DDr-F12U.js             15.27 kB
dist/shared/assets/vendor-react-router-UdACNAMy.js   20.30 kB
dist/shared/assets/vendor-zod-BLheilQr.js            54.54 kB
dist/shared/assets/vendor-react-elAOjG93.js         192.05 kB

6.4 前后对比

维度 优化前 优化后 变化
JS chunk 数 13 10 🟢 -3
sub-10KB chunk 数 8 5 🟢 -3
preload-helper (1KB) 独立 折叠进 entry 🟢 合并
bootstrap (3.7KB) 独立 折叠进各 page entry 🟢 合并
HomeAbout (0.6KB) 独立 HomeLanding 合并 🟢 合并
DashboardAbout (0.5KB) 独立 DashboardLanding 合并 🟢 合并
总字节 差异 < 1% ⚪ 持平

6.5 为什么还剩 5 个 sub-10KB chunk

剩下的小 chunk 都是不可合的
┌──────────────────────┬──────┬────────────────────────────┐
│ chunk                │ size │ 为什么保留                  │
├──────────────────────┼──────┼────────────────────────────┤
│ settings-*.js        │ 0.5K │ 独立 page 入口,无父可合    │
│ schema-zod-*.js      │ 1.1K │ dead-path 懒加载 chunk     │
│                      │      │ 合了会让 zod(54K) 进主包    │
│ HomeLanding-*.js     │ 1.2K │ lazy() 动态 import 唯一   │
│ DashboardLanding-*.js│ 1.2K │ importer 的 lazy chunk    │
│ home/dashboard 入口   │ 1.4K │ 是 entry chunk,本体     │
└──────────────────────┴──────┴────────────────────────────┘
TIP

experimentalMinChunkSize 做到了它能做的一切 这些剩下的都属于 Rollup 安全检查拒绝合并的情况——要么是 entry,要么是 dead-path lazy chunk。再合就破坏语义了。


七、📂 chunk 物理布局策略

7.1 per-page 分配算法

packages/vite-plugin/src/plugin.ts::computeChunkOwners
for each active page entry:
  BFS(entry chunk) through:
    - chunk.imports       (static imports)
    - chunk.dynamicImports (lazy imports)
  mark every reachable chunk as "owned by page X"

for each chunk:
  if owners.size === 1: move to dist/<page>/assets/
  if owners.size >= 2:  move to dist/shared/assets/

7.2 最终目录效果

dist/
dist/
├── home/
│   ├── index.html
│   └── assets/
│       ├── home-*.js            ← entry + bootstrap 折叠
│       └── HomeLanding-*.js     ← 仅 home 路由的 lazy chunk
├── dashboard/
│   ├── index.html
│   └── assets/
├── settings/
│   ├── index.html
│   └── assets/
└── shared/
    └── assets/
        ├── vendor-react-*.js         ← 多 page 共享
        ├── vendor-react-router-*.js
        └── Paragraph-*.js            ← 组件库共享

7.3 为什么要 per-page 目录

好处 说明
🎯 MPA 路由解耦 每个 page 独立 URL,CDN/nginx 按目录前缀配缓存策略简单
📦 离线打包白名单友好 whitelistPages: ['home'] → 只打包 dist/home/ + dist/shared/
🔍 排查直观 看 filename 就知道是哪个 page 的资源

八、🗜️ 预压缩

8.1 策略

packages/vite-plugin/src/compress.ts
// 对 build 产物并发跑 zlib.gzip + brotliCompress
// 仅对 > 1KB 的文本文件做
// 产物:foo.js / foo.js.gz / foo.js.br 三件套

8.2 为什么要预压缩

动态 vs 静态压缩

生产 nginx / CDN 可以请求时动态压缩,但每次都消耗 CPU。

预压缩

  • ✅ 静态内容一次压缩多次使用
  • ✅ CPU 一次性消耗,首屏 TTFB 更小
  • ✅ nginx gzip_static on / brotli_static on 直接服务 .gz / .br :::

8.3 Brotli vs Gzip

两个都出

算法 压缩比 场景限制
gzip 基线(100%) HTTP/HTTPS 全部支持
brotli 比 gzip 多省 15–25% 仅 HTTPS 下被浏览器接受

让 CDN 按 Accept-Encoding 头 content negotiation。

8.4 实测收益

examples/rmpa 的 vendor-react
┌────────┬──────────┬──────┐
│ 格式    │ 大小      │ 占比  │
├────────┼──────────┼──────┤
│ 原始    │ 192 KB   │ 100% │
│ gzip    │  60 KB   │  31% │
│ brotli  │  52 KB   │  27% │
└────────┴──────────┴──────┘

:::tip 压缩带来的收益 >> 任何 chunk 拆分 一个 React 页面的首屏关键路径从 192KB 降到 52KB(brotli)。

这就是为什么我们把预压缩放在默认开启,而不是 "用户按需打开"——它的 ROI 高到没有不开的理由。


九、🧾 决策回顾表

问题 决策 理由
10KB 以下小 chunk experimentalMinChunkSize: 10KB Rollup 原生能力,零维护
React 192KB 是否拆分 不拆 物理拆不开
Vue 35KB 是否拆分 不拆 内部调用过紧
React Router 20KB ✅ 合进 vendor-react-router 家族 三包一起用
zod 54KB ✅ 独立 chunk + 动态 import validate:true 时才加载
业务 chunk 分发 per-page owner 分析 + shared 兜底 MPA 解耦 + 离线友好
压缩 默认 gzip + brotli ROI 极高

十、🔭 未来还能做什么

10.1 短期内计划的优化

:::details 为什么不做这些

方案 不做理由
HTTP/2 Server Push 浏览器已普遍禁用,被 Early Hints (103) 替代
关键 CSS 内联 MPA 每页 CSS 已按页独立,内联收益 < HTTP 请求成本
Service Worker 预缓存 @lhx-kit/offline 的 hybrid 容器路线冲突
:::

10.2 值得做但尚未落地

  • 🧪 Module Federation 跨项目共享 React(需要有第二个项目的前提)
  • 🧪 HTTP/3 (QUIC) 环境下重新测量 RTT(初始数据显示 10KB 门槛可降到 6KB)
  • 🧪 React Server Components 前置(需要 Next.js 或自建 RSC runtime)

十一、📚 推荐阅读