前言
昨天看了在B站的一个关于 vite
插件的视频(找不到了地址了,找到了补上),一直以来也没写过 vite 插件,今天来看看 vite
插件的编写,做个记录
插件
对于 vite
中的插件,应该都不陌生,在使用 vite
创建 vue3
应用时,官方会自动添加一个 vue
插件
ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue()
],
})
最常见的是 element-plus
中的自定义插件
可以看到,无论是官方的还是第三方的都使用了 函数
来编写插件
官方
vite
插件🔗的链接,有兴趣的可以直接点击查看详情
总的来说,插件的核心在于 钩子函数
的使用
通用钩子
以下钩子在服务器启动时被调用:
以下钩子会在每个传入模块请求时被调用:
以下钩子在服务器关闭时被调用:
vite 独有钩子
Vite 插件也可以提供钩子来服务于特定的 Vite 目标。这些钩子会被 Rollup 忽略
config
可以接收 用户传进来的 options
和 环境变量,包含正在使用的 mode
和 command
,可以深度合并配置对象,也可以直接改变配置
ts
(config: UserConfig, env: { mode: string, command: string }) => UserConfig | null | void
js
// 返回部分配置(推荐)
const partialConfigPlugin = () => ({
name: 'return-partial',
config: () => ({
resolve: {
alias: {
foo: 'bar',
},
},
}),
})
// 直接改变配置(应仅在合并不起作用时使用)
const mutateConfigPlugin = () => ({
name: 'mutate-config',
config(config, { command }) {
if (command === 'build') {
config.root = 'foo'
}
},
})
configResolved
在解析 Vite 配置后调用。使用这个钩子读取和存储最终解析的配置
在下面的例子中,由于 transform
拿不到 config
钩子后的值,可以使用 configResolved
钩子
js
const examplePlugin = () => {
let config
return {
name: 'read-config',
configResolved(resolvedConfig) {
// 存储最终解析的配置
config = resolvedConfig
},
// 在其他钩子中使用存储的配置
transform(code, id) {
if (config.command === 'serve') {
// dev: 由开发服务器调用的插件
} else {
// build: 由 Rollup 调用的插件
}
},
}
}
configureServer
是用于配置开发服务器的钩子。最常见的用例是在内部 connect 应用程序中添加自定义中间件:
更多配置项请看 ViteDevServer
js
const myPlugin = () => ({
name: 'configure-server',
configureServer(server) {
server.middlewares.use((req, res, next) => {
// 自定义请求处理...
})
},
})
注入后置中间件
configureServer
钩子将在内部中间件被安装前调用,所以自定义的中间件将会默认会比内部中间件早运行。
如果你想注入一个在内部中间件 之后 运行的中间件,你可以从 configureServer
返回一个函数,将会在内部中间件安装后被调用:
js
const myPlugin = () => ({
name: 'configure-server',
configureServer(server) {
// 返回一个在内部中间件安装后
// 被调用的后置钩子
return () => {
server.middlewares.use((req, res, next) => {
// 自定义请求处理...
})
}
},
})
handleHotUpdate
执行自定义 HMR 更新处理。钩子接收一个带有以下签名的上下文对象:
js
interface HmrContext {
file: string
timestamp: number
modules: Array<ModuleNode>
read: () => string | Promise<string>
server: ViteDevServer
}
modules
是受更改文件影响的模块数组。它是一个数组,因为单个文件可能映射到多个服务模块(例如 Vue 单文件组件)。read
这是一个异步读函数,它返回文件的内容。
钩子可以选择:
- 过滤和缩小受影响的模块列表,使 HMR 更准确。
- 返回一个空数组,并通过向客户端发送自定义事件来执行完整的自定义 HMR 处理:
js
handleHotUpdate({ server }) {
server.ws.send({
type: 'custom',
event: 'special-update',
data: {}
})
return []
}
客户端代码应该使用 HMR API 注册相应的处理器(这应该被相同插件的 transform
钩子注入)
js
if (import.meta.hot) {
import.meta.hot.on('special-update', (data) => {
// 执行自定义更新
})
}
🚅 resolveId
这个钩子属于 rollupjs
,中文文档,下文有例子
✈️ transform
这个钩子属于 rollupjs
,中文文档,下文有例子
可以被用来转换单个模块。这个钩子很重要,后面例子中会使用
插件顺序
一个 Vite 插件可以额外指定一个 enforce
属性(类似于 webpack 加载器)来调整它的应用顺序。enforce
的值可以是 pre
或 post
。解析后的插件将按照以下顺序排列:
- 带有
enforce: 'pre'
的用户插件 - Vite 核心插件
- 没有 enforce 值的用户插件
- Vite 构建用的插件
- 带有
enforce: 'post'
的用户插件
情景应用
默认情况下插件在 开发(serve)和构建(build) 模式中都会调用。
如果插件只需要在预览或构建期间有条件地应用,请使用 apply
属性指明它们仅在 'build'
或 'serve'
模式时调用:
js
function myPlugin() {
return {
name: 'build-only',
apply: 'build' // 或 'serve'
}
}
同时,还可以使用函数来进行更精准的控制:
js
apply(config, { command }) {
// 非 SSR 情况下的 build
return command === 'build' && !config.build.ssr
}
钩子测试
config/configResolved
在 vite.config.ts
中定义函数 TestPlugin
ts
export default defineConfig({
plugins: [vue(),TestPlugin({name:"tsk"})],
})
实现 TestPlugin
这个测试插件
先看看 config
和 configResolved
这两个钩子
ts
import type { PluginOption } from "vite";
export default function TestPlugin(options:any): PluginOption {
console.log("🚀 ~ file: replaceKeyword.ts:4 ~ Test ~ options:", options);
return {
name: "TestPlugin",
enforce: "pre",
// config 钩子
config(config,env){
console.log("🚀 ~ file: replaceKeyword.ts:8 ~ config ~ env:", env);
console.log("🚀 ~ file: replaceKeyword.ts:8 ~ config ~ config:", config);
},
// configResolved 钩子
configResolved(resolvedConfig) {
console.log(
"🚀 ~ file: replaceKeyword.ts:11 ~ configResolved ~ resolvedConfig:",
resolvedConfig
);
},
};
}
可以看出,config 中的 env 返回了 mode
和 command
其中 command
是值运行时机,可以选择serve
或者 build
, 如果不指定,默认是开发和打包都会执行执行
如果指定属性 apply
为 build
,那么开发时就不会执行钩子函数
js
apply:"build",
// 开发环境不会执行
config(config,env){
console.log("🚀 ~ file: replaceKeyword.ts:8 ~ config ~ env:", env);
console.log("🚀 ~ file: replaceKeyword.ts:8 ~ config ~ config:", config);
},
configureServer
js
export default function TestPlugin(): PluginOption {
return {
name: "TestPlugin",
enforce: "pre",
configureServer(server){
server.middlewares.use((req,res,next) => {
console.log("🚀 ~ file: replaceKeyword.ts:11 ~ server.middlewares.use ~ req:", req);
return next()
}
)
}
};
}
configureServer
接收一个 ViteDevServer
🔗 对象,其中middlewares
对象类似于 express
的中间键
🚅resolveId
ts
resolveId(source, importer) {
console.log(
"🚀 ~ file: replaceKeyword.ts:8 ~ resolveId ~ importer:",
importer
);
console.log(
"🚀 ~ file: replaceKeyword.ts:8 ~ TestPlugin ~ source:",
source
);
},
importer
对应的是引入的绝对路径,source
对应的是引入的文件
main.ts
ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
✈️transform(重要)
ts
transform(code, id) {
console.log(
"🚀 ~ file: replaceKeyword.ts:8 ~ transform ~ code:",
code
);
console.log(
"🚀 ~ file: replaceKeyword.ts:8 ~ TestPlugin ~ id:",
id
);
},
transform
对单个文件进行了转换,id 是文件名,code 是转换后的代码字符串
我们可以利用这个进行代码转换
如果带有文件命名中 HelloWorld
的字样,就替换成 abcdefg
js
export default function TestPlugin(): PluginOption {
return {
name: "TestPlugin",
enforce: "pre",
transform(code, id) {
if (id.includes("HelloWorld")) {
return `
<template>
<div>
abcdefg
</div>
</template>
`;
}
},
};
}
例子
替换文本
原本效果如下图所示,我们想要把 react
替换成 react 和 vue
利用 transform
的特性,判断只要是 .vue
结尾,我们就要替换里面的 react
ts
export default function TestPlugin(): PluginOption {
return {
name: "TestPlugin",
enforce: "pre",
transform(code, id) {
if (id.endsWith(".vue")) {
return code.replace(/react/g,"vue and react");
}
},
};
}
也可以利用 正则表达式 进行替换,对 languageMap
进行遍历,遇见 $hi
就替换为 你好
js
const languageMap = {
$hi: "你好",
};
export default function TestPlugin(): PluginOption {
return {
name: "TestPlugin",
enforce: "pre",
transform(code, id) {
if (id.endsWith(".vue")) {
let tempCode = code;
Object.entries(languageMap).forEach(([k, v]) => {
// 对 $ 进行转义
const regex = new RegExp(k.replace("$", "\\$"), "g");
if (regex.test(code)) {
tempCode = tempCode.replace(regex, v);
}
});
return tempCode;
}
},
};
}
最后来看看 element-plus
中的 vite 插件吧,unplugin-element-plus地址
可以看出,没有引入css
文件,核心逻辑在于 transformImportStyle
方法
就以 import { ElButton, ElForm as AForm } from 'element-plus'
举例, (注意使用了 as
)
ts
const hyphenateRE = /\B([A-Z])/g
const hyphenate = (str) =>
str.replaceAll(hyphenateRE, '-$1').toLowerCase()
let x = hyphenate("ElButton") // el-button
const transformImportStyle =()=>{
// ...
// 先找到左括号
const leftBracket = statement.indexOf('{')
if (leftBracket > -1) {
// 找到标识符
// 在我们的例子是 ElButton, ElButton,ElForm as AForm
const identifiers = statement.slice(leftBracket + 1, statement.indexOf('}'))
// 以 逗号 进行切割
// [ElButton,ElForm as AForm]
const components = identifiers.split(',')
// 作为导入容器
const styleImports: string[] = []
components.forEach((c)=>{
// 去除 as 后面的值, \s 表示空白字符
// ElForm as AForm 变为 ElForm
const trimmed = c.replace(/\sas\s.+/, '').trim()
// 只要 EL 开头
if (trimmed.startsWith("El")){
// Button Form
const component = trimmed.slice(2)
// 🚗🚗🚗🚗
styleImports.push(
// hyphenate 驼峰式命名转换为短横线连接然后转小写
// ElButton 转为 el-button ElForm 转为 el-form
`import 'element-plus/es/components/${hyphenate(
component
)}/style/css'`
)
}
})
return styleImports.join('\n')
}
}
通过这一通操作,可以看到即使没有引入 css
, 插件也默默帮你做了
可以看到命名就是 el-button.css
总结
平时自己写 vite 插件比较少,(可以说没有,哈哈),通过此次摸索,可以对 vite
插件有个大概的了解