实现一个打包时将 CSS 注入到 JS 的Vite插件

2022-7-15 12 min read

TOC

前言

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

背景

在开发完成后进行打包,出现了如图三个文件:

Image.png

其中的 style.css 文件里面包含了该组件的所有样式,如果该文件单独出现的话,意味着在使用时需要进行单独引入该样式文件,就像使用组件库时需在主文件引入其样式一样。

import xxxComponent from 'xxx-component';
import 'xxx-component/dist/xxx.css'; // 引入样式

但我封装的只是单一组件,样式不多且只应用于该组件上,没有那么复杂的样式系统。

所以打包时比较好的做法是配置构建工具将样式注入到 JS 文件 中,从而无需再多一行引入语句。我们知道 Webpack 打包是可以进行配置来通过一个 自执行函数 在 DOM 上创建 style 标签并将 CSS 注入其中,最后只输出JS文件,但在 Vite 的官方文档中似乎并没有告诉我们怎么去配置。

让我们先来看一下官方提供的配置:

// vite.config.js
import { resolve } from 'path'
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'lib/main.js'),
      name: 'MyLib',
      // the proper extensions will be added
      fileName: 'my-lib'
    },
    rollupOptions: {
      // make sure to externalize deps that shouldn't be bundled
      // into your library
      external: ['vue'],
      output: {
        // Provide global variables to use in the UMD build
        // for externalized deps
        globals: {
          vue: 'Vue'
        }
      }
    }
  }
})

首先要开启 build.lib 选项,配置入口文件和文件名等基本配置,由于 Vite 生产模式下打包采用的是 rollup,所以需要开启相关选项,当我们的库是由 VueReact 编写的时候,使用的时候一般也是在该环境下,例如我的这个组件是基于 React 进行编写,那么使用时无疑也是在 React 中进行引入,这样就会造成产物冗余,所以需要在 external 配置中添加上外部化的依赖,以在打包时给剔除掉。output 选项是输出产物为 umd 格式时(具体格式查看 build.lib.formats 选项,umd 为 Universal Module Definition,可以直接 script 标签引入使用,所以需要提供一个全局变量)。

Vite

配置完上述提及到的后,我接着寻找与打包样式相关的内容,然而并没有发现。。。

没关系,我们还可以去仓库 issues 看看,说不定有人也发现了这个问题。搜索后果不其然,底下竟有高达 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。

实现

官方推荐编写的插件是一个返回 实际插件对象 的工厂函数,这样做的话可以允许用户传入配置选项作为参数来自定义插件行为。

基本结构如下:

import type { Plugin } from 'vite';

function VitePluginStyleInject(): Plugin {

  return {
    name: 'vite-plugin-style-inject',
    apply: 'build', // 应用模式
    enforce: 'post', // 作用阶段
    generateBundle(_, bundle) {
    
    }
  };
}

Vite 默认的 formatsesumd 两种格式,假设不修改该配置将会有两个 Bundle 产生,generateBundle 钩子也就会执行两次,其方法的签名及其参数类型为:

type generateBundle = (options: OutputOptions, bundle: { [fileName: string]: AssetInfo | ChunkInfo }, isWrite: boolean) => void;

type AssetInfo = {
  fileName: string;
  name?: string;
  source: string | Uint8Array;
  type: 'asset';
};

type ChunkInfo = {
  code: string;
  dynamicImports: string[];
  exports: string[];
  facadeModuleId: string | null;
  fileName: string;
  implicitlyLoadedBefore: string[];
  imports: string[];
  importedBindings: { [imported: string]: string[] };
  isDynamicEntry: boolean;
  isEntry: boolean;
  isImplicitEntry: boolean;
  map: SourceMap | null;
  modules: {
    [id: string]: {
      renderedExports: string[];
      removedExports: string[];
      renderedLength: number;
      originalLength: number;
      code: string | null;
    };
  };
  name: string;
  referencedFiles: string[];
  type: 'chunk';
};

我们只用到其中的 bundle 参数,它是一个键由 文件名字符串 值为 AssetInfoChunkInfo 组成的对象,其中一段的内容如下:

Image.png

上图看出 CSS 文件的值属于 AssetInfo,我们先遍历 bundle 找到该 CSS 部分把 source 值提取出来:

import type { Plugin } from 'vite';

function VitePluginStyleInject(): Plugin {
  let styleCode = '';

  return {
    name: 'vite-plugin-style-inject',
    apply: 'build', // 应用模式
    enforce: 'post', // 作用阶段
    generateBundle(_, bundle) {
      // + 遍历bundle
      for (const key in bundle) {
        if (bundle[key]) {
          const chunk = bundle[key]; // 拿到文件名对应的值
          // 判断+提取+移除
          if (chunk.type === 'asset' && chunk.fileName.includes('.css')) {
            styleCode += chunk.source;
            delete bundle[key];
          }
        }
      }
    }
  };
}

