Написание плагина с нуля

Система плагинов в Vite построена поверх плагинов Rollup, но при этом расширяет их собственными возможностями. Один и тот же плагин может участвовать:

  • в dev-сервере;
  • в обработке модулей;
  • в HMR;
  • в production-сборке;
  • в трансформации HTML;
  • в обработке виртуальных модулей;
  • в SSR.

Плагин представляет собой объект с набором хуков.

Минимальная структура:

export default function myPlugin() {
    return {
        name: 'my-plugin'
    }
}

Поле name обязательно. Оно используется:

  • в логах;
  • при отладке;
  • в диагностике ошибок;
  • для определения порядка выполнения.

Подключение плагина:

import { defineConfig } from 'vite'
import myPlugin from './plugins/my-plugin.js'

export default defineConfig({
    plugins: [
        myPlugin()
    ]
})

Базовая структура проекта плагина

Типичная структура:

project/
├─ plugins/
│  └─ my-plugin/
│     ├─ index.js
│     ├─ utils.js
│     └─ constants.js
├─ vite.config.js
└─ src/

Для сложных решений структура часто разделяется по хукам:

my-plugin/
├─ hooks/
│  ├─ resolveId.js
│  ├─ load.js
│  ├─ transform.js
│  └─ configureServer.js
├─ utils/
├─ index.js
└─ package.json

Функция-фабрика плагина

Практически все плагины Vite реализуются через фабрику:

export default function myPlugin(options = {}) {
    return {
        name: 'my-plugin'
    }
}

Это позволяет:

  • принимать конфигурацию;
  • создавать изолированное состояние;
  • генерировать несколько экземпляров плагина;
  • хранить внутренние кэши.

Пример:

export default function bannerPlugin(options = {}) {
    const banner = options.banner || 'DEV'

    return {
        name: 'banner-plugin'
    }
}

Подключение:

bannerPlugin({
    banner: 'PROJECT'
})

Хук transform

transform — основной хук обработки модулей.

Он вызывается для каждого файла.

export default function myPlugin() {
    return {
        name: 'my-plugin',

        transform(code, id) {
            console.log(id)

            return code
        }
    }
}

Аргументы:

Аргумент Описание
code Исходный код модуля
id Полный путь файла

Изменение кода

Простейшая модификация:

transform(code, id) {
    if (!id.endsWith('.js')) {
        return
    }

    return code.replace(
        '__VERSION__',
        '1.0.0'
    )
}

Исходный код:

console.log(__VERSION__)

После трансформации:

console.log('1.0.0')

Возврат объекта трансформации

transform может возвращать объект:

transform(code) {
    return {
        code: code.replace('foo', 'bar'),
        map: null
    }
}

Поля:

Поле Назначение
code Новый код
map Source map

Использование MagicString

Для корректных source map обычно применяется библиотека magic-string.

Установка:

npm install magic-string

Пример:

import MagicString from 'magic-string'

export default function myPlugin() {
    return {
        name: 'my-plugin',

        transform(code, id) {
            if (!id.endsWith('.js')) {
                return
            }

            const s = new MagicString(code)

            s.prepend('const __DEV__ = true;\n')

            return {
                code: s.toString(),
                map: s.generateMap({
                    hires: true
                })
            }
        }
    }
}

Хук resolveId

resolveId управляет механизмом резолвинга импортов.

resolveId(source, importer) {
    console.log(source)
    console.log(importer)
}

Аргументы:

Аргумент Описание
source Импортируемый путь
importer Модуль-источник

Создание виртуального модуля

Одна из самых популярных задач плагинов.

const virtualModuleId = 'virtual:config'
const resolvedVirtualModuleId = '\0' + virtualModuleId
export default function virtualPlugin() {
    return {
        name: 'virtual-plugin',

        resolveId(id) {
            if (id === virtualModuleId) {
                return resolvedVirtualModuleId
            }
        },

        load(id) {
            if (id === resolvedVirtualModuleId) {
                return `
                    export const API_URL = 'https://api.test.com'
                `
            }
        }
    }
}

Использование:

import { API_URL } from 'virtual:config'

Почему используется \0

Префикс \0 помечает модуль как виртуальный внутренний модуль.

Это предотвращает:

  • попытки поиска файла на диске;
  • конфликты с обычными путями;
  • повторный резолвинг.

Хук load

load отвечает за загрузку содержимого модуля.

load(id) {
    if (id.endsWith('.txt')) {
        return 'export default "TEXT"'
    }
}

Этот хук особенно полезен:

  • для виртуальных модулей;
  • генерации кода;
  • работы с нестандартными форматами;
  • импорта данных.

Обработка собственных расширений

Пример поддержки .hello:

load(id) {
    if (id.endsWith('.hello')) {
        return `
            export default "Hello World"
        `
    }
}

Импорт:

import message from './test.hello'

Хук configureServer

Позволяет получить доступ к dev-серверу Vite.

