css 包含-实现一个Vite插件,在打包时将CSS注入JS

前言

Vite在2.0版本提供了库模式,允许开发者使用Vite构建自己的库进行发布和使用。 正好我打算打包一个React组件,发布为npm包,方便以后使用。 之前也体验过使用Vite带来的快速体验,所以就使用Vite进行开发。

背景

开发完成并打包后,有如图所示的三个文件:

style.css 文件包含组件的所有样式。 如果该文件单独出现,则说明使用时需要单独导入样式文件,就像使用组件库时需要在主文件中导入样式一样。

<code class="hljs language-typescript" lang="typescript">import xxxComponent from 'xxx-component';
import 'xxx-component/dist/xxx.css'; // 引入样式

但我封装的只是单个组件,并且没有太多只适用于这个组件的样式。 没有这么复杂的风格系统。

所以,最好配置一个完善的工具,在打包时将样式注入到JS文件中,这样就不需要再引入一行语句了。 我们知道Webpack打包可以配置为通过自执行函数在DOM上创建样式标签并向其中注入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'
        }
      }
    }
  }
})

首先需要开启选项,配置入口文件、文件名等基础配置,因为Vite生产模式下使用rollup进行打包,所以需要开启相关选项。 当我们的库是用Vue或者React编译的时候,使用的时候通常也是在这个环境中。 比如我的组件是基于React编译的,那么使用时无疑是在React中引入,这样会导致产品冗余,所以需要在外部配置中添加外部依赖。 包装时可以将其取下。 输出选项是当输出结果为umd格式时(具体格式见build.lib.formats选项,umd是Universal Module Definition,可以直接用script标签导入使用,所以需要一个全局变量提供)。

配置完上面的内容后,我又查找了与包装样式相关的内容,但没有找到。 。 。

没关系,我们也可以去仓库issues看看,说不定有人也发现了这个问题。 搜索的结果出乎意料,下面的评论竟然有47条:

点击进去后,提问者询问如何避免生成CSS文件。 您回复:样式注入的DOM环境会导致服务端渲染不兼容。 如果CSS代码不多,可以使用内联样式来解决。

这个答案似乎让很多人都不满意(这可能是该issue被关闭并重新打开的原因),因为带有样式的库在编译过程中几乎不会使用内联写入,而且提问者也回答说他无法使用了模块化的Less之后,还是希望能够给出更多的库模式选项,然后大家在下面各抒己见,但是还没有提出好的解决方案。

所以,为了解决我自己的问题,我决定写一个插件。

Vite插件API

Vite插件提供的API实际上是一些hook,这些hook被定义为Vite特有hook和通用hook(Rollup hooks,由Vite插件容器调用)。 这些钩子的执行顺序是:

Vite核心插件基本都是特有的钩子,主要用于配置分析,构建插件基本都是Rollup钩子,才是真正在构建中发挥作用的钩子,现在我们要获取构建好的CSS和JS 产品 并将它们合二为一,因此编译后的插件执行顺序应该在创建的插件执行之后执行,即“userplug-inwithenforce:'post'”阶段(输出阶段)。

打开Rollup官网,章节中有这样一张图:

根据上图我们可以看到输出阶段钩子的执行顺序和特点,而我们只需要得到输出产物进行拼接即可进行写入css 包含,所以我们要使用里面的generateBundle钩子。

完成

官方推荐的插件是鞋工厂函数,它返回实际的插件对象,允许用户传入配置选项作为参数来自定义插件的行为。

基本结构如下:

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

Vite 默认格式为 es 和 umd。 如果不更改配置,则会形成两个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参数,它是一个对象,其key由文件名字符串值AssetInfo或ChunkInfo组成,其中一节的内容如下:

从上图可以看出,CSS文件的值属于AssetInfo,我们首先遍历bundle找到CSS部分并提取源值:

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 代码,因此我们需要一个自执行函数,可以创建样式标签并向其中添加 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()],
})

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

引入打包的文件,发现可以正常运行css 包含,终于搞定了~

结语

完成后,回到issue,厚着脸皮贴上项目地址

最后我整理了一下,写下了这篇文章。 这是我第一次发表唱片。 感谢您的阅读。 如果您觉得有帮助,请点击它。