跳到主要内容

工程化与构建

模块化演进​

方案时期特点
IIFE / 命名空间早期靠闭包避免全局污染,手动管理依赖顺序
CommonJSNode运行时同步加载,module.exports / require
AMD(RequireJS)浏览器异步加载,依赖前置
UMD兼容层同时兼容 CJS / AMD / 全局
ESM标准编译时静态分析,import / export

🔥 CommonJS vs ESM(必考):

CommonJSESM
加载时机运行时动态加载编译时静态分析
导出值值的拷贝(原始类型)值的引用(动态绑定)
this指向 module.exportsundefined
循环依赖可能拿到未完成的导出有 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')],
},
}

构建提速的手段(按收益排序):

  1. cache: { type: 'filesystem' } —— Webpack 5 内置持久化缓存,二次构建可能快几倍
  2. 缩小处理范围 —— include/exclude、resolve.extensions
  3. thread-loader —— 多进程处理(只对耗时的 loader 有收益,小项目反而更慢因为有进程启动开销)
  4. 换工具链 —— esbuild-loader / swc-loader 替代 babel-loader,快一个数量级
  5. DLL —— Webpack 5 后基本被持久化缓存取代,不必再配

产物瘦身:

  1. Tree Shaking —— 详见下一节
  2. 按需引入 —— 组件库、lodash-es、date-fns 替代 moment(后者无法 shake)
  3. splitChunks 分离第三方库,利用长期缓存
  4. 压缩 —— Terser(JS)、cssnano(CSS)、image-minimizer
  5. Gzip / Brotli 预压缩
  6. Bundle Analyzer 找意外引入的大包(最常见的是整个 moment 的 locale、整个 antd 的 icons)

哈希的三种类型​

变化条件用途
hash任何文件改变,整个构建的哈希都变❌ 不要用,缓存全失效
chunkhash该 chunk 的内容改变JS 文件
contenthash该文件自身的内容改变✅ 推荐,CSS 和 JS 都用

用 contenthash 的意义:改了一行 JS 不应该让 CSS 的缓存也失效。

Tree Shaking​

定义:删除「被引入但从未被使用」的导出。名字的来源是「摇树」—— 活的叶子留在树上,死的掉下来。

三个前提,缺一个就失效:

  1. 必须是 ESM —— 静态可分析(见上文)。require() 是运行时调用,无法确定
  2. 必须开启生产模式 —— Webpack 的 mode: 'production' 才启用 usedExports 和压缩器的 DCE
  3. 模块必须无副作用,或者正确声明了副作用

副作用声明​

什么算副作用:模块在顶层做了「不只是定义导出」的事 —— 修改全局对象、注册 polyfill、引入 CSS、调用有外部影响的函数。

package.json
{
"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 是只替换改动的模块,保留应用状态。

完整流程:

  1. 建立连接 —— dev server 启动时和浏览器建立 WebSocket 长连接
  2. 监听文件 —— 服务端用 chokidar 监听文件系统变化
  3. 增量编译 —— 只重新编译改动的模块(Webpack 生成新的 chunk 和 manifest;Vite 直接返回转换后的单个模块)
  4. 推送通知 —— 服务端通过 WebSocket 把「哪些模块的 ID 变了」推给客户端
  5. 拉取新模块 —— 客户端运行时发请求取新代码(Webpack 用 JSONP 拉 chunk;Vite 直接 import 带时间戳查询参数的 URL 来绕过缓存)
  6. 沿依赖图冒泡 —— 从改动的模块开始向上找「接受更新的边界」
  7. 执行替换 —— 调用边界模块注册的回调,用新模块替换旧的

更新边界​

这是 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 为什么快​

开发环境的根本差异:

WebpackVite
启动先打包整个应用再启动服务直接启动,不打包
模块处理构建时全部转换浏览器原生 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 —— 不处理 polyfill
  • entry —— 根据 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。