← 全部材料 打包
BUILD TOOLING · 14 个高频点

打包器其实只做三件事

工程化的问题有个共同点:会配的人多,能讲清原理的人少。一旦被问到"loader 和 plugin 有什么区别"、"为什么用 contenthash",只背过配置的人就露馅了。下面每一节都从它在流水线的哪个位置讲起。

01先定位,再记细节每个概念先想清楚它在构建流程的哪一步,细节才有地方挂。
02能手写骨架loader 和 plugin 各手写一遍最小的,比看十篇配置文档有用。
03注意版本差异DLL、CommonsChunkPlugin 这些已过时,答出来会显得知识没更新。
01

四种模块规范,打包器在兼容什么★

为什么会有"双包"这种产物
打包器存在的根本原因是:浏览器不认识 CJS 的 require 和 node_modules 的解析规则。它要做两件事——把各种规范的依赖关系读成一个图,再把图输出成浏览器能跑的形态。

CJS 与 ESM 的关键差别

  • require 是同步函数调用,运行时才解析
  • 导出是值拷贝(活绑定的部分靠 getter 模拟)
  • 可以做条件 require,依赖图不确定
  • 无法静态分析,所以 tree-shaking 效果差

ESM 的优势

  • import 是静态声明,编译期就能分析
  • 导出是活绑定,值变了引用方能看到
  • 顶层 await、循环引用的行为更明确
  • 天然支持 tree-shaking 和静态检查
打包器实际在做的事入口 entry → 解析(resolve)把 import 路径换成真实文件,处理 node_modules 查找 → 加载(load)读文件源码 → 转换(transform)loader 链把非 JS 变成 JS → 分析依赖,递归下去 → 得到一个模块图 → 分包、生成 chunk、注入运行时(runtime) → 输出文件
面试怎么答浏览器不能直接执行 CJS,也不认识 node_modules 的查找规则,所以需要打包器把依赖关系解析成图再输出。ESM 因为是静态的,能被分析,所以是 tree-shaking 的前提;CJS 是运行时求值,很难静态分析。
02

Babel:转语法和补 API 是两件事★

syntax transform cannot fill a missing runtime
这是最容易答错的一道题:Babel 只负责把新语法翻译成旧语法,不会给你补运行时不存在的 API。 箭头函数、可选链、解构是语法,Babel 能改写成老写法; 但 Promise、Array.prototype.includes、Proxy 是运行时提供的东西, 老浏览器里根本没有这个对象,语法怎么转都没用——只能靠 polyfill 提前往全局挂一个。
Babel 的三个阶段 parse:源码 → AST(抽象语法树)。用 @babel/parser。
transform:遍历 AST,每个插件针对特定节点类型做替换。这一步是纯数据结构操作,跟字符串替换完全不同。
generate:AST → 字符串 + source map。

所以"Babel 是不是字符串替换"这个问题,答案是不是——它改的是树,所以不会改坏字符串里的内容。

✕ 以为 Babel 万能

  • "配了 Babel 就能用最新语法" —— 语法能转,API 还是要 polyfill
  • "用了 core-js 就兼容一切" —— Proxy、WeakRef 这类补不了
  • "Babel 慢是因为它做字符串替换" —— 它走的是 AST,慢在解析和多次遍历

还有一个反向的坑:targets 写得太老,会把 async/await 转成 regenerator 状态机,体积暴涨几十 KB。

✓ 实际怎么配

  • 应用:preset-env + useBuiltIns: 'usage' + corejs: 3
  • 库:transform-runtime,不要污染全局
  • 现代浏览器优先:targets: 'last 2 versions, not dead'

Vite 的生产构建默认用 esbuild 转译,需要兼容老浏览器时才用 @vitejs/plugin-legacy 补上 Babel 那一套。

面试这样答
Babel 的工作分三步:用 parser 把源码解析成 AST,用插件遍历 AST 做节点替换,最后 generate 回字符串和 source map,所以它不是字符串替换。但它只转语法,不补 API——箭头函数、可选链这些能被改写成 ES5 的写法,可 Promise、Array.prototype.includes、Proxy 这些是运行时提供的东西,老浏览器里压根没有,只能靠 core-js 这类 polyfill 提前挂到全局。preset-env 会根据 browserslist 的 targets 算出需要转哪些特性,useBuiltIns 设为 usage 时按实际用到的 API 自动引入,产物最省。还有个边界:Proxy 和 WeakRef 这类无法用 ES5 实现,polyfill 也补不了,这也是 Vue3 不支持 IE11 的原因。如果是发 npm 包,要用 transform-runtime 而不是直接引 core-js,避免污染使用者的全局环境。
03

Webpack 的构建流程与钩子★

