Skip to content

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 DevVite Dev
启动前是否全量打包✅ 必须打完才能访问❌ 不打包,启动即服务
启动耗时与项目规模线性相关与项目规模几乎无关(只预构建依赖)
模块加载bundle 后的产物浏览器原生 ESM,一个文件一个请求
热更新重新编译受影响的 chunk精准失效单个模块,直接替换
生产打包WebpackRollup

面试加分: Vite 的「快」不是因为 ESBuild 快(那只用在预构建和 TS/JSX 转换),本质是把「打包」这件事从开发阶段去掉了 —— no-bundle 才是核心思想,ESBuild 只是锦上添花。

3. HMR 热更新 vs Live Reload ​

维度Live ReloadHMR (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 ModuleCommonJS
加载时机编译期(静态)运行时(动态)
导出值值的引用(动态绑定)值的拷贝
能否条件导入❌(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 转成了 CJSpreset-env 默认 modules: 'auto'配置 modules: false,把模块处理交给 webpack
模块有副作用删掉可能改变程序行为,打包器不敢删package.json 的 sideEffects 标记
函数调用有潜在副作用如 IIFE、类的 static 属性赋值用 /*#__PURE__*/ 注释声明可安全删除
只用了导出对象的某个属性导出的是一个大对象而非多个具名导出拆成具名导出,避免 export default { a, b, c }
没开压缩webpack 只做标记,删除靠 Tersermode: '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.exportsESBuild 统一转成 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 HMRVite 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 工具选型 ​

工具语言定位优势劣势
WebpackJS应用打包生态最全、配置最灵活、对非 JS 资源支持最好慢、配置复杂
RollupJS库打包Tree-Shaking 最彻底、产物干净、ESM 原生对 CJS、代码分割支持不如 webpack
ESBuildGo转换 / 压缩快 10~100 倍(Go 原生 + 多线程并行 + 零中间 AST 转换)不支持降级到 ES5 的部分语法、插件能力弱、无类型检查
ViteJS开发服务 + 应用打包dev 极快、开箱即用开发与生产工具链不同,存在行为差异风险
Rspack / TurbopackRustWebpack 替代兼容 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 的反复序列化。

连环追问链:

  1. 追问:Vite 开发环境有什么缺点?
    • 答:主要三点。一是首次打开页面时请求数很多,弱网或模块特别多的页面会有明显的瀑布流延迟(不过有浏览器缓存,二次就快了);二是开发用 ESBuild、生产用 Rollup,两套工具链可能产生行为差异,出现「开发正常、上线白屏」的情况,所以上线前必须跑一遍 vite build && vite preview;三是对只有 CJS 产物的老旧依赖兼容性依赖预构建,偶尔需要手动配 optimizeDeps。
  2. 追问:为什么 Vite 生产环境不继续用 ESBuild?
    • 答:ESBuild 的代码分割和 CSS 处理能力还不成熟,而生产环境更关心分包策略、Tree-Shaking 彻底性、浏览器兼容性,这些 Rollup 更可靠。社区正在做 Rolldown(Rust 版 Rollup)来统一开发和生产。
  3. 追问:既然 Vite 这么快,Webpack 还有必要吗?
    • 答:有。Webpack 生态最全,对微前端(Module Federation)、复杂的多页/库构建、各种非标准资源处理支持更好,老项目迁移成本也要考虑。追求速度又不想丢生态,现在更推荐 Rspack(Rust 实现、兼容 webpack 配置)。
  4. 追问:Vite 为什么要做依赖预构建,不做会怎样?
    • 答:三个原因 —— 很多包只有 CJS 产物浏览器不认;像 lodash-es 这种内部有几百个模块的包不打包会产生几百个请求;浏览器也不认识 import 'vue' 这种裸模块路径。预构建用 ESBuild 一次性解决格式转换 + 合并请求 + 路径重写。
  5. 追问:预构建的缓存在哪里,什么时候会失效?
    • 答:缓存在 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__*/ 注释标注。

连环追问链:

  1. 追问:sideEffects 和 usedExports 有什么区别?
    • 答:这是 webpack 的两种 Tree-Shaking。usedExports 是粒度细的「导出级」摇树,分析某个导出有没有被用到;sideEffects 是粒度粗的「文件级」摇树,如果一个文件被标记无副作用且它的导出完全没被用到,整个文件连解析都跳过,效率更高。两者配合使用。
  2. 追问:为什么 import './reset.css' 会被摇掉?
    • 答:因为这条 import 没有绑定任何变量,在 webpack 看来「导入了但没使用」。如果 sideEffects: false 就会被当成死代码删除,导致样式全丢。正确做法是 sideEffects: ["*.css", "*.scss"] 把样式文件列入白名单。
  3. 追问:lodash 和 lodash-es 有什么区别?该怎么按需引入?
    • 答:lodash 是 CJS 产物,无法 Tree-Shaking,import { debounce } from 'lodash' 会把整个 70KB 的库打进去;lodash-es 是 ESM 产物,可以正常摇树。如果必须用 lodash,可以单文件引入 import debounce from 'lodash/debounce',或者用 babel-plugin-lodash 自动改写。
  4. 追问:类的方法能被 Tree-Shaking 吗?
    • 答:很难。因为类的方法挂在 prototype 上,可能通过 this[methodName]() 这种动态方式调用,压缩器无法确定安全性。所以工具库更推荐导出独立的纯函数而不是一个大类,这也是 lodash、date-fns 的设计思路。
  5. 追问:怎么验证 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=时间戳') 重新请求单个文件,不需要任何打包,所以热更新速度和项目体积无关。

连环追问链:

  1. 追问:为什么 React 组件更新后 state 还能保留?
    • 答:这是 react-refresh(Fast Refresh)做的。它在编译时给每个组件注册一个稳定的签名,HMR 发生时只替换组件的渲染函数,Fiber 树上的 hooks state 保持不动。但有限制:只有导出的是函数组件时才有效;如果修改了 hooks 的调用顺序或数量,签名校验失败就会强制重新挂载并丢失 state;文件里混了非组件的导出也可能导致降级。
  2. 追问:为什么我改一个 utils 文件,整个页面刷新了?
    • 答:因为从 utils 到入口的链路上没有任何模块声明 HMR 边界,冒泡到顶就只能整页刷新。这也是合理的 —— 一个被几十处引用的纯函数模块,框架无法知道怎么安全地把老的引用替换掉。框架插件(vue-loader、react-refresh)只会给组件文件自动注入边界。
  3. 追问:HMR 替换掉的旧模块留下的副作用怎么清理?
    • 答:要用 module.hot.dispose(Vite 里是 import.meta.hot.dispose)注册清理函数,在旧模块被卸载前执行,用来清定时器、解绑事件、关闭 WebSocket。不清理就会出现「改一次代码多一个定时器」的内存泄漏,表现为热更新几次后页面卡死。
  4. 追问:import.meta.hot 在生产环境会报错吗?
    • 答:不会,但必须写成 if (import.meta.hot) { ... }。Vite 生产构建时 import.meta.hot 会被静态替换成 undefined,整个 if 块会被 Tree-Shaking 掉。如果不加判断直接 import.meta.hot.accept(...),生产环境就是读 undefined 的属性报错。
  5. 追问:HMR 的 WebSocket 断了怎么办?
    • 答:客户端 runtime 有重连机制,并会在重连成功后判断是否需要整页刷新来追平状态。常见踩坑是通过 nginx / 内网穿透代理开发服务时没配 WebSocket 升级头(Upgrade / Connection),导致连接建不起来、HMR 静默失效,这时要配 server.hmr.clientPort 或 protocol。

✅ 项目关联话术(按真实经历替换):

我们项目里有个全局的 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 在各个钩子上插入自己的逻辑。

连环追问链:

  1. 追问:Loader 的执行顺序是怎样的?为什么是从右到左?
    • 答:use 数组的执行顺序是从右到左、从下到上,因为它是函数组合的语义:style(css(sass(source)))。但 loader 实际有两个阶段 —— 先从左到右执行所有 loader 的 pitch 方法,再从右到左执行 normal 方法。如果某个 pitch 返回了值,就会熔断,直接跳过它右边所有 loader 并往左回溯,style-loader 就是用这个机制接管后续处理的。
  2. 追问:Compiler 和 Compilation 有什么区别?
    • 答:Compiler 是全局唯一的编译器实例,持有完整配置,生命周期贯穿整个 webpack 进程;Compilation 代表一次具体的构建,包含这次构建的所有 modules、chunks、assets。watch 模式下每次文件变化都会创建一个新的 Compilation,但 Compiler 始终只有一个。
  3. 追问:Tapable 的钩子有哪几种类型?
    • 答:按执行方式分同步(SyncHook)和异步(AsyncSeriesHook 串行、AsyncParallelHook 并行);按返回值处理分普通、Bail(有返回值就熔断)、Waterfall(上一个的返回值作为下一个的入参)、Loop(返回非 undefined 就重新循环)。写插件时根据钩子类型选 tap / tapAsync / tapPromise。
  4. 追问:Module 和 Chunk 是什么关系?
    • 答:Module 是一个文件对应的模块单元,Chunk 是打包输出的代码块。多个 Module 按入口、动态导入、splitChunks 规则被组装成 Chunk,一个 Chunk 最终输出一个或多个 Asset(比如 js + source-map)。
  5. 追问:说一个你了解原理的常用插件?
    • 答:比如 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 不重复下载。

连环追问链:

  1. 追问:怎么定位构建瓶颈?
    • 答:时间维度用 speed-measure-webpack-plugin 或 webpack5 的 stats: 'normal' + --profile;体积维度用 webpack-bundle-analyzer(Vite 用 rollup-plugin-visualizer)。没有数据就优化,很容易花大力气优化了只占 3% 耗时的环节。
  2. 追问:thread-loader 什么情况下反而会变慢?
    • 答:它每个 worker 都要启动一个 Node 进程并做进程间通信,启动开销大约几百毫秒。小项目或者单个 loader 本身耗时很短时,开销大于收益。所以只建议用在耗时明显的 babel-loader / ts-loader 上,并且放在 loader 链的最前面(最先执行的位置)。
  3. 追问:splitChunks 你是怎么配的?为什么这么配?
    • 答:至少分三组 —— vendors 把 node_modules 抽出来(变动频率低、缓存命中率高);common 把被两个以上 chunk 引用的业务模块抽出来(minChunks: 2);再把 React/Vue 这类超大且几乎不升级的框架单独抽一组,设更高的 priority。不能无脑全抽,chunk 太碎会导致 HTTP 请求数暴涨,首屏反而变慢,要结合 maxInitialRequests 和 minSize 控制。
  4. 追问:contenthash 配了为什么缓存还是经常全部失效?
    • 答:通常是两个原因。一是没抽 runtimeChunk,webpack 的运行时代码里记录了所有 chunk 的 hash 映射,任何一个 chunk 变了都会让包含运行时的入口文件 hash 变;二是模块 ID 用了自增数字,新增一个文件会让后面所有模块 ID 偏移,配 optimization.moduleIds: 'deterministic' 让 ID 基于路径生成即可。
  5. 追问:首屏优化除了打包,还能做什么?
    • 答:路由级懒加载 + 组件级 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 的定位与选型依据