Сборщик Vite предоставляет не только хороший функционал, но и удобный API для создания плагинов, позволяющих кастомизировать его практически под любую задачу. То есть, плагины можно писать не только для публикации их в npmjs.com репозитории, но и для автоматизации исключительно своих задач.

Сложность написания плагина сравнима со сложностью написания сценария для Gulp или GitHub Actions. Для примера напишем плагин, который будет вставлять фрагменты кода в файл index.html. В зависимости от проекта в данный файл необходимо помещать код Google Analytics, метатэги Open Graph и Twitter, подключение Service worker-a, виджета чата поддержки, сплэш скрин и многое другое. В результате index.html становится очень большим и ориентироваться в нем и блоках кода довольно сложно.

Наш плагин позволить держать фрагменты кода в отдельных файлах, а при сборке все будет помещаться в index.html. Причем это будет происходить не только при непосредственно сборке ( npm build ), но и при запуске Vite dev сервера с поддержкой HRM (Hot Module Replacement).

Репозиторий плагина - https://github.com/altrusl/vite-plugin-html-injection
NPM - https://www.npmjs.com/package/vite-plugin-html-injection

Система плагинов Vite

Плагины Vite являются расширением плагинов сборщика Rollup, который используется для сборки Vite проекта под капотом. За небольшим исключением плагины Rollup работают в Vite, в то же время последний добавляет несколько хуков в API для плагинов, которые мы и будет использовать.

Написание плагина по большому счету является написанием кода для данных хуков. Мы задействуем два из них.

vite-plugin-html-injection

Первый используемый хук - configResolved. Он нужен для того, чтобы получить конфиг Vite, из которого мы позже получим абсолютный путь к директории проекта - config.root

Второй хук - transformIndexHtml. В нем непосредственно нужно произвести изменение содержимого index.html. Аргументом мы получим строку с оригинальным содержимым index.html, вернуть надо модифицированный контент.

import { Plugin, ResolvedConfig } from "vite";
import path from "path";
import fs from "fs";
import { IHtmlInjectionConfig, IHtmlInjectionConfigInjection } from "./types";

export function htmlInjectionPlugin(
  htmlInjectionConfig: IHtmlInjectionConfig
): Plugin {
  let config: undefined | ResolvedConfig;

  return {
    name: "html-injection",

    configResolved(resolvedConfig) {
      config = resolvedConfig;
    },

    transformIndexHtml(html: string) {
      let out = html;
      for (let i = 0; i < htmlInjectionConfig.injections.length; i++) {
        const injection: IHtmlInjectionConfigInjection =
          htmlInjectionConfig.injections[i];

        let root = (config as ResolvedConfig).root;
        const filePath = path.resolve(root, injection.path);
        let data = fs.readFileSync(filePath, "utf8");
        if (injection.type === "js") {
          data = `<script>\n${data}\n</script>\n`;
        } else if (injection.type === "css") {
          data = `<style>\n${data}\n</style>\n`;
        }
        switch (injection.injectTo) {
          case "head":
            out = out.replace("</head>", `${data}\n</head>`);
            break;
          case "head-prepend":
            out = out.replace(/<head(.*)>/i, `$&\n${data}`);
            break;
          case "body":
            out = out.replace("</body>", `${data}\n</body>`);
            break;
          case "body-prepend":
            out = out.replace(/<body(.*)>/i, `$&\n${data}`);
            break;

          default:
            break;
        }
      }

      return out;
    },
  };
}

В массиве htmlInjectionConfig.injections содержатся описания вставляемых фрагментов кода - конфигурация плагина, передаваемая ему как аргумент в vite.config.js проекта, использующего наш плагин.

// vite.config.js
// example for Vue project

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { htmlInjectionPlugin } from "vite-plugin-html-injection";

export default defineConfig({
  plugins: [
    vue(),
    htmlInjectionPlugin({
      // Example configuration. Can be stored in a separate json file.
      injections: [
        {
          name: "Open Graph",
          path: "./src/injections/open-graph.html",
          type: "raw",
          injectTo: "head",
        },
        {
          name: "Splash screen",
          path: "./src/injections/splash-screen.html",
          type: "raw",
          injectTo: "body-prepend",
        },
        {
          name: "Service worker",
          path: "./src/injections/sw.js",
          type: "js",
          injectTo: "head",
        },
      ],
    }),
  ]
});

В случае написания "локального" плагина для себя, вместо добавления его в devDependencies и импорта из node_modules:

import { htmlInjectionPlugin } from "vite-plugin-html-injection";

можно импортить его локально:

import { htmlInjectionPlugin } from "./src/plugins/vite-plugin-html-injection";

Типы вставляемых фрагментов кода

Существует три типа фрагментов кода, с которыми работает плагин — raw, js и css.