一条流水线,钩子决定你能插在哪
Webpack 的核心是一条流水线加一套事件系统。理解三个对象(compiler、compilation、module)和两个阶段(make 造模块图、emit 出文件),钩子就都能对上位置了。
初始化读配置,创建 compiler
→
compiler.run开始一次构建
→
make递归解析,造模块图
→
seal分包、优化、生成 chunk
→
emit写文件到 assets
→
done结束,通知插件
最常被问的几个钩子分别在哪compiler.hooks.entryOption 读配置时,改 entry compiler.hooks.beforeRun 一次构建开始前 compiler.hooks.watchRun watch 模式每次构建前 compilation.hooks.optimizeChunks 改分包结果 compiler.hooks.emit 生成文件之前,可以加资源 compiler.hooks.done 构建完成 compilation.hooks.processAssets (Webpack 5 推荐在这里改产物)
面试怎么答Webpack 先初始化读配置创建 compiler,然后 run 进入 make 阶段递归解析依赖构建模块图,seal 阶段分包优化生成 chunk,emit 阶段把产物写到磁盘。插件通过 Tapable 的钩子在这条流水线的各个位置介入,改产物推荐用 processAssets。
04

loader 和 plugin 的区别★

一个处理文件,一个处理流程
loader 是文件到文件的转换函数,执行在模块加载阶段;plugin 是有钩子的对象,能在构建流程任意位置介入,能接触到 compilation 和最终产物。

loader 能做什么

  • 把 scss 编译成 css
  • 把 TS 编译成 JS
  • 把图片转成 base64 内联
  • 只能拿到当前文件的内容

plugin 能做什么

  • 生成额外的 HTML 文件
  • 删除无用产物、注入环境变量
  • 分析包体、上传 CDN
  • 能遍历所有模块和 chunk
面试怎么答loader 是文件级转换,在模块加载阶段按从右到左的顺序链式执行,输入输出都是文件内容。plugin 通过 Tapable 钩子介入整个构建流程,能访问 compilation、chunk 和最终产物,适合做产物生成、优化和上传这类事情。
05

手写一个 loader 和一个 plugin★

面试让写的时候,写骨架就够了
loader 就是一个签名为 (source) => source 的函数;plugin 就是一个带 apply(compiler) 方法的类。剩下的都是细节。
面试怎么答loader 是一个接收源码返回源码的函数,需要异步就调 this.async(),配置通过 getOptions 读取。plugin 是一个带 apply(compiler) 的类,在 apply 里用 tap 注册钩子,逻辑写在回调里,改产物用 compilation.updateAsset。
06

Vite 为什么快★

开发时不打包,生产时换个打包器
Vite 的快分两段:开发时用浏览器原生 ESM + esbuild 预构建依赖,几乎不打包;生产时用 Rollup 打包,因为产物质量和 tree-shaking 更好。
依赖预构建esbuild 把 CJS 依赖转成 ESM 并合成单文件
→
浏览器请求走原生 import,按需编译单个模块
→
生产构建Rollup 打包,tree-shaking 和分包
为什么必须预构建依赖(这一步最容易被忽略)两个原因: 1. 很多包还是 CJS,浏览器不认识 require → esbuild 转成 ESM 2. lodash-es 这种包有几百万个小文件 浏览器按 ESM 一个个请求会发出上万次请求 → 预构建把它们合成一个文件,请求数降下来
面试怎么答Vite 开发时利用浏览器原生 ESM,按需编译单个模块,只对依赖做一次 esbuild 预构建,所以冷启动和热更新都很快。生产构建换用 Rollup 以获得更好的 tree-shaking 和产物优化。代价是开发环境依赖 HTTP/2,且 dev 与 build 的转换链不同需要验证。
07

热更新的原理

为什么改了样式不用刷新页面
HMR 的前提是模块能自我替换。构建工具维护一张"模块 → 接受更新的处理函数"的表,文件变了就沿着依赖链往上找谁能接住这个更新。
文件变化watcher 监听到 mtime 变化
→
找依赖边界沿 import 链往上找 accept 的模块
→
推送更新WebSocket 发 hash 和变更列表
→
替换模块执行 accept 回调,不刷新页面
Vite 和 Webpack 的差别Webpack 变更 → 重新编译受影响的 chunk → 生成 hot-update.js/json → WS 推送 Vite 变更 → 只编译这一个文件(esbuild 或直接返回) → WS 告诉浏览器"这个模块变了" → 浏览器用带时间戳的 URL 重新 import → 沿 import 链向上 invalidate,直到有 accept 的边界
面试怎么答HMR 是构建工具监听文件变化后,沿着模块依赖图向上查找能接受更新的边界模块,通过 WebSocket 把变更推给浏览器,浏览器替换模块并执行 accept 回调,不刷新页面。找不到边界就退化成整页刷新。
08

