Vite多入口组件库构建方案,自带样式导入和treeshaking功能
背景
Vite 在 Web 项目开发中大放异彩,但其实也是可以用于库项目开发的,但是在组件库开发时,通常涉及多入口。
虽然 Vite 3.2+ 以后支持了多入口配置,但是并没有提供相关选项让我们将 CSS 和组件关联起来,这样即便我们打包成功,也无法判断组件和样式的关系,从而给库的导出和使用带来不便。
基于这个背景,我们需要编写一个插件尝试寻找二者关系,并顺势注入 CSS 引用,这样就不用再关心样式和入口的关系了。
样式自动导入
作为一个库(主要是组件库),我们希望在引用组件时自动导入样式:
/** component-lib/dist/button.js */
import './assets/button.css'; // 这是我们插件要做的事情,将这行代码注入进来
...
export default Button;
/** component-lib/dist/index.js */
import Button from './button.js';
import xxx from './xxx.js';
export { Button, xxx };
...
/** In our project's main file */
// 引用 Button 的同时将一起引入对应的样式文件
import { Button } from 'component-lib';
如上所示,其实要做的很简单,添加一行 import './style.css';
; 到生成文件的顶部即可,多个就是多行。作为库的提供者,应该尽可能的提供灵活性,将如何处理这些样式文件的任务,交给用户侧的构建工具。
市面上大部分声称自动注入 CSS 的 Vite 插件,都是采用 document.createElement('style')
这样的方式进行的,这并不优雅,并且他假设了当前是浏览器的 DOM 环境。
所以我们的问题就变成了,如何将样式文件和入口文件关联起来?
其实,Vite 在插件的生命周期中,为每一个 chunk 对象都注入了一个属性 viteMetadata,我们可以通过这个属性获取到当前 chunk 关联了哪些资源文件,其中就包括 CSS。
核心代码
基于上面的分析,我们在插件钩子 renderChunk 中进行注入即可,这是最简单和行之有效的方法。
export function plugin(){
return {
name: 'vite:inject-css',
apply: 'build',
enforce: 'post',
config() {
const { rollupOptions, ...lib } = libOptions || {} as LibOptions;
return {
build: {
/**
* 需要打开这一项,否则多入口下也只会有一个 style.css, 单入口则不受影响。
*/
cssCodeSplit: true,
},
};
},
renderChunk(code, chunk) {
if (!chunk.viteMetadata) return;
const { importedCss } = chunk.viteMetadata;
if (!importedCss.size) return;
let result = code;
for (const cssFileName of importedCss) {
let cssFilePath = path.relative(path.dirname(chunk.fileName), cssFileName);
cssFilePath = cssFilePath.startsWith('.') ? cssFilePath : `./${cssFilePath}`;
result = `import '${cssFilePath}';\n${result}`
}
return result;
},
}
}
需要注意的是,需要打开 build.cssCodeSplit,主要是因为在内部实现中,CSS 代码分割开启时才会在 viteMetadata 中记录 chunk 对应那些资源文件,只有文件关系被记录下来,我们才能正常注入。
上方代码是一个最简版本,主要展示核心逻辑,作为生产使用还缺少 sourcemap 的支持,更完善的版本已经发布为 vite-plugin-lib-inject-css,有兴趣的小伙伴可以进行尝试。
多入口构建
如何使用 Vite 创建一个开箱即用,具备 Tree-shaking 功能,还能自动导入样式的组件库呢?
大多数组件库都提供了两种使用方式,一种是全量引入:
import Vue from 'vue';
import XxxUI from 'component-lib';
Vue.use(XxxUI);
另一种是按需引入。这种方式通常需要搭配一个第三方插件进行转换,例如 babel-plugin-import
import { Button } from 'component-lib';
// ↓ ↓ ↓ transformed by plugin ↓ ↓ ↓
import Button from 'component-lib/dist/button/index.js'
import 'component-lib/dist/button/style.css'
但最好的使用方式应该是,在正常使用具名导入时,就能自动引入样式,并进行 Tree-shaking。
幸运的是,ES Module 天然具备静态分析能力,主流工具基本都实现了基于 ESM 的 Tree-shaking 功能,比如 webpack/rollup/vite
。
那么我们只需要以下两步,就能大功告成
- 将产物格式输出为 ES Module → 开箱即用的 Tree-shaking 功能
- 使用上面的插件进行样式注入 → 自动导入样式
需要注意的是,CSS 文件的导入是具有副作用的,我们还需要在库的 package.json 文件中声明 sideEffects 字段,防止用户侧构建时 CSS 文件被意外移除。
{
"name": "component-lib",
"version": "1.0.0",
"main": "dist/index.mjs",
"sideEffects": [
"**/*.css"
]
}
配置示例
上文提到的插件中,第一个参数为可选参数,照搬了 build.lib
的相关配置项。除此之外还提供了一些工具函数,目的是简化配置。
以下是一份多入口组件库的配置示例:
// vite.config.ts
import { libInjectCss, scanEntries } from 'vite-plugin-lib-inject-css';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
libInjectCss(), // 快速使用,其他配置自己写在 build.lib 里即可
// 参数是 build.lib 的 alias,目的是为了集中配置
libInjectCss({
format: ['es'],
entry: {
index: 'src/index.ts', // Don't forget the main entry!
button: 'src/components/button/index.ts',
select: 'src/components/select/index.ts',
// Uses with a similar directory structure.
...scanEntries([
'src/components',
'src/hooks',
])
},
rollupOptions: {
output: {
// Put chunk files at <output>/chunks
chunkFileNames: 'chunks/[name].[hash].js',
// Put chunk styles at <output>/styles
assetFileNames: 'assets/[name][extname]',
},
},
}),
],
})
我们的产物结构如下:
--dist
----chunks
----assets
--index.mjs
--button.mjs
--select.mjs
...
随便点开一个组件,比如 dist/button.mjs
import './assets/index2.css'
import { xxx } from 'vue';
const v = ...;
export {
v as Button
}
可以看到,产物中正确的注入了相关的样式文件,使命达成。
可能遇到的问题
额外的空导入提升
在构建过程中,我们发现,有一些额外的空导入被注入到了相对顶层的产物中,如下方的注释部分
仔细观察发现,这些内容正是分散在各个组件的依赖引用,为什么他们会被注入到每一个入口 chunk 中呢?虽然这个行为并不会影响代码的执行顺序和表现。但是作为库,空导入可能会引入副作用,导致用户侧构建时 Treeshaking 功能异常,将多余内容打包进来。
Rollup的解释是,为了优化 JavaScript 引擎对脚本的加载和解析效率。如下所示:
// input
// main.js
import value from './other-entry.js';
console.log(value);
// other-entry.js
import externalValue from 'external';
export default 2 * externalValue;
// output
// main.js
import 'external'; // this import has been hoisted from other-entry.js
import value from './other-entry.js';
console.log(value);
// other-entry.js
import externalValue from 'external';
var value = 2 * externalValue;
export default value;
没有这个优化,JavaScript 引擎将会按照如下步骤运行 main.js
- 加载并解析
main.js
,最后发现了对other-entry.js
的引用。 - 加载并解析
other-entry.js
,最后发现了对external
的引用。 - 加载并解析
external
。 - 执行
main.js
。
有这个优化之后,流程将变成
- 加载和解析
main.js
,最后发现了other-entry.js
和external
。 - 加载和解析
other-entry.js
和external
,由于other-entry.js
和external
已经被加载和解析过了,所以这里的过程被省略。 - 执行
main.js
。
当然,这个优化在一些场景下并不是我们所期望的,我们可以通过关闭 output.hoistTransitiveImports
选项来取消优化。另外,在output.preserveModules
选项开启时,不会应用这个优化。
意外的 Tree-shaking
我们每一个组件都会有一个 index 入口文件,负责将组件导入并导出,同时挂载一个 install 方法,类似这样
import A from './A.js';
A.install = Vue => Vue.component(A.name, A);
export default A;
现在假设我们的组件库导出了两个组件「A」和「B」,它们都有上面这样的一个入口文件用来导出,其中 「组件B」引用了「组件A」,那么有两种引用方式
- 引用1:「组件B」引用「组件A」的 index
- 引用2:「组件B」直接引用「组件B」
看似没什么不同,但产物却有区别。
第一种情况
-
dist/index.js
-
dist/A.js
-
dist/B.js
第二种情况
-
dist/index.js
-
dist/A.js
-
dist/B.js
-
dist/A-[hash].js // 会多出这样一个chunk
第二种情况下,「组件A」的 index 和「组件B」都引用了 A 本身,所以这个 「组件A」会变成一个公共 chunk。
由于我们的 index 源文件中,仅仅是对组件导入再导出,
// dist/A.js
import { A } from './A-[hash].js'
A.install = Vue => Vue.component('TestName', A); // 注意 A 注册的名字不是 A 的 name
export default A
与此同时,dist/index.js 中多了一行空导入,到这里我们发现事情不太对,我们原本要的效果应该是
// dist/index.js
// 我们想要的
import A from './A.js';
Vue.use(A);
// 实际上的
import './A.js'; // 目的是执行为 A 组件挂载 install 方法的逻辑
import { A } from './A-[hash].js';
Vue.use(A);
作为产物直接执行时,这样的代码没有问题,但作为库产物,这行空导入将在业务方进行生产环境构建时被 treeshaking 意外的移除掉,导致遗漏掉一些逻辑。在上面的例子中即为,漏掉了 A 组件的 install 函数的挂载。
这里有一个简单的多入口打包示例,可以快速上手体验 Rollup Playground
我们只需要改变 B.js 中对 A 组件的引用方式,从 index-A.js
更换为 A.js
就能复现相关场景:
import A from './index-A.js';
// ↓↓↓ 改成 ↓↓↓
import A from './A.js';
基于这样的现状,我们需要确保入口组件互相引用时,从 index 中进行引入,帮助构建工具合理拆分 chunk,避免直接引用相关组件,破坏依赖关系,从而导致一些问题。
🎉 看到这里的你很有前途,不妨点个赞吧~
转载自:https://juejin.cn/post/7214374960192782373