configureServer(server) {
    console.log(server)
}

Через сервер доступны:

  • middleware;
  • websocket;
  • watcher;
  • module graph;
  • HTTP-сервер.

Добавление middleware

configureServer(server) {
    server.middlewares.use((req, res, next) => {
        if (req.url === '/custom') {
            res.setHeader('Content-Type', 'application/json')

            res.end(JSON.stringify({
                ok: true
            }))

            return
        }

        next()
    })
}

После этого появляется новый endpoint:

/custom

Работа с WebSocket

Vite использует websocket для HMR.

Плагин может отправлять собственные события:

configureServer(server) {
    server.ws.send({
        type: 'custom',
        event: 'my:event',
        data: {
            message: 'upd ated'
        }
    })
}

Клиент:

if (import.meta.hot) {
    import.meta.hot.on('my:event', (data) => {
        console.log(data)
    })
}

Хук handleHotUpdate

Используется для контроля HMR.

handleHotUpdate(ctx) {
    console.log(ctx.file)
}

Контекст содержит:

Поле Описание
file Изменённый файл
server Dev server
modules Связанные модули
timestamp Время обновления

Принудительный full reload

handleHotUpdate(ctx) {
    if (ctx.file.endsWith('.config.json')) {
        ctx.server.ws.send({
            type: 'full-reload'
        })

        return []
    }
}

Хук transformIndexHtml

Позволяет модифицировать HTML.

transformIndexHtml(html) {
    return html.replace(
        '</head>',
        '<script src="/inject.js"></script></head>'
    )
}

Инъекция HTML-тегов

Vite поддерживает декларативный API:

transformIndexHtml() {
    return [
        {
            tag: 'script',
            attrs: {
                src: '/inject.js'
            },
            injectTo: 'head'
        }
    ]
}

injectTo

Варианты вставки:

Значение Место вставки
head В конец <head>
head-prepend В начало <head>
body В конец <body>
body-prepend В начало <body>

Хук config

Позволяет изменять конфигурацию Vite.

config(config) {
    config.server ||= {}
    config.server.port = 5000
}

Возврат частичной конфигурации

Предпочтительный способ:

config() {
    return {
        define: {
            __DEV__: true
        }
    }
}

Хук configResolved

Вызывается после полной обработки конфигурации.

let resolvedConfig

export default function myPlugin() {
    return {
        name: 'my-plugin',

        configResolved(config) {
            resolvedConfig = config
        }
    }
}

Через него можно получить:

  • mode;
  • command;
  • root;
  • plugins;
  • resolve;
  • build;
  • env.

Разделение dev и build

let isBuild = false

configResolved(config) {
    isBuild = config.command === 'build'
}

Выполнение логики только в production

transform(code) {
    if (!isBuild) {
        return
    }

    return code.replace(
        '__BUILD__',
        'true'
    )
}

enforce

Управляет порядком выполнения.

{
    name: 'my-plugin',
    enforce: 'pre'
}

Варианты:

Значение Назначение
pre До обычных плагинов
post После обычных

apply

Позволяет ограничивать область работы плагина.

{
    name: 'my-plugin',
    apply: 'build'
}

Варианты:

Значение Описание
serve Только dev
build Только build

Функциональный apply

apply(config, env) {
    return env.mode === 'production'
}

Фильтрация файлов

Критически важный аспект производительности.

Плохой вариант:

transform(code) {
    return code.replace(...)
}

Правильный вариант:

transform(code, id) {
    if (!id.endsWith('.js')) {
        return
    }

    return code.replace(...)
}

Исключение node_modules

if (id.includes('node_modules')) {
    return
}

Использование createFilter

Обычно применяется @rollup/pluginutils.

npm install @rollup/pluginutils
import { createFilter } from '@rollup/pluginutils'

const filter = createFilter(
    ['**/*.js'],
    ['node_modules/**']
)
transform(code, id) {
    if (!filter(id)) {
        return
    }

    return code.replace(...)
}

Хранение состояния

Плагины могут хранить внутреннее состояние:

export default function myPlugin() {
    const cache = new Map()

    return {
        name: 'my-plugin',

        transform(code, id) {
            cache.se t(id, code)
        }
    }
}

Очистка состояния

Для этого существует buildStart.

buildStart() {
    cache.clear()
}

Генерация файлов

Во время build доступны хуки Rollup.

Пример:

generateBundle() {
    console.log('bundle generated')
}

Добавление файлов в сборку

generateBundle(options, bundle) {
    this.emitFile({
        type: 'asset',
        fileName: 'meta.json',
        source: JSON.stringify({
            build: Date.now()
        })
    })
}

Создание chunk

this.emitFile({
    type: 'chunk',
    id: '/src/extra.js'
})

Работа с AST

Vite поддерживает полноценную AST-трансформацию.

Пример через Babel:

npm install @babel/parser @babel/traverse @babel/generator
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import generate from '@babel/generator'

