跳到主要内容Vite 插件开发实战:从 Hook 机制到虚拟模块 | 极客日志TypeScriptNode.js大前端
Vite 插件开发实战:从 Hook 机制到虚拟模块
Vite 插件基于 Rollup 扩展构建能力,核心在于理解 Hook 机制与生命周期。本文详解通用 Hook 与 Vite 独有 Hook(如 config、configureServer),梳理执行顺序及 apply/enforce 配置策略。通过虚拟模块加载与 SVG 组件化两个实战案例,演示如何利用 resolveId、load、transform 钩子实现内存模块注入与资源转换,并介绍 vite-plugin-inspect 调试技巧,帮助开发者掌握自定义构建工具的核心方法。
GopherDev0 浏览 说到自定义能力,大家很容易想到插件机制。利用插件扩展构建工具的能力是 Vite 生态的核心之一。在掌握基础概念后,我们直接进入实战,看看如何从零开发一个 Vite 插件。
一、插件基础结构
Vite 插件与 Rollup 插件结构类似,本质上是一个包含 name 和各种 Hook 的对象:
{
name: 'vite-plugin-xxx',
load(code) {
},
}
命名规范上,如果插件发布为 npm 包,推荐以 vite-plugin 开头。考虑到外部传参的灵活性,通常不会直接写死对象,而是实现一个返回插件对象的工厂函数:
export function myVitePlugin(options) {
console.log(options)
return {
name: 'vite-plugin-xxx',
load(id) {
}
}
}
import { myVitePlugin } from './myVitePlugin';
export default {
plugins: [myVitePlugin({ })]
}
二、Hook 机制详解
2.1 通用 Hook
Vite 开发阶段会模拟 Rollup 的行为,调用一系列兼容的钩子。这些钩子主要分为三个阶段:
- 服务器启动阶段:
options 和 buildStart 钩子会在服务启动时被调用。
- 请求响应阶段:当浏览器发起请求时,Vite 内部依次调用
resolveId、load 和 transform 钩子。
- 服务器关闭阶段:Vite 会依次执行
buildEnd 和 closeBundle 钩子。
除了以上钩子,其他 Rollup 插件钩子(如 moduleParsed、renderChunk)均不会在 Vite 开发阶段调用。而生产环境下,由于 Vite 直接使用 Rollup,所有 Rollup 的插件钩子都会生效。
2.2 Vite 独有 Hook
接下来介绍 Vite 特有的一些 Hook,这些只在 Vite 内部调用,放到 Rollup 中会被忽略。
config
Vite 读取完配置文件(即 vite.config.ts)后,会执行 config 钩子。你可以在这里对配置对象进行自定义操作。
官方推荐用法是返回一个配置对象,它会与 Vite 已有配置深度合并:
const editConfigPlugin = () => ({
name: 'vite-plugin-modify-config',
config: () => ({
alias: {
react: require.resolve('react')
}
})
})
你也可以通过入参拿到 config 对象进行修改,例如在生产环境中修改 root 参数:
const mutateConfigPlugin = () => ({
name: 'mutate-config',
config(config, { command }) {
if (command === 'build') {
config.root = __dirname;
}
}
})
对于深层对象配置(如 optimizeDeps.esbuildOptions.plugins),直接修改可能比较繁琐。此时直接返回新配置对象会更简洁:
config() {
return {
optimizeDeps: {
esbuildOptions: {
plugins: []
}
}
}
}
configResolved
解析完配置后会调用 configResolved 钩子。这个钩子一般用来记录最终配置信息,不建议再修改配置:
const examplePlugin = () => {
let config
return {
name: 'read-config',
configResolved(resolvedConfig) {
config = resolvedConfig
},
transform(code, id) {
console.log(config)
}
}
}
configureServer
该钩子仅在开发阶段调用,用于扩展 Dev Server,通常用来增加自定义中间件:
const myPlugin = () => ({
name: 'configure-server',
configureServer(server) {
server.middlewares.use((req, res, next) => {
})
return () => {
server.middlewares.use((req, res, next) => {
})
}
}
})
transformIndexHtml
用来灵活控制 HTML 内容,你可以拿到原始 HTML 后进行任意转换:
const htmlPlugin = () => {
return {
name: 'html-transform',
transformIndexHtml(html) {
return html.replace(
/<title>(.*?)</title>/,
`<title>换了个标题</title>`
)
}
}
}
const htmlPlugin = () => {
return {
name: 'html-transform',
transformIndexHtml(html) {
return {
html,
tags: [
{
injectTo: 'body',
attrs: { type: 'module', src: './index.ts' },
tag: 'script',
},
],
}
}
}
}
handleHotUpdate
主要用于服务端热更新。在这个钩子中可以拿到热更新上下文,过滤模块或进行自定义处理:
const handleHmrPlugin = () => {
return {
async handleHotUpdate(ctx) {
console.log(ctx.file)
console.log(ctx.modules)
console.log(ctx.timestamp)
console.log(ctx.server)
ctx.server.ws.send({
type: 'custom',
event: 'special-update',
data: { a: 1 }
})
return []
}
}
}
if (import.meta.hot) {
import.meta.hot.on('special-update', (data) => {
console.log(data)
window.location.reload();
})
}
config: 修改配置。
configResolved: 记录最终配置。
configureServer: 获取 Dev Server 实例,添加中间件。
transformIndexHtml: 转换 HTML 内容。
handleHotUpdate: 处理热更新。
2.3 Hook 执行顺序
这么多钩子,到底谁先执行?我们通过一个具体示例来复盘:
export default function testHookPlugin() {
return {
name: 'test-hooks-plugin',
config(config) {
console.log('config');
},
configResolved(resolvedConfig) {
console.log('configResolved');
},
options(opts) {
console.log('options');
return opts;
},
configureServer(server) {
console.log('configureServer');
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 3000)
},
buildStart() {
console.log('buildStart');
},
buildEnd() {
console.log('buildEnd');
},
closeBundle() {
console.log('closeBundle');
}
}
}
将插件加入配置并启动项目,可以观察到如下执行顺序:
- 服务启动阶段:
config、configResolved、options、configureServer、buildStart
- 请求响应阶段:HTML 文件仅执行
transformIndexHtml;非 HTML 文件依次执行 resolveId、load 和 transform。
- 热更新阶段:执行
handleHotUpdate。
- 服务关闭阶段:依次执行
buildEnd 和 closeBundle。
2.4 插件应用场景
默认情况下 Vite 插件同时用于开发环境和生产环境,你可以通过 apply 属性决定应用场景:
apply(config, { command }) {
return command === 'build' && !config.build.ssr
}
此外,可以通过 enforce 属性指定插件执行顺序:
三、实战案例
3.1 虚拟模块加载
虚拟模块并不存在于磁盘文件系统中,而是存储在内存里。这允许我们将手写代码字符串或计算得出的变量作为模块内容加载。
新建 plugins 目录,编写 virtual-module.ts:
import { Plugin, ResolvedConfig } from 'vite';
const virtualFibModuleId = 'virtual:fib';
const resolvedFibVirtualModuleId = '\0' + virtualFibModuleId;
export default function virtualFibModulePlugin(): Plugin {
let config: ResolvedConfig | null = null;
return {
name: 'vite-plugin-virtual-module',
resolveId(id) {
if (id === virtualFibModuleId) {
return resolvedFibVirtualModuleId;
}
},
load(id) {
if (id === resolvedFibVirtualModuleId) {
return 'export default function fib(n) { return n <= 1 ? n : fib(n - 1) + fib(n - 2); }';
}
}
}
}
注意 Vite 约定虚拟模块解析后的路径需要加上 \0 前缀。
import virtual from './plugins/virtual-module'
export default {
plugins: [react(), virtual()]
}
import fib from 'virtual:fib'
alert(`结果:${fib(10)}`)
虽然模块不存在于文件系统,但浏览器中可以正常执行。接着我们可以尝试读取内存中的变量,比如环境变量:
const virtualEnvModuleId = 'virtual:env';
const resolvedEnvVirtualModuleId = '\0' + virtualEnvModuleId;
export default function virtualFibModulePlugin(): Plugin {
let config: ResolvedConfig | null = null;
return {
name: 'vite-plugin-virtual-fib-module',
configResolved(c: ResolvedConfig) {
config = c;
},
resolveId(id) {
if (id === virtualFibModuleId) {
return resolvedFibVirtualModuleId;
}
if (id === virtualEnvModuleId) {
return resolvedEnvVirtualModuleId;
}
},
load(id) {
if (id === resolvedFibVirtualModuleId) {
return 'export default function fib(n) { return n <= 1 ? n : fib(n - 1) + fib(n - 2); }';
}
if (id === resolvedEnvVirtualModuleId) {
return `export default ${JSON.stringify(config!.env)}`;
}
}
}
}
为了消除类型报错,需增加类型声明文件 types/shim.d.ts:
declare module 'virtual:*' {
export default any;
}
这样就能在浏览器中打印出正确的环境变量了。虚拟模块非常灵活,社区知名插件如 vite-plugin-windicss 也大量使用了这项技术。
3.2 SVG 组件形式加载
有时候我们希望将 SVG 当作组件引入,以便修改属性,比 <img> 标签更优雅。Vite 本身不支持此功能,需要通过插件实现。
npm i resolve @svgr/core -D
import { Plugin } from 'vite';
import * as fs from 'fs';
import * as resolve from 'resolve';
interface SvgrOptions {
defaultExport: 'url' | 'component';
}
export default function viteSvgrPlugin(options: SvgrOptions): Plugin {
const { defaultExport='component' } = options;
return {
name: 'vite-plugin-svgr',
async transform(code, id) {
if (!id.endsWith('.svg')) {
return code;
}
const svgrTransform = require('@svgr/core').transform;
const esbuildPackagePath = resolve.sync('esbuild', { basedir: require.resolve('vite') });
const esbuild = require(esbuildPackagePath);
const svg = await fs.promises.readFile(id, 'utf8');
const svgrResult = await svgrTransform(
svg,
{},
{ componentName: 'ReactComponent' }
);
let componentCode = svgrResult;
if (defaultExport === 'url') {
componentCode += code;
componentCode = componentCode.replace('export default ReactComponent', 'export { ReactComponent }');
}
const result = await esbuild.transform(componentCode, {
loader: 'jsx',
});
return {
code: result.code,
map: null
};
},
};
}
- 根据
id 过滤 SVG 资源。
- 读取文件内容。
- 利用
@svgr/core 转换为 React 组件代码。
- 处理默认导出为 URL 的情况。
- 利用
esbuild 转译 JSX 为浏览器可运行代码。
import svgr from './plugins/svgr'
export default {
plugins: [svgr()]
}
import Logo from './logo.svg'
function App() {
return <Logo />
}
3.3 调试技巧
开发调试插件时,推荐使用 vite-plugin-inspect:
import inspect from 'vite-plugin-inspect'
export default {
plugins: [inspect()]
}
启动项目后会出现调试地址,点击特定文件可以看到模块经过各个插件处理后的中间结果,这对排查问题非常有帮助。
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online