博客 / 詳情

返回

實現一個打包時將CSS注入到JS的Vite插件

前言

Vite 在2.0版本提供了Library Mode(庫模式),讓開發者可以使用Vite來構建自己的庫以發佈使用。正好我準備封裝一個React組件並將其發佈為npm包以供日後方便使用,同時之前也體驗到了使用Vite帶來的快速體驗,於是便使用Vite進行開發。

背景

在開發完成後進行打包,出現瞭如圖三個文件:

image

其中的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標籤引入使用,所以需要提供一個全局變量)。

配置完上述提及到的後,我接着尋找與打包樣式相關的內容,然而並沒有發現。。。

image

沒關係,我們還可以去倉庫issues看看,説不定有人也發現了這個問題。搜索後果不其然,底下竟有高達47條評論:

image

點進去後,提問者問到如何才能不生成CSS文件,尤回答説:進行樣式注入的DOM環境會產生服務端渲染的不兼容問題,如果CSS代碼不多,使用行內樣式進行解決。

image

這個回答顯然不能讓很多人滿意(這可能是該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

根據上圖可以看到輸出階段鈎子的執行順序及其特性,而我們只需要在寫入之前拿到輸出的產物進行拼接,因此就得用到上面的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

上圖看出CSS文件的值屬於AssetInfo,所以我們需要把它提取出來:

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

引入打包後的文件發現其能正常運行,終於搞定啦~

尾言

完成後回到該issue下厚着臉皮放上項目地址 😁

image

最後整理了下寫了這篇文章,這是我第一次將記錄發表成文,感謝您的閲讀,覺得有幫助的話就點個👍吧。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.