前端构建工具面试里,"讲讲 webpack 的 loader 和 plugin"几乎是必考题,紧跟着大概率会追问"那 Vite 呢"。这篇笔记把这两个问题连起来讲清楚:webpack 的 Loader/Plugin 到底是什么、边界在哪;Vite 里有没有对应概念;以及 Vite 这几年在底层做了什么、为什么最近两个大版本(Vite 7、Vite 8)会成为构建工具圈的分水岭。
一、Webpack:先构建依赖图,再统一打包
理解 webpack 的 Loader 和 Plugin,得先理解它的两个核心对象:
- Compiler:全局唯一,贯穿整个 webpack 生命周期,持有完整的配置信息。
- Compilation:每一次构建(watch 模式下每次重新编译)都会创建一个新的 Compilation 实例,记录当次构建的模块、依赖图、chunk 和产物信息。
这两者都基于 Tapable ------一个专门做事件流的库------对外暴露各种 hook。Loader 和 Plugin,本质上就是挂在这套 hook 体系两个不同粒度上的扩展点。
Loader:单文件转换器
Loader 本质是一个 Node 函数,接收源码字符串(或 Buffer),返回转换后的代码(可以附带 source map)。它的作用范围是单个模块,感知不到整个依赖图,也不知道其他文件长什么样。
它要解决的问题是:webpack 原生只认识 JS,但项目里有 CSS、图片、TypeScript、Vue 单文件组件......这些"非 JS 语义"的资源需要先被转换成 webpack 能处理的 JS 模块。常见的有 babel-loader(ES6+ 转兼容语法)、ts-loader、css-loader/style-loader、vue-loader,以及 webpack 5 里用内置 asset modules 取代的 file-loader/url-loader。
执行顺序上,loader 数组是从右到左、从下到上链式调用的;另外每个 loader 还有一个 pitch 阶段,会先从左到右跑一遍,用来提前拦截或短路后续 loader。
ini
// 一个最简单的 loader
module.exports = function (source) {
const result = transform(source);
return result; // 转换后的代码字符串
};
Plugin:贯穿全流程的扩展
Plugin 本质是一个带 apply(compiler) 方法的类,通过 tap/tapAsync/tapPromise 订阅 compiler、compilation 生命周期上的具体 hook。
它的能力边界比 Loader 大得多:启动、模块解析、chunk 划分、asset 生成、优化压缩......构建流程里几乎每个阶段都有对应的 hook 可以插入自定义逻辑。典型的比如生成 HTML 的 HtmlWebpackPlugin、抽取 CSS 的 MiniCssExtractPlugin、注入全局变量的 DefinePlugin,以及更重量级的 Module Federation。
Loader 做不到、只有 Plugin 能做的事情包括:重新划分 chunk、在打包结束后额外生成或修改产物文件、跨模块地注入信息。
javascript
// 一个最简单的 plugin
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, cb) => {
// 可以在这里读写整个 compilation 的产物
cb();
});
}
}
一句话区分二者:Loader 解决"如何读懂一个文件",Plugin 解决"如何组织整条构建流水线"。
二、Vite:反过来,先启动,按需编译
Vite 的心智模型跟 webpack 正好相反。webpack 需要先把完整依赖图构建出来才能启动开发服务器,项目越大启动越慢;Vite 开发模式下利用浏览器原生支持的 ESM,浏览器请求哪个文件,Vite 才编译哪个文件返回------没有"打包"这一步,启动时间基本和项目里的模块总数无关。
依赖预构建:esbuild 上场的地方
第三方依赖体量大、更新频率低,而且很多还是 CommonJS/UMD 格式,没法直接被浏览器当 ESM 请求。Vite 用 esbuild (Go 编写,比纯 JS 工具快一到两个数量级)在启动时把 node_modules 里的依赖转成 ESM,并把一个包内部零散的模块合并成少数几个文件。
这一步解决两件事:CJS/UMD → ESM 的格式兼容;避免一个包内部几十上百个小模块被浏览器逐个请求形成瀑布。结果会缓存到 node_modules/.vite,依赖没变化时直接复用。注意这一步只处理 node_modules,业务代码不会被预构建。
Vite 插件:Rollup 接口的超集
Vite 的插件接口兼容 Rollup 的 resolveId/load/transform 等钩子,同时扩展了开发服务器专属的钩子:
configureServer:直接操作底层 connect 开发服务器实例,相当于中间件。transformIndexHtml:转换index.html。handleHotUpdate:自定义 HMR 行为。apply: 'serve' | 'build'与enforce: 'pre' | 'post':控制插件在开发/构建哪个阶段生效,以及执行顺序。
javascript
// Vite 插件骨架
export default () => ({
name: 'my-plugin',
enforce: 'pre',
transform(code, id) {
// ...
},
});
值得注意的是,Vite 并没有像 webpack 那样区分"Loader"和"Plugin"两套体系------单文件转换(对应 Loader)和全流程扩展(对应 Plugin)都是同一个插件对象上的不同钩子。
生产构建:Rollup,以及正在替换它的 Rolldown
开发环境不打包,但线上仍然需要产物。传统上 Vite 的生产构建交给 Rollup ------tree-shaking 彻底、输出干净。从 Vite 7 开始,官方推出了可选的 rolldown-vite 包,把 Rolldown(Rust 编写、兼容 Rollup 接口的打包器)作为可插拔的替代品;到 Vite 8,Rolldown 已经成为默认打包器。这段演进值得单独展开讲。
三、放在一起看
| 维度 | Webpack | Vite |
|---|---|---|
| 单文件转换 | Loader(链式函数,返回转换后源码) | 插件的 transform 钩子(Rollup 风格) |
| 全流程扩展 | Plugin(订阅 Tapable hook) | 插件的其余钩子(configureServer、transformIndexHtml 等),不单独区分 Loader/Plugin |
| 底层事件机制 | Tapable(tap / tapAsync / tapPromise) | Rollup 风格的函数式钩子 + Vite 自定义钩子 |
| 依赖图与构建产物 | Compiler(全局)+ Compilation(每次构建一个实例) | 开发模式无完整依赖图打包,只维护模块依赖关系用于 HMR 失效传播;生产构建时依赖图由 Rollup/Rolldown 管理 |
| 开发服务器启动 | 先构建完整依赖图再启动,项目越大越慢 | 先启动,请求到哪个文件才编译哪个,启动时间与模块总数基本无关 |
| 第三方依赖处理 | 和业务代码一起进入同一套打包流程 | esbuild 预构建,转 ESM + 合并小模块,与业务代码路径分开 |
四、Vite 版本演进:从"双引擎"到"统一 Rust 引擎"
这条时间线的主线,是"开发用 esbuild、生产用 Rollup"这套务实的双引擎组合,如何被同一批 Rust 工具逐步统一替换。
- v1(2020.12) :面向 Vue 单文件组件的实验性开发服务器,与 Vue 强绑定,还不是通用构建工具。
- v2(2021.02) :架构重写,插件接口对齐 Rollup,变成框架无关的通用工具;开发阶段引入 esbuild 做依赖预构建与 TS/JSX 转译。今天所说的"Vite 架构"基本从这一版定型。
- v3(2022.07) :优化配置解析与 base 路径处理,dev/build 行为进一步对齐,SSR 能力增强。
- v4(2022.12) :升级 Rollup 3,构建性能与产物体积优化。
- v5(2023.11) :升级 Rollup 4(核心 parser 用 Rust 重写以提速),要求 Node 18+,为后续多运行时能力打基础。
- v6(2024.11) :引入实验性 Environment API,把"浏览器 client / SSR / Edge runtime / Worker"抽象成可插拔的多个 environment,为非浏览器运行时(比如 Cloudflare Workers)提供统一的构建心智模型。
- v7(2025) :推出可选的
rolldown-vite包,把 Rolldown 作为可插拔替代打包器;新增buildApp钩子支持跨 environment 的插件协作;默认浏览器 baseline 提升(Chrome 87→107 等),发布形态改为纯 ESM,要求 Node 20.19+/22.12+。 - v8(2026) :Rolldown 转正为默认且唯一的打包器,取代"dev 用 esbuild、build 用 Rollup"的双引擎架构;与 Oxc 工具链(同一批 Rust parser/transformer)对齐,官方宣称构建速度提升 10--30 倍,同时保留对现有 Rollup/Vite 插件的兼容层。
五、为什么会走向统一:更长的技术脉络
把版本号背后的动机连起来看,会更清楚"Vite 8 默认切到 Rolldown"这件事在解决什么问题。
起点问题:JS 生态的构建工具长期是"用 JS 写 JS 工具"------webpack、Rollup 早期实现都是纯 JS,大规模 AST 遍历和字符串处理天然受制于 JS 本身的性能。
第一波系统语言重写:esbuild(Go,Evan Wallace)率先证明了用系统语言重写 transform/bundle 热点路径能带来数量级的性能提升;随后 swc(Rust)、Rspack(Rust,字节跳动出品,尽量兼容 webpack 的 loader/plugin 配置)、Turbopack(Rust,Vercel/Next.js)相继出现,走的是同一条路:不改变现有心智模型,替换掉性能瓶颈的执行引擎。
Vite 2 的务实选择:dev 用 esbuild(重启动速度、转译够用),build 用 Rollup(重产物质量、tree-shaking 更彻底)------本质是在当时 Rust/Go 工具链还不够成熟、esbuild 又不追求做到 Rollup 级别打包质量的情况下,用两套引擎覆盖两种场景的折中方案。代价是 dev 和 build 走两套转译/打包语义,长期存在"esbuild 转译结果和 Rollup 打包结果不完全一致"导致的边缘 bug。
走向统一 :Vite 作者 Evan You 创立 VoidZero,目标是用一套 Rust 工具链同时覆盖 dev 和 build 两个场景------Oxc 负责 parser/transformer/linter,Rolldown 负责打包,二者共享同一套底层设施。Vite 7 的 rolldown-vite 是过渡期的可选项,Vite 8 让它成为默认,标志着 Vite 从"胶水层 + 双引擎"演进为"自有 Rust 统一引擎 + 插件生态兼容层"。
和 webpack 生态的呼应:webpack 核心至今仍是 JS 实现,但 Rspack 在做的是相同方向的事------保留 webpack 的配置项、Loader/Plugin 心智模型,把执行引擎换成 Rust,尽量做到"改一行配置就能迁移"。两条生态路径(Vite→Rolldown、Webpack→Rspack)殊途同归,都是"接口不变、引擎换成系统语言"。