Babel / ESLint / Webpack / Vite 插件开发
现代前端工程依赖构建工具和代码质量工具来提升开发效率与代码质量。这些工具大多提供插件(Plugin)机制,允许开发者扩展其核心能力。本文档系统介绍 Babel、ESLint、Webpack、Vite 四大工具的插件开发方法。
1. Babel 插件开发
Babel 是一个 JavaScript 编译器,主要用于将 ECMAScript 2015+ 代码转换为向后兼容的 JavaScript 版本。其插件系统是 Babel 的核心扩展机制。
1.1 Babel 处理流程
Babel 的编译过程分为三个阶段:
- 解析(Parse):将源代码解析为抽象语法树(AST)。此阶段由
@babel/parser(基于 acorn)完成。 - 转换(Transform):遍历并修改 AST。这是插件工作的阶段,Babel 核心本身不进行任何转换,所有转换逻辑均由插件完成。
- 生成(Generate):将修改后的 AST 重新生成源代码。此阶段由
@babel/generator完成。
源代码 --> [Parse] --> AST --> [Transform] --> 修改后的 AST --> [Generate] --> 目标代码1.2 AST 节点类型
AST 由各种节点类型组成,以下是常见的 Babel AST 节点:
| 节点类型 | 说明 | 示例 |
|---|---|---|
Identifier | 标识符 | foo, bar, console |
VariableDeclaration | 变量声明 | const a = 1 |
FunctionDeclaration | 函数声明 | function foo() {} |
CallExpression | 函数调用表达式 | console.log('hi') |
MemberExpression | 成员访问表达式 | console.log 中的 console.log |
Literal | 字面量 | 字符串、数字、布尔值 |
ArrowFunctionExpression | 箭头函数 | () => {} |
BinaryExpression | 二元表达式 | a + b |
IfStatement | if 语句 | if (x) {} |
ReturnStatement | return 语句 | return x |
1.3 Babel 插件结构
Babel 插件采用 visitor(访问者)模式。插件导出一个函数,该函数返回一个包含 visitor 属性的对象。
1.3.1 基本插件结构
module.exports = function (babel) {
const { types: t } = babel;
return {
name: 'my-plugin',
visitor: {
// 进入节点时触发
Identifier(path, state) {
// ...
},
// 也可以指定 enter/exit 钩子
FunctionDeclaration: {
enter(path, state) {
// 进入函数声明
},
exit(path, state) {
// 离开函数声明
},
},
},
};
};1.3.2 path 对象
插件接收的 path 对象是连接 AST 节点的枢纽,提供了丰富的节点操作方法:
// 替换节点
path.replaceWith(t.identifier('newName'));
// 替换为多个节点
path.replaceWithMultiple([t.identifier('a'), t.identifier('b')]);
// 移除节点
path.remove();
// 插入兄弟节点
path.insertBefore(t.expressionStatement(t.stringLiteral('before')));
path.insertAfter(t.expressionStatement(t.stringLiteral('after')));
// 获取父节点
const parent = path.parentPath;
// 查找作用域
const binding = path.scope.getBinding('foo');
// 跳过当前节点的子节点遍历
path.skip();
// 停止遍历
path.stop();1.4 Babel 插件实例:移除 console.log
// babel-plugin-remove-console.js
module.exports = function () {
return {
name: 'babel-plugin-remove-console',
visitor: {
CallExpression(path) {
// 获取被调用表达式的 callee
const callee = path.node.callee;
// 判断是否为 console.log / console.error 等形式
if (
t.isMemberExpression(callee) &&
t.isIdentifier(callee.object, { name: 'console' }) &&
t.isIdentifier(callee.property, { name: 'log' })
) {
path.remove();
}
},
},
};
};插件用法:
// babel.config.js
module.exports = {
plugins: ['babel-plugin-remove-console'],
};更完整的实现可以支持配置白名单(保留部分 console 方法):
module.exports = function (api, options) {
const whitelist = options.whitelist || [];
return {
name: 'babel-plugin-remove-console',
visitor: {
CallExpression(path) {
const callee = path.node.callee;
if (
t.isMemberExpression(callee) &&
t.isIdentifier(callee.object, { name: 'console' })
) {
const methodName = callee.property.name;
// 如果方法名不在白名单中则移除
if (!whitelist.includes(methodName)) {
path.remove();
}
}
},
},
};
};1.5 Babel 预设 vs 插件
| 特性 | 插件(Plugin) | 预设(Preset) |
|---|---|---|
| 作用 | 执行单一的转换功能 | 插件的集合,用于组合多个转换 |
| 配置顺序 | 从前往后执行 | 从后往前执行(先于插件执行) |
| 典型示例 | @babel/plugin-transform-arrow-functions | @babel/preset-env, @babel/preset-react |
预设本质上就是一个插件数组的封装:
// my-preset.js
module.exports = function () {
return {
presets: [],
plugins: [
'plugin-a',
'plugin-b',
],
};
};2. ESLint 插件开发
ESLint 是 JavaScript 的静态代码分析工具,用于发现代码中的问题。其插件机制允许开发者定义自定义规则和配置。
2.1 ESLint 工作原理
ESLint 的工作流程如下:
- 解析(Parse):使用解析器(默认是 espree,也支持 @typescript-eslint/parser 等)将源代码解析为 AST。
- AST 构建:生成 AST 后,ESLint 向 AST 添加了
traverse能力。 - 规则遍历(Rule Traversal):ESLint 遍历 AST 的每个节点,并在进入/离开节点时触发对应的规则。
- 报告(Report):规则通过
context.report()报告问题。
源代码 --> [Parser (espree)] --> AST --> [Rule 遍历(visitor 模式)] --> [context.report() 报告问题]2.2 自定义规则结构
2.2.1 规则基本结构
// no-console.js
module.exports = {
meta: {
type: 'suggestion', // 'problem' | 'suggestion' | 'layout'
docs: {
description: '禁止使用 console.log',
recommended: true,
},
schema: [
// 可选的参数校验 schema(JSON Schema 格式)
{
type: 'object',
properties: {
allowedMethods: {
type: 'array',
items: { type: 'string' },
},
},
additionalProperties: false,
},
],
messages: {
unexpectedConsole: '不允许使用 console 方法: {{method}}',
},
fixable: 'code', // 或 'whitespace',表示该规则提供修复能力
},
create(context) {
return {
MemberExpression(node) {
if (
node.object.type === 'Identifier' &&
node.object.name === 'console'
) {
const method = node.property.name || '(unknown)';
context.report({
node,
messageId: 'unexpectedConsole',
data: { method },
fix(fixer) {
// 返回 fixer 操作
return fixer.remove(node.parent);
},
});
}
},
};
},
};2.2.2 meta 对象详解
meta: {
type: 'suggestion', // 规则类型
docs: {
description: '规则描述',
category: 'Best Practices', // 分类(deprecated in ESLint v9)
recommended: true, // 是否推荐
url: 'https://...', // 文档 URL
},
fixable: 'code', // 是否可自动修复
schema: [], // 选项的 JSON Schema
messages: {
msgId1: '错误消息 {{placeholder}}',
},
deprecated: false, // 是否废弃
replacedBy: [], // 替代规则
}2.2.3 context.report 参数
context.report({
node, // 关联的 AST 节点(必填)
messageId, // meta.messages 中的消息 ID(推荐)
message, // 直接消息字符串(与 messageId 二选一)
data, // 模板插值数据
fix(fixer) { // 修复函数
return fixer.replaceText(node, 'newText');
// fixer.replaceTextRange([start, end], 'text');
// fixer.insertTextBefore(node, 'text');
// fixer.insertTextAfter(node, 'text');
// fixer.remove(node);
},
suggest: [ // 提供建议(非自动修复)
{
desc: '建议描述',
fix(fixer) { /* ... */ },
},
],
});2.3 规则实例:禁止使用 console.log
完整的规则实现,带白名单配置:
// rules/no-console.js
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: '禁止使用 console 方法(白名单除外)',
recommended: false,
},
schema: [
{
type: 'object',
properties: {
allowedMethods: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
},
},
additionalProperties: false,
},
],
messages: {
unexpected: '不允许使用 console.{{method}}()',
},
fixable: 'code',
},
create(context) {
const options = context.options[0] || {};
const allowedMethods = options.allowedMethods || [];
return {
CallExpression(node) {
const { callee } = node;
// 匹配 console.xxx() 调用
if (
callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'console'
) {
const method = callee.property.name;
if (!allowedMethods.includes(method)) {
context.report({
node,
messageId: 'unexpected',
data: { method },
fix(fixer) {
// 修复方式:移除整条语句
return fixer.remove(node.parent.type === 'ExpressionStatement'
? node.parent
: node
);
},
});
}
}
},
};
},
};2.4 RuleTester 测试
ESLint 提供了 RuleTester 工具类来帮助测试自定义规则:
// tests/no-console.test.js
const { RuleTester } = require('eslint');
const rule = require('../rules/no-console');
const ruleTester = new RuleTester({
parserOptions: { ecmaVersion: 2020 },
});
ruleTester.run('no-console', rule, {
// 合法的代码用例
valid: [
{ code: 'foo()' },
{ code: 'console.warn()', options: [{ allowedMethods: ['warn'] }] },
],
// 不合法的代码用例
invalid: [
{
code: 'console.log("hello")',
errors: [{ messageId: 'unexpected', data: { method: 'log' } }],
output: '', // 修复后的代码
},
{
code: 'console.error("err")',
options: [{ allowedMethods: ['warn'] }],
errors: [{ messageId: 'unexpected', data: { method: 'error' } }],
},
],
});2.5 ESLint 插件发布
完整的 ESLint 插件包含规则导出和配置预设:
// index.js
module.exports = {
rules: {
'no-console': require('./rules/no-console'),
'no-debugger': require('./rules/no-debugger'),
},
configs: {
recommended: {
plugins: ['my-plugin'],
rules: {
'my-plugin/no-console': 'warn',
'my-plugin/no-debugger': 'error',
},
},
all: {
plugins: ['my-plugin'],
rules: {
'my-plugin/no-console': 'error',
'my-plugin/no-debugger': 'error',
},
},
},
};命名约定
- 插件包名格式:
eslint-plugin-xxx(如eslint-plugin-vue、eslint-plugin-react) - 规则 key 格式:
plugin-name/rule-name(如vue/no-unused-vars) - 配置 key 格式:
plugin-name/config-name(如plugin:vue/recommended)
package.json 配置
{
"name": "eslint-plugin-my-plugin",
"version": "1.0.0",
"main": "index.js",
"peerDependencies": {
"eslint": ">=8.0.0"
}
}3. Webpack 插件开发
Webpack 是一个静态模块打包工具。其插件系统基于 Tapable 事件流架构,允许开发者在 Webpack 编译生命周期的各个阶段注入自定义行为。
3.1 Webpack 完整编译流程
Webpack 的编译过程分为以下几个阶段:
- 初始化(Initialize):读取配置参数,创建 Compiler 对象,加载插件。
- 编译(Compile):从入口(entry)开始,递归解析依赖,创建模块。
- 构建模块(Build Module):使用 loader 转换模块,生成 AST,分析依赖。
- 封装(Seal):根据依赖关系生成 Chunk,优化模块。
- 输出(Emit):将 Chunk 输出为文件到文件系统。
- 完成(Done):编译结束,触发 done 钩子。
[Initialize] --> [Compile] --> [Build Module] --> [Seal] --> [Emit] --> [Done]
| | | | | |
初始化 开始编译 loader 转换 生成 Chunk 写入文件 编译完成3.2 Compiler 和 Compilation API
Webpack 插件通过 Tapable 钩子系统与编译流程交互。Webpack 内部基于 Tapable 实现了一套类似 EventEmitter 的事件机制。
3.2.1 Compiler 对象
Compiler 代表完整的 Webpack 配置环境,在整个编译周期内只创建一次。它暴露了主要的生命周期钩子:
// Compiler 常用钩子
compiler.hooks.run // 在 compiler.run 开始执行时触发
compiler.hooks.compile // 在编译开始前触发
compiler.hooks.compilation // 在 compilation 创建后触发
compiler.hooks.make // 在开始创建模块依赖图时触发
compiler.hooks.emit // 在输出资源到 output 目录之前触发
compiler.hooks.afterEmit // 在输出资源到 output 目录之后触发
compiler.hooks.done // 在编译完成后触发
compiler.hooks.failed // 在编译失败时触发3.2.2 Compilation 对象
Compilation 代表一次单次编译过程。当 Webpack 运行在 watch 模式下,每次文件变化都会产生一个新的 Compilation 实例。
// Compilation 常用钩子和 API
compilation.hooks.succeedModule // 模块成功构建后触发
compilation.hooks.optimize // 优化阶段触发
compilation.hooks.optimizeChunks // 优化 Chunk 阶段触发
// 操作 assets
compilation.assets['filename.js'] = source;
compilation.emitAsset('filename.js', source);
compilation.deleteAsset('filename.js');
// 获取模块、chunk 信息
compilation.modules // 所有模块
compilation.chunks // 所有 chunk
compilation.entrypoints // 入口点3.2.3 钩子注册方式
// 同步钩子
compiler.hooks.done.tap('MyPlugin', (stats) => {
console.log('编译完成');
});
// 异步钩子(callback 方式)
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
// 异步操作
setTimeout(() => {
callback();
}, 1000);
});
// 异步钩子(Promise 方式)
compiler.hooks.emit.tapPromise('MyPlugin', async (compilation) => {
await someAsyncOperation();
});3.3 自定义插件实例:FileListPlugin
以下插件在打包完成后生成一个 filelist.md 文件,列出所有打包产物的文件名和大小。
// plugins/FileListPlugin.js
class FileListPlugin {
constructor(options) {
this.filename = options && options.filename || 'filelist.md';
}
apply(compiler) {
compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
// 构建文件列表
const fileList = [];
// 遍历 compilation.assets 中的所有资源
for (const [filename, asset] of Object.entries(compilation.assets)) {
const size = asset.size();
fileList.push(`- ${filename} (${size} bytes)`);
}
// 生成 markdown 内容
const content = `# 打包文件列表\n\n${fileList.join('\n')}\n`;
// 向 compilation.assets 添加新文件
compilation.assets[this.filename] = {
source: () => content,
size: () => content.length,
};
callback();
});
}
}
module.exports = FileListPlugin;使用方式(webpack.config.js):
const FileListPlugin = require('./plugins/FileListPlugin');
module.exports = {
plugins: [
new FileListPlugin({ filename: 'assets-list.md' }),
],
};3.4 Loader 开发
Loader 是 Webpack 中用于处理模块转换的函数。与插件不同,Loader 工作在模块级别。
3.4.1 Loader 基本结构
// loaders/markdown-loader.js
const marked = require('marked');
module.exports = function (source) {
// this 是 Loader Context,包含一系列工具方法
const options = this.getOptions();
const html = marked.parse(source);
// 必须返回 JavaScript 代码字符串(或 Buffer)
return `module.exports = ${JSON.stringify(html)};`;
};3.4.2 Loader 关键特性
pitch 方法:Loader 的请求顺序是从右到左执行的,但如果某个 loader 实现了 pitch 方法,且 pitch 方法有返回值,则会跳过后续 loader。
module.exports = function (source) {
// 正常处理
return transformedSource;
};
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
// pitch 阶段先执行
// 若有返回值则跳过后续 loader 和模块读取
data.value = '共享数据';
};链式调用:Loader 可以串联使用,上一个 loader 的处理结果传递给下一个 loader。
// use: ['style-loader', 'css-loader', 'sass-loader']
// 执行顺序:sass-loader -> css-loader -> style-loader缓存控制:Webpack 默认会缓存 loader 的执行结果。可以通过 this.cacheable(false) 禁用缓存。
module.exports = function (source) {
// 结果不可缓存(如每次运行都可能变化)
this.cacheable(false);
return source;
};异步 Loader:当 loader 需要执行异步操作时,使用 this.async()。
module.exports = function (source) {
const callback = this.async();
setTimeout(() => {
const result = source.toUpperCase();
callback(null, result);
}, 100);
};获取选项:通过 this.getOptions() 获取配置参数(需配合 schema 校验)。
// schema.json
{
"type": "object",
"properties": {
"language": { "type": "string" }
},
"additionalProperties": false
}
// loader
module.exports = function (source) {
const options = this.getOptions();
// options.language
};3.4.3 自定义 markdown-loader 实例
// loaders/markdown-loader.js
const { marked } = require('marked');
module.exports = function (source) {
const options = this.getOptions();
// 设置 marked 选项
if (options) {
marked.setOptions(options);
}
const html = marked.parse(source);
// 导出 HTML 字符串
return `module.exports = ${JSON.stringify(html)};`;
};
module.exports.pitch = function () {
// pitch 阶段不做处理
};4. Vite 插件开发
Vite 是一种新型前端构建工具,开发阶段基于 ESM 原生模块,生产构建使用 Rollup。Vite 插件 API 在 Rollup 插件 API 的基础上扩展了 Vite 特有的钩子。
4.1 Vite 插件 API
Vite 插件同时兼容 Rollup 和 Vite 特有的钩子。
4.1.1 基本结构
// vite-plugin-example.js
function myPlugin(options = {}) {
return {
name: 'vite-plugin-example', // 插件名称,必须唯一
enforce: 'pre', // 可选:'pre' | 'post' | undefined
apply: 'serve', // 可选:'serve' | 'build' | 函数
// Rollup 通用钩子
resolveId(source, importer, options) {
// 自定义模块解析
},
load(id) {
// 加载模块
},
transform(code, id) {
// 转换模块代码
},
// Vite 特有钩子
config(config, env) {
// 在解析 Vite 配置前调用,可以修改配置
},
configResolved(config) {
// 在解析 Vite 配置后调用
},
configureServer(server) {
// 配置开发服务器
},
transformIndexHtml(html) {
// 转换 index.html
},
handleHotUpdate(ctx) {
// 自定义 HMR 更新
},
};
}4.1.2 Vite 特有钩子详解
config:在解析 Vite 配置之前调用。可以返回一个对象来合并配置。
config(config, { command, mode }) {
return {
define: {
__APP_VERSION__: JSON.stringify('1.0.0'),
},
};
}configResolved:在解析完 Vite 配置后调用。读取最终配置。
configResolved(config) {
this.viteConfig = config;
}configureServer:配置开发服务器。可以添加自定义中间件。
configureServer(server) {
// server 是 ViteDevServer 实例
server.middlewares.use((req, res, next) => {
// 自定义中间件
next();
});
// 监听 websocket 事件
server.ws.on('custom-event', (data) => {
console.log(data);
});
}transformIndexHtml:转换 index.html 文件。
transformIndexHtml(html) {
return {
html,
tags: [
{
tag: 'script',
attrs: { src: '/injected-script.js' },
injectTo: 'head',
},
],
};
}handleHotUpdate:自定义 HMR 处理。
handleHotUpdate({ file, server, modules }) {
// 只对特定文件进行 HMR
if (file.includes('node_modules')) {
return [];
}
// 返回需要更新的模块
return modules;
}4.2 虚拟模块
虚拟模块是一种在内存中动态生成模块内容的技术,不需要实际的文件存在于磁盘上。
// vite-plugin-virtual-module.js
function virtualModulePlugin() {
// 虚拟模块 ID 通常以虚拟前缀开始
const virtualModuleId = 'virtual:my-module';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
return {
name: 'vite-plugin-virtual-module',
resolveId(id) {
if (id === virtualModuleId) {
// 返回已解析的 ID,\0 前缀标记为虚拟模块
return resolvedVirtualModuleId;
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
// 返回模块内容(字符串)
return `
export const version = '1.0.0';
export function hello() {
return 'Hello from virtual module!';
}
`;
}
},
};
}在应用代码中使用:
// main.js
import { version, hello } from 'virtual:my-module';
console.log(version); // '1.0.0'
console.log(hello()); // 'Hello from virtual module!'虚拟模块注入 HTML 示例:
function htmlInjectionPlugin() {
const virtualModuleId = 'virtual:inject';
const resolvedId = '\0' + virtualModuleId;
return {
name: 'vite-plugin-html-inject',
resolveId(id) {
if (id === virtualModuleId) return resolvedId;
},
load(id) {
if (id === resolvedId) {
return `
const injectedScript = document.createElement('script');
injectedScript.textContent = 'console.log("injected by virtual module");';
document.head.appendChild(injectedScript);
`;
}
},
transformIndexHtml(html) {
return {
html,
tags: [
{
tag: 'script',
attrs: { type: 'module', src: '/@id/__x00__virtual:inject' },
injectTo: 'head',
},
],
};
},
};
}4.3 Vite 插件实例:环境变量注入 + index.html 拦截
// vite-plugin-env-inject.js
function envInjectPlugin(options = {}) {
const { prefix = 'APP_' } = options;
const virtualModuleId = 'virtual:env';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
let resolvedConfig;
return {
name: 'vite-plugin-env-inject',
enforce: 'pre',
configResolved(config) {
resolvedConfig = config;
},
// 虚拟模块:暴露环境变量给客户端代码
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
const filteredEnv = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith(prefix)) {
filteredEnv[key] = value;
}
}
return `export default ${JSON.stringify(filteredEnv)};`;
}
},
// 拦截 index.html,注入环境变量标签
transformIndexHtml(html) {
return {
html,
tags: [
{
tag: 'meta',
attrs: {
name: 'build-time',
content: new Date().toISOString(),
},
injectTo: 'head',
},
],
};
},
// 开发服务器中间件:添加自定义响应头
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url === '/env-info') {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
mode: resolvedConfig.mode,
isProduction: resolvedConfig.isProduction,
}));
} else {
next();
}
});
},
};
}
module.exports = envInjectPlugin;使用方式:
// vite.config.js
import { defineConfig } from 'vite';
import envInjectPlugin from './vite-plugin-env-inject';
export default defineConfig({
plugins: [
envInjectPlugin({ prefix: 'VITE_' }),
],
});在应用代码中使用虚拟模块:
// main.js
import env from 'virtual:env';
console.log(env); // { VITE_API_URL: '...', VITE_APP_TITLE: '...' }4.4 Vite vs Webpack 插件设计对比
| 维度 | Webpack | Vite |
|---|---|---|
| 底层机制 | Tapable 事件流 | Rollup 插件钩子 + 扩展钩子 |
| 核心钩子 | compiler.hooks / compilation.hooks | resolveId / load / transform / config |
| 资源操作 | compilation.assets 对象 | emit 钩子中的 bundle 对象 |
| 开发服务器 | webpack-dev-server 独立配置 | configureServer 内置钩子 |
| HTML 处理 | html-webpack-plugin 插件实现 | transformIndexHtml 原生钩子 |
| 模块热替换 | HotModuleReplacementPlugin | handleHotUpdate 原生钩子 |
| 虚拟模块 | 通过 additionalAssets 等模拟 | resolveId + load 原生支持 |
| 配置修改 | 需自定义逻辑或插件 | config / configResolved 原生钩子 |
| 插件复杂度 | 学习曲线陡峭,API 层次深 | 相对简洁,API 设计更直观 |
| 构建阶段 | 基于 Node.js 的打包流程 | 开发基于 ESM,生产基于 Rollup |
| 适用场景 | 大型项目、复杂构建需求 | 快速开发、现代前端项目 |
核心设计差异:
- Webpack 采用 Compiler/Compilation 两阶段架构:Compiler 负责全局生命周期,Compilation 负责单次编译,层次清晰但 API 较为复杂。
- Vite 采用 Rollup 风格的插件 API:在 Rollup 的基础上增加了 Vite 特有钩子,API 设计更加扁平和直观。对于熟悉 Rollup 的开发者来说迁移成本较低。
- 开发模式差异:Vite 开发模式基于 ESM 原生模块,无需打包,插件在开发阶段的职责更多是模块转换和服务器配置,而非打包优化。Webpack 在开发阶段也需要打包,插件介入更早。
- HTML 处理方式:Vite 原生提供
transformIndexHtml钩子,无需额外插件即可修改 HTML。Webpack 依赖html-webpack-plugin实现类似功能。 - 虚拟模块支持:Vite 通过
resolveId+load原生支持虚拟模块,Webpack 需要借助webpack-virtual-modules等第三方库或通过compilation.assets模拟。