09

工程化与构建

1,119 行约 52 分钟38 个代码块27 张表格更新于 2026-11-03

09 · 工程化与构建(详解版)

一句话定位:工程化是把「人写的源码」变成「浏览器能高效运行的产物」的全套流水线,包括模块化、依赖管理、编译、打包、压缩、缓存、发布与质量门禁。

读完本篇你应该能做到

  1. 解释一次 npm run build 背后发生了什么,产物为什么是这个形状;
  2. 独立配置 Vite/Rspack,做代码分割、Tree Shaking、环境变量与资源处理;
  3. 诊断「包体过大、构建太慢、依赖重复、线上白屏」四类高频问题;
  4. 为团队设计可持续的构建规范、Monorepo 策略与升级路线。

0. 领域导论

0.1 这个领域解决什么问题

现象根因归属
本地正常,线上白屏,控制台 Failed to load module scriptbase/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);

// ❌ 不要在客户端读取密钥:变量会被打进产物,人人可见

规则:

  1. 客户端环境变量 = 公开信息,永远不能放密钥;
  2. 变量在构建时被静态替换,运行时改 .env 不生效;
  3. 不同环境用不同文件(.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

三个必看信息:

  1. 总大小 vs gzip 后大小(gzip 通常 25%–35%);
  2. 首屏加载了哪些 chunk(Network 面板按 Initiator 过滤);
  3. 有没有重复模块(同一库多个版本会出现在不同 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 注入
阶段输入输出慢在哪优化手段
resolveimport 'x'绝对路径深层目录、exports 复杂缓存、减少 alias 链
transformTS/JSX/SCSSJS/CSSBabel/SWC、Sass 编译换原生工具、缩小 include
parse源码模块图文件数量减少模块数、跳过无关目录
bundle模块图作用域合并大图、循环依赖scope hoisting、拆包
chunk产物分块策略复杂稳定分包规则
emit产物文件压缩、sourcemap并行压缩、关闭无用 map

两个关键认知:

  1. 构建时间 ≈ 模块数 × 单模块处理成本。减少模块数(合并小文件、用原生 ESM 依赖)往往比换工具更有效。
  2. 开发服务器不需要 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

自检题

  1. 为什么 require 的包很难被 Tree Shaking?——require 可在运行时条件调用,导出对象还能被改写,打包器无法静态确定使用面;ESM 的导入导出在语法层固定,才能安全删除。
  2. 锁文件为什么必须提交?删掉重装有什么风险?——它锁定完整依赖树与完整性哈希;删掉重装会拉到新的子依赖版本,出现「本地能跑、线上不能」,也放大供应链投毒风险。
  3. 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';

四个必要条件:

  1. 依赖是 ESM;
  2. 没有被 sideEffects 误标(package.json 的 sideEffects: false 表示「无副作用,可安全删除」);
  3. 导入路径能被静态分析(不要 import(variable));
  4. 构建器开启了压缩与摇树(生产模式默认开)。
// 库作者的 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/CDNHTML、静态资源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"
    }
  }
}

三条纪律:

  1. 库包的 peerDependencies 要写宽(如 >=17 <20),把版本选择权交给应用;
  2. 应用侧统一锁定,不在不同子包声明互相冲突的区间;
  3. 每次升级后看产物,不要只看 package.json 里写了什么。