Tree-shaking 的三个前提★

少一个,效果就大打折扣
要摇掉未使用的代码,必须同时满足:用 ESM、包标记无副作用、构建处于 production 且开启压缩。缺一个,那些代码就还在包里。
◐ 可交互:勾一勾,看哪些代码真的没进产物
源码里 import 并使用了哪些
模块格式
sideEffects
构建模式
产物
0KB(含 8KB 运行时)

摇不掉的典型原因

  • 用了 CJS(require 是运行时求值)
  • 包里有顶层副作用(比如 window.foo = 1)
  • 写成 import _ from lodash 整包引入
  • sideEffects 没声明,打包器不敢删

能摇掉

  • import { debounce } from lodash-es
  • 纯函数、无顶层副作用
  • 包内声明 "sideEffects": false
  • production 模式加 Terser/SWC 压缩
面试怎么答tree-shaking 需要三个条件:使用 ESM 静态语法、包声明 sideEffects 表明无副作用、production 构建并开启压缩做死代码消除。CJS 因为 require 是运行时求值无法静态分析,整包引入和顶层副作用都会让优化失效。
09

代码分割:什么时候拆,拆多细

不是越碎越好
分割的目标是让首屏不需要的代码不下载,不是"文件越小越好"。切太碎会导致请求数暴涨、公共依赖重复打包,反而更慢。
Webpack 的 chunk 分成三类initial chunk 入口直接依赖的,首屏必须加载 async chunk 动态 import 产生的,用到才加载 runtime chunk 模块加载和缓存的胶水代码,很小但常变 <em>关键点:runtime 单独抽出来,业务代码变了它才不会跟着变, 缓存命中率会明显提高。</em>
面试怎么答代码分割的目的是让首屏不需要的代码不下载,主要手段是动态 import 和路由级懒加载。切分粒度以路由为单位,再对明显重的模块单独拆,同时要抽出公共依赖并单独抽出 runtime chunk 来提升缓存命中率。
10

三种 hash 与长缓存★

改一个字,应该只有一个小文件变
hash 跟整次构建走(任何改动都变,最差);chunkhash 跟 chunk 内容走;contenthash 跟文件内容走(最精确)。想要长缓存,就该用 contenthash。
三者的区别[hash] 同一次编译的产物共享同一个 hash 改任何一个文件,全部文件名都变 → 缓存全废 [chunkhash] 同一个 chunk 共享 但 chunk 里如果含 runtime,引用别的 chunk 的 id 变了它也会变 → 还是会误伤 [contenthash] 只跟这个文件的内容有关 ← 静态资源应该用这个
面试怎么答hash 是构建级、chunkhash 是 chunk 级、contenthash 是文件内容级。静态资源要用 contenthash 才能做到"改一个文件只有一个文件名变",配合一年的 max-age 实现长缓存。还要单独抽出 runtime chunk,否则映射表变化会连带影响入口文件。
11

Source Map:调试和泄露的两面

生产环境该给哪种
Source Map 让压缩后的代码能映射回源码。开发用最详细的,生产要谨慎——因为 map 文件里可能包含你的完整源码。
常见模式的取舍eval 最快,重构建最快,但只能映射到模块 开发环境常用,生产不能用 cheap-module-source-map 有行号没列号,够用且快 hidden-source-map 生成 map 但不在 JS 末尾写注释 → 浏览器不会自动加载,适合上传到错误监控平台 nosources-source-map 有映射关系但<strong>不含源码内容</strong> → 泄露风险最小,堆栈还能还原
面试怎么答Source Map 用于把压缩代码映射回源码。开发建议用 eval-cheap-module-source-map 保证速度,生产用 hidden-source-map 或 nosources-source-map,避免源码泄露同时保留错误堆栈还原能力。绝对不要把 .map 公开部署。
12

环境变量:它其实是字符串替换★

所以它不会在运行时变
DefinePlugin 做的事是编译期文本替换。所以 process.env.NODE_ENV 在产物里已经变成字面量了,改成动态读取是拿不到的。
三个后果1. 必须写完整字符串 process.env.XXX const key = 'NODE_ENV'; process.env[key] ❌ 替换不了 2. 只有被显式声明的变量才会被替换 Vite 里是 import.meta.env.VITE_XXX,必须以 VITE_ 开头 3. 不要塞密钥 产物里的字符串人人在浏览器都能看到
面试怎么答环境变量在打包时是文本替换,process.env.NODE_ENV 会被替换成字面量,压缩器据此做死代码消除。所以必须写完整的静态属性访问,动态取值替换不了。密钥不能放进去,因为产物对所有人可见。需要运行时配置就得用 window 上的注入。
13

构建性能优化

