上周帮一个同事复盘他们团队的后台管理系统,发现一个特别典型的问题:项目里已经躺了几十个页面,路由还是手工在 router/index.ts 里一条条维护。新页面加进 views 目录后,经常忘了同步路由,一进页面就是404。我说这种事完全可以交给 Vite 插件去干,写一个几十行代码的插件,启动构建时扫描目录、自动生成路由,连新增页面后的热更新都能一并处理。
这件事的本质,其实是“要不要写插件、怎么写插件”的问题。Vite 插件开发听起来像是构建工具源码级玩家才碰的东西,实际上门槛比想象中低很多。只要你会写 JavaScript 对象,能理解几个钩子函数的执行时机,就能做出很有价值的构建增强工具。这篇文章不打算罗列官方文档,而是用两个能直接运行的插件案例,把 Vite 插件开发的核心概念一次讲透:核心钩子怎么用、虚拟模块怎么玩、如何调试排错,以及 element-plus icons 自动注册这类热门插件背后的实现思路。适合已经会用 Vite 建项目、想进一步掌控构建流程的前端开发阅读。
1. Vite 插件到底解决什么问题,什么时候别自己造轮子
1.1 插件能解决的典型问题
Vite 插件本质上是一段能“钩入”构建流程的代码。你用它可以在源码被最终打包之前做各种事情:修改配置、转换代码、生成不在磁盘上的模块,甚至拦截开发服务器的请求。
把插件想象成快递中转站里的自动化分拣线。你的源文件是包裹,打包过程是传送带。插件就是你插在传送带不同位置的“分拣单元”:有的负责检查包裹外观(解析路径),有的负责拆开填充缓冲材料(读取内容),有的负责贴上新的标签(转换代码),有的负责在最终装车前多塞一张质量卡(生成额外文件)。至于包裹最终怎么装车、走哪条运输线,Vite 已经用默认配置帮你安排好了。
实际开发中,插件最常解决四类问题:
- 代码转换:某些特殊文件格式或 DS L需要转成浏览器能识别的 JS。例如处理
.vue单文件组件、.md文档转组件、自定义 DSL 的解析。 - 资源与模块注入:需要暴露运行时的构建信息、读取本地目录生成路由表、按需引入组件库。这些内容通常以“虚拟模块”的方式提供给业务代码。
- 构建流程增强:修改压缩方式、生成额外清单文件、上传构建产物到 CDN、在打包前做代码静态检查。
- 开发体验增强:mock 接口数据、拦截页面请求返回自定义内容、修改 dev server 中间件。
我在实际项目里用得最多的是第二类和第三类。自动路由插件、构建信息插件、vite-plugin-inspect 这类调试工具,都是 Vite 插件能力的典型体现。
1.2 该写与不该写的边界
很多同学容易掉进“什么都要自己写插件”的坑。判断标准其实很简单:先搜一下生态里有没有现成方案。
我整理了一张决策表:
| 适合写插件的场景 | 不建议写插件的场景 |
|---|---|
| 团队内部强定制化的构建流程,通用工具覆盖不到 | 官方文档或社区已有成熟稳定方案,直接安装即可用 |
| 需要读取本地环境、注入运行时信息 | 只是为了“绕过”某个不熟悉的配置,想在插件里偷偷改配置 |
| 需要把重复劳动自动化,例如目录扫描生成路由 | 业务逻辑层面能解决的问题,例如通过 if/else 判断环境 |
| 学习目的,想深入理解构建工具工作原理 | 临时一次性需求,用脚本执行一次就够了 |
写插件前,我建议在 npm 上搜一下 vite-plugin- 前缀或 unplugin- 前缀。unplugin 是一个用同一套代码兼容 Vite、Webpack、Rollup、esbuild 的插件开发方案,生态里很多知名插件都是用它写的。如果 unplugin-vue-components、unplugin-auto-import、vite-plugin-inspect 这些工具已经覆盖了你的需求,就不必重复造轮子。
1.3 Vite 插件和 Rollup 插件到底什么关系
这个认知非常关键:Vite 的生产构建底层复用 Rollup 来进行打包,所以 Vite 插件体系在很大程度上一脉相承地继承了 Rollup 的插件设计。你在 Vite 插件里写的 resolveId、load、transform、generateBundle 这些钩子,本质上就是 Rollup 插件钩子。Vite 会在启动时把插件交给内部的 Rollup 实例执行。
在此基础上,Vite 又扩展了几个 dev server 专用的钩子,比如 configureServer、handleHotUpdate。这部分能力只会在 vite serve 开发模式时发挥作用,生产构建阶段不会执行。
理解了这一层关系,再看那些 “Vite 插件开发” 的资料就不会觉得混乱了。很多入门文章直接讲 Rollup 插件钩子,那是完全正确的路径,只是会漏掉 Vite 独有的几个部分。遇到问题时,查 Rollup 官方插件文档往往比查 Vite 文档更详细。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心钩子机制:把构建流程的生命周期摸透
2.1 钩子执行顺序全景
Vite 插件开发的核心,是理解钩子(hook)以及它们在构建流程中的执行时机。一个源文件从“磁盘路径”变成“浏览器能运行的代码”,会经历这样一条流水线:
- 路径解析:
resolveId接收导入语句中的路径,比如import x from 'virtual:foo',解析成真实文件路径或虚拟模块标记。 - 内容读取:
load根据解析出的 id 读取文件内容,或者生成虚拟模块的代码。 - 内容转换:
transform对读取到的代码做进一步转换,替换变量、编译语法、注入代码都发生在这里。 - 模块图构建与打包:Vite/Rollup 根据模块间的依赖关系组织模块图,最后通过 Rollup 做 Tree Shaking 和代码分割。
- 产物生成:
generateBundle、writeBundle等钩子可以在产物写入磁盘前/后做一些处理,比如生成额外的 manifest 文件、执行 CDN 上传。
config 和 configResolved 这两个钩子发生在整个流水线之前。configureServer 在 dev server 启动时执行。handleHotUpdate 则是在开发模式文件变动时触发。
我见过不少新手在 transform 里尝试修改另一个模块的代码,发现改半天没效果,就是因为没理解每个钩子只处理当前模块,模块之间无法互相访问。
2.2 config 与 configResolved:拿到配置的两种姿势
config 钩子会在 Vite 解析配置之前调用,你可以通过返回一个对象,把自定义配置合并进最终配置。比如某个插件需要强制修改压缩方式:
ts复制export default function myPlugin(): Plugin {
return {
name: 'vite-plugin-force-terser',
config() {
return {
build: {
minify: 'terser',
terserOptions: {
format: {
comments: false
}
}
}
};
}
};
}
这里顺便解决一个很多人困惑的问题:minify: 'terser' 和 minify: 'esbuild' 到底有什么区别?
| 对比维度 | esbuild | terser |
|---|---|---|
| 实现语言 | Go,极快 | JavaScript,相对慢 |
| 压缩质量 | 大多数场景很接近 terser,但对某些极端代码的处理略粗糙 | 压缩更细致,兼容性更好 |
| 额外能力 | 内置 transpile(移除 TypeScript 类型等) | 可精确控制注释保留、mangle 规则、输出格式 |
| 使用场景 | Vite 默认的压缩器,适合大多数项目 | 需要精细控制产物格式、兼容低版本语法时切换 |
翻译成人话就是:esbuild 主打“快”,terser 主打“稳”。团队项目追求构建速度快,保持默认 esbuild 就行;但如果你发现压缩后的产物在某台老化线上机器上报语法错误,或者需要对注释和变量名有严格管控,那就是切换到 terser 的合理时机。
configResolved 钩子则在配置完全确定后执行,此时所有默认值、插件配置都已合并完毕。你通常会在这里把最终配置保存到一个变量里,供其他钩子使用:
ts复制let resolvedConfig: ResolvedConfig;
export default function myPlugin(): Plugin {
return {
name: 'vite-plugin-example',
configResolved(config) {
resolvedConfig = config;
},
transform(code, id) {
// 这里可以读取 resolvedConfig.command 判断是 build 还是 serve
console.log('当前命令:', resolvedConfig.command);
}
};
}
2.3 resolveId、load、transform:构建主线的三个关键点
这三个钩子是大多数代码级插件的核心,分别对应“从路径到 id”“从 id 到内容”“从内容到新内容”三个步骤。
resolveId 的工作像门卫。你在业务代码里写 import xxx from 'virtual:config',Vite 一开始根本不知道 virtual:config 是哪个文件。门卫负责任地说:这个 id 给我,我告诉你它应该指向哪里。它返回的字符串就是后续 load 钩子的输入。
load 的工作像配菜员。收到门卫传过来的 id 后,它负责把真正的“食材”(代码内容)端上来。可以读磁盘文件,也可以直接生成字符串返回。
transform 的工作像厨师。拿到食材后进行加工处理。例如把 ES6+ 语法转成目标浏览器能识别、把 SCSS 转成 CSS、把模板代码编译成 render 函数。
举一个极简例子,让读者直观感受这三者的配合:
ts复制export default function virtualModulePlugin(): Plugin {
const virtualModuleId = 'virtual:hello';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
return {
name: 'vite-plugin-virtual-hello',
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
return `export default 'Hello from virtual module!'`;
}
}
};
}
这里有个重要细节:virtual:hello 是业务代码里可以见到的导入路径,但真正传给 load 钩子的 id 是 \0virtual:hello。\0 是 Rollup 的约定,用来标记“这不是真实文件”,避免其他插件误处理。这个细节在实际开发中非常容易踩坑。
2.4 configureServer 与 handleHotUpdate:Vite 专有的钩子
configureServer 在 dev server 被创建后、内部中间件运行前调用。你可以用它给 dev server 增加自定义中间件,最典型的就是 mock 数据:
ts复制export default function mockPlugin(): Plugin {
return {
name: 'vite-plugin-mock',
configureServer(server) {
server.middlewares.use('/api', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ code: 0, data: 'mock data' }));
});
}
};
}
handleHotUpdate 则负责开发模式下的热更新策略。当文件变动时,Vite 会调用这个钩子,让你决定哪些模块需要被标记为“热更边界”。如果自动路由插件监听到 views 目录下新增了文件,就可以在这里触发热更新,刷新路由模块,而不需要整页刷新。
理解这些专属钩子,能帮你在开发模式下实现很多生产构建里做不了的事情。
3. 手写一个构建信息注入插件
3.1 设计思路与命名
理论讲再多,不动手写一个完整插件都是白搭。这个案例我选“构建信息注入插件”,因为需求非常常见:前端页面 footer 里想显示版本号、构建时间、git commit 哈希,方便测试和运维定位线上版本。
通常的做法是手工维护一个 config.ts,但是构建时间没法手写,commit 哈希也没法手写,人工维护一定会忘记更新。通过插件动态生成这些信息,才是最优解。
设计思路很清晰:通过一个虚拟模块 virtual:build-info,把信息暴露给业务代码。业务代码只负责 import,值的来源完全交给插件在构建时动态生成。
插件的命名遵循社区惯例:vite-plugin-build-info。这是在 Vite 插件生态里辨识度最高的命名方式,用户一看到名字就知道它是干什么的。
3.2 完整代码与逐段解读
先看完整实现:
ts复制import type { Plugin, ResolvedConfig } from 'vite';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const VIRTUAL_ID = 'virtual:build-info';
const RESOLVED_ID = '\0' + VIRTUAL_ID;
function getGitInfo() {
try {
const hash = execSync('git rev-parse --short HEAD', {
encoding: 'utf-8'
}).trim();
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
encoding: 'utf-8'
}).trim();
return { hash, branch };
} catch {
// 在非 git 目录或 CI 环境执行 git 命令可能失败,不要阻塞构建
return { hash: 'unknown', branch: 'unknown' };
}
}
export default function buildInfoPlugin(): Plugin {
let config: ResolvedConfig;
return {
name: 'vite-plugin-build-info',
enforce: 'pre',
configResolved(resolvedConfig) {
config = resolvedConfig;
},
resolveId(id) {
if (id === VIRTUAL_ID) {
return RESOLVED_ID;
}
},
load(id) {
if (id !== RESOLVED_ID) {
return;
}
const pkg = JSON.parse(
readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8')
);
const buildInfo = {
version: pkg.version,
name: pkg.name,
buildTime: new Date().toISOString(),
mode: config.mode,
command: config.command,
isBuild: config.command === 'build',
git: getGitInfo()
};
return `export default ${JSON.stringify(buildInfo, null, 2)};`;
}
};
}
逐段拆开看几个核心点:
name 字段必须唯一。Vite 依赖插件名字做去重和顺序管理,尽量遵守 vite-plugin- 前缀,防止和其他插件重名。
enforce: 'pre' 的作用是把这个插件排在多数插件之前执行。对于构建信息插件来说,越早执行越能避免某些 transform 误伤。enforce 字段还有 'post' 和其他默认值,类似于中间件里的 oneTick/切片顺序。
configResolved 里保存最终的 resolved config 对象。command 字段在开发和生产两种模式下值不同,正好可以拿来判断当前是 build 还是 serve。在这个插件里没有直接用,但保存下来的好处是后续扩展(比如在 transform 钩子里注入环境相关代码)时不用再重新取配置。
resolveId 和 load 配合的很直接:业务代码里写 import buildInfo from 'virtual:build-info',resolveId 把它转换成带 \0 前缀的内部 id,load 根据这个 id 动态生成导出代码。JSON.stringify(buildInfo, null, 2) 的作用是把对象转成可执行的 JS 对象字面量,同时保留缩进,方便后续阅读产物代码时排查问题。
3.3 接入项目与验证效果
在 vite.config.ts 里接入这个插件:
ts复制import { defineConfig } from 'vite';
import buildInfo from './plugins/build-info';
export default defineConfig({
plugins: [buildInfo()]
});
业务代码里直接使用:
ts复制import buildInfo from 'virtual:build-info';
console.log(buildInfo);
// { version: '1.2.0', buildTime: '...', git: { hash: 'abc1234', branch: 'main' } }
验证插件是否生效,我一般分三步走:
- 页面里打印
buildInfo,看控制台是否输出对象。 - 执行
vite build后,在dist产物目录里搜索buildTime,确认信息被正确注入。 - 改动 package.json 的 version 字段再次构建,确认版本号能实时更新。
如果插件没生效,首先排查 vite.config.ts 里是否真的引入了插件函数并执行了(漏掉 buildInfo() 最后的括号是高频低级错误),其次检查是否有缓存残留,必要时执行 vite build --force 清掉 Vite 的依赖预构建缓存。
4. 虚拟模块:让插件提供“无限内容”
4.1 虚拟模块的价值
“虚拟模块”指的不是磁盘上的真实文件,而是由插件在内存中动态生成的模块。业务代码可以像引用真实文件一样 import 它,底层由插件的 resolveId 和 load 钩子提供服务。
这种设计解决了一个很实际的问题:很多配置、路由、组件清单是“算出来的”,不是“写出来的”。把它们写进真实文件,需要额外维护生成脚本;而虚拟模块把“生成”这件事内化到构建流程里,业务代码不用关心数据从哪来。
我在前面构建信息插件里已经用到了虚拟模块,下面再写一个自动路由插件,这个案例在实际后台系统中非常实用。
4.2 自动路由插件案例
需求:自动扫描 src/views 目录下的 .vue 文件,生成对应的路由配置。约定文件路径就是路由路径,例如 src/views/user/list.vue 对应路由 /user/list。
核心实现思路:插件启动时递归读取目录里的文件,动态生成一个包含完整 routes 数组的 JS 模块,通过虚拟模块暴露给业务代码。
ts复制import type { Plugin } from 'vite';
import { readdirSync, statSync } from 'node:fs';
import { resolve, relative, sep } from 'node:path';
const VIRTUAL_ROUTES_ID = 'virtual:routes';
const RESOLVED_ROUTES_ID = '\0' + VIRTUAL_ROUTES_ID;
const VIEWS_ROOT = resolve(process.cwd(), 'src/views');
function walk(dir: string): string[] {
const results: string[] = [];
for (const name of readdirSync(dir)) {
const fullPath = resolve(dir, name);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
results.push(...walk(fullPath));
} else if (name.endsWith('.vue')) {
results.push(fullPath);
}
}
return results;
}
export default function autoRoutesPlugin(): Plugin {
return {
name: 'vite-plugin-auto-routes',
resolveId(id) {
if (id === VIRTUAL_ROUTES_ID) {
return RESOLVED_ROUTES_ID;
}
},
load(id) {
if (id !== RESOLVED_ROUTES_ID) {
return;
}
const files = walk(VIEWS_ROOT);
const routes = files.map((file) => {
const routePath = '/' + relative(VIEWS_ROOT, file).replace(/\.vue$/, '').split(sep).join('/');
const componentPath = '/' + relative(process.cwd(), file).split(sep).join('/');
return `{
path: '${routePath}',
component: () => import('${componentPath}')
}`;
});
return `export const routes = [${routes.join(',')}];`;
}
};
}
这里有两个细节值得关注。
第一,Windows 路径分隔符。relative 在 Windows 上返回的是反斜杠 \ 分隔的路径,直接拼进 import 语句会出问题。用 .split(sep).join('/') 统一转成正斜杠,这是插件开发中非常容易踩的平台兼容性坑。
第二,懒加载路由。() => import('...') 让每个页面组件独立分包,这也是后台系统常见的优化手段。插件生成的路由天然支持代码分割,不需要手动写懒加载。
这样业务代码就变得非常干净:
ts复制import { routes } from 'virtual:routes';
export default routes;
以后每新增一个页面文件,路由自动就生成好了。对于几十个页面的中后台项目,这种自动化带来的开发体验提升非常明显。
5. 按需引入与图标自动注册的实战解析
5.1 element-plus icons 自动注册背后的原理
很多同学在项目里用过 unplugin-vue-components 配合 IconsResolver 实现 element-plus icons 的自动注册。直观感受是:模板里写 <el-icon><Edit /></el-icon>,不需要手动 import,最终打包产物里也只会包含用到的图标。这个看起来很“魔法”的能力,本质上就是前面讲的虚拟模块加 transform 的复合运用。
它的工作流程是这样的:
- 开发阶段,插件在 transform 钩子里检查每一个
.vue文件。 - 解析编译后的模板内容,发现用到了元素
Edit。 - 插件根据命名规则推导出对应的图标导入语句:
import Edit from '@element-plus/icons-vue/edit'。 - 把导入语句自动注入到
<script>区域。 - 构建时 Rollup 通过 Tree Shaking 只保留真正被引用的图标。
如果你理解了 Vite 插件的钩子机制,会发现这个流程每一步都在前面讲过:transform 做代码分析,自动注入 import,虚拟模块或真实模块解析路径,最终由 Rollup 完成 tree-shaking。
5.2 极简版图标自动注册插件
动手写一个简化版,把这个过程的原理演示清楚。思路是匹配模板里的 <icon-xxx> 写法,自动导入 @element-plus/icons-vue 里对应的图标:
ts复制import type { Plugin } from 'vite';
function pascalize(name: string): string {
return name
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
}
export default function simpleIconsPlugin(): Plugin {
return {
name: 'vite-plugin-simple-icons',
enforce: 'pre',
transform(code, id) {
if (!id.endsWith('.vue')) {
return;
}
const matches = [...code.matchAll(/<icon-([a-z-]+)>/g)];
if (matches.length === 0) {
return;
}
const imports = [
...new Set(
matches.map((match) => {
const componentName = pascalize(match[1]) + 'Icon';
return `import ${componentName} from '@element-plus/icons-vue/${match[1]}'`;
})
)
].join('\n');
return {
code: imports + '\n' + code,
map: null
};
}
};
}
这个实现很粗糙,只适合理解原理,生产环境请直接用 unplugin-vue-components。但它把最关键一环暴露得很清楚:插件通过正则分析模板字符串,推导出依赖,再通过 transform 把 import 语句注入源代码。
这里有一个必须注意的细节:模板里出现 <icon-edit> 的时候,实际 Vue 组件会被注册为 IconEdit,所以导入的变量名必须和模板里使用的组件名保持一致,否则运行时 Vue 会报 “Failed to resolve component”。很多新手在这个细节上栽跟头,因为模板标签可以写 kebab-case,但脚本里的变量名必须和最终组件注册名对得上。
map: null 的作用是告诉 Vite “这个转换不需要 sourcemap”,避免在复杂的 SFC 编译链中产生错位映射。大多数 transform 场景尽量返回 null,而不是手动伪造 sourcemap。
6. 调试、排错与避坑指南
6.1 高频问题排查速查表
插件开发写多了,常见的坑其实就那几类。我把高频问题和排查方向整理成一张速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 插件完全没执行 | 插件未在 vite.config 注册,或函数少调用了一对括号 | 检查 plugins 数组;在插件最开头打日志 |
| 插件里改了配置但没生效 | config 钩子返回结构不对,或配置被其他插件覆盖 | 用 configResolved 打印最终配置确认 |
| transform 转换结果不生效 | enforce 时机太晚,源码已经被其他插件转换过 | 给插件加 enforce: 'pre' 再试 |
| 虚拟模块无法解析 | resolveId 返回值缺少 \0 前缀,或被其他插件的 resolveId 拦截 |
检查返回 id 是否和 load 里判定的 id 完全一致 |
| sourcemap 报错或产物定位错乱 | 手动伪造了 map,或 transform 返回的 code 与原始代码差异过大 | 返回 map: null,让 Vite 自动处理 |
| 热更新不触发 | handleHotUpdate 条件没有覆盖目标文件,或监听目录错误 | 在 handleHotUpdate 里打断点确认文件是否进入钩子 |
| 构建产物莫名变大 | transform 向大量模块注入了公共代码 | 检查注入逻辑的范围条件,缩小到特定文件 |
| 路径在 Windows 上报错 | 生成的路径用了反斜杠分隔符 | 统一转成正斜杠 / |
6.2 调试工具与经验
调试 Vite 插件的效率,很大程度上决定开发插件是否愉快。我常用的手段有三个。
第一个是 vite-plugin-inspect,这是 Vite 插件开发的必备工具。安装后在浏览器访问 /__inspect 面板,可以看到每个模块经过每个插件前后的代码变化。排查“transform 到底改没改成功”这类问题时,比瞎猜快十倍。配置方式:
ts复制import Inspect from 'vite-plugin-inspect';
export default defineConfig({
plugins: [Inspect()]
});
第二个是打断点调试。如果插件逻辑复杂,比如自动路由插件扫描了大量文件,可以在 node 进程上打开调试端口:
bash复制node --inspect node_modules/vite/bin/vite.js
然后用浏览器 devtools 连接调试端口,给插件代码打断点,逐行看变量变化。这个方法适合排查逻辑分叉问题,比 console.log 定位更精准。
第三个是善用日志级别。Vite 本身提供 vite --debug 参数,会输出模块解析、插件执行等底层日志。加上 DEBUG=vite:* 环境变量可以过滤到具体命名空间。插件内部需要报错时,优先用 this.error 抛出 Vite 能识别的构建错误,而不是简单的 console.error,这样命令行会显示“构建失败”而不是继续往下跑,问题暴露得会更明显。
根据我的经验,插件开发和普通业务开发最不一样的地方是:你必须时刻清楚“当前代码跑在 Node 环境,不是浏览器环境”。在插件里不小心用了 window、document、localStorage,启动构建时会直接报错。插件生成的模块代码则是跑在浏览器里的,这两段逻辑要分得非常清楚,否则很容易写出“打包时调用浏览器 API”这类诡异的 bug。
最后再分享一点实际体会
写 Vite 插件这件事,最难的不是 API,而是想清楚要在什么时机插入自己的逻辑。API 就那么十几个,但时机错了、顺序错了,后面怎么调试都很别扭。我自己的习惯是,哪怕只是给现有插件加一个小功能,也会先想清楚它属于哪个阶段——是配置阶段、路径解析阶段、代码转换阶段,还是产物生成阶段,然后再去查对应钩子的签名。
新手阶段最有效的提升方式,是打开 vite-plugin-inspect 面板,观察别人写的插件(比如 unplugin-auto-import、vite-plugin-svg-icons、unplugin-vue-components)在每个模块上做了什么。模仿是最快的进步路径,等你能看明白那些成熟插件的“套路”,自己写插件时就基本不会卡壳了。最近各群里都在聊打包内核 rolldown 的事,未来的 Vite 版本里插件生态肯定会有一轮适配调整,但核心的钩子思维一定会延续。希望这篇实战记录能让你在遇到“构建层需求”时,不用再绕路。
