Appearance
Day7 前端工程化:Webpack / Vite、HMR、Tree-Shaking
📖 基础知识讲解【基础原理 + 高级深挖知识点】
基础原理
1. Webpack 五大核心概念
| 概念 | 作用 | 常见配置 |
|---|---|---|
| Entry | 构建的起点,从这里开始递归查找依赖 | 单入口 './src/index.js'、多入口 { main, admin } |
| Output | 产物输出位置与命名规则 | path、filename: '[name].[contenthash:8].js'、publicPath |
| Loader | 转换非 JS 模块(本质是函数,源码进、源码出) | babel-loader、css-loader、ts-loader |
| Plugin | 通过生命周期钩子扩展全流程能力 | HtmlWebpackPlugin、MiniCssExtractPlugin、DefinePlugin |
| Mode | 预设一组优化策略 | development / production / none |
js
// webpack.config.js 最小可用骨架
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
mode: "production",
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "js/[name].[contenthash:8].js",
clean: true, // webpack5 内置,替代 clean-webpack-plugin
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/, // ⚠️ 必须排除,否则构建速度灾难
use: "babel-loader",
},
{
test: /\.s?css$/,
// ⚠️ loader 执行顺序:从右到左、从下到上
use: ["style-loader", "css-loader", "sass-loader"],
},
{
test: /\.(png|jpe?g|svg)$/,
type: "asset", // webpack5 内置资源模块,替代 url-loader/file-loader
parser: { dataUrlCondition: { maxSize: 8 * 1024 } }, // 8kb 以下转 base64
},
],
},
plugins: [new HtmlWebpackPlugin({ template: "./public/index.html" })],
};一句话区分 Loader 和 Plugin: Loader 是翻译官,只负责把一种文件转成 webpack 能理解的 JS 模块;Plugin 是监工,在 webpack 的 100 多个生命周期钩子上挂载逻辑,能力覆盖整个构建流程。
2. Vite:两套截然不同的工具链
开发环境 dev 生产环境 build
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ 1. ESBuild 预构建第三方依赖 │ │ 1. Rollup 打包 │
│ 产物缓存 node_modules/.vite │ │ 2. Tree-Shaking + 代码分割 │
│ 2. 启动 connect 静态服务 │ │ 3. 压缩 + contenthash 命名 │
│ 3. 浏览器用 <script type= │ │ │
│ "module"> 直接请求源文件 │ │ 为什么不用 ESBuild 打包? │
│ 4. 请求到哪个文件才编译哪个 │ │ → 代码分割 / CSS 处理 / 插件 │
│ (.vue / .ts 实时转换) │ │ 生态不如 Rollup 成熟 │
└──────────────────────────────┘ └──────────────────────────────┘
按需编译,启动耗时与项目体积无关 追求产物质量,慢一点可以接受| 维度 | Webpack Dev | Vite Dev |
|---|---|---|
| 启动前是否全量打包 | ✅ 必须打完才能访问 | ❌ 不打包,启动即服务 |
| 启动耗时 | 与项目规模线性相关 | 与项目规模几乎无关(只预构建依赖) |
| 模块加载 | bundle 后的产物 | 浏览器原生 ESM,一个文件一个请求 |
| 热更新 | 重新编译受影响的 chunk | 精准失效单个模块,直接替换 |
| 生产打包 | Webpack | Rollup |
面试加分: Vite 的「快」不是因为 ESBuild 快(那只用在预构建和 TS/JSX 转换),本质是把「打包」这件事从开发阶段去掉了 —— no-bundle 才是核心思想,ESBuild 只是锦上添花。
3. HMR 热更新 vs Live Reload
| 维度 | Live Reload | HMR (Hot Module Replacement) |
|---|---|---|
| 粒度 | 整个页面刷新 | 单个模块替换 |
| 页面状态 | ❌ 全部丢失(表单、路由、弹窗) | ✅ 保留 |
| 速度 | 和首次加载一样慢 | 毫秒级 |
| 失败兜底 | — | 自动降级为整页刷新 |
js
// HMR 的用户侧 API(框架脚手架已帮你接好,手写需要自己 accept)
if (module.hot) {
// webpack
module.hot.accept("./render", () => {
render(); // 接受 ./render 模块的更新,手动重新执行
});
module.hot.dispose((data) => {
clearInterval(timer); // 旧模块销毁前清理副作用,否则定时器会叠加
});
}
if (import.meta.hot) {
// vite(原生 ESM 规范)
import.meta.hot.accept((newModule) => {
newModule?.render();
});
import.meta.hot.dispose(() => clearInterval(timer));
}注意: React 的
react-refresh、Vue 的@vitejs/plugin-vue内部都已经调用了accept,并且能保留组件 state —— 这是框架插件的能力,不是 HMR 协议本身自带的。
4. Tree-Shaking:摇掉没用到的代码
js
// utils.js
export function used() {
return 1;
}
export function unused() {
return 2;
} // ✅ 会被摇掉
// index.js
import { used } from "./utils";
console.log(used());生效的三个必要条件:
| 条件 | 说明 |
|---|---|
| ESM 静态语法 | 必须是 import / export,import 只能在顶层、路径必须是字面量,才能在编译期确定依赖关系 |
| 不能被 Babel 转成 CJS | @babel/preset-env 的 modules 必须设为 false,否则 ESM 被降级成 require,静态分析能力丢失 |
| 开启压缩 | Webpack 只做「标记」(usedExports),真正删除代码靠 Terser;mode: 'production' 才默认开启 |
js
// ❌ CommonJS 无法 Tree-Shaking:require 是运行时的,路径可以是变量
const utils = require("./utils");
const name = Math.random() > 0.5 ? "used" : "unused";
utils[name](); // 打包工具无法在编译期知道用了哪个
// ❌ 整体导入命名空间也会让摇树变困难
import * as utils from "./utils";
// ✅ 具名导入,可静态分析
import { used } from "./utils";| 对比项 | ES Module | CommonJS |
|---|---|---|
| 加载时机 | 编译期(静态) | 运行时(动态) |
| 导出值 | 值的引用(动态绑定) | 值的拷贝 |
| 能否条件导入 | ❌(import() 除外) | ✅ |
| 支持 Tree-Shaking | ✅ | ❌ |
✨ 高级深挖知识点
1. Webpack 完整构建流程(必背)
① 初始化参数
读取 webpack.config.js + CLI 参数 → 合并出最终 options
│
▼
② 创建 Compiler 对象
Compiler 是全局唯一的「大管家」,持有完整配置与所有钩子
遍历 plugins 调用 plugin.apply(compiler) 注册钩子
│
▼
③ 开始编译 run / watch
触发 compile 钩子 → 创建 Compilation(一次编译的上下文)
│
▼
④ 从 Entry 出发确定入口
addEntry → NormalModuleFactory 创建 Module
│
▼
⑤ 编译模块 buildModule
├─ 用 loader 链处理源码(从右到左)→ 得到标准 JS
└─ 用 acorn 解析成 AST → 收集 import / require 依赖
│
▼
⑥ 递归处理依赖
对每个依赖重复 ⑤,直到所有模块处理完 → 形成【依赖图 Module Graph】
│
▼
⑦ seal 封装:生成 Chunk
按 entry / 动态 import / splitChunks 规则把 Module 分组成 Chunk
Tree-Shaking 标记、作用域提升、代码压缩都在这个阶段
│
▼
⑧ 输出资源 emit
Chunk → Asset(最终文件内容),emit 是【最后能修改产物的时机】
写入 output.path,触发 done 钩子| 核心对象 | 职责 | 生命周期 |
|---|---|---|
Compiler | 全局唯一,持有配置与全部钩子,控制启动/监听 | 整个 webpack 进程 |
Compilation | 一次具体编译的上下文,持有本次所有 module/chunk/asset | 每次编译(watch 下多次) |
Module | 一个文件对应一个模块 | 可跨 compilation 缓存 |
Chunk | 若干 Module 的集合,最终对应一个输出文件 | seal 阶段生成 |
面试加分: Webpack 的插件体系基于 Tapable,提供同步钩子(
SyncHook、SyncBailHook)和异步钩子(AsyncSeriesHook、AsyncParallelHook),对应tap/tapAsync/tapPromise三种注册方式。理解这点,写插件就不会踩「异步钩子忘了调 callback 导致构建卡死」的坑。
2. Tree-Shaking 失效场景与 sideEffects
| 失效场景 | 原因 | 解法 |
|---|---|---|
| 使用 CommonJS 导出 | require 是运行时动态行为,无法静态分析 | 让库提供 ESM 产物(module / exports.import 字段) |
| Babel 把 ESM 转成了 CJS | preset-env 默认 modules: 'auto' | 配置 modules: false,把模块处理交给 webpack |
| 模块有副作用 | 删掉可能改变程序行为,打包器不敢删 | package.json 的 sideEffects 标记 |
| 函数调用有潜在副作用 | 如 IIFE、类的 static 属性赋值 | 用 /*#__PURE__*/ 注释声明可安全删除 |
| 只用了导出对象的某个属性 | 导出的是一个大对象而非多个具名导出 | 拆成具名导出,避免 export default { a, b, c } |
| 没开压缩 | webpack 只做标记,删除靠 Terser | mode: 'production' 或显式配 optimization.minimize |
json
// package.json —— 二者取其一
{
"sideEffects": false,
"sideEffects": ["*.css", "*.scss", "./src/polyfill.js"]
}js
// ⚠️ 典型副作用:只 import 不使用,但必须保留
import "./reset.css"; // 没标 sideEffects 的话会被摇掉 → 样式全丢
import "core-js/stable"; // polyfill 同理
// ✅ PURE 注释:告诉 Terser 这个调用没有副作用
const instance = /*#__PURE__*/ createInstance();
// ❌ 反例:default 导出一个大对象,用不到的也会被打进去
export default { formatDate, formatMoney, deepClone };
// ✅ 正例:具名导出,可按需摇树
export { formatDate, formatMoney, deepClone };验证手段: 配
optimization: { usedExports: true }且mode: 'development',产物里会出现/* unused harmony export xxx */注释;或直接用webpack-bundle-analyzer看体积构成。
3. splitChunks 代码分割
js
module.exports = {
optimization: {
runtimeChunk: "single", // runtime 单独抽出,避免它变动影响所有 chunk 的 hash
splitChunks: {
chunks: "all", // 同步 + 异步模块都参与分割(默认只处理 async)
minSize: 20000, // 小于 20kb 不单独拆,避免请求数爆炸
maxAsyncRequests: 30, // 并发请求数上限
cacheGroups: {
// 体积大、更新频率低的库单独拆,长期命中强缓存
react: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
name: "vendor-react",
priority: 20, // priority 越大越优先匹配
},
vendors: {
test: /[\\/]node_modules[\\/]/,
name: "vendors",
priority: 10,
},
common: {
minChunks: 2, // 被 2 个以上 chunk 引用才抽取
name: "common",
priority: 5,
reuseExistingChunk: true, // 复用已拆出的 chunk,避免重复打包
},
},
},
},
};| 分割方式 | 写法 | 适用场景 |
|---|---|---|
| 入口分割 | 多 entry | 多页应用 |
| 动态导入 | import('./Modal') | 路由懒加载、大组件按需加载 |
| splitChunks | 配置 cacheGroups | 抽离第三方库与公共模块 |
真正的收益点: 业务代码每天在变,react/lodash 几个月才升级一次。拆开之后用户只需重新下载几十 KB 的业务 chunk,几百 KB 的 vendor 直接走浏览器缓存 —— 这才是代码分割的价值,而不是单纯「文件变小」。
4. Vite 依赖预构建原理
启动 vite dev
│
├─ 扫描 index.html 与源码中的 import,找出所有裸模块(bare import)
│ import { debounce } from 'lodash-es' ← 裸模块,需要预构建
│ import App from './App.vue' ← 相对路径,不预构建
│
├─ ESBuild 把这些依赖各自打包成单个 ESM 文件
│ 输出到 node_modules/.vite/deps/
│
└─ 请求重写:'lodash-es' → '/node_modules/.vite/deps/lodash-es.js'为什么必须预构建(三个原因缺一不可):
| 问题 | 不预构建会怎样 | 预构建如何解决 |
|---|---|---|
| 格式兼容 | 很多 npm 包只有 CJS 产物,浏览器原生 ESM 不认识 require / module.exports | ESBuild 统一转成 ESM |
| 请求瀑布流 | lodash-es 内部有 600+ 个模块,浏览器会发 600+ 个请求,页面直接卡住 | 打包成 1 个文件,1 个请求搞定 |
| 路径解析 | 浏览器不认识 import 'vue' 这种裸模块说明符 | 重写为合法的相对/绝对路径 |
js
// vite.config.js
export default {
optimizeDeps: {
include: ["lodash-es", "some-cjs-pkg"], // 强制预构建(如被动态 import 的依赖)
exclude: ["my-local-link-pkg"], // 排除(如本地 link 的包,希望改动实时生效)
force: true, // 忽略缓存,强制重新预构建
},
};缓存机制: 预构建产物缓存在
node_modules/.vite,Vite 用package.json的依赖声明、锁文件、optimizeDeps配置等生成 hash 做校验。如果手动改了 node_modules 里的代码(patch),hash 不会变,需要删掉.vite目录或执行vite --force。
5. HMR 完整链路:Webpack vs Vite
【Webpack HMR】
文件变更
│
▼
webpack watch 监听到变化 → 增量重新编译受影响的模块
│
▼
生成两个产物:
① [hash].hot-update.json —— 描述哪些 chunk 变了
② [chunkId].[hash].hot-update.js —— 变更模块的新代码
│
▼
webpack-dev-server 通过 WebSocket 向客户端推送新的 hash
│
▼
客户端 HMR Runtime 收到 hash,发起 ajax 拉取 json 清单
│
▼
再用 JSONP 拉取 hot-update.js(JSONP 可以直接执行代码并回调)
│
▼
沿着模块依赖图向上冒泡查找 accept 回调:
├─ 找到 → 删除旧模块缓存,执行 dispose 清理,执行 accept 回调 ✅
└─ 冒泡到入口仍没有 → 降级 location.reload() 整页刷新 ⚠️
【Vite HMR】
文件变更
│
▼
Vite 基于模块图(Module Graph)精准定位该文件及其「HMR 边界」
│
▼
使该模块缓存失效,通过 WebSocket 推送一条 update 消息(含模块 url + 时间戳)
│
▼
客户端用 import(`/src/App.vue?t=1699999999`) 重新拉取【单个模块】
│
▼
执行 import.meta.hot.accept 回调完成替换| 维度 | Webpack HMR | Vite HMR |
|---|---|---|
| 是否需要重新编译 | ✅ 需要增量打包,项目越大越慢 | ❌ 只转换单个文件 |
| 更新粒度 | chunk 级 | 模块级 |
| 更新耗时 | 随项目规模增长 | 基本恒定(毫秒级) |
| 传输方式 | WebSocket 通知 + ajax/JSONP 拉取 | WebSocket 通知 + 原生 import() 拉取 |
| 失效 URL 技巧 | hash 文件名 | query 加时间戳 ?t= |
关键概念 HMR 边界:
accept的位置决定了更新会冒泡到哪一层。Vue SFC、React 组件之所以能「改哪个组件只更新哪个组件」,是因为框架插件在每个组件文件里自动注入了accept,让每个组件都成为一个边界。而修改一个被全局引用的utils.js,往往就只能一路冒泡到入口 → 整页刷新。
6. 长缓存优化:三种 hash 的区别
| hash 类型 | 变化时机 | 问题 | 适用 |
|---|---|---|---|
[hash] | 整个项目任意文件变化就变 | 改一行业务代码,所有文件 hash 全变,缓存全失效 | ❌ 不要用 |
[chunkhash] | 所在 chunk 的内容变化才变 | JS 和它 import 的 CSS 属于同一 chunk,改 JS 会导致 CSS 的 hash 也变 | JS 可用 |
[contenthash] | 单个文件内容变化才变 | — | ✅ 推荐,JS/CSS 都用 |
js
module.exports = {
output: {
filename: "js/[name].[contenthash:8].js",
chunkFilename: "js/[name].[contenthash:8].chunk.js", // 异步 chunk
assetModuleFilename: "assets/[name].[contenthash:8][ext]",
},
plugins: [
new MiniCssExtractPlugin({ filename: "css/[name].[contenthash:8].css" }),
],
optimization: {
runtimeChunk: "single", // ⚠️ 不抽 runtime,模块 id 变化会污染所有 chunk 的 hash
moduleIds: "deterministic", // 模块 id 稳定,避免新增文件导致 id 重排、hash 全变
},
};配套的 HTTP 缓存策略:
nginx
# 带 contenthash 的静态资源:永久强缓存,内容变了文件名就变
location ~* \.(js|css|png|jpg|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# index.html:绝不能强缓存,否则永远加载不到新的资源文件名
location = /index.html {
add_header Cache-Control "no-cache";
}完整闭环:
contenthash保证文件名随内容变化 →index.html不缓存保证每次拿到最新的文件名列表 → 静态资源永久强缓存。少了任何一环,要么用户加载不到新代码,要么缓存命中率极低。
7. 构建性能优化清单
| 方向 | 手段 | 效果 / 注意点 |
|---|---|---|
| 缩小搜索范围 | exclude: /node_modules/、resolve.extensions 精简、resolve.alias | 收益大、成本低,优先做 |
| 缓存 | webpack5 cache: { type: 'filesystem' } | 二次构建提速 50%~80%,替代 cache-loader |
| 多进程 | thread-loader | ⚠️ 进程启动有 ~600ms 开销,小项目反而更慢 |
| 减少工作量 | DllPlugin(已被持久化缓存基本取代)、externals + CDN | 第三方库不参与打包 |
| 换工具链 | esbuild-loader 替代 babel-loader、swc-loader | 转换提速明显,但 polyfill 能力弱于 babel |
| 压缩 | TerserPlugin({ parallel: true })、CSS 用 cssnano | 生产环境最大耗时项之一 |
| 产物分析 | webpack-bundle-analyzer、speed-measure-webpack-plugin | 先测量再优化,别凭感觉 |
| 按需引入 | babel-plugin-import、改用 ESM 版本的库(lodash-es、date-fns) | 直接减小体积 |
js
// webpack5 持久化缓存(最高性价比的一条)
module.exports = {
cache: {
type: "filesystem",
buildDependencies: { config: [__filename] }, // 配置文件变化时缓存失效
},
};常见误区: 很多人一上来就加
thread-loader和happypack。实际上先用speed-measure-webpack-plugin测出耗时分布,往往发现瓶颈是某个没排除 node_modules 的 loader、或者是 source-map 配置过重(eval-cheap-module-source-map在开发环境足够用)。
8. Rollup / ESBuild / Webpack / Vite 工具选型
| 工具 | 语言 | 定位 | 优势 | 劣势 |
|---|---|---|---|---|
| Webpack | JS | 应用打包 | 生态最全、配置最灵活、对非 JS 资源支持最好 | 慢、配置复杂 |
| Rollup | JS | 库打包 | Tree-Shaking 最彻底、产物干净、ESM 原生 | 对 CJS、代码分割支持不如 webpack |
| ESBuild | Go | 转换 / 压缩 | 快 10~100 倍(Go 原生 + 多线程并行 + 零中间 AST 转换) | 不支持降级到 ES5 的部分语法、插件能力弱、无类型检查 |
| Vite | JS | 开发服务 + 应用打包 | dev 极快、开箱即用 | 开发与生产工具链不同,存在行为差异风险 |
| Rspack / Turbopack | Rust | Webpack 替代 | 兼容 webpack 生态且快数倍 | 生态仍在完善 |
ESBuild 为什么快(面试高频): ① Go 编写、编译为机器码,无需 JS 的 JIT 预热;② 充分利用多核并行,JS 单线程做不到;③ 从头设计,解析/链接/生成共用同一套数据结构,避免 AST 在不同工具间反复序列化;④ 内存高效,尽量避免对象分配。
为什么 Vite 生产环境不用 ESBuild: ESBuild 的代码分割(code splitting)和 CSS 处理能力尚不成熟,而生产产物的质量(分包策略、兼容性、CSS 提取)比构建速度更重要。Vite 4+ 已在探索 Rolldown(Rust 版 Rollup)来统一两端。
❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】
题 1:Webpack 和 Vite 的核心差异?Vite 开发环境为什么启动更快?
精简口述答案:
最本质的差异是开发环境要不要打包。Webpack 是 bundle 架构,启动开发服务前必须把整个项目的模块全部解析、转换、打包成 bundle,所以启动时间和项目规模成正比,大项目几十秒到几分钟。Vite 是 no-bundle 架构,启动时只做一件事 —— 用 ESBuild 把 node_modules 里的第三方依赖预构建成 ESM,然后直接启动静态服务;业务代码完全不打包,浏览器通过原生
<script type="module">按需请求,Vite 收到请求才实时转换这一个文件。所以启动时间几乎和项目体积无关。生产环境 Vite 则换成 Rollup 打包,因为生产要的是产物质量而不是速度。另外 ESBuild 之所以快,是因为它用 Go 编写、编译成机器码、充分利用多核并行,而且内部各阶段共用数据结构,避免了 AST 的反复序列化。
连环追问链:
- 追问:Vite 开发环境有什么缺点?
- 答:主要三点。一是首次打开页面时请求数很多,弱网或模块特别多的页面会有明显的瀑布流延迟(不过有浏览器缓存,二次就快了);二是开发用 ESBuild、生产用 Rollup,两套工具链可能产生行为差异,出现「开发正常、上线白屏」的情况,所以上线前必须跑一遍
vite build && vite preview;三是对只有 CJS 产物的老旧依赖兼容性依赖预构建,偶尔需要手动配optimizeDeps。
- 答:主要三点。一是首次打开页面时请求数很多,弱网或模块特别多的页面会有明显的瀑布流延迟(不过有浏览器缓存,二次就快了);二是开发用 ESBuild、生产用 Rollup,两套工具链可能产生行为差异,出现「开发正常、上线白屏」的情况,所以上线前必须跑一遍
- 追问:为什么 Vite 生产环境不继续用 ESBuild?
- 答:ESBuild 的代码分割和 CSS 处理能力还不成熟,而生产环境更关心分包策略、Tree-Shaking 彻底性、浏览器兼容性,这些 Rollup 更可靠。社区正在做 Rolldown(Rust 版 Rollup)来统一开发和生产。
- 追问:既然 Vite 这么快,Webpack 还有必要吗?
- 答:有。Webpack 生态最全,对微前端(Module Federation)、复杂的多页/库构建、各种非标准资源处理支持更好,老项目迁移成本也要考虑。追求速度又不想丢生态,现在更推荐 Rspack(Rust 实现、兼容 webpack 配置)。
- 追问:Vite 为什么要做依赖预构建,不做会怎样?
- 答:三个原因 —— 很多包只有 CJS 产物浏览器不认;像
lodash-es这种内部有几百个模块的包不打包会产生几百个请求;浏览器也不认识import 'vue'这种裸模块路径。预构建用 ESBuild 一次性解决格式转换 + 合并请求 + 路径重写。
- 答:三个原因 —— 很多包只有 CJS 产物浏览器不认;像
- 追问:预构建的缓存在哪里,什么时候会失效?
- 答:缓存在
node_modules/.vite,Vite 根据 package.json 依赖、锁文件、optimizeDeps配置算 hash 来判断是否复用。手动 patch 了 node_modules 代码时 hash 不变,需要删.vite或用vite --force。
- 答:缓存在
✅ 项目关联话术(按真实经历替换):
我们一个有 2000 多个模块的中后台项目,Webpack 冷启动要 45 秒,改一行代码热更新要 6 到 8 秒,开发体验很差。迁移到 Vite 后启动稳定在 3 秒以内,热更新基本是毫秒级。迁移时主要处理了三件事:
require改成import,process.env换成import.meta.env,以及几个只有 CJS 产物的内部包加到了optimizeDeps.include里。另外我们在 CI 里加了vite build的卡点,避免开发环境正常但生产构建失败。
题 2:Tree-Shaking 的原理是什么?哪些场景会失效?
精简口述答案:
Tree-Shaking 依赖 ES Module 的静态特性 ——
import/export必须写在顶层、路径必须是字符串字面量,所以打包工具在编译期就能构建出完整的导入导出关系图,标记出哪些导出从未被引用,再交给 Terser 这类压缩器把死代码真正删掉。注意 webpack 本身只负责「标记」(usedExports),删除是压缩阶段做的,所以不开 production 模式是看不到效果的。失效场景主要有:使用 CommonJS 导出,因为require是运行时的、路径还能是变量,没法静态分析;Babel 的preset-env把 ESM 转成了 CJS,这是最隐蔽的一种,要把modules设为false;模块存在副作用,比如import './reset.css'这种只导入不使用的,打包器不敢删,需要用package.json的sideEffects字段声明哪些文件是无副作用的;还有导出一个大对象而不是具名导出,以及函数调用本身可能有副作用的情况,后者可以用/*#__PURE__*/注释标注。
连环追问链:
- 追问:
sideEffects和usedExports有什么区别?- 答:这是 webpack 的两种 Tree-Shaking。
usedExports是粒度细的「导出级」摇树,分析某个导出有没有被用到;sideEffects是粒度粗的「文件级」摇树,如果一个文件被标记无副作用且它的导出完全没被用到,整个文件连解析都跳过,效率更高。两者配合使用。
- 答:这是 webpack 的两种 Tree-Shaking。
- 追问:为什么
import './reset.css'会被摇掉?- 答:因为这条 import 没有绑定任何变量,在 webpack 看来「导入了但没使用」。如果
sideEffects: false就会被当成死代码删除,导致样式全丢。正确做法是sideEffects: ["*.css", "*.scss"]把样式文件列入白名单。
- 答:因为这条 import 没有绑定任何变量,在 webpack 看来「导入了但没使用」。如果
- 追问:
lodash和lodash-es有什么区别?该怎么按需引入?- 答:
lodash是 CJS 产物,无法 Tree-Shaking,import { debounce } from 'lodash'会把整个 70KB 的库打进去;lodash-es是 ESM 产物,可以正常摇树。如果必须用lodash,可以单文件引入import debounce from 'lodash/debounce',或者用babel-plugin-lodash自动改写。
- 答:
- 追问:类的方法能被 Tree-Shaking 吗?
- 答:很难。因为类的方法挂在 prototype 上,可能通过
this[methodName]()这种动态方式调用,压缩器无法确定安全性。所以工具库更推荐导出独立的纯函数而不是一个大类,这也是 lodash、date-fns 的设计思路。
- 答:很难。因为类的方法挂在 prototype 上,可能通过
- 追问:怎么验证 Tree-Shaking 到底生效了?
- 答:开发模式配
usedExports: true看产物里的/* unused harmony export */注释;或者用webpack-bundle-analyzer/rollup-plugin-visualizer对比优化前后体积;也可以直接搜产物里有没有残留某个本应被删掉的函数名。
- 答:开发模式配
✅ 项目关联话术(按真实经历替换):
我们内部的 UI 组件库最初只打了一份 UMD 产物,业务方
import { Button }时整个 800KB 的库都会进包。我做的改造是:Rollup 同时输出 ESM、CJS、UMD 三份产物,在 package.json 里用module字段指向 ESM、main指向 CJS、exports字段做条件导出;Babel 配置modules: false保留 ESM 语法;sideEffects设为["*.css"]保护样式文件。改完之后业务方首屏 JS 体积减少了约 40%,接入webpack-bundle-analyzer看确认未使用的组件已经完全不在产物里了。
题 3:HMR 的原理是什么?和 Live Reload 有什么区别?
精简口述答案:
Live Reload 是整页刷新,页面所有 JS 重新执行,路由、表单、组件 state 全部丢失。HMR 是模块级替换,只把变更的那个模块的新代码换进运行时,页面不刷新,其他模块的状态都保留。实现上需要三方配合:构建工具监听文件变化并算出哪些模块受影响;通过 WebSocket 把变更通知推给浏览器;浏览器端的 HMR runtime 拉取新模块代码,然后沿着模块依赖图向上冒泡寻找最近的一个声明了
module.hot.accept的父模块(这个点叫 HMR 边界),在那里执行替换和回调;如果一路冒泡到入口都没有边界,就只能降级成整页刷新。Webpack 的做法是服务端生成新的 hash,浏览器先拉一个 json 清单知道哪些 chunk 变了,再用 JSONP 拉对应的 hot-update.js;Vite 更轻,因为本来就是 no-bundle,服务端在模块图上找到失效模块后直接推路径,浏览器用import('/src/App.vue?t=时间戳')重新请求单个文件,不需要任何打包,所以热更新速度和项目体积无关。
连环追问链:
- 追问:为什么 React 组件更新后 state 还能保留?
- 答:这是
react-refresh(Fast Refresh)做的。它在编译时给每个组件注册一个稳定的签名,HMR 发生时只替换组件的渲染函数,Fiber 树上的 hooks state 保持不动。但有限制:只有导出的是函数组件时才有效;如果修改了 hooks 的调用顺序或数量,签名校验失败就会强制重新挂载并丢失 state;文件里混了非组件的导出也可能导致降级。
- 答:这是
- 追问:为什么我改一个 utils 文件,整个页面刷新了?
- 答:因为从 utils 到入口的链路上没有任何模块声明 HMR 边界,冒泡到顶就只能整页刷新。这也是合理的 —— 一个被几十处引用的纯函数模块,框架无法知道怎么安全地把老的引用替换掉。框架插件(vue-loader、react-refresh)只会给组件文件自动注入边界。
- 追问:HMR 替换掉的旧模块留下的副作用怎么清理?
- 答:要用
module.hot.dispose(Vite 里是import.meta.hot.dispose)注册清理函数,在旧模块被卸载前执行,用来清定时器、解绑事件、关闭 WebSocket。不清理就会出现「改一次代码多一个定时器」的内存泄漏,表现为热更新几次后页面卡死。
- 答:要用
- 追问:
import.meta.hot在生产环境会报错吗?- 答:不会,但必须写成
if (import.meta.hot) { ... }。Vite 生产构建时import.meta.hot会被静态替换成undefined,整个 if 块会被 Tree-Shaking 掉。如果不加判断直接import.meta.hot.accept(...),生产环境就是读 undefined 的属性报错。
- 答:不会,但必须写成
- 追问:HMR 的 WebSocket 断了怎么办?
- 答:客户端 runtime 有重连机制,并会在重连成功后判断是否需要整页刷新来追平状态。常见踩坑是通过 nginx / 内网穿透代理开发服务时没配 WebSocket 升级头(
Upgrade/Connection),导致连接建不起来、HMR 静默失效,这时要配server.hmr.clientPort或protocol。
- 答:客户端 runtime 有重连机制,并会在重连成功后判断是否需要整页刷新来追平状态。常见踩坑是通过 nginx / 内网穿透代理开发服务时没配 WebSocket 升级头(
✅ 项目关联话术(按真实经历替换):
我们项目里有个全局的 WebSocket 推送模块,早期每次热更新都会新建一个连接,开发半小时后浏览器就会堆几十个连接、页面明显卡顿。定位到是 HMR 替换旧模块时没做清理,后来在这个模块里加了
import.meta.hot.dispose(() => ws.close()),问题就解决了。另外我们把一个 1000 多行的「万能 utils」按领域拆成了多个小文件,因为之前改任何一个工具函数都会触发全页刷新,拆分后热更新影响面小了很多。
题 4:Loader 和 Plugin 的区别?简述 Webpack 的构建流程
精简口述答案:
Loader 是文件转换器,本质是一个函数,输入源码字符串、输出 JS 字符串,职责单一,作用在单个模块上,比如 babel-loader 转 ES6、css-loader 处理
@import。Plugin 是功能扩展器,本质是一个带apply(compiler)方法的类,通过 Tapable 往 webpack 生命周期的几十个钩子上挂回调,能力覆盖整个构建流程,比如 HtmlWebpackPlugin 在 emit 阶段生成 html。一句话:Loader 是翻译官,Plugin 是监工。构建流程大致八步:读取配置合并 CLI 参数初始化 Compiler;注册所有 Plugin;从 Entry 开始编译;用 Loader 把模块转成 JS 并用 AST 解析出它的依赖;递归处理所有依赖,构建完整的依赖图;根据入口和动态导入把模块组装成 Chunk;每个 Chunk 生成对应的 Asset 代码;最后 emit 阶段写入磁盘。整个过程中 Plugin 在各个钩子上插入自己的逻辑。
连环追问链:
- 追问:Loader 的执行顺序是怎样的?为什么是从右到左?
- 答:
use数组的执行顺序是从右到左、从下到上,因为它是函数组合的语义:style(css(sass(source)))。但 loader 实际有两个阶段 —— 先从左到右执行所有 loader 的pitch方法,再从右到左执行 normal 方法。如果某个 pitch 返回了值,就会熔断,直接跳过它右边所有 loader 并往左回溯,style-loader就是用这个机制接管后续处理的。
- 答:
- 追问:Compiler 和 Compilation 有什么区别?
- 答:Compiler 是全局唯一的编译器实例,持有完整配置,生命周期贯穿整个 webpack 进程;Compilation 代表一次具体的构建,包含这次构建的所有 modules、chunks、assets。watch 模式下每次文件变化都会创建一个新的 Compilation,但 Compiler 始终只有一个。
- 追问:Tapable 的钩子有哪几种类型?
- 答:按执行方式分同步(
SyncHook)和异步(AsyncSeriesHook串行、AsyncParallelHook并行);按返回值处理分普通、Bail(有返回值就熔断)、Waterfall(上一个的返回值作为下一个的入参)、Loop(返回非 undefined 就重新循环)。写插件时根据钩子类型选tap/tapAsync/tapPromise。
- 答:按执行方式分同步(
- 追问:Module 和 Chunk 是什么关系?
- 答:Module 是一个文件对应的模块单元,Chunk 是打包输出的代码块。多个 Module 按入口、动态导入、splitChunks 规则被组装成 Chunk,一个 Chunk 最终输出一个或多个 Asset(比如 js + source-map)。
- 追问:说一个你了解原理的常用插件?
- 答:比如
MiniCssExtractPlugin,它的 loader 在处理 CSS 时不像 style-loader 那样返回注入 DOM 的 JS,而是把 CSS 内容存到 Chunk 上,再由插件在renderManifest/ emit 阶段把这些内容抽成独立的.css文件,并让 HtmlWebpackPlugin 注入<link>。这样 CSS 可以并行下载、独立缓存,也避免了 FOUC。
- 答:比如
✅ 项目关联话术(按真实经历替换):
我写过两个内部插件。一个是构建产物体积卡口插件,在
emit钩子里遍历compilation.assets,如果单个 JS 超过 500KB 就往compilation.warnings推警告、超过 1MB 直接 push 到errors让 CI 失败,防止有人无意间把大依赖带进主包。另一个是国际化文案校验 loader,在编译阶段扫描源码里的t('key')调用,和语言包比对,缺失的 key 直接在编译期报错,不用等到线上才发现文案显示成 key。
题 5:项目构建速度慢、首屏包体积大,你会怎么优化?
精简口述答案:
我会分成「构建速度」和「产物体积」两条线,而且先测量再优化。构建速度先用
speed-measure-webpack-plugin看是哪个 loader / plugin 耗时,常见手段是:开启 webpack5 的 filesystem 持久化缓存(收益最大,二次构建能快 80% 以上)、给 loader 加exclude: /node_modules/和include缩小范围、用thread-loader对耗时的 babel 做多进程、把稳定的大依赖用 DllPlugin 或 externals + CDN 移出构建、开发环境把 source-map 降到eval-cheap-module-source-map、resolve.extensions收窄减少文件探测。产物体积先用webpack-bundle-analyzer看谁最大,手段是:确保 Tree-Shaking 真的生效、按路由做动态import()分割、用 splitChunks 把 node_modules 和公共模块抽出来、moment 这类带全量语言包的库用IgnorePlugin裁剪或换dayjs、图片走压缩和 CDN、开 gzip/brotli。最后配 contenthash + 长缓存,让没变的 chunk 不重复下载。
连环追问链:
- 追问:怎么定位构建瓶颈?
- 答:时间维度用
speed-measure-webpack-plugin或 webpack5 的stats: 'normal'+--profile;体积维度用webpack-bundle-analyzer(Vite 用rollup-plugin-visualizer)。没有数据就优化,很容易花大力气优化了只占 3% 耗时的环节。
- 答:时间维度用
- 追问:
thread-loader什么情况下反而会变慢?- 答:它每个 worker 都要启动一个 Node 进程并做进程间通信,启动开销大约几百毫秒。小项目或者单个 loader 本身耗时很短时,开销大于收益。所以只建议用在耗时明显的 babel-loader / ts-loader 上,并且放在 loader 链的最前面(最先执行的位置)。
- 追问:splitChunks 你是怎么配的?为什么这么配?
- 答:至少分三组 ——
vendors把node_modules抽出来(变动频率低、缓存命中率高);common把被两个以上 chunk 引用的业务模块抽出来(minChunks: 2);再把 React/Vue 这类超大且几乎不升级的框架单独抽一组,设更高的priority。不能无脑全抽,chunk 太碎会导致 HTTP 请求数暴涨,首屏反而变慢,要结合maxInitialRequests和minSize控制。
- 答:至少分三组 ——
- 追问:contenthash 配了为什么缓存还是经常全部失效?
- 答:通常是两个原因。一是没抽
runtimeChunk,webpack 的运行时代码里记录了所有 chunk 的 hash 映射,任何一个 chunk 变了都会让包含运行时的入口文件 hash 变;二是模块 ID 用了自增数字,新增一个文件会让后面所有模块 ID 偏移,配optimization.moduleIds: 'deterministic'让 ID 基于路径生成即可。
- 答:通常是两个原因。一是没抽
- 追问:首屏优化除了打包,还能做什么?
- 答:路由级懒加载 + 组件级
React.lazy;关键资源preload、次要域名dns-prefetch;非首屏 JS 加defer;大列表虚拟滚动;骨架屏改善体感;真正要命的首屏可以上 SSR 或预渲染。打包优化只是其中一环,要结合 Lighthouse 和真实用户的 LCP 数据看。
- 答:路由级懒加载 + 组件级
✅ 项目关联话术(按真实经历替换):
我们一个 Webpack 项目构建从 4 分钟优化到了 50 秒左右,关键的三步是:开 webpack5 filesystem 缓存(二次构建直接降到 40 秒)、babel-loader 加 exclude 并挂 thread-loader、把 echarts 和地图相关的大依赖改成 CDN externals。产物侧首屏 JS 从 2.3MB 降到 780KB,主要靠路由级动态 import、echarts 按需引入、moment 换 dayjs。最后配了 contenthash + runtimeChunk +
moduleIds: 'deterministic',nginx 上对带 hash 的静态资源设一年强缓存、index.html 设no-cache,灰度期间统计到静态资源缓存命中率提升了一倍多。
✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】
题 1:手写一个 Webpack Plugin(生成产物清单)
核心实现:
js
// FileListPlugin.js
class FileListPlugin {
constructor(options = {}) {
this.filename = options.filename || "FILELIST.md";
}
// webpack 会调用 apply,并把 compiler 实例传进来
apply(compiler) {
const pluginName = FileListPlugin.name;
// emit:产物已生成、尚未写入磁盘,是修改/新增 assets 的最佳时机
compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
const assets = compilation.assets;
const names = Object.keys(assets);
let content = `# 本次构建共产出 ${names.length} 个文件\n\n`;
content += "| 文件 | 体积(KB) |\n| --- | --- |\n";
names
.map((name) => ({ name, size: assets[name].size() }))
.sort((a, b) => b.size - a.size)
.forEach(({ name, size }) => {
content += `| ${name} | ${(size / 1024).toFixed(2)} |\n`;
});
// 往产物里新增一个文件:必须提供 source() 和 size()
compilation.assets[this.filename] = {
source: () => content,
size: () => content.length,
};
// 异步钩子必须调用 callback,否则构建卡死
callback();
});
// 同步钩子用 tap,没有 callback
compiler.hooks.done.tap(pluginName, (stats) => {
console.log(
`[FileListPlugin] 构建耗时 ${stats.endTime - stats.startTime}ms`
);
});
}
}
module.exports = FileListPlugin;三个必须记住的点: ① Plugin 是一个类,核心是
apply(compiler);② 根据钩子类型选tap(同步)/tapAsync(回调)/tapPromise(Promise),用错会直接卡住或报错;③ 新增 asset 的对象必须同时有source()和size()两个方法。webpack 5 更推荐用compilation.hooks.processAssets并指定stage。
✅ 边界 Case 自测:
apply里直接执行耗时逻辑 → 应该只注册钩子,真正的工作放在回调里tapAsync忘记调callback()→ 构建永久挂起,没有任何报错,极难排查- 选错钩子时机:
afterEmit之后改 assets 不会被写盘,compile阶段又还没有 assets - 插件内抛异常未捕获 → 整个构建崩溃,应该 push 到
compilation.errors/warnings - 多次构建(watch 模式)时在
apply里累积数组 → 数据会越积越多,状态要放在 compilation 维度 compilation.assets的 key 带了publicPath前缀 → 实际 key 是相对 output 的路径,不要手动拼
🔁 变种拓展题:
js
// 变种 1:构建产物体积卡口(CI 中超标直接失败)
class SizeLimitPlugin {
constructor({ maxSize = 500 * 1024, errorSize = 1024 * 1024 } = {}) {
this.maxSize = maxSize;
this.errorSize = errorSize;
}
apply(compiler) {
compiler.hooks.emit.tap("SizeLimitPlugin", (compilation) => {
Object.entries(compilation.assets).forEach(([name, asset]) => {
if (!name.endsWith(".js")) return;
const size = asset.size();
if (size > this.errorSize) {
compilation.errors.push(new Error(`${name} 超过 1MB:${size}`));
} else if (size > this.maxSize) {
compilation.warnings.push(new Error(`${name} 超过 500KB:${size}`));
}
});
});
}
}
// 变种 2:给所有 JS 产物注入版权声明(也可用 BannerPlugin)
class BannerPlugin2 {
constructor(banner) {
this.banner = banner;
}
apply(compiler) {
compiler.hooks.emit.tap("BannerPlugin2", (compilation) => {
Object.keys(compilation.assets).forEach((name) => {
if (!name.endsWith(".js")) return;
const old = compilation.assets[name].source();
const next = `/*! ${this.banner} */\n${old}`;
compilation.assets[name] = {
source: () => next,
size: () => next.length,
};
});
});
}
}题 2:手写一个 Webpack Loader
核心实现:
js
// replace-loader.js
// Loader 本质:字符串进 -> 合法 JS 字符串出
module.exports = function (source, sourceMap, meta) {
// webpack 5 内置,自动做 schema 校验,不再需要 loader-utils
const options = this.getOptions() || {};
const { search = "", replace = "" } = options;
// 缓存:源文件和依赖没变时直接复用上次结果(必须是纯函数才能开)
this.cacheable && this.cacheable(true);
let result = source;
if (search) {
result = source.replace(new RegExp(search, "g"), replace);
}
// 多返回值要用 this.callback,才能把 sourceMap 透传给下一个 loader
this.callback(null, result, sourceMap, meta);
};
// pitch 阶段:从左到右执行,返回非 undefined 会熔断右侧所有 loader
module.exports.pitch = function (remainingRequest) {
// style-loader 就是在这里返回一段「注入 DOM 的代码」接管后续流程
// console.log('pitch', remainingRequest);
};js
// async-banner-loader.js —— 异步 loader 写法
const fs = require("fs/promises");
module.exports = async function (source) {
// 方式一:声明成 async 函数,直接 return
// 方式二:const callback = this.async(); ... callback(null, result);
const { bannerFile } = this.getOptions() || {};
if (bannerFile) {
// 把外部文件加入依赖,它变化时也要重新走 loader
this.addDependency(bannerFile);
const banner = await fs.readFile(bannerFile, "utf-8");
return `/*! ${banner.trim()} */\n${source}`;
}
return source;
};js
// webpack.config.js 中使用本地 loader
module.exports = {
resolveLoader: {
// 让 webpack 除了 node_modules 也去 ./loaders 找
modules: ["node_modules", path.resolve(__dirname, "loaders")],
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: [
// 执行顺序:normal 阶段从下往上(async-banner 先,replace 后)
{
loader: "replace-loader",
options: { search: "__VERSION__", replace: "1.2.0" },
},
{
loader: "async-banner-loader",
options: { bannerFile: "./LICENSE" },
},
],
},
],
},
};一句话抓住本质: Loader 是一个函数,
this上挂着 webpack 提供的上下文(getOptions/async/callback/addDependency/emitFile/resourcePath)。注意不要用箭头函数,否则拿不到this。
✅ 边界 Case 自测:
- 返回值不是合法 JS 字符串(比如直接返回 CSS 文本)→ 后续解析报
Unexpected token,处理非 JS 资源要返回module.exports = ...形式 - 用了箭头函数 →
this丢失,this.getOptions is not a function - 异步逻辑却直接
return而没用this.async()/async→ 拿到的是undefined - 依赖了外部文件但没调
this.addDependency→ 改那个文件不触发重新构建 - 没加
exclude: /node_modules/→ 把整个依赖都过一遍 loader,构建时间翻几倍 - loader 顺序写反(如
['css-loader', 'style-loader'])→ style-loader 先拿到的是原始 CSS,直接报错 - 对同一文件既配了
test又在别处配了oneOf→ 规则重复命中,产物异常 - 开了
cacheable但 loader 内部依赖了时间戳/随机数 → 结果被错误缓存
🔁 变种拓展题:
js
// 变种 1:极简 style-loader —— 利用 pitch 熔断 + 行内 loader 请求
module.exports = function () {};
module.exports.pitch = function (remainingRequest) {
// !! 前缀表示忽略配置里的其他 loader,只用 remainingRequest 里的
const request = JSON.stringify(
this.utils.contextify(this.context, "!!" + remainingRequest)
);
return `
import css from ${request};
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
if (import.meta.hot) {
import.meta.hot.dispose(() => style.remove());
}
export default css;
`;
};
// 变种 2:国际化文案校验 loader —— 编译期拦截缺失 key
const zh = require("../locales/zh-CN.json");
module.exports = function (source) {
const missing = [];
const reg = /\bt\(\s*['"]([^'"]+)['"]/g;
let m;
while ((m = reg.exec(source))) {
if (!(m[1] in zh)) missing.push(m[1]);
}
if (missing.length) {
// emitError 不中断构建,throw 会中断;按严格程度二选一
this.emitError(new Error(`缺失文案 key: ${missing.join(", ")}`));
}
return source;
};题 3:手写一个极简打包器(依赖图 + 运行时)
核心实现:
js
// mini-pack.js
const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");
let ID = 0;
// 1. 解析单个模块:拿到它的代码 + 依赖列表
function createAsset(filename) {
const content = fs.readFileSync(filename, "utf-8");
const ast = parser.parse(content, { sourceType: "module" });
const dependencies = [];
traverse(ast, {
ImportDeclaration({ node }) {
dependencies.push(node.source.value);
},
});
// ESM 转 CJS,产物里才能用我们自己实现的 require
const { code } = babel.transformFromAstSync(ast, content, {
presets: [["@babel/preset-env", { targets: { node: "current" } }]],
});
return { id: ID++, filename, dependencies, code, mapping: {} };
}
// 2. 从入口开始广度遍历,构建完整依赖图
function createGraph(entry) {
const mainAsset = createAsset(entry);
const queue = [mainAsset];
for (const asset of queue) {
const dirname = path.dirname(asset.filename);
asset.dependencies.forEach((relativePath) => {
const absolutePath = path.resolve(dirname, relativePath);
const child = createAsset(absolutePath);
// 记录「相对路径 -> 模块 id」的映射,供运行时 require 查找
asset.mapping[relativePath] = child.id;
queue.push(child);
});
}
return queue;
}
// 3. 生成 bundle:IIFE + 模块映射表 + 自实现 require
function bundle(graph) {
let modules = "";
graph.forEach((mod) => {
modules += `${mod.id}: [
function (require, module, exports) { ${mod.code} },
${JSON.stringify(mod.mapping)}
],`;
});
return `(function (modules) {
const cache = {};
function require(id) {
if (cache[id]) return cache[id].exports; // 循环依赖 & 单例的关键
const [fn, mapping] = modules[id];
const module = { exports: {} };
cache[id] = module;
// 把模块内的相对路径翻译成数字 id
fn(function localRequire(rel) { return require(mapping[rel]); }, module, module.exports);
return module.exports;
}
require(0);
})({ ${modules} });`;
}
fs.writeFileSync("./dist/bundle.js", bundle(createGraph("./src/index.js")));这道题考的不是 API,而是对「模块化」的理解: 打包器做的就是把「文件路径的依赖关系」在编译期翻译成「数字 id 的映射表」,再用一个自己实现的
require+ 闭包模拟出模块作用域。理解了这个,webpack 产物里那一大坨__webpack_require__就不神秘了。
✅ 边界 Case 自测:
- 循环依赖:A 引 B、B 又引 A → 靠
cache[id]先占位再执行解决,否则无限递归爆栈;注意此时拿到的可能是「未完成的 exports」 - 重复依赖:同一个文件被多处引用 → 上面的实现会重复
createAsset,应该用absolutePath -> asset的 Map 去重 - 没有扩展名的引入
./utils→ 需要实现 resolve 逻辑依次尝试.js/.json//index.js - 引入 node_modules 裸模块
import vue from 'vue'→ 需要逐级向上查找 node_modules - 动态
import()→ 上面的实现不支持,真实打包器要在这里切分 chunk 并生成异步加载的运行时 - 非 JS 资源(css / 图片)→ 需要 loader 机制先转成 JS 字符串
- 模块内
this指向 → CJS 里顶层this应等于module.exports,示例未处理
🔁 变种拓展题:
js
// 变种 1:给极简打包器加上 loader 机制
const loaders = [
{ test: /\.css$/, use: (src) => `module.exports = ${JSON.stringify(src)}` },
];
function applyLoaders(filename, content) {
return loaders
.filter((l) => l.test.test(filename))
.reduce((code, l) => l.use(code), content);
}
// 变种 2:实现一个支持 HMR 的极简 dev server(思路)
// ① chokidar 监听 src 变化
// ② ws 推送 { type: 'update', path: '/src/App.js' }
// ③ 客户端运行时收到后 import(`${path}?t=${Date.now()}`)
// ④ 沿 importers 反向查找最近的 accept 回调,找不到则 location.reload()⚠️ 当日踩坑实录
| 常见坑 | 解决方案 |
|---|---|
| ❌ Tree-Shaking 完全不生效,配置看着都对 | ✅ 检查 Babel:presets: [['@babel/preset-env', { modules: false }]],ESM 被转成 CJS 后一切摇树都失效 |
| ❌ 开发模式下看不到 Tree-Shaking 效果 | ✅ 摇树的「删除」由 Terser 在压缩阶段完成,必须 mode: 'production';开发期只能看 usedExports 注释 |
❌ 配了 sideEffects: false,上线后样式全丢 | ✅ 改成 "sideEffects": ["*.css", "*.scss", "*.less", "./src/polyfill.js"],把有副作用的文件列白名单 |
❌ import { debounce } from 'lodash' 把 70KB 全打进来 | ✅ 换 lodash-es,或 import debounce from 'lodash/debounce',或上 babel-plugin-lodash |
| ❌ 改了 node_modules 里的代码,Vite 不生效 | ✅ 预构建缓存没失效,删 node_modules/.vite 或 vite --force;长期方案用 patch-package |
| ❌ Vite 首次打开页面白屏一下又重新加载 | ✅ 运行时才发现新依赖触发二次预构建,把该依赖加进 optimizeDeps.include 提前处理 |
❌ 只有 CJS 产物的老包在 Vite 里 does not provide an export | ✅ 加 optimizeDeps.include,必要时配 build.commonjsOptions.include |
❌ 开发一切正常,vite build 后线上白屏 | ✅ 开发/生产是两套工具链,上线前必须本地跑 vite build && vite preview,并在 CI 里加构建卡点 |
❌ 用了 [hash] 导致改一个文件所有产物缓存全失效 | ✅ JS/CSS 一律用 [contenthash],hash 是整次构建级别的 |
| ❌ 配了 contenthash,缓存命中率还是很低 | ✅ 抽 optimization.runtimeChunk: 'single' + moduleIds: 'deterministic' |
| ❌ index.html 被 CDN 强缓存,发版后用户看到老页面 | ✅ 带 hash 的静态资源设一年强缓存,index.html 必须 Cache-Control: no-cache |
❌ 发版后老用户报错 Loading chunk xxx failed | ✅ 旧 html 请求已被删除的 chunk,服务端保留几个版本的产物 + 前端捕获该错误后提示刷新 |
| ❌ babel-loader 没配 exclude,构建慢到无法忍受 | ✅ exclude: /node_modules/ 或 include: path.resolve('src'),这是收益最大的一条 |
❌ loader 顺序写成 ['css-loader', 'style-loader'] | ✅ 从右到左执行,正确顺序是 ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'] |
❌ 加了 thread-loader 构建反而变慢 | ✅ 每个 worker 有几百 ms 启动开销,只给耗时久的 babel/ts 用,小项目直接不用 |
| ❌ 改一个 utils 文件就整页刷新 | ✅ 链路上没有 HMR 边界,拆分「万能 utils」,或在合适位置手动 accept |
| ❌ 热更新几次后页面越来越卡 | ✅ 旧模块的定时器/事件/WS 没清理,用 import.meta.hot.dispose() 或 module.hot.dispose() 释放 |
❌ import.meta.hot.accept() 生产环境报错 | ✅ 必须包在 if (import.meta.hot) {} 里,生产构建时整块会被摇掉 |
| ❌ 通过 nginx / 内网穿透代理 dev server,HMR 静默失效 | ✅ 代理需配 Upgrade/Connection 头,或指定 server.hmr.clientPort 与 protocol: 'wss' |
| ❌ 部署到子路径后资源 404 | ✅ Webpack 配 output.publicPath,Vite 配 base,并确认路由 basename 一致 |
| ❌ 生产 source-map 直接上传导致源码泄露 | ✅ 用 hidden-source-map 并只上传到 Sentry 等平台,产物里不留 sourceMappingURL |
❌ 开发时 source-map 配成 source-map,构建奇慢 | ✅ 开发用 eval-cheap-module-source-map,兼顾速度与行号准确 |
❌ 配了 externals 但页面报 React is not defined | ✅ externals 只是不打包,还必须在 html 模板里手动加对应的 CDN <script> |
| ❌ splitChunks 配得太细,首屏请求数暴涨反而更慢 | ✅ 用 minSize / maxInitialRequests 控制粒度,按「变更频率」而非「文件数量」分组 |
| ❌ moment 打包后体积巨大 | ✅ IgnorePlugin 忽略 ./locale,或直接换 dayjs / date-fns |
📌 当日复盘任务
- [ ] 能脱稿画出 Webpack 的 8 步构建流程图,并说清每一步产出什么
- [ ] 能说清 Compiler / Compilation / Module / Chunk / Asset 五者的关系
- [ ] 能解释 Loader 和 Plugin 的本质区别,以及 loader 的 pitch 熔断机制
- [ ] 手写一个 Webpack Plugin(不看资料,能写出
apply+emit钩子 + 新增 asset) - [ ] 手写一个 Loader(含
getOptions、this.async()、addDependency) - [ ] 手写极简打包器,能讲清依赖图如何转成模块映射表,以及如何处理循环依赖
- [ ] 能完整复述 Tree-Shaking 的三个生效前提和六种失效场景
- [ ] 在自己项目里配
usedExports+webpack-bundle-analyzer,验证摇树是否真的生效 - [ ] 能说清
sideEffects(文件级)和usedExports(导出级)的差异 - [ ] 能画出 Webpack HMR 与 Vite HMR 两条完整链路,并解释为什么 Vite 更快
- [ ] 能解释 HMR 边界的概念,以及为什么改 utils 会降级成整页刷新
- [ ] 能说清 Vite 依赖预构建解决的三个问题和缓存失效条件
- [ ] 能辨析 hash / chunkhash / contenthash,并写出长缓存的完整闭环配置
- [ ] 给现有项目做一次构建提速(先用 speed-measure 测量,再动手),记录前后数据
- [ ] 整理一份属于自己的「构建优化清单」,区分速度优化和体积优化两条线
- [ ] 能对比 Webpack / Vite / Rollup / ESBuild / Rspack 的定位与选型依据