Vite
在2.0版本提供了Library Mode(库模式),让开发者可以使用Vite
来构建自己的库以发布使用。正好我准备封装一个React组件并将其发布为npm包以供日后方便使用,同时之前也体验到了使用Vite
带来的快速体验,于是便使用Vite
进行开发。
背景
在开发完成后进行打包,出现了如图三个文件:

Image.png
其中的style.css
文件里面包含了该组件的所有样式,如果该文件单独出现的话,意味着在使用时需要进行单独引入该样式文件,就像使用组件库时需在主文件引入其样式一样。
1import xxxComponent from 'xxx-component';
2import 'xxx-component/dist/xxx.css'; // 引入样式
但我封装的只是单一组件,样式不多且只应用于该组件上,没有那么复杂的样式系统。
所以打包时比较好的做法是配置构建工具将样式注入到JS文件中,从而无需再多一行引入语句。我们知道Webpack
打包是可以进行配置来通过一个自执行函数在DOM上创建style
标签并将CSS注入其中,最后只输出JS文件,但在Vite
的官方文档中似乎并没有告诉我们怎么去配置。
让我们先来看一下官方提供的配置:
1// vite.config.js
2
3import { resolve } from 'path'
4import { defineConfig } from 'vite'
5
6export default defineConfig({
7 build: {
8 lib: {
9 entry: resolve(__dirname, 'lib/main.js'),
10 name: 'MyLib',
11 // the proper extensions will be added
12
13 fileName: 'my-lib'
14 },
15 rollupOptions: {
16 // make sure to externalize deps that shouldn't be bundled
17
18 // into your library
19
20 external: ['vue'],
21 output: {
22 // Provide global variables to use in the UMD build
23
24 // for externalized deps
25
26 globals: {
27 vue: 'Vue'
28 }
29 }
30 }
31 }
32})
首先要开启build.lib
选项,配置入口文件和文件名等基本配置,由于Vite
生产模式下打包采用的是rollup
,所以需要开启相关选项,当我们的库是由Vue
或React
编写的时候,使用的时候一般也是在该环境下,例如我的这个组件是基于React
进行编写,那么使用时无疑也是在React
中进行引入,这样就会造成产物冗余,所以需要在external
配置中添加上外部化的依赖,以在打包时给剔除掉。output
选项是输出产物为umd格式时(具体格式查看build.lib.formats
选项,umd为**Universal Module Definition**,可以直接script
标签引入使用,所以需要提供一个全局变量)。
配置完上述提及到的后,我接着寻找与打包样式相关的内容,然而并没有发现。。。
7625CB0E-F4E2-4AC2-BFFC-DC8C00CD604C.jpeg
没关系,我们还可以去仓库[issues](https://link.zhihu.com/?target=https%3A//github.com/vitejs/vite/issues/1579)
看看,说不定有人也发现了这个问题。搜索后果不其然,底下竟有高达47条评论:

Image.png
点进去后,提问者问到如何才能不生成CSS文件,尤回答说:进行样式注入的DOM环境会产生服务端渲染的不兼容问题,如果CSS代码不多,使用行内样式进行解决。

Image.png
这个回答显然不能让很多人满意(这可能是该issue关闭后又重新打开的原因),因为带样式的库在编写过程中几乎不会采用行内的写法,提问者也回复说道那样自己就不能使用模块化的Less
了,依旧希望能够给出更多的库模式options
,然后下面都各抒己见,但都没有一种很好的解决方案被提出。
因此,为了解决我自己的问题,我决定写一个插件。
Vite Plugin API
Vite
插件提供的API实际上是一些hook
,其划分为Vite
独有hook和通用hook(Rollup
的hook,由Vite
插件容器进行调用)。这些hook执行的顺序为:
- Alias
- 带有
enforce: 'pre'
的用户插件 - Vite 核心插件
- 没有 enforce 值的用户插件
- Vite 构建用的插件
- 带有
enforce: 'post'
的用户插件 - Vite 后置构建插件(最小化,manifest,报告)
Vite
核心插件基本上是独有hook,主要用于配置解析,构建插件基本上都是Rollup
的hook,这才是真正起构建作用的hook,而我们现在想要将获取构建好的CSS和JS产物并将其合二为一,所以编写的插件执行顺序应该在构建的插件执行之后,也就是‘**带有 enforce: 'post'
的用户插件’(输出阶段)**这一阶段执行。
打开Rollup
官网,里面的输出钩子部分有这么一张图:

Image.png
根据上图可以看到输出阶段钩子的执行顺序及其特性,而我们只需要在写入之前拿到输出的产物进行拼接,因此就得用到上面的generateBundle
这个hook。
实现
官方推荐编写的插件是一个返回实际插件对象的工厂函数,这样做的话可以允许用户传入配置选项作为参数来自定义插件行为。
基本结构如下:
1import type { Plugin } from 'vite';
2
3function VitePluginStyleInject(): Plugin {
4
5 return {
6 name: 'vite-plugin-style-inject',
7 apply: 'build', // 应用模式
8
9 enforce: 'post', // 作用阶段
10
11 generateBundle(_, bundle) {
12
13 }
14 };
15}
Vite
默认的formats
有es和umd两种格式,假设不修改该配置将会有两个Bundle
产生,generateBundle
钩子也就会执行两次,其方法的签名及其参数类型为:
1type generateBundle = (options: OutputOptions, bundle: { [fileName: string]: AssetInfo | ChunkInfo }, isWrite: boolean) => void;
2
3type AssetInfo = {
4 fileName: string;
5 name?: string;
6 source: string | Uint8Array;
7 type: 'asset';
8};
9
10type ChunkInfo = {
11 code: string;
12 dynamicImports: string[];
13 exports: string[];
14 facadeModuleId: string | null;
15 fileName: string;
16 implicitlyLoadedBefore: string[];
17 imports: string[];
18 importedBindings: { [imported: string]: string[] };
19 isDynamicEntry: boolean;
20 isEntry: boolean;
21 isImplicitEntry: boolean;
22 map: SourceMap | null;
23 modules: {
24 [id: string]: {
25 renderedExports: string[];
26 removedExports: string[];
27 renderedLength: number;
28 originalLength: number;
29 code: string | null;
30 };
31 };
32 name: string;
33 referencedFiles: string[];
34 type: 'chunk';
35};
我们只用到其中的bundle
参数,它是一个键由文件名字符串值为AssetInfo
或ChunkInfo
组成的对象,其中一段的内容如下:

Image.png
上图看出CSS文件的值属于AssetInfo
,我们先遍历bundle
找到该CSS部分把source
值提取出来:
1import type { Plugin } from 'vite';
2
3function VitePluginStyleInject(): Plugin {
4 let styleCode = '';
5
6 return {
7 name: 'vite-plugin-style-inject',
8 apply: 'build', // 应用模式
9
10 enforce: 'post', // 作用阶段
11
12 generateBundle(_, bundle) {
13 // + 遍历bundle
14
15 for (const key in bundle) {
16 if (bundle[key]) {
17 const chunk = bundle[key]; // 拿到文件名对应的值
18
19 // 判断+提取+移除
20
21 if (chunk.type === 'asset' && chunk.fileName.includes('.css')) {
22 styleCode += chunk.source;
23 delete bundle[key];
24 }
25 }
26 }
27 }
28 };
29}
现在styleCode
存储的就是构建后的所有CSS代码,因此我们需要一个能够实现创建style标签并将styleCode
添加其中的自执行函数,然后把它插入到其中一个符合条件的ChunkInfo.code
当中即可:
1import type { Plugin } from 'vite';
2
3function VitePluginStyleInject(): Plugin {
4 let styleCode = '';
5
6 return {
7 name: 'vite-plugin-style-inject',
8 apply: 'build', // 应用模式
9
10 enforce: 'post', // 作用阶段
11
12 generateBundle(_, bundle) {
13 // 遍历bundle
14
15 for (const key in bundle) {
16 if (bundle[key]) {
17 const chunk = bundle[key]; // 拿到文件名对应的值
18
19 // 判断+提取+移除
20
21 if (chunk.type === 'asset' && chunk.fileName.includes('.css')) {
22 styleCode += chunk.source;
23 delete bundle[key];
24 }
25 }
26 }
27
28 // + 重新遍历bundle,一次遍历无法同时实现提取注入,例如'style.css'是bundle的最后一个键
29
30 for (const key in bundle) {
31 if (bundle[key]) {
32 const chunk = bundle[key];
33 // 判断是否是JS文件名的chunk
34
35 if (chunk.type === 'chunk' &&
36 chunk.fileName.match(/.[cm]?js$/) !== null &&
37 !chunk.fileName.includes('polyfill')
38 ) {
39 const initialCode = chunk.code; // 保存原有代码
40
41 // 重新赋值
42
43 chunk.code = '(function(){ try {var elementStyle = document.createElement(\'style\'); elementStyle.appendChild(document.createTextNode(';
44 chunk.code += JSON.stringify(styleCode.trim());
45 chunk.code += ')); ';
46 chunk.code += 'document.head.appendChild(elementStyle);} catch(e) {console.error(\'vite-plugin-css-injected-by-js\', e);} })();';
47 // 拼接原有代码
48
49 chunk.code += initialCode;
50 break; // 一个bundle插入一次即可
51
52 }
53 }
54 }
55 }
56 };
57}
最后,我们给这个style
标签加上id属性以方便用户获取操作:
1import type { Plugin } from 'vite';
2
3// - function VitePluginStyleInject(): Plugin {
4
5function VitePluginStyleInject(styleId: ''): Plugin {
6 let styleCode = '';
7
8 return {
9 name: 'vite-plugin-style-inject',
10 apply: 'build', // 应用模式
11
12 enforce: 'post', // 作用阶段
13
14 generateBundle(_, bundle) {
15 // 遍历bundle
16
17 for (const key in bundle) {
18 if (bundle[key]) {
19 const chunk = bundle[key]; // 拿到文件名对应的值
20
21 // 判断+提取+移除
22
23 if (chunk.type === 'asset' && chunk.fileName.includes('.css')) {
24 styleCode += chunk.source;
25 delete bundle[key];
26 }
27 }
28 }
29
30 // 重新遍历bundle,一次遍历无法同时实现提取注入,例如'style.css'是bundle的最后一个键
31
32 for (const key in bundle) {
33 if (bundle[key]) {
34 const chunk = bundle[key];
35 // 判断是否是JS文件名的chunk
36
37 if (chunk.type === 'chunk' &&
38 chunk.fileName.match(/.[cm]?js$/) !== null &&
39 !chunk.fileName.includes('polyfill')
40 ) {
41 const initialCode = chunk.code; // 保存原有代码
42
43 // 重新赋值
44
45 chunk.code = '(function(){ try {var elementStyle = document.createElement(\'style\'); elementStyle.appendChild(document.createTextNode(';
46 chunk.code += JSON.stringify(styleCode.trim());
47 chunk.code += ')); ';
48 // + 判断是否添加id
49
50 if (styleId.length > 0)
51 chunk.code += ` elementStyle.id = "${styleId}"; `;
52 chunk.code += 'document.head.appendChild(elementStyle);} catch(e) {console.error(\'vite-plugin-css-injected-by-js\', e);} })();';
53 // 拼接原有代码
54
55 chunk.code += initialCode;
56 break; // 一个bundle插入一次即可
57
58 }
59 }
60 }
61 }
62 };
63}
至此,这个插件就写好了,是不是很简单。
使用
在项目中使用该插件:
1// vite.config.js
2
3import { defineConfig } from 'vite';
4import VitePluginStyleInject from 'vite-plugin-style-inject';
5
6export default defineConfig({
7 plugins: [VitePluginStyleInject()],
8})
执行构建命令后,只输出两个文件:

Image.png
引入打包后的文件发现其能正常运行,终于搞定啦~