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

前言

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

背景

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

其中的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标签引入使用,所以需要提供一个全局变量)。

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

没关系,我们还可以去仓库issues看看,说不定有人也发现了这个问题。搜索后果不其然,底下竟有高达47条评论:

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

这个回答显然不能让很多人满意(这可能是该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官网,里面的输出钩子章节有这么一张图:

根据上图可以看到输出阶段钩子的执行顺序及其特性,而我们只需要在写入之前拿到输出的产物进行拼接,因此就得用到上面的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组成的对象,其中一段的内容如下:

上图看出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()],
})

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

Image5.png

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

尾言

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

最后整理了下写了这篇文章,这是我第一次将记录发表成文,感谢您的阅读,觉得有帮助的话就点个👍吧。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容