先测量,再决定要不要上多进程
构建慢的三大来源:重复编译没变的东西、串行处理大量文件、无谓的类型检查和压缩。顺序上先做缓存,再考虑并行。
按收益从高到低1. 持久化缓存 Webpack 5: cache: { type: 'filesystem' } Vite: 依赖预构建缓存 + esbuild 转换本来就快 2. 缩小处理范围 include / exclude 限定 loader 范围,别把 node_modules 也过一遍 3. 并行 thread-loader(JS 转换)、esbuild-loader 替代 babel → 但进程通信有开销,小项目反而更慢 4. 把类型检查挪出构建 fork-ts-checker 单独跑,让 tsc 不阻塞打包 5. 减少无用工作 dev 环境不做压缩、不生成 sourcemap 的完整版
面试怎么答构建优化先做持久化缓存,再缩小 loader 处理范围,然后才考虑多进程并行。把类型检查挪出构建流程,dev 环境关掉压缩和完整 sourcemap。用 esbuild 或 swc 替代 babel 通常是收益最大的单项改动。DLL 方案已被 Webpack 5 的 filesystem cache 取代。
14

模块联邦:运行时共享代码

和"抽成 npm 包"不是一回事
模块联邦让两个独立部署的应用在运行时互相引用模块,不用重新发版。它解决的是"公共代码怎么共享"里最麻烦的那种:各自独立构建、独立发布。

抽 npm 包的痛点

  • 每次改动都要发版 + 所有使用方升级
  • 版本不一致时会出现多份实例
  • 共享状态(如 React 实例)容易出问题

模块联邦的做法

  • 远程应用暴露模块,宿主运行时加载
  • 共享依赖声明为 singleton,避免多实例
  • 各自独立部署,刷新即生效
核心概念只有三个host 消费方,运行时去加载远程模块 remote 提供方,暴露自己的模块 shared 声明共享依赖(react、vue 等), 配 singleton 保证全局只有一份实例
面试怎么答模块联邦让独立构建、独立部署的应用在运行时互相加载模块,通过 host、remote、shared 三个概念工作。相比抽 npm 包,它不需要发版就能生效,并且能通过 shared 加 singleton 避免 React 这类依赖出现多实例。代价是版本协商和调试更复杂。
15

速答清单

考前十分钟只看这页
01打包器到底在做什么?
把各种模块规范的依赖解析成一张图,再输出浏览器能执行的 chunk,并注入运行时。
02loader 和 plugin 的区别?
loader 是文件到文件的转换,链式从右往左;plugin 用钩子介入整个构建流程,能碰 compilation 和产物。
03compiler 和 compilation?
compiler 全局唯一,compilation 是单次构建,watch 下会反复创建。状态别挂在 compiler 上。
04Vite 为什么快?
开发时用原生 ESM 按需编译 + esbuild 预构建依赖,几乎不打包;生产用 Rollup。
05HMR 的原理?
沿依赖图向上找能 accept 的边界,通过 WebSocket 推送,浏览器替换模块不刷新。找不到就整页刷新。
06tree-shaking 的前提?
ESM + 声明 sideEffects + production 压缩。CJS 和顶层副作用都会让优化失效。
07三种 hash 怎么选?
contenthash 跟文件内容,静态资源用它;chunkhash 跟 chunk;hash 跟整次构建,最差。
08生产该用哪种 sourcemap?
hidden-source-map 或 nosources-source-map,避免源码泄露同时能还原堆栈。别公开部署 .map。
09环境变量为什么是替换?
DefinePlugin 做编译期文本替换,压缩器才能做死代码消除。所以不能动态取值,也不能放密钥。
10模块联邦解决什么?
运行时共享模块,独立部署即生效,shared 加 singleton 避免依赖多实例。
11Babel 的工作原理是什么?
三步:用 @babel/parser 把源码解析成 AST,用插件遍历 AST 做节点替换,最后 generate 回字符串和 source map。所以它不是字符串替换,改字符串里的内容不会被误伤。
12Babel 能解决所有兼容问题吗?
不能。它只转语法,不补 API。Promise、Array.prototype.includes 这些是运行时提供的,老浏览器里没有这个对象,语法怎么转都没用,要靠 core-js 之类的 polyfill 提前挂到全局。Proxy、WeakRef 这类甚至 polyfill 也补不了。
13preset-env 是怎么决定转什么的?
读 browserslist 的 targets,算出这些目标浏览器缺少哪些特性,只转缺的部分。不配 targets 等于把 ES5 全转一遍,产物体积白白变大。配得太老还会把 async/await 转成 regenerator 状态机,多出几十 KB。
14useBuiltIns 的三个值有什么区别?
false 不自动引入,要自己 import;entry 只在入口 import core-js 时按 targets 替换成需要的模块;usage 按代码里实际用到的 API 自动引入,体积最省,推荐用这个。