判断是否真的去重:grep -c "react.production.min" dist/*.js,或看构建分析器里的模块列表;同名模块出现两次就是没收敛。

2.10 L2 小结与自检

主题关键结论
摇树ESM + sideEffects + 静态导入
分割按路由/交互/依赖三个维度
预加载只预加载真正需要的
转译语法靠编译器,API 靠 polyfill
Monorepo有共享与协同诉求才上
缓存内容 hash + 持久缓存 + 远程缓存

自检题

  1. import _ from 'lodash' 和 import debounce from 'lodash/debounce' 差多少体积?为什么?——全量入口压缩后约 70KB、gzip 约 24KB,单函数约 2KB;全量会引入所有模块,且 lodash 是 CJS,摇树很难生效。
  2. sourcemap: 'hidden' 解决什么问题?——生成 map 但不在产物里写 sourceMappingURL,既不向用户暴露源码,又能上传监控平台还原堆栈。
  3. 什么情况下不该上 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 5JS全量打包 + HMR自身生态最全、配置复杂
ViteJS + esbuild/Rollup原生 ESM 按需Rollup启动快、生态成熟
RspackRust兼容 Webpack 配置自身迁移成本低、速度快
TurbopackRust增量自身Next.js 深度集成
esbuildGo可做 dev server自身极快,插件生态弱
RolldownRust—自身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"]
}

关键点:

  1. exports 是现代解析入口,优先于 main/module;
  2. 同时提供 ESM 与 CJS,避免使用方被迫全量引入;
  3. 声明 sideEffects,否则使用方无法摇树;
  4. 类型文件要能被 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"]
    }
  }
}

远程缓存的三个前提:

  1. 构建是确定性的(同样输入 → 同样输出,不依赖时间/随机/本机路径);
  2. 任务粒度可拆分(按包/按目标),否则一个大任务无法复用;
  3. 有命中率指标,否则「开了缓存但一直不命中」会被误认为有效。

典型的收益量级(经验值):

冷构建 10–15 分钟 → 本地增量 1–2 分钟 → 远程命中 30–90 秒
命中率从 0 到 70% 的收益,通常大于把单步工具换快一倍

四个常见坑:

坑后果修复
key 不含环境变量灰度/生产产物串味把 env 写进 hash
用文件修改时间判断切分支后误命中用内容 hash
缓存目录未纳入清理磁盘涨满、旧产物残留定期 prune + 上限
并行任务共享输出目录产物互相覆盖每个任务独立输出

3.10 L3 小结与自检

主题关键结论
Vite开发靠原生 ESM,生产靠 Rollup
插件生命周期钩子,改模块或改产物
瓶颈先测量,再换工具或拆范围
库构建ESM/CJS 双产物 + exports + 类型
CI锁文件、类型、包体、产物四道门
供应链锁版本、审脚本、校验产物

自检题

  1. Vite 开发模式为什么不打包整个应用?生产为什么又要打包?——开发期用浏览器原生 ESM 按需请求,启动快、热更新细;生产打包是为减少请求数、压缩、摇树、兼容旧浏览器并做长期缓存。
  2. 一个库的 sideEffects: false 写错了会发生什么?——有副作用的模块(polyfill、全局样式、注册逻辑)会被摇掉,表现为本地正常、线上缺功能。
  3. 远程缓存命中率很高,但线上出现了旧代码,可能是什么原因?——缓存键没覆盖环境变量、配置、依赖版本或构建参数,或复用了其他分支的产物;需要可复现构建与缓存命名空间隔离。

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    # 升级说明

三条原则:

  1. 默认即最佳:应用不写配置也能得到合理产物;
  2. 可逃生:允许局部覆盖,但要留注释与审批;
  3. 可升级:preset 用 SemVer,破坏性变更配 codemod。

4.3 迁移策略:不要停业务重写

以 Webpack → Rspack 为例:

  1. 并行验证:同一份代码双构建,对比产物 hash 与体积;
  2. 灰度:先在一个边缘应用切换;
  3. 兼容层:保留 Webpack 配置入口,通过开关切换;
  4. 回退:任何阶段都能一键回退;
  5. 清理:观察两周无异常后删除旧链路。

反模式:一次性替换所有项目 → 一个问题阻塞全公司发布。

4.4 成本视角

决策显性成本隐性成本
上 Monorepo迁移工作量依赖升级协同、CI 复杂度
自研脚手架开发人力长期维护、文档、招聘
远程缓存服务器/存储缓存失效导致的诡异 bug
全量 Rust 化迁移与兼容插件生态缺口

判断标准:收益是否可量化(构建时长、发布频率、故障率),且大于维护成本。

4.5 L4 自检

  1. 公司 40 个前端项目各自配置构建,如何收敛成一套?——先抽配置 preset 与脚手架,提供迁移 codemod,再逐步接管构建服务与缓存;保留逃生舱,按项目灰度迁移。
  2. 构建从 3 分钟优化到 1 分钟,值不值得投入两个人月?怎么算?——用「每次节省 2 分钟 × 每日构建次数 × 参与人数」折算人力,加上发布等待与上下文切换的机会成本;能在回收期内回本且持续收益就值。
  3. 如何保证「任何一次发布都能精确复现产物」?——固定依赖与工具版本、固定路径与时间戳(如 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
重复 lodashpnpm 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 可动态求值,无法可靠静态分析。
  • 追问:为什么加了 sideEffects: false 还是没删掉?
    • 要点:可能是被 CJS 包间接引入、导出被 Object.assign 或 getter 动态访问、或模块顶层有副作用被保守保留。用打包器的 stats 看模块是否进入产物,再看 usedExports 标记。
  • 再追问:sideEffects 写错会怎样?
    • 要点:把有副作用的模块(polyfill、全局样式、注册逻辑)标成无副作用,会被直接摇掉,表现为「本地正常、线上缺功能」;应只对纯模块声明,或用数组精确列出有副作用的文件。

链 2 · 分包 → 缓存命中 → 预加载

  • 面试官:为什么要拆 chunk?
    • 要点:让「不变的依赖」和「常变的业务」分离,利用长缓存;同时按路由拆包,减少首屏下载量。拆得太碎会增加请求数与运行时代理开销。
  • 追问:内容哈希在什么情况下会变?
    • 要点:模块内容、依赖的导出签名、构建配置、压缩器版本变化都会改变哈希;runtimeChunk 单独抽出可以避免「新增一个 chunk 导致所有引用它的哈希全变」。
  • 再追问:preload 与 prefetch 的区别?
    • 要点:preload 是当前页面需要、提前以较高优先级下载;prefetch 是未来可能用、空闲时低优先级下载。两者都不执行代码,误用会抢占首屏带宽。

链 3 · 构建性能 → 原生工具 → 缓存体系

  • 面试官:esbuild / SWC 为什么快?
    • 要点:用 Go / Rust 编写,避免 JS 单线程瓶颈;解析与生成高度并行;不做类型检查(类型检查交给 tsc --noEmit 并行跑)。但插件生态与产物优化能力不如成熟打包器。
  • 追问:构建缓存为什么会「假命中」?
    • 要点:缓存键没覆盖全部输入(环境变量、插件版本、配置文件、依赖 lockfile),或缓存了含时间戳/绝对路径的产物。要做可复现构建:固定路径、固定时间、把配置纳入哈希。
  • 再追问:远程缓存怎么保证安全与命中率?
    • 要点:按内容寻址(输入哈希 → 产物),上传前校验产物完整性;团队共享同一套工具链版本;敏感环境变量不参与缓存键时会导致产物串味,必须纳入或禁用缓存。

链 4 · 包体分析 → 重复依赖 → 分包策略

  • 面试官:首屏包 2.8MB,你怎么定位问题?
    • 要点:先看 gzip 后体积与首屏 chunk 组成;用分析器按「模块占比、重复模块、未使用导出」三类看;再区分「必需」「可延后」「可移除」。
  • 追问:为什么同一个库会有两份?
    • 要点:依赖树里存在不兼容的 semver 区间,包管理器各装一份;或 peerDependencies 没被满足,子包各自打包。用 why/overrides 收敛,并在产物里验证。
  • 再追问:拆包一定能减小首屏吗?
    • 要点:不一定。拆得太碎会增加请求与运行时代理成本;如果拆出的包仍在首屏同步加载,总字节没变。要按「首屏是否需要」而不是「按目录」拆。

链 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 分钟?

评估点:是否先测量再优化、是否利用缓存与影响面。

参考方案:

  1. 测量:记录每个任务的耗时与输入,找出 Top 5 长尾任务;区分「构建」「类型检查」「测试」「镜像」四类。
  2. 影响面:用依赖图算受影响包,只重建变更包及其下游;配合 turbo / nx 的 affected 能力。
  3. 缓存:本地 + 远程缓存,缓存键覆盖源码、依赖、配置与环境;CI 挂载持久化缓存目录。
  4. 并行:按 CPU 核数设并发,避免内存打满;把类型检查与打包拆成独立任务并行跑。
  5. 产物:容器镜像分层复用、依赖层单独缓存;减少不必要的 postinstall 与全量 npm ci。

加分:指出「快」不能以牺牲正确性为代价,缓存要可失效;指出要度量 p50/p95 而不是均值,并做趋势看板。

减分:直接换更快的构建器却不解决全量构建;缓存键覆盖不全导致脏产物;无限加并发导致 OOM。

题 2:团队决定把 Webpack 5 迁到 Rspack(或 Vite),你负责这次迁移。怎么在两周内完成且不影响发布?

评估点:是否先建基线、是否处理插件/loader 兼容、是否有灰度与回退。

参考方案:

  1. 建基线:记录冷构建、增量构建、HMR、产物体积、首屏指标;明确「迁移成功的判据」而不只是「能构建」。
  2. 盘点差异:列出用到的 loader/plugin,标注「官方支持 / 社区替代 / 需自研 / 可删除」;优先处理影响构建正确性的(如环境变量注入、CSS Modules、SVG 处理)。
  3. 双构建并行:新旧配置同时存在,CI 同时产出两份产物,做体积、hash、运行时行为对比;用同一套 E2E 跑两份产物。
  4. 灰度发布:按应用/路由灰度,观察错误率与性能;保留一键切回旧配置的能力。
  5. 收尾:删除旧配置、更新脚手架与文档、把迁移差异写进团队规范;监控一周后再宣布完成。

加分:指出「迁移的最大风险是隐性行为差异」(如 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 面板(未使用代码分析)