现在 styleCode 存储的就是构建后的所有 CSS 代码,因此我们需要一个能够实现创建 style 标签并将 styleCode 添加其中的自执行函数,然后把它插入到其中一个符合条件的 ChunkInfo.code 当中即可:

import type { Plugin } from 'vite';

function VitePluginStyleInject(): Plugin {
  let styleCode = '';

  return {
    name: 'vite-plugin-style-inject',
    apply: 'build', // 应用模式
    enforce: 'post', // 作用阶段
    generateBundle(_, bundle) {
      // 遍历bundle
      for (const key in bundle) {
        if (bundle[key]) {
          const chunk = bundle[key]; // 拿到文件名对应的值
          // 判断+提取+移除
          if (chunk.type === 'asset' && chunk.fileName.includes('.css')) {
            styleCode += chunk.source;
            delete bundle[key];
          }
        }
      }

      // + 重新遍历bundle,一次遍历无法同时实现提取注入,例如'style.css'是 bundle 的最后一个键
      for (const key in bundle) {
        if (bundle[key]) {
          const chunk = bundle[key];
          // 判断是否是 JS 文件名的chunk
          if (chunk.type === 'chunk' &&
            chunk.fileName.match(/.[cm]?js$/) !== null &&
            !chunk.fileName.includes('polyfill')
          ) {
            const initialCode = chunk.code; // 保存原有代码
            // 重新赋值
            chunk.code = '(function(){ try {var elementStyle = document.createElement(\'style\'); elementStyle.appendChild(document.createTextNode(';
            chunk.code += JSON.stringify(styleCode.trim());
            chunk.code += ')); ';
            chunk.code += 'document.head.appendChild(elementStyle);} catch(e) {console.error(\'vite-plugin-css-injected-by-js\', e);} })();';
            // 拼接原有代码
            chunk.code += initialCode;
            break; // 一个 bundle 插入一次即可
          }
        }
      }
    }
  };
}

最后,我们给这个 style 标签加上 id属性 以方便用户获取操作:

import type { Plugin } from 'vite';

// - function VitePluginStyleInject(): Plugin {
function VitePluginStyleInject(styleId: ''): Plugin {
  let styleCode = '';

  return {
    name: 'vite-plugin-style-inject',
    apply: 'build', // 应用模式
    enforce: 'post', // 作用阶段
    generateBundle(_, bundle) {
      // 遍历bundle
      for (const key in bundle) {
        if (bundle[key]) {
          const chunk = bundle[key]; // 拿到文件名对应的值
          // 判断+提取+移除
          if (chunk.type === 'asset' && chunk.fileName.includes('.css')) {
            styleCode += chunk.source;
            delete bundle[key];
          }
        }
      }

      // 重新遍历bundle,一次遍历无法同时实现提取注入,例如'style.css'是 bundle 的最后一个键
      for (const key in bundle) {
        if (bundle[key]) {
          const chunk = bundle[key];
          // 判断是否是 JS 文件名的chunk
          if (chunk.type === 'chunk' &&
            chunk.fileName.match(/.[cm]?js$/) !== null &&
            !chunk.fileName.includes('polyfill')
          ) {
            const initialCode = chunk.code; // 保存原有代码
            // 重新赋值
            chunk.code = '(function(){ try {var elementStyle = document.createElement(\'style\'); elementStyle.appendChild(document.createTextNode(';
            chunk.code += JSON.stringify(styleCode.trim());
            chunk.code += ')); ';
            // + 判断是否添加id
            if (styleId.length > 0)
              chunk.code += ` elementStyle.id = "${styleId}"; `;
            chunk.code += 'document.head.appendChild(elementStyle);} catch(e) {console.error(\'vite-plugin-css-injected-by-js\', e);} })();';
            // 拼接原有代码
            chunk.code += initialCode;
            break; // 一个 bundle 插入一次即可
          }
        }
      }
    }
  };
}

至此,这个插件就写好了,是不是很简单。

使用

在项目中使用该插件:

// vite.config.js
import { defineConfig } from 'vite';
import VitePluginStyleInject from 'vite-plugin-style-inject';

export default defineConfig({
  plugins: [VitePluginStyleInject()],
})

执行构建命令后,只输出两个文件:

Image.png

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

尾言

完成后回到该 issue 下厚着脸皮放上 项目地址 😁

Image.png