工程化与构建
模块化演进
| 方案 | 时期 | 特点 |
|---|---|---|
| IIFE / 命名空间 | 早期 | 靠闭包避免全局污染,手动管理依赖顺序 |
| CommonJS | Node | 运行时同步加载,module.exports / require |
| AMD(RequireJS) | 浏览器 | 异步加载,依赖前置 |
| UMD | 兼容层 | 同时兼容 CJS / AMD / 全局 |
| ESM | 标准 | 编译时静态分析,import / export |
🔥 CommonJS vs ESM(必考):
| CommonJS | ESM | |
|---|---|---|
| 加载时机 | 运行时动态加载 | 编译时静态分析 |
| 导出值 | 值的拷贝(原始类型) | 值的引用(动态绑定) |
this | 指向 module.exports | undefined |
| 循环依赖 | 可能拿到未完成的导出 | 有 TDZ 保护,更安全 |
| Tree Shaking | ❌ 不支持 | ✅ 支持 |
顶层 await | ❌ | ✅ |
「值拷贝 vs 值引用」的实际差异:
// CJS:counter.js
let count = 0
module.exports = { count, inc: () => count++ }
// 使用方拿到的 count 永远是 0(拷贝了那一刻的值)
// ESM:counter.mjs
export let count = 0
export const inc = () => count++
// 使用方的 count 会跟着变(引用绑定)
ESM 为什么能 Tree Shaking —— import/export 必须在顶层、模块路径必须是字符串字面量,所以构建时就能确定完整的依赖图和每个导出是否被使用。CommonJS 的 require(变量) 可以动态拼接路径,静态分析无法确定。
🔥 Webpack 构建流程
1. 初始化 —— 合并配置、创建 Compiler、注册插件
2. 开始编译 —— 从 entry 出发
3. 模块解析 —— 用 resolver 找到文件
4. 调用 loader —— 把非 JS 转成 JS(从右到左 / 从下到上执行)
5. 解析 AST —— 找出该模块的依赖(import / require)
6. 递归处理依赖 —— 回到第 3 步,构建出完整的依赖图
7. 生成 Chunk —— 按入口和分割规则把模块组织成 chunk
8. 输出 —— 生成 assets 写入磁盘
核心概念:
- Entry —— 构建的起点
- Module —— 一切皆模块(JS、CSS、图片)
- Chunk —— 模块的集合,按入口 / 异步 import /
splitChunks规则划分 - Bundle —— 最终输出的文件
- Loader —— 文件转换器,处理单个文件(
babel-loader、css-loader) - Plugin —— 基于生命周期钩子,能力更强(优化、资源管理、注入环境变量)
Loader 和 Plugin 的区别(必考):Loader 作用于单个文件的内容转换,是一个纯函数(输入源码,输出源码);Plugin 作用于整个构建过程,通过 Tapable 的钩子在特定时机介入,能访问 compilation 做任何事。
Loader 的执行顺序是从右到左:use: ['style-loader', 'css-loader', 'sass-loader'] 的实际顺序是 sass → css → style。
常用优化配置
module.exports = {
// 1. 限制 loader 处理范围
module: {
rules: [{
test: /\.jsx?$/,
include: path.resolve('src'), // 只处理源码
exclude: /node_modules/,
use: ['babel-loader?cacheDirectory=true'] // 开 babel 缓存
}]
},
// 2. 持久化缓存(Webpack 5 内置,提速最明显的一项)
cache: { type: 'filesystem' },
// 3. 代码分割
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: { test: /node_modules/, name: 'vendors', priority: 10 },
common: { minChunks: 2, name: 'common', priority: 5 }
}
},
runtimeChunk: 'single', // 把 runtime 单独抽出,避免影响其他 chunk 哈希
},
// 4. 加快模块解析
resolve: {
extensions: ['.js', '.jsx'], // 少写几个,按频率排序
alias: { '@': path.resolve('src') },
modules: [path.resolve('node_modules')],
},
}
构建提速的手段(按收益排序):
cache: { type: 'filesystem' }—— Webpack 5 内置持久化缓存,二次构建可能快几倍- 缩小处理范围 ——
include/exclude、resolve.extensions thread-loader—— 多进程处理(只对耗时的 loader 有收益,小项目反而更慢因为有进程启动开销)- 换工具链 ——
esbuild-loader/swc-loader替代babel-loader,快一个数量级 - DLL —— Webpack 5 后基本被持久化缓存取代,不必再配
产物瘦身:
- Tree Shaking —— 详见下一节
- 按需引入 —— 组件库、
lodash-es、date-fns替代moment(后者无法 shake) splitChunks分离第三方库,利用长期缓存- 压缩 —— Terser(JS)、cssnano(CSS)、
image-minimizer - Gzip / Brotli 预压缩
- Bundle Analyzer 找意外引入的大包(最常见的是整个
moment的 locale、整个antd的 icons)
哈希的三种类型
| 变化条件 | 用途 | |
|---|---|---|
hash | 任何文件改变,整个构建的哈希都变 | ❌ 不要用,缓存全失效 |
chunkhash | 该 chunk 的内容改变 | JS 文件 |
contenthash | 该文件自身的内容改变 | ✅ 推荐,CSS 和 JS 都用 |
用 contenthash 的意义:改了一行 JS 不应该让 CSS 的缓存也失效。
Tree Shaking
定义:删除「被引入但从未被使用」的导出。名字的来源是「摇树」—— 活的叶子留在树上,死的掉下来。
三个前提,缺一个就失效:
- 必须是 ESM —— 静态可分析(见上文)。
require()是运行时调用,无法确定 - 必须开启生产模式 —— Webpack 的
mode: 'production'才启用usedExports和压缩器的 DCE - 模块必须无副作用,或者正确声明了副作用
副作用声明
什么算副作用:模块在顶层做了「不只是定义导出」的事 —— 修改全局对象、注册 polyfill、引入 CSS、调用有外部影响的函数。
{
"sideEffects": false,
"module": "dist/index.esm.js"
}
"sideEffects": false 是在告诉打包器:这个包里任何没被用到的模块都可以整个删掉。如果包里有 CSS 这类必须保留的文件,用数组白名单:
{
"sideEffects": ["*.css", "*.scss", "./src/polyfill.js"]
}
:::warning 漏写和错写都有代价
漏写 sideEffects(默认当作 true)→ 打包器不敢删,Tree Shaking 近乎无效。
错写成 false 但实际有副作用 → 样式文件或 polyfill 被删掉,表现是生产环境样式丢失或报未定义,而开发环境正常。这个 bug 很难查,因为只在生产出现。
:::
常见失效原因
按踩到的频率排:
| 原因 | 表现 | 修 |
|---|---|---|
| Babel 把 ESM 转成了 CJS | 完全不生效 | @babel/preset-env 设 modules: false,把模块转换交给打包器 |
| 库只发布了 CJS 产物 | 整个库被打进去 | 换有 ESM 产物的替代品(lodash → lodash-es) |
| 导入整个命名空间 | 全量引入 | import * as _ from 'lodash' 改成 import { debounce } from 'lodash-es' |
包未声明 sideEffects | 部分生效 | 给自己的包加上;第三方的在 module.rules 里标 sideEffects: false |
| 类的方法被引用判定保留 | 类整个保留 | 压缩器对类成员较保守,改用独立函数导出 |
| 访问了被判定有副作用的 getter | 保留 | 避免在顶层执行取值 |
怎么验证:webpack --stats-error-details 看 usedExports,或用 Bundle Analyzer 对比产物 —— 最直接的办法是注释掉某个 import 看体积变化。
HMR 的原理
HMR(Hot Module Replacement)和 Live Reload 的区别:Live Reload 是刷新整个页面(state 全丢),HMR 是只替换改动的模块,保留应用状态。
完整流程:
- 建立连接 —— dev server 启动时和浏览器建立 WebSocket 长连接
- 监听文件 —— 服务端用
chokidar监听文件系统变化 - 增量编译 —— 只重新编译改动的模块(Webpack 生成新的 chunk 和 manifest;Vite 直接返回转换后的单个模块)
- 推送通知 —— 服务端通过 WebSocket 把「哪些模块的 ID 变了」推给客户端
- 拉取新模块 —— 客户端运行时发请求取新代码(Webpack 用 JSONP 拉 chunk;Vite 直接
import带时间戳查询参数的 URL 来绕过缓存) - 沿依赖图冒泡 —— 从改动的模块开始向上找「接受更新的边界」
- 执行替换 —— 调用边界模块注册的回调,用新模块替换旧的
更新边界
这是 HMR 的核心概念,也是追问点。模块必须显式声明「我能接受自己的更新」:
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
// 用新模块重新渲染
render(newModule.default)
})
import.meta.hot.dispose(() => {
// 清理旧模块的副作用:定时器、事件监听、订阅
})
}
向上冒泡的规则:如果改动的模块自己没有 accept,就去问它的引入者;一路问上去,只要有一个祖先接受,就从那里开始重新执行;如果一直冒泡到入口都没有人接受,降级为整页刷新。
:::tip 为什么改了某些文件会整页刷新
这正是上面那条规则的体现 —— 改组件文件能热更新(框架插件注入了 accept),但改 vite.config.js、.env、或者被所有模块共享的工具函数,往往找不到合适的边界,只能整页刷新。
:::
React Fast Refresh 为什么能保留 state
朴素的 HMR 替换 React 组件会丢失 state,因为重新执行模块产生了新的组件函数引用,React 认为类型变了就卸载重建。
Fast Refresh 的做法:Babel 插件给每个组件注入一个稳定的签名(基于文件路径 + 组件名 + Hooks 调用序列),替换时按签名匹配而非函数引用,于是 Fiber 节点上的 memoizedState 得以保留。
所以它有这些限制:
- 组件必须是具名导出(匿名箭头函数拿不到稳定签名)
- 改了 Hooks 的调用顺序或数量,state 会重置(签名变了,无法保证新旧状态对应)
- 非组件的导出变化会触发整页刷新(无法判断影响范围)
- 文件里同时导出组件和非组件时,往往退化为整页刷新 —— 这是实践里最常见的「为什么我的 HMR 不生效」
Vite 为什么快
开发环境的根本差异:
| Webpack | Vite | |
|---|---|---|
| 启动 | 先打包整个应用再启动服务 | 直接启动,不打包 |
| 模块处理 | 构建时全部转换 | 浏览器原生 ESM,按需转换请求到的模块 |
| 启动耗时 | 随项目规模线性增长 | 几乎恒定 |
| HMR | 需要重新构建受影响的 chunk | 只失效改动的模块,与项目规模无关 |
Vite 的两套工具:
- 开发环境用 esbuild 做依赖预构建(Go 写的,比 JS 工具快 10~100 倍)—— 把 CJS 依赖转成 ESM,并把
lodash-es这种有几百个内部模块的包打成一个文件(避免几百个 HTTP 请求) - 生产环境用 Rollup 打包 —— 因为原生 ESM 在生产会产生太多请求,且需要更成熟的 chunk 拆分和 Tree Shaking
:::tip 为什么生产还要打包 这是高频追问。原因:① 原生 ESM 的大量小请求在 HTTP/1.1 下灾难、即使 HTTP/2 也有开销;② 需要 Tree Shaking、代码压缩、按需的 polyfill;③ 需要可控的 chunk 划分来优化缓存。
所以 Vite 的「no-bundle」只是开发时的策略,不是生产方案。 :::
Vite 的代价:开发和生产用两套不同的工具链(esbuild vs Rollup),可能出现「开发正常生产出错」;首次打开页面时请求数很多(但有浏览器缓存)。Rolldown(Rust 版 Rollup)正在统一这两套。
Babel
三个阶段:解析(源码 → AST)→ 转换(遍历 AST 并修改,插件在这里工作)→ 生成(AST → 代码)。
@babel/preset-env 的核心价值 —— 根据 targets(browserslist)只转换必要的语法。配得越精确,产物越小。
useBuiltIns 的三个值:
false—— 不处理 polyfillentry—— 根据 targets 把入口的import 'core-js'替换成需要的那些模块usage—— 按代码里实际用到的 API 按需引入 polyfill(推荐,产物最小)
polyfill 和语法转换的区别(高频):
- 语法转换(箭头函数、
class、解构)—— Babel 直接改写代码 - API polyfill(
Promise、Array.prototype.includes、Object.entries)—— 语法转换解决不了,必须注入实现代码
所以 Babel 转译后在老浏览器仍可能报 Promise is not defined,需要 polyfill。
@babel/plugin-transform-runtime —— 避免污染全局(不往 Array.prototype 上加方法),而是引入 helper 模块。写库必须用它,否则会影响使用方的全局环境。
其他工程化话题
Monorepo —— pnpm workspace / Turborepo / Nx。好处:代码共享、统一依赖版本、原子化的跨包改动。pnpm 的硬链接机制让 node_modules 体积和安装速度都明显优于 npm/yarn,且严格的依赖隔离(不允许访问未声明的依赖)能暴露隐式依赖问题。
npm / yarn / pnpm —— npm 和 yarn 用扁平化 node_modules(有「幽灵依赖」问题:能 import 没在 package.json 里声明的包);pnpm 用内容寻址存储 + 符号链接,全局只存一份,项目里是链接,既省空间又严格。
package.json 的版本号 —— ^1.2.3 允许次版本和补丁更新(1.x.x),~1.2.3 只允许补丁(1.2.x),1.2.3 锁定。lock 文件必须提交,它才是真正保证一致性的东西。
CI/CD 里前端该做什么 —— lint + 类型检查 + 单测 + 构建 + 产物体积检查(防止体积突增)+ Lighthouse CI(防止性能回退)+ 部署。husky + lint-staged 在提交前拦住格式和 lint 问题。
微前端 —— qiankun(基于 single-spa,JS 沙箱 + 样式隔离)、Module Federation(Webpack 5 内置,运行时共享模块)。适用场景是「多团队独立开发部署、技术栈可能不同的大型应用」。代价很大:样式和 JS 隔离、公共依赖版本冲突、路由协调、通信机制、调试困难。不要为了「听起来先进」而上微前端 —— 这个判断在面试里比知道实现细节更重要。
SSR / SSG / ISR —— 见Next.js 与 SSR。