12-Vite插件开发实战
# 0.前言
本节课程代码的仓库 (opens new window):
14-vite-plugin-development
本篇博客为《深入浅出 Vite
》掘金手册第十二章的总结。
# 1.概念梳理
# 问题1: vite插件与 rollup 插件的关系,请叙述两者的兼容性与差异性?
首先需要理解的是 vite 与 webpack 等传统的打包工具从架构上是很不相同的,传统打包工具,无论是开发环境还是生产环境从头到尾使用一套工具打包。这样有个明显的好处是开发与生产的构建完全是一套东西,开发者在开发结束后,直接使用 build
打包并发布。
但 vite 采用的是双构建引擎机制,即开发阶段为 esbuild ,生产上 rollup(ps:这句话不严谨,但是大部分人的认知如此),这样做的好处很明显,基于现代浏览器的 module 机制开发体验得到大幅提升,但代价是开发生产不一致的问题。(无解)
基于上述原因,为了尽量减少两者差异,从写法上,模拟兼容整套 rollup 构建机制,让开发者无需区分开发生产,开发阶段做的工作,在生产阶段也能用。
其中,vite 在开发阶段可调用的可兼容 rollup 的钩子如下:
- 启动阶段:
options
和buildStart
钩子(在生产阶段为服务启动,生产阶段为脚本执行) - 请求响应阶段:浏览器发送请求,vite 会一次调用
resolveId => load => transform
(生产阶段无此概念,最多就是 watch 模式监听源文件的变化,再次触发构建流程) - 关闭阶段:
buildEnd => closeBundle
(生产阶段中为脚本执行结束,开发阶段服务器关闭)
除上述钩子外,其余 rollup 均无法在生产阶段生效。如 moduleParsed
、renderChunk
等,其实这块也很好理解,vite 的最大优势就是按需加载,只有当网页请求才会对应加载新的模块,因此开发阶段都没有 chunk 或者 bundle 的概念,最多处理下依赖解析工作 如 resovleId
。
# 2.开发实战
# 问题2:Vite独有的插件Hook有哪些,简述作用?
vite 独有的钩子有 5 个:
config :进一步修改配置。
有些配置无法一开始就在
vite.config.ts
中配置好,或者我们封装config.ts
时,希望对外暴露一个最简易的配置文件。以下为推荐写法,返回的是一个对象是,vite 内部会自动 深合并。
// 返回部分配置(推荐) const editConfigPlugin = () => ({ name: 'vite-plugin-modify-config', config: () => ({ alias: { react: require.resolve('react') } }) })
1
2
3
4
5
6
7
8
9当然也可以直接修改
config
对象const mutateConfigPlugin = () => ({ name: 'mutate-config', // command 为 `serve`(开发环境) 或者 `build`(生产环境) config(config, { command }) { // 生产环境中修改 root 参数 if (command === 'build') { config.root = __dirname; } } })
1
2
3
4
5
6
7
8
9
10但是对于深合并的逻辑就需要自己处理了
// 防止出现 undefined 的情况 config.optimizeDeps = config.optimizeDeps || {} config.optimizeDeps.esbuildOptions = config.optimizeDeps.esbuildOptions || {} config.optimizeDeps.esbuildOptions.plugins = config.optimizeDeps.esbuildOptions.plugins || []
1
2
3
4configResolved:获取完整的配置文件
可以用此技巧将最终版的
conifg
缓存下来,但是注意的是这个钩子后就不要再修改config
,要改就直接在config
钩子里头改。const exmaplePlugin = () => { let config return { name: 'read-config', configResolved(resolvedConfig) { // 记录最终配置 config = resolvedConfig }, } }
1
2
3
4
5
6
7
8
9
10configureServer:配置中间件
常用操作:可以自定义
template html
文件。const myPlugin = () => ({ name: 'configure-server', configureServer(server) { // 姿势 1: 在 Vite 内置中间件之前执行 server.middlewares.use((req, res, next) => { // 自定义请求处理逻辑 }) // 姿势 2: 在 Vite 内置中间件之后执行 return () => { server.middlewares.use((req, res, next) => { // 自定义请求处理逻辑 }) } } })
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15上面的例子还是太抽象了,可以参考我的另一个项目:
hello-island-dev-indexHtml (opens new window)
实现功能:通过
island dev docs
启动项目时,自动加载一个index.html
模板代码兜底,这部分代码需要卸载return
的回调函数中()=>{}
。export function pluginIndexHtml(): Plugin { return { name: "island:index-html", configureServer(server) { return () => { server.middlewares.use(async (req, res, next) => { let html = await readFile(DEFAULT_HTML_PATH, "utf-8"); /* 将 html 返回给 server */ try { html = await server.transformIndexHtml( req.url, html, req.originalUrl ); res.statusCode = 200; res.setHeader("Content-Type", "text/html"); res.end(html); } catch (e) { return next(e); } }); }; }, }; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25hello-island-dev-config (opens new window)
读取配置文件时,除了需要启动一个开发服务器,还使用
sirv
中间件对静态资源进行代理。注:这里不能直接使用默认的 vite dev 作为静态服务器,因为当访问时,会直接下载资源文件。
当时在开发时就发现这个问题,这样处理开发阶段图片能正常访问,生产阶段需要手动将
public
文件夹移动到产物中。export function pluginConfig( config: SiteConfig, restartServer?: () => Promise<void> ): Plugin { return { name: "island:config", configureServer(server) { const publicDir = path.join(config.root, "public"); if (fs.pathExistsSync(publicDir)) { server.middlewares.use(sirv(publicDir)); } }, } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14transformIndexHtml:当去请求一个 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: [ { // 放到 body 末尾,可取值还有`head`|`head-prepend`|`body-prepend`,顾名思义 injectTo: 'body', // 标签属性定义 attrs: { type: 'module', src: './index.ts' }, // 标签名 tag: 'script', }, ], } } } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33handleHotUpdata:vite 的 hmr 是模块化加载的最大亮点哈。
获取热更新的上下文,并进行自定义的热更新处理,或热更模块的过滤
const handleHmrPlugin = () => { return { async handleHotUpdate(ctx) { // 需要热更的文件 console.log(ctx.file) // 需要热更的模块,如一个 Vue 单文件会涉及多个模块 console.log(ctx.modules) // 时间戳 console.log(ctx.timestamp) // Vite Dev Server 实例 console.log(ctx.server) // 读取最新的文件内容 console.log(await read()) // 自行处理 HMR 事件, 可后续捕获(在 island.js 中就监听 mdx 的变化) ctx.server.ws.send({ type: 'custom', event: 'special-update', data: { a: 1 } }) return [] } } } // 前端代码中加入 if (import.meta.hot) { import.meta.hot.on('special-update', (data) => { // 执行自定义更新 // { a: 1 } console.log(data) window.location.reload(); }) }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 问题3:Vite中插件Hook执行顺序是怎样的?
执行仓库中:npm run lifecycle
脚本
import { Plugin } from "vite";
export default function viteLogLifeCycle(options = {}): Plugin {
return {
name: "vite-plugin-log-lifecycle",
// Vite 独有钩子
config(config) {
console.log("config");
},
// Vite 独有钩子
configResolved(resolvedCofnig) {
console.log("configResolved");
},
// 通用钩子
options(opts) {
console.log("options", opts);
return opts;
},
// Vite 独有钩子
configureServer(server) {
console.log("configureServer");
console.log("3s 后自动关闭");
setTimeout(() => {
// 手动退出进程
process.kill(process.pid, "SIGTERM");
}, 3000);
},
// 通用钩子
buildStart() {
console.log("buildStart");
},
// 通用钩子
buildEnd() {
console.log("buildEnd");
},
// 通用钩子
closeBundle() {
console.log("closeBundle");
},
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
结果如下:
config
configResolved
options {}
configureServer
3s 后自动关闭
buildStart
Port 5173 is in use, trying another one...
VITE v4.2.1 ready in 252 ms
➜ Local: http://127.0.0.1:5174/
➜ Network: use --host to expose
➜ press h to show help
buildEnd
closeBundle
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 问题4:插件enforce属性的作用和取值?
以上为内部的插件执行图,一般为普通用户插件,根据测试执行次序取决于数组中的书写次序。
通过插件中指定 {enforce: "post"}
可以提到不仅可以将插件安排在末尾,并且要晚于内置的vite
生产环境的钩子,相当于兜底。
举例来说:在 island
这个项目 (opens new window)中,生产环境由于是 island
架构,所有静态文件均由mpa
打包的静态html
提供,我们在水合的过程只需要提供交互相关的代码,因此需要构建产物中所有的 assets
文件。
此时则可以将插件指定到末尾执行。
# 问题5:如何制定Vite插件的应用场景(开发环境或生产环境)?
默认情况下,vite
插件会同时在开发和生产环境中生效,但仍可以通过 apply
属性决定场景。
{
// 'serve' 表示仅用于开发环境,'build'表示仅用于生产环境
apply: 'serve'
}
2
3
4
一开始不是很理解这个钩子作用的,因为在前面不是钩子的执行环境已经区分好了吗?比如 renderChunk 就不会再 dev 环境触发,因此这里的 apply 这里主要是为了区分 dev 和 prod 同时触发的场景。
比如我的另一个项目中:hello-island-dev (opens new window)
对于 mdx
文件 hmr
时,需要通过 transform
时往其中注入 "import.meta.hot.accept()"
语句。
由于 transform
钩子会同时在 dev 或者 prod 时触发,因此必须通过 apply
以作区分。
除此以外,apply 支持改为函数,此时返回值为 true
or false
apply(config,{command}){
return command === "build" & !config.build.ssr
}
2
3
# 问题6:使用过vite-plugin-inspect插件吗?作用是什么?
用过,直接 vite-plugin-inspect
配置如下:
// vite.config.ts
import inspect from 'vite-plugin-inspect';
// 返回的配置
{
plugins: [
// 省略其它插件
inspect()
]
}
2
3
4
5
6
7
8
9
10
启动后,还可以获取完整存在的内置的插件耗时面板:
可以发现,其中的 pre
插件有:
vite:react-babel
react代码转化(不知道和下面有啥区别)vite:react-refresh
控制react 组件刷新的。vite-plugin-inspect
面板插件vite:react-jsx
react 代码转化
# 3.插件实战
# 问题7:请阐述如何开发虚拟模块插件?
与 rollup
开发基本没区别,只有两处需要注意:
vite
插件需要添加前缀:"\0"
在
vite-env.d.ts
中添加对应的typescript
定义declare module "virtual:*" { const data: any; export default data; }
1
2
3
4
完整代码示例:
import { Plugin, ResolvedConfig } from "vite";
/* 虚拟模块名称 */
const virtualModuleId = "virtual:env";
const virtualConfigId = "virtual:config";
export default function virtualModulePlugin(): Plugin {
let config: ResolvedConfig | null = null;
return {
/* 对于 vite 插件推荐使用 vite-plugin 的方式命名 */
name: "vite-plugin-virtual-module",
resolveId(id) {
if (id === virtualModuleId) {
// Vite 中约定对于虚拟模块,解析后的路径需要加上`\0`前缀
return "\0" + virtualModuleId;
}
if (id === virtualConfigId) {
return "\0" + virtualConfigId;
}
},
configResolved(c: ResolvedConfig) {
config = c;
},
load(id) {
/* 加载虚拟模块 */
if (id === "\0" + virtualModuleId) {
return `export default ${JSON.stringify(process.env)}`;
}
if (id === "\0" + virtualConfigId) {
return `export default ${JSON.stringify(config.env)}`;
}
},
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
启动页面效果,将编译时信息打印如下:
# 问题9:请阐述下如何开发 SVG 插件?
实现思路:
先思考需要用到的钩子?
transform(code,id)
打印如下:code svg 会被 file-loader 处理 > 'export default "/src/assets/react.svg"' id 模块路径 > '/Users/.../14-vite-plugin-development/src/assets/react.svg'
1
2
3
4
5根据 id 获取
svg
图片内容const svg = await readFile(id, "utf8");
1使用
@svgr/core
去将svg
转化为react
组件。经测试
@svgr/core
需要使用6
版本,7
版本有问题,见测试代码 (opens new window)/* 注: 需安装 6 版本 */ const svgrTransform = await import("@svgr/core"); /* 利用 `@svgr/core` 将 svg 转化为 React 组件 */ const svgrResult = await svgrTransform.transform( svg, { icon: true }, { componentName: "ReactComponent" }, );
1
2
3
4
5
6
7
8
9此时接受额外
options
参数,支持url
参数,此时需要转译下代码:/* 当为 url 时,仍将组件格式导出,以 url 的形式默认导出 */ if (defaultExport === "url") { componentCode += code; componentCode = componentCode.replace( "export default ReactComponent", "export { ReactComponent }", ); }
1
2
3
4
5
6
7
8由于
react
组件无法直接渲染在页面上,因此需要转化下jsx
转化为js
代码。此时会个问题?用什么转?
答:使用
esbuild.transform(code,{loader:"jsx"})
的loader
转/* 这里:使用 esbuild(起的 babel 作用) 处理 jsx 代码 */ const esbuildPackagePath = resolve.sync("esbuild", { basedir: resolve.sync("vite"), });
1
2
3
4由于
vite
默认安装esbuild
,如何使用内部的esbuild
?答:在
esm
中可使用resolve.sync
找esbuild
;在cjs
可直接使用require.resolve
/* 这里:使用 esbuild(起的 babel 作用) 处理 jsx 代码 */ const esbuildPackagePath = resolve.sync("esbuild", { basedir: resolve.sync("vite"), });
1
2
3
4
完整代码:
interface SvgrOptions {
/* 导出为一个 url 还是一个 React 组件 */
defaultExport?: "url" | "component";
}
export default function viteSvgrPlugin(options: SvgrOptions = {}): Plugin {
const { defaultExport = "component" } = options;
return {
name: "vite-plugin-svgr",
async transform(code, id) {
/* 转换逻辑 svg => React 组件 */
/* 1.根据 id 过滤出 svg 资源 */
if (path.extname(id) !== ".svg") {
return code;
}
/* 注: 需安装 6 版本 */
const svgrTransform = await import("@svgr/core");
/* 这里:使用 esbuild(起的 babel 作用) 处理 jsx 代码 */
const esbuildPackagePath = resolve.sync("esbuild", {
basedir: resolve.sync("vite"),
});
const esbuild = await import(esbuildPackagePath);
// 2. 读取 svg 文件内容;
const svg = await readFile(id, "utf8");
/* 3. 利用 `@svgr/core` 将 svg 转化为 React 组件 */
const svgrResult = await svgrTransform.transform(
svg,
{ icon: true },
{ componentName: "ReactComponent" },
);
let componentCode = svgrResult;
/* 4. 当为 url 时,仍将组件格式导出,以 url 的形式默认导出 */
if (defaultExport === "url") {
componentCode += code;
componentCode = componentCode.replace(
"export default ReactComponent",
"export { ReactComponent }",
);
}
/* 5. 利用 esbuild 将组件中的 jsx 代码转译为浏览器可运行的代码 */
/* 即使用 React.createElement 去处理 */
const result = await esbuild.transform(componentCode, {
loader: "jsx",
});
return {
code: result.code,
map: null, //
};
},
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
代码使用:
import ReactLogo from "./assets/react.svg";
function App() {
return (
<div className="App">
<ReactLogo />
</div>
);
}
export default App;
2
3
4
5
6
7
8
9