09 · 工程化与构建(详解版)
一句话定位:工程化是把「人写的源码」变成「浏览器能高效运行的产物」的全套流水线,包括模块化、依赖管理、编译、打包、压缩、缓存、发布与质量门禁。
读完本篇你应该能做到
- 解释一次
npm run build背后发生了什么,产物为什么是这个形状;- 独立配置 Vite/Rspack,做代码分割、Tree Shaking、环境变量与资源处理;
- 诊断「包体过大、构建太慢、依赖重复、线上白屏」四类高频问题;
- 为团队设计可持续的构建规范、Monorepo 策略与升级路线。
0. 领域导论
0.1 这个领域解决什么问题
| 现象 | 根因 | 归属 |
|---|---|---|
本地正常,线上白屏,控制台 Failed to load module script | base/publicPath 配错,或 MIME 类型不对 | 产物路径与部署 |
| 首屏 JS 3MB,LCP 5 秒 | 全量引入组件库、没有代码分割 | 包体治理 |
| 同一个库出现 3 个版本,体积翻倍 | 依赖树里有多个 semver 区间 | 依赖治理 |
| CI 构建 18 分钟,改一行要等半天 | 无持久缓存、串行任务、全量重建 | 构建性能 |
工程化的价值不在「能打包」,而在可重复、可诊断、可优化:任何人任何机器,都能得到同样的产物;出问题能定位到具体模块;改动能控制在秒级反馈。
0.2 一个类比
把构建想成中央厨房的备菜流水线:
- 源码是食材,模块系统是切配标准(统一大小才好下锅)。
- 编译器是灶台,把 TS/JSX/Sass 这些「生食」做成熟食。
- 打包器是打包盒,把几十道菜按套餐组合,避免每次单点(减少请求)。
- Tree Shaking 是去边角料:没点的菜不装盒。
- 代码分割是分餐盒:先上主食,甜点 later。
- 缓存是提前备好的半成品:只处理改动的部分。
理解这条流水线,你才知道「为什么构建慢」「为什么产物大」「为什么这个配置改了没用」。
0.3 学习地图
09 工程化与构建
├─ 前置:03 JavaScript(模块)、04 TypeScript(编译)
├─ 紧接:13 测试(CI 门禁)、10 网络(缓存与压缩)
├─ 交叉:11 性能(包体与加载)、12 安全(供应链)
└─ 长期:17 架构(Monorepo 与平台化)
0.4 分级标尺
| 级别 | 能力特征 | 典型产出 | 自检标准 |
|---|---|---|---|
| L1 基础 | 会用脚手架与默认配置 | 能跑通开发与构建 | 能解释产物目录结构 |
| L2 进阶 | 会配分包、环境、资源、Monorepo | 项目构建配置、包体优化 | 能解释为什么某个包没被摇掉 |
| L3 高级 | 懂构建器原理与插件机制 | 自研插件、构建平台 | 能定位构建瓶颈 |
| L4 专家 | 建标准、控成本、管迁移 | 团队构建规范、平台化方案 | 能对构建时长与稳定性负责 |
1. L1 基础:跑通一条流水线
1.1 模块化:ESM 与 CJS 的差别
| 维度 | ESM(import/export) | CJS(require/module.exports) |
|---|---|---|
| 加载时机 | 静态分析,编译期确定 | 运行时执行 |
| 是否异步 | 支持异步加载 | 同步 |
| Tree Shaking | 支持(静态结构可分析) | 基本不支持 |
顶层 await | 支持 | 不支持 |
| 循环依赖 | 有绑定提升,较可预测 | 可能拿到未完成的对象 |
| 浏览器原生 | 支持(type="module") | 不支持 |
// ESM:导出的是「绑定」,不是值拷贝
export let count = 0;
export function inc() { count += 1; }
// 导入方看到的是实时值(只读)
import { count, inc } from './counter.js';
inc();
console.log(count); // 1
// CJS:导出的是对象快照,解构后就固定了
let count = 0;
module.exports = { get count() { return count; }, inc: () => { count += 1; } };
工程含义:新代码一律写 ESM。CJS 依赖会让 Tree Shaking 失效,是包体膨胀的常见原因。
1.2 包管理与锁文件
{
"dependencies": {
"react": "^18.3.1"
},
"devDependencies": {
"vite": "^5.4.0"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
}
}
| 符号 | 含义 | 风险 |
|---|---|---|
^1.2.3 | 允许 1.x 内升级 | 次版本也可能有行为变化 |
~1.2.3 | 只允许补丁升级 | 较稳 |
1.2.3 | 锁定 | 需手动升级 |
latest | 永远最新 | 禁止用于生产 |
锁文件(package-lock.json / pnpm-lock.yaml / yarn.lock)必须提交。它保证「今天装」和「三个月后装」得到同一棵树。删锁文件重装 = 引入未知变更。
1.3 一份最小但完整的 Vite 配置
/**
* 构建配置的四个核心关注点:
* 1) 入口与输出 2) 路径别名 3) 代理 4) 分包
*/
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
server: {
port: 5173,
proxy: {
// 开发期代理,避免本地跨域
'/api': { target: 'http://localhost:8080', changeOrigin: true },
},
},
build: {
target: 'es2020', // 决定语法降级程度
sourcemap: true, // 线上排错必需(可用 hidden 模式)
assetsInlineLimit: 4096, // 小于 4KB 的资源内联为 base64
rollupOptions: {
output: {
// 把变化频率低的第三方库单独拆出,利于长期缓存
manualChunks: {
react: ['react', 'react-dom'],
},
},
},
},
});
1.4 环境变量:只认特定前缀
# .env.production
VITE_API_BASE=https://api.example.com
// 只有 VITE_ 前缀的变量会注入客户端
console.log(import.meta.env.VITE_API_BASE);
// ❌ 不要在客户端读取密钥:变量会被打进产物,人人可见
规则:
- 客户端环境变量 = 公开信息,永远不能放密钥;
- 变量在构建时被静态替换,运行时改
.env不生效; - 不同环境用不同文件(
.env.development/.env.production),并纳入版本管理(密钥除外)。
1.5 资源处理:图片、字体、JSON
// 小于阈值 → base64 内联;否则 → 独立文件 + hash 文件名
import logo from './logo.svg'; // 返回 URL
import data from './config.json'; // 默认解析为对象
import Worker from './worker?worker'; // Vite 特有:以 Worker 方式加载
// 显式指定处理方式
import raw from './note.md?raw'; // 作为字符串
import url from './big.png?url'; // 强制返回 URL
hash 文件名的意义:内容变则文件名变 → 可以设置「永久缓存」,发布时只失效变化的文件。
1.6 看懂构建产物
dist/
├─ index.html # 入口,含 <script type="module" src="/assets/index-a1b2.js">
├─ assets/
│ ├─ index-a1b2.js # 业务代码
│ ├─ react-c3d4.js # 手动拆出的 vendor
│ ├─ OrderList-e5f6.js # 路由懒加载 chunk
│ └─ index-g7h8.css
└─ favicon.ico
三个必看信息:
- 总大小 vs gzip 后大小(gzip 通常 25%–35%);
- 首屏加载了哪些 chunk(Network 面板按 Initiator 过滤);
- 有没有重复模块(同一库多个版本会出现在不同 chunk)。
1.7 构建流水线的七个阶段
① resolve 把 import 说明符解析为绝对路径(含 alias、exports、扩展名)
② load 读取文件内容(磁盘/网络/缓存)
③ transform 转译(TS/JSX/Sass/图片),产出 JS 与 sourcemap
④ parse 生成 AST,收集 import/export,构建模块图
⑤ bundle 按图拼接作用域(scope hoisting)或包装为模块函数
⑥ chunk 按入口/动态导入/策略切分,计算 hash
⑦ emit 压缩、写盘、生成 manifest 与 HTML 注入
| 阶段 | 输入 | 输出 | 慢在哪 | 优化手段 |
|---|---|---|---|---|
| resolve | import 'x' | 绝对路径 | 深层目录、exports 复杂 | 缓存、减少 alias 链 |
| transform | TS/JSX/SCSS | JS/CSS | Babel/SWC、Sass 编译 | 换原生工具、缩小 include |
| parse | 源码 | 模块图 | 文件数量 | 减少模块数、跳过无关目录 |
| bundle | 模块图 | 作用域合并 | 大图、循环依赖 | scope hoisting、拆包 |
| chunk | 产物 | 分块 | 策略复杂 | 稳定分包规则 |
| emit | 产物 | 文件 | 压缩、sourcemap | 并行压缩、关闭无用 map |
两个关键认知:
- 构建时间 ≈ 模块数 × 单模块处理成本。减少模块数(合并小文件、用原生 ESM 依赖)往往比换工具更有效。
- 开发服务器不需要 bundle。Vite 用浏览器原生 ESM 按需编译,所以冷启动快;生产构建仍然要打包,因为 HTTP/1.1 与压缩效率。
症状 / 根因 / 修复 / 验证
| 症状 | 根因 | 修复 | 验证 |
|---|---|---|---|
| 改一行要等 10 秒 | 全量重建、依赖预构建失效 | 开启 HMR 边界、检查依赖是否被误加入源码目录 | 改文件到浏览器更新耗时 |
| 构建内存溢出 | 单进程处理过多模块 | 提高堆上限、拆包、用 SWC/esbuild | 峰值 RSS |
| 产物里出现源码路径 | sourcemap 公开或未剥离 | hidden 模式 + 私有上传 | 搜索产物中的 src/ |
| 本地与 CI 产物不一致 | Node 版本/依赖/环境变量不同 | 固定版本 + 容器 + 锁文件 | 产物 hash 对比 |
1.8 L1 小结与自检
| 主题 | 关键结论 |
|---|---|
| 模块 | 新代码用 ESM;CJS 阻塞摇树 |
| 依赖 | 锁文件必须提交;latest 禁用 |
| 配置 | 入口/别名/代理/分包四件事 |
| 环境变量 | 客户端变量是公开的 |
| 产物 | 关注 hash、体积、首屏 chunk |
自检题
- 为什么
require的包很难被 Tree Shaking?——require可在运行时条件调用,导出对象还能被改写,打包器无法静态确定使用面;ESM 的导入导出在语法层固定,才能安全删除。 - 锁文件为什么必须提交?删掉重装有什么风险?——它锁定完整依赖树与完整性哈希;删掉重装会拉到新的子依赖版本,出现「本地能跑、线上不能」,也放大供应链投毒风险。
- gzip 后 200KB 的 JS,算大还是小?怎么判断?——看同类页面基线与首屏必需比例,再折算中端机 4G 下的下载与解析时间;超出性能预算就拆包、延后加载或移除,不能只看绝对值。
2. L2 进阶:包体与工程结构
2.1 Tree Shaking 为什么有时不生效
// ❌ 副作用导入:整个包都会被打进来
import _ from 'lodash';
_.debounce(fn, 300);
// ✅ 按需导入
import debounce from 'lodash/debounce';
// ✅ 更好的做法:换成 ESM 友好、可摇树的库
import { debounce } from 'es-toolkit';
四个必要条件:
- 依赖是 ESM;
- 没有被
sideEffects误标(package.json的sideEffects: false表示「无副作用,可安全删除」); - 导入路径能被静态分析(不要
import(variable)); - 构建器开启了压缩与摇树(生产模式默认开)。
// 库作者的 sideEffects:告诉打包器哪些文件不能删
{
"sideEffects": ["*.css", "./src/polyfill.js"]
}
自检方法:用 rollup-plugin-visualizer 生成 treemap,搜索那个库名,看实际打入了多少。
2.2 代码分割的四种手段
// 1) 路由级:最常用
const Detail = lazy(() => import('./pages/Detail'));
// 2) 交互级:点击后才需要
button.onclick = async () => {
const { openEditor } = await import('./editor');
openEditor();
};
// 3) 条件级:大模块按特性加载
if (features.charts) {
const { renderChart } = await import('./charts');
renderChart(el);
}
// 4) 依赖级:把稳定的大库单独拆出
// build.rollupOptions.output.manualChunks
分包的判断标准:
| 内容 | 是否单独拆 | 理由 |
|---|---|---|
| 框架运行时(React/Vue) | 是 | 极少变化,长期缓存 |
| 大型可视化库 | 是 | 只在特定页面用 |
| 业务公共组件 | 谨慎 | 拆太细增加请求 |
| 首屏必需的小工具 | 否 | 合并更划算 |
2.3 预加载与优先级
<!-- 关键资源提前发现 -->
<link rel="modulepreload" href="/assets/react-c3d4.js" />
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
<link rel="prefetch" href="/assets/Detail-e5f6.js" />
| 指令 | 时机 | 用途 |
|---|---|---|
preload | 当前页立即需要 | 字体、首屏图、关键 JS |
modulepreload | 当前页模块依赖 | ESM chunk |
prefetch | 空闲时下载 | 下一页资源 |
fetchpriority | 调整优先级 | 提升 LCP 图片 |
预加载不是越多越好:抢占带宽会拖慢真正关键的资源。
2.4 转译与 polyfill
// .browserslistrc:决定语法降级与 polyfill 范围
> 0.5%
last 2 versions
not dead
not op_mini all
// 语法降级(esbuild/swc/babel)≠ API 补齐(polyfill)
// 可选链能被降级;Promise.allSettled 需要 polyfill
import 'core-js/actual/promise/all-settled';
判断标准:新语法(?.、??、装饰器)→ 编译器处理;新 API(Array.prototype.at、structuredClone)→ polyfill。
2.5 样式方案与构建
| 方案 | 作用域 | 运行时成本 | 适用 |
|---|---|---|---|
| 全局 CSS | 全局 | 无 | 基础重置、主题变量 |
| CSS Modules | 文件级 | 无 | 组件样式(推荐默认) |
| CSS-in-JS | 组件级 | 有(运行时插入) | 动态主题,注意性能 |
| Tailwind | 原子类 | 无(构建期生成) | 快速迭代、设计约束 |
| 预处理器(Sass/Less) | 文件级 | 无 | 复杂嵌套与函数 |
/* CSS Modules:类名在构建时被哈希,天然隔离 */
.title { color: red; }
import styles from './card.module.css';
// styles.title → '_title_a1b2c'
注意:CSS-in-JS 在 SSR 下需要提取关键 CSS,否则首屏会闪一下无样式内容(FOUC)。
2.6 Monorepo:什么时候值得
repo/
├─ apps/
│ ├─ web/ # 用户端
│ └─ admin/ # 管理端
├─ packages/
│ ├─ ui/ # 组件库
│ ├─ utils/ # 工具
│ └─ config/ # 共享配置(eslint/tsconfig/vite preset)
├─ pnpm-workspace.yaml
└─ turbo.json
值得用的信号:
- 多个应用共享大量组件/工具,靠「复制粘贴」同步;
- 需要一次改动同时更新多个包(原子提交);
- 有统一的规范诉求(lint/tsconfig/build preset)。
不值得的信号:
- 只有一个应用;
- 团队没有 CI 与版本纪律;
- 依赖升级无人负责(Monorepo 会放大这个问题)。
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
// turbo.json:声明任务依赖与缓存,避免重复构建
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": { "dependsOn": ["build"] }
}
}
2.7 缓存:构建提速的第一杠杆
| 层 | 缓存什么 | 失效条件 |
|---|---|---|
| 浏览器 | 产物文件 | 文件名 hash 变化 |
| HTTP/CDN | HTML、静态资源 | TTL / purge |
| 构建器 | 模块转译结果 | 文件内容或配置变化 |
| CI | 依赖、构建缓存 | lockfile 或缓存 key 变化 |
| 远程缓存 | 整个任务的产物 | 输入 hash 变化 |
// 内容 hash:内容不变 → 文件名不变 → 缓存长期有效
output: {
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
}
2.8 Source Map 与线上排错
| 模式 | 产物 | 用途 |
|---|---|---|
sourcemap: true | 独立 .map 文件 | 私有部署可用 |
sourcemap: 'hidden' | 生成但不写 //# sourceMappingURL | 上报后平台私有解析(推荐) |
sourcemap: false | 无 | 体积最小,排错困难 |
安全:.map 文件不要公开可访问,它包含完整源码。
2.9 依赖去重与版本收敛
同一个库在产物里出现多份,是包体膨胀最隐蔽的原因。
# 找出重复版本(pnpm)
pnpm why react
pnpm list react --depth 10
# npm
npm ls react
| 症状 | 根因 | 修复 | 验证 |
|---|---|---|---|
| 产物里有两份 React | 依赖树存在不兼容的 peer 区间 | 提升主版本或加 overrides | 产物中搜索 react.production 次数 |
| 组件库样式重复注入 | 多版本各自带一份 CSS | 收敛版本 + 去重插件 | CSS 体积与重复选择器 |
| 同一工具出现多个小版本 | 传递依赖区间过宽 | resolutions/overrides 锁定 | 锁文件 diff |
| 打包报「Invalid hook call」 | 两份 React 实例 | 对齐 peer + 别名到同一路径 | 运行时错误消失 |
{
"pnpm": {
"overrides": {
"react": "18.3.1",
"react-dom": "18.3.1"
}
}
}
三条纪律:
- 库包的
peerDependencies要写宽(如>=17 <20),把版本选择权交给应用; - 应用侧统一锁定,不在不同子包声明互相冲突的区间;
- 每次升级后看产物,不要只看
package.json里写了什么。
判断是否真的去重:grep -c "react.production.min" dist/*.js,或看构建分析器里的模块列表;同名模块出现两次就是没收敛。
2.10 L2 小结与自检
| 主题 | 关键结论 |
|---|---|
| 摇树 | ESM + sideEffects + 静态导入 |
| 分割 | 按路由/交互/依赖三个维度 |
| 预加载 | 只预加载真正需要的 |
| 转译 | 语法靠编译器,API 靠 polyfill |
| Monorepo | 有共享与协同诉求才上 |
| 缓存 | 内容 hash + 持久缓存 + 远程缓存 |
自检题
import _ from 'lodash'和import debounce from 'lodash/debounce'差多少体积?为什么?——全量入口压缩后约 70KB、gzip 约 24KB,单函数约 2KB;全量会引入所有模块,且 lodash 是 CJS,摇树很难生效。sourcemap: 'hidden'解决什么问题?——生成 map 但不在产物里写sourceMappingURL,既不向用户暴露源码,又能上传监控平台还原堆栈。- 什么情况下不该上 Monorepo?——项目间几乎无共享代码、发布节奏完全独立、权限与合规要求隔离、工具链无法统一;否则只增加协调与 CI 成本。
3. L3 高级:构建器原理与平台化
3.1 Vite 为什么开发快
开发模式(Vite)
浏览器请求 /src/main.tsx
→ 服务端按需转译「单个文件」(esbuild,毫秒级)
→ 以 ESM 形式返回
→ 浏览器自己处理 import(原生 ESM)
不打包整个应用 → 冷启动与 HMR 都只处理改动链路
生产模式(Vite)
Rollup 全量打包 → 更适合优化产物(摇树、分包、压缩)
代价:开发时请求数多(HTTP/2 可缓解),某些 CJS 依赖需要预构建(optimizeDeps)。
3.2 主流构建器对比
| 工具 | 语言 | 开发模式 | 生产打包 | 特点 |
|---|---|---|---|---|
| Webpack 5 | JS | 全量打包 + HMR | 自身 | 生态最全、配置复杂 |
| Vite | JS + esbuild/Rollup | 原生 ESM 按需 | Rollup | 启动快、生态成熟 |
| Rspack | Rust | 兼容 Webpack 配置 | 自身 | 迁移成本低、速度快 |
| Turbopack | Rust | 增量 | 自身 | Next.js 深度集成 |
| esbuild | Go | 可做 dev server | 自身 | 极快,插件生态弱 |
| Rolldown | Rust | — | 自身 | Rollup 的 Rust 实现(演进中) |
选型:新项目 Vite;Webpack 存量且构建慢 → Rspack 渐进迁移;Next.js 项目跟随官方。
3.3 插件机制:以 Rollup/Vite 为例
/**
* 一个最小插件:在产物里注入构建信息。
* 插件本质是「在构建生命周期的特定钩子上做事情」。
*/
function buildInfoPlugin() {
return {
name: 'build-info',
transform(code, id) {
if (!id.endsWith('.ts')) return null;
return null; // 不改代码
},
generateBundle(_options, bundle) {
for (const file of Object.values(bundle)) {
if (file.type === 'chunk' && file.isEntry) {
file.code = `/* built at ${new Date().toISOString()} */\n${file.code}`;
}
}
},
};
}
常见钩子:resolveId(找模块)、load(读内容)、transform(改内容)、generateBundle(改产物)。Webpack 对应 resolve/load/emit 与 loader/plugin 两套体系。
3.4 定位构建瓶颈
# 1) 时间花在哪
DEBUG=vite:* vite build
# 或 webpack --profile + speed-measure-webpack-plugin
# 2) 体积花在哪
npx vite-bundle-visualizer
# 3) 哪个插件最慢
--debug 查看插件耗时
| 症状 | 常见原因 | 处理 |
|---|---|---|
| 冷启动 30s+ | 预构建依赖多、插件串行 | 缩小 optimizeDeps、换 Rust 工具 |
| HMR 2s+ | 模块图太大、样式全量重算 | 拆包、限制 HMR 范围 |
| 生产构建 10min+ | 压缩串行、sourcemap 全量 | 并行压缩、只在 CI 生成 sourcemap |
| 产物重复 | 多版本依赖、CJS/ESM 双份 | pnpm dedupe、alias 统一版本 |
3.5 库构建:产物格式与 exports
{
"name": "my-ui",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css"
},
"sideEffects": ["*.css"]
}
关键点:
exports是现代解析入口,优先于main/module;- 同时提供 ESM 与 CJS,避免使用方被迫全量引入;
- 声明
sideEffects,否则使用方无法摇树; - 类型文件要能被
types找到,否则 TS 报错。
3.6 CI/CD:一条可靠的流水线
# .github/workflows/ci.yml(示意)
jobs:
build:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile # 锁文件不一致直接失败
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test -- --coverage
- run: pnpm build
- run: pnpm size-limit # 包体预算门禁
四个必须的门禁:
| 门禁 | 防什么 |
|---|---|
--frozen-lockfile | 依赖漂移 |
| 类型检查 | 类型回归 |
| 包体预算 | 体积失控 |
| 构建产物校验 | 产物缺文件、路径错误 |
3.7 构建平台与远程缓存
开发者机器 → 上传输入 hash → 远程缓存命中 → 直接下载产物(秒级)
↘ 未命中 → 分布式构建 → 回传缓存
收益:CI 时长下降 50%–80%;代价:缓存 key 设计错误会导致「用了旧产物」,必须把配置、环境变量、依赖版本全部纳入 hash。
3.8 供应链安全
| 风险 | 防护 |
|---|---|
| 恶意包/投毒 | 锁文件、私有 registry、npm audit、依赖白名单 |
| 依赖混淆 | 锁定 scope、校验 registry |
| 构建产物被篡改 | SRI、产物签名、可复现构建 |
| 脚本执行 | --ignore-scripts、审查 postinstall |
<!-- SRI:CDN 资源被替换时浏览器拒绝执行 -->
<script src="https://cdn.example.com/lib.js"
integrity="sha384-xxxx" crossorigin="anonymous"></script>
3.9 增量构建与持久缓存
构建提速的杠杆按收益排序:减少工作量 > 并行 > 单步提速。
| 层级 | 缓存什么 | key(决定是否复用) | 失效条件 |
|---|---|---|---|
| 浏览器 | 产物文件 | 文件名 hash | 内容变化 |
| CDN | 产物文件 | URL + 缓存头 | 发布/刷新 |
| 本地构建 | 模块转译结果 | 文件内容 + 配置 + 工具版本 | 任一输入变化 |
| 远程构建 | 任务产物(含依赖图) | 任务输入 hash + 环境 | 输入或环境变化 |
| HMR | 模块边界 | 模块路径 + 依赖 | 边界外模块变化 |
缓存的唯一正确性标准:所有影响输出的输入都必须进入 key。漏一个环境变量,就会出现「缓存命中但产物是错的」——这是最难排查的一类构建事故。
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json", ".env.production"],
"outputs": ["dist/**"],
"env": ["NODE_ENV", "API_BASE"]
}
}
}
远程缓存的三个前提:
- 构建是确定性的(同样输入 → 同样输出,不依赖时间/随机/本机路径);
- 任务粒度可拆分(按包/按目标),否则一个大任务无法复用;
- 有命中率指标,否则「开了缓存但一直不命中」会被误认为有效。
典型的收益量级(经验值):
冷构建 10–15 分钟 → 本地增量 1–2 分钟 → 远程命中 30–90 秒
命中率从 0 到 70% 的收益,通常大于把单步工具换快一倍
四个常见坑:
| 坑 | 后果 | 修复 |
|---|---|---|
| key 不含环境变量 | 灰度/生产产物串味 | 把 env 写进 hash |
| 用文件修改时间判断 | 切分支后误命中 | 用内容 hash |
| 缓存目录未纳入清理 | 磁盘涨满、旧产物残留 | 定期 prune + 上限 |
| 并行任务共享输出目录 | 产物互相覆盖 | 每个任务独立输出 |
3.10 L3 小结与自检
| 主题 | 关键结论 |
|---|---|
| Vite | 开发靠原生 ESM,生产靠 Rollup |
| 插件 | 生命周期钩子,改模块或改产物 |
| 瓶颈 | 先测量,再换工具或拆范围 |
| 库构建 | ESM/CJS 双产物 + exports + 类型 |
| CI | 锁文件、类型、包体、产物四道门 |
| 供应链 | 锁版本、审脚本、校验产物 |
自检题
- Vite 开发模式为什么不打包整个应用?生产为什么又要打包?——开发期用浏览器原生 ESM 按需请求,启动快、热更新细;生产打包是为减少请求数、压缩、摇树、兼容旧浏览器并做长期缓存。
- 一个库的
sideEffects: false写错了会发生什么?——有副作用的模块(polyfill、全局样式、注册逻辑)会被摇掉,表现为本地正常、线上缺功能。 - 远程缓存命中率很高,但线上出现了旧代码,可能是什么原因?——缓存键没覆盖环境变量、配置、依赖版本或构建参数,或复用了其他分支的产物;需要可复现构建与缓存命名空间隔离。
4. L4 专家:标准、成本与迁移
4.1 构建性能的度量体系
| 指标 | 定义 | 目标参考 |
|---|---|---|
| 冷启动 | 清缓存到 dev server 可用 | < 5s(中型项目) |
| HMR | 保存到页面更新 | < 300ms |
| 生产构建 | 清缓存到产物完成 | < 3min(中型) |
| CI 总时长 | 安装到制品上传 | < 10min |
| 产物体积 | 首屏 JS(gzip) | < 200KB |
| 缓存命中率 | 远程/本地缓存 | > 70% |
怎么用:把指标接入 CI 报表,按周看趋势;超过阈值阻断合并。没有度量的「优化」都是猜测。
4.2 标准化:把配置变成产品
packages/config/
├─ eslint/ # 统一规则
├─ tsconfig/ # 统一编译选项
├─ vite-preset/ # 统一构建预设
└─ changelog.md # 升级说明
三条原则:
- 默认即最佳:应用不写配置也能得到合理产物;
- 可逃生:允许局部覆盖,但要留注释与审批;
- 可升级:preset 用 SemVer,破坏性变更配 codemod。
4.3 迁移策略:不要停业务重写
以 Webpack → Rspack 为例:
- 并行验证:同一份代码双构建,对比产物 hash 与体积;
- 灰度:先在一个边缘应用切换;
- 兼容层:保留 Webpack 配置入口,通过开关切换;
- 回退:任何阶段都能一键回退;
- 清理:观察两周无异常后删除旧链路。
反模式:一次性替换所有项目 → 一个问题阻塞全公司发布。
4.4 成本视角
| 决策 | 显性成本 | 隐性成本 |
|---|---|---|
| 上 Monorepo | 迁移工作量 | 依赖升级协同、CI 复杂度 |
| 自研脚手架 | 开发人力 | 长期维护、文档、招聘 |
| 远程缓存 | 服务器/存储 | 缓存失效导致的诡异 bug |
| 全量 Rust 化 | 迁移与兼容 | 插件生态缺口 |
判断标准:收益是否可量化(构建时长、发布频率、故障率),且大于维护成本。
4.5 L4 自检
- 公司 40 个前端项目各自配置构建,如何收敛成一套?——先抽配置 preset 与脚手架,提供迁移 codemod,再逐步接管构建服务与缓存;保留逃生舱,按项目灰度迁移。
- 构建从 3 分钟优化到 1 分钟,值不值得投入两个人月?怎么算?——用「每次节省 2 分钟 × 每日构建次数 × 参与人数」折算人力,加上发布等待与上下文切换的机会成本;能在回收期内回本且持续收益就值。
- 如何保证「任何一次发布都能精确复现产物」?——固定依赖与工具版本、固定路径与时间戳(如
SOURCE_DATE_EPOCH)、把配置纳入缓存键,并用产物哈希做一致性校验。
5. 综合实战:构建从 4 分钟到 40 秒,包体降 40%
背景:中型后台,3000 模块,Webpack 5,首屏 JS 2.8MB(gzip 900KB)。
步骤 1 · 先测量
- 构建耗时分布:babel-loader 55%,terser 20%,其余 25%
- 包体构成:组件库全量 600KB、moment 300KB、重复 lodash 2 份
- 首屏 chunk:包含所有页面代码(无路由分割)
步骤 2 · 对症下药
| 问题 | 措施 | 预期收益 |
|---|---|---|
| babel 慢 | 换 esbuild/swc 转译 | -50% 构建时间 |
| 全量组件库 | 按需引入 + ESM 版本 | -400KB |
| moment | 换 dayjs(或仅引入需要的 locale) | -250KB |
| 重复 lodash | pnpm dedupe + alias | -150KB |
| 无路由分割 | React.lazy + Suspense | 首屏 -60% |
| 无持久缓存 | 开启 cache: { type: 'filesystem' } | 二次构建 -70% |
步骤 3 · 加门禁防回退
{
"size-limit": [
{ "path": "dist/assets/index-*.js", "limit": "200 kB" },
{ "path": "dist/assets/*.css", "limit": "60 kB" }
]
}
步骤 4 · 结果与验收
- 冷构建 4min → 1min50s;热构建 40s;
- 首屏 JS gzip 900KB → 210KB;
- LCP 4.2s → 1.9s;
- 包体超预算时 CI 直接失败。
6. 常见误区对照表
| 误区 | 事实 |
|---|---|
| 「打包就是压缩代码」 | 还包含模块解析、摇树、分包、转译、资源处理 |
| 「Tree Shaking 开了就有效」 | CJS、副作用、动态导入都会让它失效 |
| 「依赖装得越多越方便」 | 每个依赖都是体积、安全与升级负担 |
| 「锁文件冲突就删掉重装」 | 等于放弃可复现性,引入未知变更 |
| 「sourcemap 上线不安全」 | 用 hidden + 私有上传即可兼顾排错与安全 |
| 「构建慢就换工具」 | 先测瓶颈,很多问题是 loader/插件配置 |
| 「Monorepo 能解决协作问题」 | 它放大纪律问题,不替代纪律 |
| 「预加载越多首屏越快」 | 抢带宽会拖慢关键资源 |
| 「CI 缓存一定安全」 | key 设计错误会复用旧产物 |
| 「devDependency 不影响产物」 | 误引入到运行时代码就会被打包 |
7. 面试高频题(附答题思路)
Q1:从输入 npm run build 到产出文件,发生了什么?
读配置 → 解析入口 → 构建模块图 → 转译/摇树 → 分包 → 压缩 → 生成 hash 文件名 → 写产物。按阶段答,体现全局理解。
Q2:Tree Shaking 的原理与失效场景?
ESM 静态结构 → 标记未使用导出 → 压缩阶段删除;失效:CJS、副作用、动态导入、sideEffects 误配。
Q3:Vite 为什么快?生产为什么还用 Rollup? 开发用原生 ESM 按需转译,不打包;生产需要优化产物(摇树、分包、压缩),Rollup 更成熟。
Q4:如何减小首屏体积? 分析(visualizer)→ 去重/替换大依赖 → 按需引入 → 路由分割 → 预加载关键 chunk → 预算门禁。
Q5:Monorepo 的优缺点? 优点:共享、原子提交、统一规范;缺点:CI 复杂、依赖协同、权限与发布管理。给适用信号。
Q6:如何保证构建可复现? 锁文件 + 固定 Node/包管理器版本 + 确定性 hash + 远程缓存 key 完整 + 产物签名。
Q7:构建慢怎么排查? 分阶段计时 → 插件/loader 耗时 → 模块数量 → 缓存命中 → 并行度;再决定换工具还是改配置。
Q8:ESM 与 CJS 混用会有什么问题?
双包体积、摇树失效、__dirname 缺失、循环依赖语义差异、require 无法加载 ESM(需动态 import)。
8. 面试深挖
8.1 面试官在考察什么
| 维度 | 看什么 | 加分回答 | 减分回答 |
|---|---|---|---|
| 模块图 | 是否理解「构建就是图遍历」 | 能画出依赖图到产物的映射 | 「打包就是把文件合起来」 |
| 产物分析 | 会不会读 stats | 用模块占比与重复依赖定位问题 | 只看总体积 |
| 缓存设计 | 是否懂内容哈希与长缓存 | 说清 runtime chunk 的作用 | 「加 hash 就行」 |
| 性能判断 | 知道构建器为什么快 | 从语言、并行、缓存三层解释 | 「Vite 就是快」 |
| 工程权衡 | 能否为团队做取舍 | 给出可回退的迁移路径 | 追新不评估成本 |
8.2 追问链
链 1 · 模块图 → Tree Shaking → 失效条件
- 面试官:Tree Shaking 的原理是什么?
- 要点:基于 ESM 的静态结构(导入导出在语法层确定),先标记「被使用」的导出,再在压缩阶段删除未引用代码;CJS 因为
require可动态求值,无法可靠静态分析。
- 要点:基于 ESM 的静态结构(导入导出在语法层确定),先标记「被使用」的导出,再在压缩阶段删除未引用代码;CJS 因为
- 追问:为什么加了
sideEffects: false还是没删掉?- 要点:可能是被 CJS 包间接引入、导出被
Object.assign或 getter 动态访问、或模块顶层有副作用被保守保留。用打包器的 stats 看模块是否进入产物,再看usedExports标记。
- 要点:可能是被 CJS 包间接引入、导出被
- 再追问:
sideEffects写错会怎样?- 要点:把有副作用的模块(polyfill、全局样式、注册逻辑)标成无副作用,会被直接摇掉,表现为「本地正常、线上缺功能」;应只对纯模块声明,或用数组精确列出有副作用的文件。
链 2 · 分包 → 缓存命中 → 预加载
- 面试官:为什么要拆 chunk?
- 要点:让「不变的依赖」和「常变的业务」分离,利用长缓存;同时按路由拆包,减少首屏下载量。拆得太碎会增加请求数与运行时代理开销。
- 追问:内容哈希在什么情况下会变?
- 要点:模块内容、依赖的导出签名、构建配置、压缩器版本变化都会改变哈希;
runtimeChunk单独抽出可以避免「新增一个 chunk 导致所有引用它的哈希全变」。
- 要点:模块内容、依赖的导出签名、构建配置、压缩器版本变化都会改变哈希;
- 再追问:
preload与prefetch的区别?- 要点:
preload是当前页面需要、提前以较高优先级下载;prefetch是未来可能用、空闲时低优先级下载。两者都不执行代码,误用会抢占首屏带宽。
- 要点:
链 3 · 构建性能 → 原生工具 → 缓存体系
- 面试官:esbuild / SWC 为什么快?
- 要点:用 Go / Rust 编写,避免 JS 单线程瓶颈;解析与生成高度并行;不做类型检查(类型检查交给
tsc --noEmit并行跑)。但插件生态与产物优化能力不如成熟打包器。
- 要点:用 Go / Rust 编写,避免 JS 单线程瓶颈;解析与生成高度并行;不做类型检查(类型检查交给
- 追问:构建缓存为什么会「假命中」?
- 要点:缓存键没覆盖全部输入(环境变量、插件版本、配置文件、依赖 lockfile),或缓存了含时间戳/绝对路径的产物。要做可复现构建:固定路径、固定时间、把配置纳入哈希。
- 再追问:远程缓存怎么保证安全与命中率?
- 要点:按内容寻址(输入哈希 → 产物),上传前校验产物完整性;团队共享同一套工具链版本;敏感环境变量不参与缓存键时会导致产物串味,必须纳入或禁用缓存。
链 4 · 包体分析 → 重复依赖 → 分包策略
- 面试官:首屏包 2.8MB,你怎么定位问题?
- 要点:先看 gzip 后体积与首屏 chunk 组成;用分析器按「模块占比、重复模块、未使用导出」三类看;再区分「必需」「可延后」「可移除」。
- 追问:为什么同一个库会有两份?
- 要点:依赖树里存在不兼容的 semver 区间,包管理器各装一份;或
peerDependencies没被满足,子包各自打包。用why/overrides收敛,并在产物里验证。
- 要点:依赖树里存在不兼容的 semver 区间,包管理器各装一份;或
- 再追问:拆包一定能减小首屏吗?
- 要点:不一定。拆得太碎会增加请求与运行时代理成本;如果拆出的包仍在首屏同步加载,总字节没变。要按「首屏是否需要」而不是「按目录」拆。
链 5 · Monorepo → 任务图 → 影响范围构建
- 面试官:12 个包、40 个应用,CI 全量构建 20 分钟,怎么优化?
- 要点:先建任务图(包依赖 + 脚本依赖),只构建受影响包及其下游;再上远程缓存与并行;最后才考虑换更快的工具。
- 追问:怎么判断「受影响」?
- 要点:基于文件变更 + 依赖图做传递闭包;注意配置、依赖版本、环境变量变化也会影响;对不确定的情况宁可多构建。
- 再追问:并行度越高越好吗?
- 要点:受 CPU 与内存限制,过多并行会互相抢占导致更慢;还要避免共享输出目录与端口冲突。按机器规格设上限,用队列调度。
8.3 手写题
题 1 · 极简打包器
考察点:依赖图、模块包装、运行时 require。
/**
* 极简打包器:只处理相对路径与 ESM 的 import/export 语法子集。
* @param {string} entry 入口绝对路径
* @returns {string} 可执行的 bundle 代码
*/
function createBundle(entry) {
const fs = require('fs');
const path = require('path');
/** @type {Map<string, {code: string, deps: string[]}>} */
const modules = new Map();
/** 递归收集依赖,返回模块 ID */
const build = (file) => {
if (modules.has(file)) return modules.get(file).id;
const code = fs.readFileSync(file, 'utf8');
const deps = [...code.matchAll(/from\s+['"](\.[^'"]+)['"]/g)].map((m) =>
path.resolve(path.dirname(file), m[1])
);
const id = modules.size;
modules.set(file, { id, code, deps });
deps.forEach(build);
return id;
};
build(entry);
/** 把 import 改写成 require,导出挂到 module.exports */
const wrap = (mod) => {
const body = mod.code
.replace(/import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g, (_, name, spec) => {
const dep = path.resolve(path.dirname([...modules.keys()][mod.id]), spec);
return `const ${name} = require(${modules.get(dep).id})`;
})
.replace(/export\s+default\s+/g, 'module.exports = ')
.replace(/export\s+(const|function|class)\s+(\w+)/g, '$1 $2; module.exports.$2 = $2;');
return `${mod.id}: function(module, exports, require) {\n${body}\n}`;
};
return `(function(modules){
const cache = {};
function require(id) {
if (cache[id]) return cache[id].exports;
const module = { exports: {} };
cache[id] = module;
modules[id](module, module.exports, require);
return module.exports;
}
require(0);
})({${[...modules.values()].map(wrap).join(',\n')}});`;
}
追问:
- 这个实现缺什么?→ 只支持相对路径、单行 import、无循环依赖特殊处理(缺
exports预置)、无 tree shaking 与 source map。 - 循环依赖会怎样?→ 未执行完的模块
exports是空对象,取值得到undefined,与 CJS 行为一致。 - 为什么真实打包器不用正则?→ 需要 AST 保证语义正确(别名、动态导入、TS/JSX 语法),正则会被字符串与注释误伤。
题 2 · 生成资源清单的构建插件
/**
* 构建结束后输出 manifest,供服务端模板或预加载使用。
* @returns {{name: string, generateBundle(options: object, bundle: object): void}}
*/
function manifestPlugin() {
return {
name: 'emit-manifest',
generateBundle(_options, bundle) {
/** @type {Record<string, {file: string, isEntry: boolean, imports: string[]}>} */
const manifest = {};
Object.values(bundle).forEach((chunk) => {
if (chunk.type !== 'chunk') return;
manifest[chunk.name] = {
file: chunk.fileName,
isEntry: chunk.isEntry,
imports: chunk.imports,
};
});
this.emitFile({
type: 'asset',
fileName: 'manifest.json',
source: JSON.stringify(manifest, null, 2),
});
},
};
}
追问:
- 为什么在
generateBundle而不是writeBundle?→ 前者还能修改产物且哈希尚未最终确定,后者只能读盘。 imports里可能是相对路径,怎么用?→ 用chunk.fileName拼绝对路径,或在插件里统一转成公开路径。- 清单对 SSR 有什么用?→ 服务端模板据此注入正确的
<script>/<link>,避免手写文件名。
8.4 场景设计题
题:一个 monorepo 有 12 个包、40 个应用,CI 全量构建要 20 分钟,主干队列经常堵。怎么压到 3 分钟?
评估点:是否先测量再优化、是否利用缓存与影响面。
参考方案:
- 测量:记录每个任务的耗时与输入,找出 Top 5 长尾任务;区分「构建」「类型检查」「测试」「镜像」四类。
- 影响面:用依赖图算受影响包,只重建变更包及其下游;配合
turbo/nx的affected能力。 - 缓存:本地 + 远程缓存,缓存键覆盖源码、依赖、配置与环境;CI 挂载持久化缓存目录。
- 并行:按 CPU 核数设并发,避免内存打满;把类型检查与打包拆成独立任务并行跑。
- 产物:容器镜像分层复用、依赖层单独缓存;减少不必要的
postinstall与全量npm ci。
加分:指出「快」不能以牺牲正确性为代价,缓存要可失效;指出要度量 p50/p95 而不是均值,并做趋势看板。
减分:直接换更快的构建器却不解决全量构建;缓存键覆盖不全导致脏产物;无限加并发导致 OOM。
题 2:团队决定把 Webpack 5 迁到 Rspack(或 Vite),你负责这次迁移。怎么在两周内完成且不影响发布?
评估点:是否先建基线、是否处理插件/loader 兼容、是否有灰度与回退。
参考方案:
- 建基线:记录冷构建、增量构建、HMR、产物体积、首屏指标;明确「迁移成功的判据」而不只是「能构建」。
- 盘点差异:列出用到的 loader/plugin,标注「官方支持 / 社区替代 / 需自研 / 可删除」;优先处理影响构建正确性的(如环境变量注入、CSS Modules、SVG 处理)。
- 双构建并行:新旧配置同时存在,CI 同时产出两份产物,做体积、hash、运行时行为对比;用同一套 E2E 跑两份产物。
- 灰度发布:按应用/路由灰度,观察错误率与性能;保留一键切回旧配置的能力。
- 收尾:删除旧配置、更新脚手架与文档、把迁移差异写进团队规范;监控一周后再宣布完成。
加分:指出「迁移的最大风险是隐性行为差异」(如 process.env 替换、CSS 顺序、polyfill 注入),要用产物对比与 E2E 兜底;指出迁移期间 CI 时间会翻倍,需要临时扩容。
减分:一次性替换配置直接上线;只看「构建成功」不看产物差异;没有回退方案。
8.5 最容易答错的点
| 常见说法 | 纠正 |
|---|---|
| 「ESM 就一定能 Tree Shaking」 | 还要无副作用、无动态访问、压缩器能证明未使用 |
「sideEffects: false 是无脑优化项」 | 标错会删掉样式与注册逻辑,必须逐包确认 |
| 「chunk 越碎加载越快」 | 请求数与运行时代理开销会反噬,按路由与变更频率拆 |
| 「hash 变了就是代码变了」 | 依赖签名、配置、压缩器版本都会影响 |
| 「Vite 生产也靠 ESM 原生加载」 | 生产仍用 Rollup 打包,开发期才走原生 ESM |
| 「Source Map 只是调试用」 | 它还用于错误还原与体积分析,且要注意访问权限 |
| 「构建缓存命中就等于正确」 | 输入未纳入哈希时会产生脏产物,必须可复现 |
| 「polyfill 越多越兼容」 | 会显著增大体积,应按目标浏览器与用量注入 |
| 「构建越快,用户加载越快」 | 构建速度只影响研发效率,用户侧取决于产物体积与请求链路 |
| 「缓存开得越大越好」 | 脏缓存、磁盘与命中率都要管,关键是输入覆盖完整且可失效 |
| 「拆得越细越现代」 | 过度拆分增加请求、预加载复杂度与运行时代理开销 |
9. 延伸资源
- Vite 官方文档:依赖预构建、插件 API、
build.rollupOptions - Rollup 文档:Tree Shaking、插件钩子
- Webpack 5 文档:模块联邦、持久化缓存
- Rspack / Turbopack / Rolldown 官方博客与迁移指南
webpack-bundle-analyzer/rollup-plugin-visualizer/size-limit- pnpm workspace、Turborepo、Nx 文档
- SLSA / Sigstore / npm provenance:供应链安全
- 「Reproducible Builds」项目与规范
- esbuild / SWC 官方文档:为什么快、插件与限制
package.json的exports/imports字段规范(Node.js 文档)- Module Federation 官方示例与最佳实践
- Chrome 开发者工具 Coverage 面板(未使用代码分析)