现代工程链三件套
# 14.现代工程链三件套
📍 上接第 13 篇《模块系统双轨互操作》。代码组织方式清楚了。本文回答交付环节的三个核心问题:怎么打包最小?怎么测最稳?怎么看性能瓶颈?
# 目录介绍
- 1. 案例与疑问引入
- 2. 架构全景概览
- 3. Vite开发模式
- 4. esbuild快
- 5. 生产构建打包
- 6. 测试金字塔
- 7. 快照测试的内部机制
- 8. 火焰图读法
- 9. Memory
- 10. 综合案例串讲
# 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!
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%)
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
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 节
2
3
4
5
6
7
8
# 1.3 我们要回答什么
这个 500 行 / 2MB 的案例就是本篇的主线案例:
- ① Vite No-bundle:为什么 Webpack 冷启动 20s、Vite 冷启动 1s?
- ② esbuild:Go 语言 + 无完整 AST + 多核并行 → 具体怎么快了 100 倍?
- ③ Tree Shaking + 代码分割:CJS 为什么摇不掉?
manualChunks最佳实践? - ④ 测试金字塔:静态分析→单元→组件→E2E 每层测什么、成本多少?
- ⑤ 火焰图 + Lighthouse:Self Time = 优化目标;LCP 是用户感知速度核心
本篇路线:
工程流水线全景 (第 2 章)
↓
Vite No-bundle (第 3 章) ─→ 解开"开发模式为什么快"
↓
esbuild 深度 (第 4 章) ─→ 解开"编译为什么能快 100x"
↓
Rollup + Tree Shaking (第 5 章) ─→ 解开"生产 bundle 怎么最小化"
↓
测试金字塔 (第 6-7 章) ─→ 从静态分析到 E2E 的逐层成本
↓
火焰图 + 内存 + Lighthouse (第 8-9 章) ─→ 工具链的"诊断武器库"
↓
综合案例 (第 10 章) ─→ 完整性能诊断闭环
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 │
└────────────────────────────────────────────────────────────┘
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 精准替换
2
3
4
5
6
7
8
Vite 快在四条根基:
- 原生 ESM:浏览器 2018 年起原生支持
<script type="module">——不需要打包器把 1000 个模块拼成 1 个文件 - 按需编译:首屏只访问 20 个模块 → Vite 只编译这 20 个。Webpack 编译全部 1000 个
- esbuild 预构建:node_modules 中的 CJS 依赖用 esbuild 一次性转成 ESM,缓存到
node_modules/.vite - 内存缓存:已编译模块缓存在内存中,第二次请求直接返回
性能对比实测(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 ← 哈希信息,判断缓存是否过期
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 机制判断过期)
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 回调 → 新模块替换旧模块
→ ⑤ 页面不刷新、状态保留!
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 暂停 + 无大量临时对象
2
3
4
5
6
7
8
9
三个不做的事——也是快的关键:
- 不生成完整 AST:大多数 token 直接流过,不包装成节点对象
- 不创建中间字符串:内存中使用紧凑表示,最终一次性输出
- 无 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 但没有任何被使用的导出" 的模块
2
3
4
5
6
7
8
9
10
11
12
// package.json
{ "sideEffects": false }
// → 告诉打包器:这个包的所有模块都没有副作用,可以安全删除未使用的导出
// 如果某个文件有副作用(如 CSS import、全局注册):
{ "sideEffects": ["*.css", "*.scss", "./src/polyfills.js"] }
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 的路径必须是字符串字面量 → 静态分析可以确定
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 时才请求
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
}
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');
});
});
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) ← 这是测试内部实现
});
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
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 文件比对 → 不一致→测试失败
2
3
内部流程:
toMatchSnapshot()调pretty-format把对象序列化为格式化字符串- 检查是否存在对应
.snap文件 → 不存在 → 创建(测试通过) - 存在 → 逐行比对 → 不匹配 → 打印 diff → 测试失败
# 7.2 快照更新的正确姿势
vitest --update # 更新所有快照
vitest -u # 同上(简写)
2
关键原则:只在确认"新行为是正确的"时才运行更新命令。 如果失败是因为代码改了——先检查 diff 是否符合预期——确认后再 -u。CI 上永远不带 -u——CI 上快照失败 = 构建失败。
# 7.3 什么场景不适合快
- 含随机值/时间戳的输出(每次快照都不同→永远失败)→ 用
expect.any(Date)或 mockDate.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
}
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' });
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 降序排列 → 找到"自己很小但拖着一大块内存"的对象
2
3
4
5
# 9.2 堆快照对比(He
诊断流程:
1. 录制快照 1:页面刚加载完
2. 执行操作(如打开对话框→关闭对话框 10 次)
3. 录制快照 2
4. 对比 Delta 列:值增加最多的对象类别 = 泄漏源
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 ✅
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 → 优先优化最大内容元素的加载路径
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
下一步:工具链齐了。但代码怎么写才优雅?进入 15.设计模式与函数式哲学。