编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

    • 专栏博客

      • README
      • 引擎解析编译执行
      • 隐藏类与回收机制
      • 类型隐式转换精算
      • 作用域链闭包原理
      • 函数绑定规则组合
      • 原型链语法糖本质
      • 代理与元编程协议
      • 事件循环承诺机制
      • 工作线程并发调度
      • 页面渲染像素原理
      • 网络接口存储架构
      • 服务端运行时编程
      • 模块系统双轨操作
      • 现代工程链三件套
        • 1. 案例与疑问引入
          • 1.1 一个 500 行
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构全景概览
          • 2.1 开发 → 构建
          • 2.2 为什么 Vite
        • 3. Vite开发模式
          • 3.1 原生 ESM →
          • 3.2 预构建(Pre
          • 3.3 HMR 热更新
        • 4. esbuild快
          • 4.1 Go 语言编写
          • 4.2 多核并行 vs
          • 4.3 esbuild
        • 5. 生产构建打包
          • 5.1 ESM 静态分析
          • 5.2 为什么 CJS
          • 5.3 代码分割:ven
          • 5.4 压缩(Terser
        • 6. 测试金字塔
          • 6.1 静态分析(Typ
          • 6.2 单元测试(Vit
          • 6.3 组件测试(Tes
          • 6.4 E2E(Play
        • 7. 快照测试的内部机制
          • 7.1 toMatchS
          • 7.2 快照更新的正确姿势
          • 7.3 什么场景不适合快
        • 8. 火焰图读法
          • 8.1 Self Tim
          • 8.2 火焰图中的布局
          • 8.3 长任务(Long
        • 9. Memory
          • 9.1 Shallow
          • 9.2 堆快照对比(He
          • 9.3 Lighthou
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 对一个组件做完整
          • 10.3 设计哲学回扣
          • 10.4 速查表:Vite
      • 设计模式函数哲学
      • 跨端架构终局总结
  • CodeX
  • JavaScript入门
  • 专栏博客
杨充
2026-06-11
目录

现代工程链三件套

# 14.现代工程链三件套

📍 上接第 13 篇《模块系统双轨互操作》。代码组织方式清楚了。本文回答交付环节的三个核心问题:怎么打包最小?怎么测最稳?怎么看性能瓶颈?

# 目录介绍

  • 1. 案例与疑问引入
    • 1.1 一个 500 行
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构全景概览
    • 2.1 开发 → 构建
    • 2.2 为什么 Vite
  • 3. Vite开发模式
    • 3.1 原生 ESM →
    • 3.2 预构建(Pre
    • 3.3 HMR 热更新
  • 4. esbuild快
    • 4.1 Go 语言编写
    • 4.2 多核并行 vs
    • 4.3 esbuild
  • 5. 生产构建打包
    • 5.1 ESM 静态分析
    • 5.2 为什么 CJS
    • 5.3 代码分割:ven
    • 5.4 压缩(Terser
  • 6. 测试金字塔
    • 6.1 静态分析(Typ
    • 6.2 单元测试(Vit
    • 6.3 组件测试(Tes
    • 6.4 E2E(Play
  • 7. 快照测试的内部机制
    • 7.1 toMatchS
    • 7.2 快照更新的正确姿势
    • 7.3 什么场景不适合快
  • 8. 火焰图读法
    • 8.1 Self Tim
    • 8.2 火焰图中的布局
    • 8.3 长任务(Long
  • 9. Memory
    • 9.1 Shallow
    • 9.2 堆快照对比(He
    • 9.3 Lighthou
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 对一个组件做完整
    • 10.3 设计哲学回扣
    • 10.4 速查表:Vite

# 1. 案例与疑问引入

# 1.1 一个 500 行

你接手了一个"非常简单"的仪表盘项目——总共 500 行代码。npm run build 后看一眼输出:

$ npm run build
✓ built in 12.3s
dist/index.html           0.45 kB
dist/assets/index.css     3.21 kB
dist/assets/index.js   2048.00 kB  ← ⚠ 2MB!
1
2
3
4
5

打开 npx vite-bundle-visualizer 一看:

index.js (2,048 KB)
├── lodash             712 KB  (34.8%)  ← 只用了 _.map
├── moment             232 KB  (11.3%)  ← 只用了 moment().format()
├── chart.js           522 KB  (25.5%)  ← 只用了 Line Chart
├── axios               48 KB  ( 2.3%)  ← 合理的 HTTP 库
├── 你的业务代码         12 KB  ( 0.6%)  ← 这才是你写的
└── 其他依赖           522 KB  (25.5%)
1
2
3
4
5
6
7

你写的 500 行业务代码只占 0.6%——剩下 99.4% 是你只用 5% 功能的库,但你引入了 100% 的代码:

// main.js —— 问题根源
import _ from 'lodash';              // 整库引入 712KB
import moment from 'moment';         // 整库引入 232KB(含所有 locale)
import { Chart, LineController } from 'chart.js'; // 522KB

// 实际只用了:
// _.map([1,2,3], x => x*2)   → lodash/map 只需 3KB
// moment().format('YYYY-MM-DD') → dayjs 只需 2KB
// new Chart(ctx, { type: 'line' }) → 可以 tree-shake
1
2
3
4
5
6
7
8
9

# 1.2 顺藤摸到根因

  • 假设 1:"是不是打包器没做 Tree Shaking?"——Tree Shaking 确实在跑,但 lodash 是 CJS 格式(module.exports = { map, filter, ... }),CJS 的动态 require 让静态分析无法确定哪些导出被使用,只能"整库打入"。

  • 假设 2:"换成 import { map } from 'lodash-es' 不就行了?"——对。lodash-es 是 ESM 格式,Rollup 可以摇树。但如果你在 vite.config.js 里配了 optimizeDeps.include: ['lodash'],预构建可能丢失 usedExports 标记。

  • 假设 3:"moment 为什么这么大?"——moment 的入口文件用 require('./locale/' + lang) 动态加载了全部 134 种语言的 locale 数据。静态分析无法确定运行时 lang 变量是什么。

  • 假设 4:"Tree Shaking 对 chart.js 有效吗?"——有效但有限。chart.js 是 ESM,但内部有副作用代码(Chart.register(LineController))。sideEffects: false 可以帮打包器判断——但 chart.js 的 package.json 里没标记。

  • 假设 5:"那怎么修复?"——手动按需引入 + 替换重型库 + 配置 manualChunks + 压缩。这是本篇要回答的核心。

这五个假设背后藏着 8 个原理点:

① 为什么 CJS 不能被 Tree Shaking?require 动态性 vs ESM import 静态性  → 第 5.2 节
② Vite 预构建对 Tree Shaking 有什么影响?optimizeDeps 做了什么?         → 第 3.2 节
③ sideEffects: false 的标记怎么工作?标记错了会有什么后果?               → 第 5.1 节
④ esbuild 为什么比 Terser 快 20~50 倍?Go 在打包场景的优势具体在哪?     → 第 4 章
⑤ Vite HMR 怎么做到"改一行只替换一行"?                                → 第 3.3 节
⑥ 代码分割(chunk splitting)怎么最大化缓存命中率?                      → 第 5.3 节
⑦ 火焰图的 Self Time 和 Total Time 是什么?为什么 Bottom-Up 才是找瓶颈?  → 第 8 章
⑧ Lighthouse 的 LCP 和 TBT 分别衡量什么?LCP > 2.5s 有什么后果?        → 第 9.3 节
1
2
3
4
5
6
7
8

# 1.3 我们要回答什么

这个 500 行 / 2MB 的案例就是本篇的主线案例:

  1. ① Vite No-bundle:为什么 Webpack 冷启动 20s、Vite 冷启动 1s?
  2. ② esbuild:Go 语言 + 无完整 AST + 多核并行 → 具体怎么快了 100 倍?
  3. ③ Tree Shaking + 代码分割:CJS 为什么摇不掉?manualChunks 最佳实践?
  4. ④ 测试金字塔:静态分析→单元→组件→E2E 每层测什么、成本多少?
  5. ⑤ 火焰图 + Lighthouse:Self Time = 优化目标;LCP 是用户感知速度核心

本篇路线:

工程流水线全景 (第 2 章)
   ↓
Vite No-bundle (第 3 章) ─→ 解开"开发模式为什么快"
   ↓
esbuild 深度 (第 4 章) ─→ 解开"编译为什么能快 100x"
   ↓
Rollup + Tree Shaking (第 5 章) ─→ 解开"生产 bundle 怎么最小化"
   ↓
测试金字塔 (第 6-7 章) ─→ 从静态分析到 E2E 的逐层成本
   ↓
火焰图 + 内存 + Lighthouse (第 8-9 章) ─→ 工具链的"诊断武器库"
   ↓
综合案例 (第 10 章) ─→ 完整性能诊断闭环
1
2
3
4
5
6
7
8
9
10
11
12
13

📌 本篇定位:JavaScript 专栏中「从写代码到上线」的工程化篇。前 13 篇讲了引擎、作用域、事件循环、模块——本篇回答:你的代码是怎么变成用户浏览器里那几 KB 的、怎么确保它没 bug、怎么诊断它慢在哪。


# 2. 架构全景概览

# 2.1 开发 → 构建

┌────────────────────────────────────────────────────────────┐
│                   现代前端工程流水线                          │
│                                                            │
│  开发            构建             测试            部署       │
│  ────           ────             ────           ────       │
│  Vite           Rollup           TypeScript     Vercel     │
│  (No-bundle)    + esbuild        + ESLint       Netlify    │
│  HMR 热更新     Tree Shaking     单元(Vitest)   CDN        │
│  原生 ESM       代码分割         组件(Testing               │
│  按需编译       压缩              Library)                 │
│                                  E2E(Playwright)           │
│                                                            │
│  诊断工具:Performance 面板 / Memory 面板 / Lighthouse      │
└────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14

每个阶段的核心使命:

阶段 工具 使命 什么时候出问题
开发 Vite 秒级反馈 冷启动慢、HMR 卡
构建 Rollup + esbuild 最小化、最快产物 bundle 过大、Tree Shaking 不生效
静态分析 TypeScript + ESLint 写代码时就发现错误 运行时才发现 undefined is not a function
单元测试 Vitest 每个函数/模块行为正确 改了 A 模块导致 B 模块挂了
组件测试 Testing Library 从用户视角验证行为 内部重构导致交互行为变化
E2E Playwright 完整用户流程正确 所有单元通过但整体流程不可用
诊断 Perf/Memory/Lighthouse 找到"慢在哪"的精确位置 用户反馈卡但不知道哪个函数的问题

# 2.2 为什么 Vite

疑惑:"Vite 不就是另一个打包器吗?和 Webpack 有什么区别?"

论证:区别不在于"能不能打包",而在于开发时"编译什么、什么时机编译":

Webpack 开发模式:
源代码(1000模块) ──全量打包成1个bundle(20s)──► browser加载执行
每次修改 → 重新打包 → 重新加载整个 bundle

Vite 开发模式:
源代码(1000模块) ──浏览器按需请求──► 只编译当前访问的模块
节点依赖 ──esbuild预构建(一次性)──► 转 ESM + 合并
每次修改 → 只编译改动的 1 个文件 → HMR 精准替换
1
2
3
4
5
6
7
8

Vite 快在四条根基:

  1. 原生 ESM:浏览器 2018 年起原生支持 <script type="module">——不需要打包器把 1000 个模块拼成 1 个文件
  2. 按需编译:首屏只访问 20 个模块 → Vite 只编译这 20 个。Webpack 编译全部 1000 个
  3. esbuild 预构建:node_modules 中的 CJS 依赖用 esbuild 一次性转成 ESM,缓存到 node_modules/.vite
  4. 内存缓存:已编译模块缓存在内存中,第二次请求直接返回

性能对比实测(1000 模块的项目):

指标 Webpack 5 Vite 4+ 差距
冷启动 18~25s 1~2s 10~20x
HMR 热更新 2~5s <50ms 40~100x
首屏编译 全量打包 只编译 ~20 个模块 策略不同

# 3. Vite开发模式

# 3.1 原生 ESM →

疑惑:"浏览器去请求 1000 个 JS 文件?那不就得发 1000 个 HTTP 请求?"

论证:实际请求数远小于这个数,原因有三:

① 级联请求自动跟进:main.js import foo.js → foo.js import baz.js → 浏览器自动跟进整条链。Vite dev server 在本地运行——每个响应 ~2ms(无网络延迟),30 个请求 = 60ms。

② 预构建合并:vue 由 30 个内部模块组成——如果每个独立请求就是 30 个。Vite 的预构建把它们合并成 1 个文件——1 个请求拿到全部。

③ 强缓存:所有已编译模块带 Cache-Control: max-age=31536000,immutable——浏览器下次加载直接从 HTTP 缓存读,0 请求。模块内容变化时 Vite 改变 URL 中的版本 hash 强制刷新。

# 冷启动日志
$ npm run dev
Pre-bundling dependencies: vue, vue-router, lodash, axios...
  (done in 1.2s)

# 产物:
node_modules/.vite/deps/
  vue.js            ← 30 个内部模块 → 1 个 ESM 文件
  lodash.js         ← 200 个内部模块 → 1 个 ESM 文件
  _metadata.json     ← 哈希信息,判断缓存是否过期
1
2
3
4
5
6
7
8
9
10

# 3.2 预构建(Pre

Vite 冷启动时的预构建做两件事:转换(CJS→ESM)和合并(多个内部模块→1个文件):

预构建流程:
1. 扫描所有源码中的 import → 找出 node_modules 依赖
2. CJS → ESM(esbuild --format=esm)
3. 合并:一个包的所有内部模块合并成 1 个文件
4. 缓存到 node_modules/.vite/deps/(hash 机制判断过期)
1
2
3
4
5

为什么需要合并:lodash 有 200 个 CJS 文件。如果每个独立请求 → 200 个 HTTP 请求、浏览器对同一域名并发连接限制为 6 → 200/6 ≈ 34 个"批次"才加载完。合并 = 1 个请求。

# 3.3 HMR 热更新

HMR 完整流程:
编辑器保存 → Vite Server 检测文件变更
  → ① 只重新编译变更文件
  → ② 查模块图:谁 import 了它?
  → ③ 通过 WebSocket 推送更新路径
  → ④ 浏览器用 import.meta.hot.accept 回调 → 新模块替换旧模块
  → ⑤ 页面不刷新、状态保留!
1
2
3
4
5
6
7

关键设计——边界传播:Vite 只在模块图中传播到第一个有 accept 回调的模块为止。如果 App.vue 有 accept,变更只影响 App.vue 自身,main.js 不受影响。

和 Webpack HMR 的核心差异:

维度 Webpack HMR Vite HMR
依赖基础 自己的模块系统(__webpack_require__) 浏览器原生 ESM
变更后操作 重建 chunk → 替换 chunk 只编译 1 个文件 → 模块图传播
大型项目 5~15s <50ms

# 4. esbuild快

# 4.1 Go 语言编写

疑惑:esbuild 说自己比 webpack 快 100 倍——具体快在哪?

论证:100 倍是对"完整打包流程"的端到端加速——每条管线都有贡献:

传统 JS 打包器:
JS源码 → Parser → 完整AST → Transform → 新AST → CodeGen → 字符串
         第1遍遍历           第2遍遍历          第3遍遍历
         每遍都创建大量JS对象 → GC压力巨大

esbuild (Go):
JS源码 → Parser → 部分AST → Transform → 边解析边输出 → 字符串
                   └── 唯一一遍遍历 ──┘
         Go 无 GC 暂停 + 无大量临时对象
1
2
3
4
5
6
7
8
9

三个不做的事——也是快的关键:

  1. 不生成完整 AST:大多数 token 直接流过,不包装成节点对象
  2. 不创建中间字符串:内存中使用紧凑表示,最终一次性输出
  3. 无 GC 暂停:Go 的逃逸分析 + 栈分配避免了 JS 引擎的 GC 暂停问题

# 4.2 多核并行 vs

疑惑:"Webpack 不是也有 thread-loader 并行编译吗?"

论证:Webpack 的多线程是进程级——启动 Node worker → IPC 序列化传数据 → worker 执行 → 序列化结果回传。IPC 开销有时比单线程还大。esbuild 用 Go 的 goroutine——轻量协程、共享内存、调度开销 ~几微秒、无序列化。

esbuild 的并行阶段:

阶段 可并行? 并行单位
解析 ✅ 每个文件独立解析
链接 ❌ 需要全局模块关系,必须串行
压缩 ✅ 每个 chunk 独立压缩

# 4.3 esbuild

esbuild 不做的事:

能力 支持情况 替代方案
AST 级别自定义插件 ❌ 不支持 Rollup 插件系统
IE 兼容语法降级 ❌ 不做 esbuild 的 target 只到 ES2015
TypeScript 类型检查 ❌ 只擦除类型,不检查 tsc --noEmit 单独跑
CSS 处理(PostCSS 等) ❌ 功能有限 Vite 用 PostCSS,esbuild 只做 minify

结论:生产构建 = Rollup(完整插件生态 + Tree Shaking) + esbuild(minify 压缩) 组合。


# 5. 生产构建打包

# 5.1 ESM 静态分析

打包器通过三层递进判断"这段代码能不能删":

第一层:静态分析(ESM 专有)
  import { map } from 'lodash-es'
  → 分析整个 lodash-es 的导出 → map 被使用、filter 没被使用 → 标记 filter 为 unused

第二层:usedExports 优化(Terser/Rollup)
  → 对于标记为 unused 的导出:移除函数体,只保留空的导出声明
  → 对于完全没有被导入的模块:整个文件删除

第三层:sideEffects 标记
  package.json: { "sideEffects": false }
  → 告诉打包器"这个包里没有任何模块有副作用"
  → 可以安全删除 "被 import 但没有任何被使用的导出" 的模块
1
2
3
4
5
6
7
8
9
10
11
12
// package.json
{ "sideEffects": false }
// → 告诉打包器:这个包的所有模块都没有副作用,可以安全删除未使用的导出

// 如果某个文件有副作用(如 CSS import、全局注册):
{ "sideEffects": ["*.css", "*.scss", "./src/polyfills.js"] }
1
2
3
4
5
6

# 5.2 为什么 CJS

// ❌ CJS——无法 Tree Shaking
const pluginName = 'plugin' + Math.random();
const plugin = require(`./plugins/${pluginName}`);
// require 的参数是运行时计算的字符串 → 静态分析完全无法预知

// ✅ ESM——可以 Tree Shaking
import { map } from 'lodash-es';
// import 的路径必须是字符串字面量 → 静态分析可以确定
1
2
3
4
5
6
7
8

# 5.3 代码分割:ven

三种 chunk 类型的缓存策略:

// vite.config.js
export default {
    build: {
        rollupOptions: {
            output: {
                manualChunks: {
                    // vendor: 框架代码——变更频率极低,缓存命中率最高
                    'vendor-vue': ['vue', 'vue-router', 'pinia'],
                    // 图表库——只有用到图表的页面才加载(配合动态 import)
                    'vendor-charts': ['chart.js', 'echarts'],
                }
            }
        }
    }
};

// 路由懒加载 → 自动生成 async chunk
const Dashboard = () => import('./views/Dashboard.vue');
// 构建产物:Dashboard.[hash].js → 用户访问 /dashboard 时才请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

chunk 决策优先级:

chunk 类型 生成方式 缓存策略 适用
vendor manualChunks 手动指定 长期缓存(框架版本不变就不变) React/Vue/UI 库
common 自动(多个 chunk 共享的代码) 中等 工具函数、公共组件
async import() 自动拆分 按需加载 路由页面、重型组件

# 5.4 压缩(Terser

工具 速度 压缩率 适用
Terser 慢(JS 实现) 最优(AST 级优化) Webpack 生态
esbuild minify 快 20~50 倍 接近 Terser(~98%) Vite 默认

sourcemap 策略:生产用 hidden-source-map——sourcemap 文件单独生成,bundle.js 末尾不加 //# sourceMappingURL 注释。用户看不到,但错误监控平台(Sentry/Fundebug)可以关联。


# 6. 测试金字塔

# 6.1 静态分析(Typ

为什么是"免费"的:不需要写测试用例、不需要运行时间——在你打字的瞬间就生效。TypeScript 捕获类型错误(user.name vs user.namme),ESLint 捕获代码规范问题。在写代码时就发现 bug——成本最低。

// TypeScript 在你打字时就报错——不需要运行任何测试
function greet(user: { name: string }) {
    return `Hello, ${user.namme}`;  // ❌ Property 'namme' does not exist
}
1
2
3
4

# 6.2 单元测试(Vit

Vitest 兼容 Jest API 但基于 Vite——天然 ESM、HMR 级别的热重跑、多线程:

import { describe, it, expect, vi } from 'vitest';

// 被测函数
async function fetchUser(id) {
    const res = await fetch(`/api/user/${id}`);
    if (!res.ok) throw new Error('Network error');
    return res.json();
}

describe('fetchUser', () => {
    it('returns user data on success', async () => {
        // Mock 全局 fetch——返回自定义响应
        const mockFetch = vi.spyOn(global, 'fetch')
            .mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: 1, name: 'Alice' }) });

        const user = await fetchUser(1);
        expect(user).toEqual({ id: 1, name: 'Alice' });
        expect(mockFetch).toHaveBeenCalledWith('/api/user/1');

        mockFetch.mockRestore();  // 清理 mock
    });

    it('throws on network error', async () => {
        vi.spyOn(global, 'fetch').mockResolvedValue({ ok: false });
        await expect(fetchUser(1)).rejects.toThrow('Network error');
    });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

Vitest vs Jest:

维度 Jest Vitest
底层 自定义模块系统(jest-runtime) Vite(原生 ESM)
配置文件 jest.config.js 直接复用 vite.config.ts
热重跑 文件变更 → 全量重跑 HMR 级别(只重跑受影响的测试)
ESM 支持 需 transformIgnorePatterns 配置 原生支持
速度 中等 快(多线程 + Vite 缓存)

# 6.3 组件测试(Tes

Testing Library 的核心理念:不测试组件内部状态,只测试用户看到的和能交互的:

import { render, screen, fireEvent } from '@testing-library/vue';
import Counter from './Counter.vue';

test('increments count on button click', async () => {
    render(Counter);

    // ① 以用户视角查找元素(按文本内容)
    const button = screen.getByText('Count: 0');

    // ② 以用户视角交互
    await fireEvent.click(button);

    // ③ 以用户视角验证
    expect(screen.getByText('Count: 1')).toBeTruthy();
    // 注意:不是 expect(component.count).toBe(1) ← 这是测试内部实现
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

jsdom vs happy-dom:

维度 jsdom happy-dom
速度 中等 快 ~2x
DOM API 兼容性 较完整 部分 API 缺失(如 getClientRects)
CSS 支持 有限 基本无
适用 需要完整 DOM 模拟的场景 简单组件、追求速度

# 6.4 E2E(Play

E2E 启动真实的 Chromium/Firefox/WebKit 浏览器——自动化点击、输入、等待——模拟真实用户:

import { test, expect } from '@playwright/test';

test('user can complete checkout flow', async ({ page }) => {
    // ① 拦截网络请求——mock 后端数据
    await page.route('**/api/products', route =>
        route.fulfill({ json: [{ id: 1, name: 'Widget', price: 99 }] })
    );

    // ② 打开页面
    await page.goto('/shop');

    // ③ 用户操作
    await page.click('text=Widget');
    await page.click('button:has-text("Add to cart")');
    await page.click('button:has-text("Checkout")');

    // ④ 验证结果
    await expect(page.locator('.order-confirm')).toContainText('Order placed');
});

// Playwright 可以录像 + 生成 trace:
// npx playwright test --trace on
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

测试金字塔的成本比例:

层级 工具 每条测试成本 发现 bug 速度 覆盖率建议
静态分析 TS/ESLint $0 / 实时 输入时 100%
单元测试 Vitest 低(ms 级) CI 5s 80%+
组件测试 Testing Library 中(几十 ms) CI 30s 关键组件
E2E Playwright 高(s 级) CI 5min 核心流程 5~10 条

# 7. 快照测试的内部机制

# 7.1 toMatchS

expect(component).toMatchSnapshot();
// 第一次运行 → 生成 __snapshots__/test.js.snap(序列化结果存为文本)
// 后续运行 → 当前序列化结果 vs .snap 文件比对 → 不一致→测试失败
1
2
3

内部流程:

  1. toMatchSnapshot() 调 pretty-format 把对象序列化为格式化字符串
  2. 检查是否存在对应 .snap 文件 → 不存在 → 创建(测试通过)
  3. 存在 → 逐行比对 → 不匹配 → 打印 diff → 测试失败

# 7.2 快照更新的正确姿势

vitest --update     # 更新所有快照
vitest -u           # 同上(简写)
1
2

关键原则:只在确认"新行为是正确的"时才运行更新命令。 如果失败是因为代码改了——先检查 diff 是否符合预期——确认后再 -u。CI 上永远不带 -u——CI 上快照失败 = 构建失败。

# 7.3 什么场景不适合快

  • 含随机值/时间戳的输出(每次快照都不同→永远失败)→ 用 expect.any(Date) 或 mock Date.now()
  • 特别大的序列化输出(如整个页面的 HTML——快照文件 1000+ 行→难以 review)
  • 非确定性 UI(如动画中间状态→两次快照可能不同)

# 8. 火焰图读法

# 8.1 Self Tim

视角 怎么看 什么时候用
火焰图(主视图) 倒置调用栈——宽度=耗时,从下往上=调用关系 总体概览——谁在"烧"时间
Bottom-Up Self Time 降序——"哪个函数自身耗时最长" 找优化目标(最常用的视角)
Call Tree Total Time——"这个函数包括子调用总共花了多少" 分析"重型函数"的调用链
Event Log 按时间顺序列出每个事件 调试竞态条件、事件顺序

Self Time vs Total Time:

函数 A {                    ← Total Time = 100ms
    函数 B()                ← Self Time = 80ms(自己做的工作)
    函数 C()                ← Self Time = 10ms
    // A 自己的代码           ← Self Time = 10ms
}
1
2
3
4
5

# 8.2 火焰图中的布局

颜色 阶段 含义 优化策略
🟨 黄色 JS 执行 脚本运行时间 拆分长任务
🟪 紫色 Recalculate Style + Layout 重新计算样式和布局 减少 DOM 读取、用 requestAnimationFrame
🟩 绿色 Paint 光栅化像素 减少绘制区域、用 will-change
⬜ 灰色 Composite Layers GPU 合成 目标:大部分工作在灰色层完成

优化顺序:消除紫色 → 减少绿色 → 让灰色做最多的工作。

# 8.3 长任务(Long

火焰图中超过 50ms 的任务标红。修复策略:

// ❌ 200ms 长任务——阻塞主线程、掉帧、用户输入无响应
function processLargeArray(items) {
    for (const item of items) {
        heavyTransform(item);  // 每个 item 耗时相同
    }
}

// ✅ 拆成多个 <50ms 的任务——让浏览器在中间处理输入和渲染
async function processInChunks(items, chunkSize = 100) {
    for (let i = 0; i < items.length; i += chunkSize) {
        const chunk = items.slice(i, i + chunkSize);
        chunk.forEach(item => heavyTransform(item));
        await new Promise(r => setTimeout(r, 0));  // 交出主线程
    }
}

// 或使用 scheduler.postTask (Chrome 94+)
scheduler.postTask(() => heavyWork(), { priority: 'user-blocking' });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 9. Memory

# 9.1 Shallow

  • Shallow Size:对象本身占的直接内存
  • Retained Size:对象被回收后能释放的总内存(= 自身 + 只被它引用的所有子对象)
场景:一个 32B 的闭包引用了一个 200MB 的 Context
  → Shallow Size = 32B(闭包对象本身)
  → Retained Size = 200MB(Context 只被这个闭包引用——闭包一死,Context 可回收)

核心排查策略:按 Retained Size 降序排列 → 找到"自己很小但拖着一大块内存"的对象
1
2
3
4
5

# 9.2 堆快照对比(He

诊断流程:
1. 录制快照 1:页面刚加载完
2. 执行操作(如打开对话框→关闭对话框 10 次)
3. 录制快照 2
4. 对比 Delta 列:值增加最多的对象类别 = 泄漏源
1
2
3
4
5

典型泄漏模式:

  • 事件监听器未移除(addEventListener 但没有 removeEventListener)
  • 闭包持有大对象引用(闭包中的变量永远不释放)
  • 定时器未清理(setInterval 未调 clearInterval)
  • Detached DOM 节点(JS 仍引用已从 DOM 移除的元素)

# 9.3 Lighthou

指标 含义 优秀阈值 优化方向
FCP (First Contentful Paint) 首次内容绘制——用户看到第一个像素的时刻 <1.8s 减少阻塞资源(CSS/字体)、服务端渲染首屏
LCP (Largest Contentful Paint) 最大内容绘制——主要内容加载完成的时刻 <2.5s 优化图片(压缩/WebP/预加载)、减少首屏 JS
TBT (Total Blocking Time) 总阻塞时间——主线程被长任务阻塞的总时长 <200ms 拆分长任务、减少主线程 JS 执行
CLS (Cumulative Layout Shift) 累积布局偏移——页面元素意外移动 <0.1 给图片/视频预留宽高、避免动态注入广告
SI (Speed Index) 速度指数——视觉上页面加载完成的速度 ❤️.4s 综合优化——LCP + TBT 的联合改进

📊 LCP > 2.5s → 用户流失率暴增 32%(Google 数据)。LCP 是用户感知速度的唯一核心指标。


# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章的 500 行 / 2MB:

疑问 答案
① Vite No-bundle 为什么快 第 3.1-3.3:原生 ESM 按需编译(只编译首屏访问的 ~20 个模块 vs Webpack 全量 1000 个);esbuild 预构建 CJS 依赖并合并为 1 个文件;HMR 只编译变更文件 <50ms
② esbuild 为什么快 100x 第 4 章:Go 编译为原生码+无完整 AST(单遍遍历)+多核并行(goroutine 共享内存)+无 GC 暂停
③ Tree Shaking 怎么生效 第 5.1-5.2:ESM 静态分析 usedExports + sideEffects: false 标记;CJS 的 require() 动态参数无法静态分析→摇不掉
④ 测试金字塔每层成本 第 6-7 章:静态分析(免费/实时)→单元(Vitest/ms级)→组件(Testing Library/几十ms)→E2E(Playwright/s级)
⑤ 火焰图/Lighthouse 怎么看 第 8-9 章:Self Time 降序(Bottom-Up)=找优化目标;紫色=Layout/绿色=Paint→优先消除紫色;LCP<2.5s 是核心

# 10.2 对一个组件做完整

完整诊断闭环:

1. Lighthouse 跑整体扫描
   → 发现 LCP=4.2s(超标)、TBT=350ms(超标)
   → 定位问题:首屏 JS bundle 过大、图片未优化

2. Performance 面板录制 → 火焰图 → Bottom-Up
   → Self Time 最高:heavySort() 占了 180ms
   → 优化:拆分为 4 个 45ms 的 chunk

3. Memory 面板 → 两个快照对比
   → 打开/关闭对话框 10 次 → Delta: +15MB
   → 根因:对话框关闭时未清理事件监听器

4. 优化后重新跑 Lighthouse + Performance
   → LCP: 4.2s → 1.8s ✅
   → TBT: 350ms → 120ms ✅
   → 内存: +15MB → +0.5MB ✅
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 10.3 设计哲学回扣

哲学一·「No-bundle 不是"不打包"——是"只在需要时编译"」

Vite 的开发模式把"打包"从"启动前一次性完成"变成了"运行时按需编译"——和 Ignition 的分层编译(第 01 篇)异曲同工:冷代码就不编译,热代码才值得优化。 这是从 Eager(饥渴)到 Lazy(懒惰)的哲学转变——你不需要提前为"可能访问"的代码付出编译时间。

哲学二·「测试的成本和收益呈反比——底层的 bug 不要在高层测」

静态分析能发现的类型错误,比 E2E 测试快 1000 倍。测试金字塔不是教条——它是"把检测 bug 的成本降到最低"的工程策略。在 E2E 里测"用户名为空时显示错误提示"之前,先用 TypeScript 确保 username 永远不会是 undefined。

哲学三·「性能优化的第一步是测量——工具让你从"我觉得慢"到"火焰图说慢在哪"」

在没有火焰图之前,性能优化是"猜谜游戏"——改了一堆代码但不知道哪个改对了。有了 Self Time / Bottom-Up / Lighthouse——你找到了唯一一个值得优化的函数或指标。工具把优化从"玄学"变成了"工程"。

# 10.4 速查表:Vite

速查表 A:Vite 流水线

阶段 工具 关键优化
开发 Vite (No-bundle) 原生 ESM + 按需编译 + esbuild 预构建
生产 Rollup + esbuild ESM Tree Shaking + sideEffects:false + esbuild minify
代码分割 manualChunks + import() vendor(长期缓存) / async(按需加载)

速查表 B:测试金字塔四层

层级 工具 成本 何时用
静态 TS + ESLint 免费/实时 所有项目
单元 Vitest ms 级 工具函数/数据转换
组件 Testing Library 几十 ms 关键交互组件
E2E Playwright s 级 核心用户流程 5~10 条

速查表 C:火焰图四视角

视角 用法 找什么
火焰图主视图 概览 谁"烧"的时间最多
Bottom-Up Self Time 降序 优化目标(最常用)
Call Tree Total Time 重型函数的完整调用链
Event Log 时序列表 竞态条件排查

速查表 D:Lighthouse 五指标

指标 阈值 优化第一优先级
FCP <1.8s 减少阻塞资源
LCP <2.5s 优化图片 + 预加载关键资源(核心)
TBT <200ms 拆分长任务 <50ms
CLS <0.1 给图片预留宽高
SI ❤️.4s LCP + TBT 的综合优化

60 秒诊断清单:

# bundle 过大?
→ npx vite-bundle-visualizer → 找最大的依赖
→ CJS 库 → 换 ESM 版本或按需引入
→ moment → 换 dayjs/date-fns(Tree Shaking 友好)

# 开发启动慢?
→ Vite 替代 Webpack → 原生 ESM 按需编译
→ node_modules 过大 → .vite 缓存是否生效

# 热更新慢?
→ Webpack HMR → 换 Vite(<50ms)

# 哪个函数慢?
→ Performance 面板 → Bottom-Up → Self Time 降序 → 第1名

# 内存泄漏?
→ Memory 面板 → 两个快照对比 → Delta 列增最多的对象

# 用户流失?
→ Lighthouse → LCP > 2.5s → 优先优化最大内容元素的加载路径
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

下一步:工具链齐了。但代码怎么写才优雅?进入 15.设计模式与函数式哲学。

上次更新: 2026/06/16, 12:36:20
模块系统双轨操作
设计模式函数哲学

← 模块系统双轨操作 设计模式函数哲学→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式