likes
comments
collection
share

Vite多入口组件库构建方案,自带样式导入和treeshaking功能

作者站长头像
站长
· 阅读数 16

背景

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
}

可以看到,产物中正确的注入了相关的样式文件,使命达成。

可能遇到的问题

额外的空导入提升

在构建过程中,我们发现,有一些额外的空导入被注入到了相对顶层的产物中,如下方的注释部分

Vite多入口组件库构建方案,自带样式导入和treeshaking功能

仔细观察发现,这些内容正是分散在各个组件的依赖引用,为什么他们会被注入到每一个入口 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

  1. 加载并解析main.js,最后发现了对 other-entry.js的引用。
  2. 加载并解析other-entry.js,最后发现了对 external的引用。
  3. 加载并解析external
  4. 执行main.js

有这个优化之后,流程将变成

  1. 加载和解析main.js,最后发现了other-entry.jsexternal
  2. 加载和解析other-entry.jsexternal,由于other-entry.jsexternal已经被加载和解析过了,所以这里的过程被省略。
  3. 执行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」,那么有两种引用方式

Vite多入口组件库构建方案,自带样式导入和treeshaking功能

  1. 引用1:「组件B」引用「组件A」的 index
  2. 引用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,避免直接引用相关组件,破坏依赖关系,从而导致一些问题。


🎉 看到这里的你很有前途,不妨点个赞吧~