Raw фрагменты вставляются как есть, js и css оборачиваются в теги <script> и <style> соответственно.

Также есть четыре места, куда можно вставить фрагмент кода: начало и конец тега head и начало и конец body. Соответствующие значения свойства injectTo: head-prepend, head, body-prepend и body.

Файл ./src/injections/ga.html может выглядеть как-то так:

<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-8W4X32XXXX" />
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag() {
    dataLayer.push(arguments);
  }
  gtag("js", new Date());

  gtag("config", "G-8W4X32XXXX");
</script>

Сборка плагина как пакета

В коде плагина мы используем библиотеки fs и path, которые помещать с сборку плагина не нужно, потому что они будут предоставлены самым Vite во время выполнения плагина. В vite.config.js проекта плагина это нужно указать явно.

// vite.config.js

import { defineConfig } from "vite";
import { resolve } from "path";

export default defineConfig({
  plugins: [],
  build: {
    lib: {
      entry: resolve(__dirname, "./index.ts"),
      name: "HtmlInjection",
      fileName: "index",
      formats: ["es", "cjs"],
    },
    rollupOptions: {
      external: ["fs", "path"],
    },
  },
});

В итоге размер плагина получается меньше 1КБ.

Ну и package.json плагина:

{
  "name": "vite-plugin-html-injection",
  "version": "1.1.9",
  "description": "Vite plugin for injecting html, js, css code snippets into index.html",
  "license": "MIT",
  "homepage": "https://github.com/altrusl/vite-plugin-html-injection",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  },
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "vite build && copy types.d.ts dist\\index.d.ts"
  },
  "devDependencies": {
    "@antfu/eslint-config": "^0.39.8",
    "@types/node": "^20.4.5",
    "eslint": "^8.46.0",
    "typescript": "^5.1.6",
    "vite": "^4.4.7"
  },
  "peerDependencies": {
    "vite": ">= 2.0.0"
  }
}

После сборки плагина публикуем его на npmjs.com:

npm publish --access public

Комментарии (5)


  1. Spaceoddity
    10.08.2023 14:05
    -1

    Слишком сложно для индустрии, где через пару лет появится новый сборщик (ну или Хромиум научится сам собирать) ;)


  1. dom1n1k
    10.08.2023 14:05

    Я правильно понимаю, что хук получает содержимое index.htmlв виде сплошной строки, которую предлагается вручную расковыривать регулярками? То есть какого-то осмысленного разбора HTML или, скажем, модификации узлов по селекторам не предусмотрено. Как-то эта апишка ни о чём.


    1. gmtd Автор
      10.08.2023 14:05

      Хороший вопрос

      На вход хуку поступает действительно всего лишь строка и разбора её средствами Vite не предусмотрено и не может быть, потому как данный разбор, скажем так, не может быть универсальным/"стандартным" (нет стандартной библиотека парсинга HTML, непонятно как парсить невалидный html и т.п.). Поэтому "эта апишка" не про парсинг HTML. Подключить свою библиотеку и отпарсить самому при желании - 5 строк кода

      Однако возвращать хук может намного больше, чем строку. Вот сигнатура хука:

      IndexHtmlTransformHook
      type IndexHtmlTransformHook = (
        html: string,
        ctx: {
          path: string
          filename: string
          server?: ViteDevServer
          bundle?: import('rollup').OutputBundle
          chunk?: import('rollup').OutputChunk
        },
      ) =>
        | IndexHtmlTransformResult
        | void
        | Promise<IndexHtmlTransformResult | void>
      
      type IndexHtmlTransformResult =
        | string
        | HtmlTagDescriptor[]
        | {
            html: string
            tags: HtmlTagDescriptor[]
          }
      
      interface HtmlTagDescriptor {
        tag: string
        attrs?: Record<string, string | boolean>
        children?: string | HtmlTagDescriptor[]
        /**
         * default: 'head-prepend'
         */
        injectTo?: 'head' | 'body' | 'head-prepend' | 'body-prepend'
      }

      Другими словам, можно вернуть массив объектов HtmlTagDescriptor, каждый из которых говорит какой тэг, с каким содержимым и куда надо вставить, что может быть иногда удобней. В моем случае это не подходит, так как фрагмент кода может быть чем угодно, в частности, массивом тэгов - как в случае с GA фрагментом, и пришлось бы его парсить на тэги, чтобы добавить.


      1. dom1n1k
        10.08.2023 14:05

        Я просто подумал поначалу, что продукт такой важности и известности, вероятно, поступает по аналогии с PostCSS. Там система делает парсинг/токенизацию стилей — плагин получает всё разобранное по полочкам, ему остается сосредоточиться на смысловой части. Ну нет, значит нет.


  1. RegIon
    10.08.2023 14:05

    Очень странно, что хук не получает AST, как например это в бабеле или вебпаке, или в ТС