transform(code) {
    const ast = parse(code, {
        sourceType: 'module'
    })

    traverse(ast, {
        Identifier(path) {
            if (path.node.name === '__DEV__') {
                path.node.name = 'true'
            }
        }
    })

    return generate(ast).code
}

Асинхронные хуки

Большинство хуков могут быть async.

async transform(code, id) {
    const result = await processCode(code)

    return result
}

Логирование

Внутри хуков доступен контекст Rollup.

this.warn('warning')
this.error('fatal error')

Ошибки с позициями

this.error({
    message: 'Invalid syntax',
    id,
    pos: 15
})

Debug-режим

Удобно использовать namespace:

import debug from 'debug'

const log = debug('vite:my-plugin')
log('transform', id)

Запуск:

DEBUG=vite:* vite

SSR-режим

Некоторые плагины должны учитывать SSR.

transform(code, id, options) {
    console.log(options?.ssr)
}

Разделение client и SSR

if (options?.ssr) {
    return code
}

Совместимость с Rollup

Большинство Rollup-плагинов работают в Vite без изменений:

import replace from '@rollup/plugin-replace'
plugins: [
    replace({
        __DEV__: true
    })
]

Но Vite-специфичные возможности:

  • HMR;
  • dev server;
  • transformIndexHtml;
  • configureServer;
  • import analysis;
  • pre-bundling.

В Rollup отсутствуют.


Создание production-ready плагина

Полноценный пример:

import MagicString from 'magic-string'
import { createFilter } from '@rollup/pluginutils'

export default function replacePlugin(options = {}) {
    const filter = createFilter(
        options.include || ['**/*.js'],
        options.exclude || ['node_modules/**']
    )

    const values = options.values || {}

    return {
        name: 'replace-plugin',

        transform(code, id) {
            if (!filter(id)) {
                return
            }

            let changed = false

            const s = new MagicString(code)

            for (const [key, value] of Object.entries(values)) {
                if (!code.includes(key)) {
                    continue
                }

                changed = true

                const regexp = new RegExp(key, 'g')

                let match

                while ((match = regexp.exec(code))) {
                    s.overwrite(
                        match.index,
                        match.index + key.length,
                        JSON.stringify(value)
                    )
                }
            }

            if (!changed) {
                return
            }

            return {
                code: s.toString(),
                map: s.generateMap({
                    hires: true
                })
            }
        }
    }
}

Использование:

replacePlugin({
    values: {
        __API_URL__: 'https://api.test.com',
        __VERSION__: '2.0.0'
    }
})

Тестирование плагинов

Плагины обычно тестируются через:

  • Vitest;
  • Vite Node API;
  • snapshot testing;
  • integration testing.

Пример Vitest:

import { describe, it, expect } from 'vitest'
import plugin from '../index.js'

describe('plugin', () => {
    it('creates plugin', () => {
        const result = plugin()

        expect(result.name).toBe('my-plugin')
    })
})

Публикация npm-плагина

Минимальный package.json:

{
    "name": "vite-plugin-example",
    "version": "1.0.0",
    "main": "./dist/index.js",
    "type": "module",
    "peerDependencies": {
        "vite": "^5.0.0"
    }
}

Рекомендации по разработке

Избегать тяжёлых transform-операций

transform вызывается очень часто.

Дорогостоящие операции:

  • AST-парсинг;
  • чтение файлов;
  • сетевые запросы;
  • сложные regex;
  • синхронный filesystem API.

могут серьёзно замедлить HMR.


Минимизировать количество обрабатываемых файлов

Чем уже фильтр — тем быстрее работает dev-сервер.

Хорошо:

createFilter(['src/**/*.js'])

Плохо:

createFilter(['**/*'])

Не хранить бесконечные кэши

const cache = new Map()

без очистки способен вызывать утечки памяти в больших проектах.


Не модифицировать код через naive replace

Плохо:

code.replace('foo', 'bar')

Это может случайно изменить:

  • строки;
  • комментарии;
  • имена свойств;
  • часть идентификатора.

Для серьёзных трансформаций предпочтительны:

  • AST;
  • MagicString;
  • parser-based подходы.

Не блокировать event loop

Плохо:

while (true) {}

или:

fs.readFileSync(...)

внутри transform.

Dev-сервер Vite чрезвычайно чувствителен к блокировкам event loop.


Типизация плагинов через TypeScript

import type { Plugin } from 'vite'

export default function myPlugin(): Plugin {
    return {
        name: 'my-plugin'
    }
}

Пользовательские типы опций

interface PluginOptions {
    enabled?: boolean
    apiUrl?: string
}
export default function myPlugin(
    options: PluginOptions = {}
): Plugin {
    return {
        name: 'my-plugin'
    }
}

Комбинирование нескольких плагинов

Плагин может возвращать массив:

export default function plugins() {
    return [
        pluginA(),
        pluginB()
    ]
}

Это часто используется для создания meta-plugin архитектуры.