Передача опций в плагин через фабричную функцию

В экосистеме Vite плагины практически всегда подключаются через вызов функции. Вместо передачи готового объекта плагина используется фабричная функция, которая принимает пользовательские параметры и возвращает объект с хуками, настройками и логикой работы.

Базовая структура выглядит следующим образом:

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

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

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

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

Такой подход позволяет:

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

Почему используется именно фабричная функция

Плагин Vite представляет собой обычный JavaScript-объект. Однако объект сам по себе не умеет принимать параметры. Если экспортировать только объект:

export default {
  name: 'my-plugin'
}

то конфигурировать его извне будет невозможно.

Фабричная функция решает эту проблему:

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

Теперь параметры можно передавать при подключении.


Простейшая передача опций

Плагин

export default function loggerPlugin(options = {}) {
  const {
    prefix = '[LOG]'
  } = options

  return {
    name: 'logger-plugin',

    configureServer(server) {
      server.middlewares.use((req, res, next) => {
        console.log(prefix, req.url)
        next()
      })
    }
  }
}

Подключение

import loggerPlugin from './plugins/logger-plugin.js'

export default {
  plugins: [
    loggerPlugin({
      prefix: '[DEV SERVER]'
    })
  ]
}

Результат:

[DEV SERVER] /src/main.js

Значения по умолчанию

Один из важнейших аспектов фабричной функции — наличие безопасных значений по умолчанию.

Неправильный вариант:

function plugin(options) {
  console.log(options.enabled)
}

Если пользователь не передаст объект:

plugin()

возникнет ошибка:

Cannot read properties of undefined

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

function plugin(options = {}) {
  const {
    enabled = true
  } = options
}

Теперь плагин устойчив к отсутствию конфигурации.


Деструктуризация параметров

Чаще всего параметры извлекаются через деструктуризацию.

export default function bannerPlugin(options = {}) {
  const {
    banner = 'Default Banner',
    include = /\.js$/,
    enabled = true
  } = options

  return {
    name: 'banner-plugin'
  }
}

Преимущества:

  • компактность;
  • наглядность;
  • поддержка дефолтных значений;
  • упрощение дальнейшего кода.

Использование параметров внутри хуков

Опции фабрики доступны во всех внутренних хуках плагина благодаря замыканию.

export default function htmlPlugin(options = {}) {
  const {
    title = 'Vite App'
  } = options

  return {
    name: 'html-plugin',

    transformIndexHtml(html) {
      return html.replace(
        /<title>.*<\/title>/,
        `<title>${title}</title>`
      )
    }
  }
}

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

htmlPlugin({
  title: 'Admin Panel'
})

Результат:

<title>Admin Panel</title>

Передача путей

Очень часто фабричная функция принимает пути.

import path from 'node:path'

export default function assetsPlugin(options = {}) {
  const {
    outputDir = 'dist/assets'
  } = options

  return {
    name: 'assets-plugin',

    config(config) {
      config.build ??= {}
      config.build.assetsDir = outputDir
    }
  }
}

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

assetsPlugin({
  outputDir: 'static'
})

Булевы параметры

Булевы опции обычно управляют включением и отключением возможностей.

export default function debugPlugin(options = {}) {
  const {
    debug = false
  } = options

  return {
    name: 'debug-plugin',

    transform(code, id) {
      if (debug) {
        console.log(id)
      }

      return code
    }
  }
}

Передача регулярных выражений

Плагин может принимать фильтры файлов.

export default function filterPlugin(options = {}) {
  const {
    include = /\.js$/,
    exclude = /node_modules/
  } = options

  return {
    name: 'filter-plugin',

    transform(code, id) {
      if (!include.test(id)) {
        return
      }

      if (exclude.test(id)) {
        return
      }

      return code
    }
  }
}

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

filterPlugin({
  include: /\.(js|ts)$/
})

Передача массивов

Массивы часто используются для списков расширений, директорий или разрешённых модулей.

export default function extensionsPlugin(options = {}) {
  const {
    extensions = ['.js']
  } = options

  return {
    name: 'extensions-plugin',

    resolveId(source) {
      for (const ext of extensions) {
        console.log(ext)
      }

      return null
    }
  }
}

Передача callback-функций

Фабричная функция может принимать пользовательские обработчики.

export default function hookPlugin(options = {}) {
  const {
    onTransform
  } = options

  return {
    name: 'hook-plugin',

    transform(code, id) {
      if (onTransform) {
        onTransform(code, id)
      }

      return code
    }
  }
}

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

hookPlugin({
  onTransform(code, id) {
    console.log('Transform:', id)
  }
})

Глубокая конфигурация

В сложных плагинах используется вложенная структура параметров.

export default function apiPlugin(options = {}) {
  const {
    server = {},
    auth = {}
  } = options

  const {
    host = 'localhost',
    port = 3000
  } = server

  const {
    enabled = false,
    token = ''
  } = auth

  return {
    name: 'api-plugin'
  }
}

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

apiPlugin({
  server: {
    host: '127.0.0.1',
    port: 8080
  },

  auth: {
    enabled: true,
    token: 'secret'
  }
})

Валидация параметров

Хороший плагин проверяет входные параметры.

Простая ручная проверка

export default function portPlugin(options = {}) {
  const {
    port = 3000
  } = options

  if (typeof port !== 'number') {
    throw new Error(
      'Option "port" must be a number'
    )
  }

  return {
    name: 'port-plugin'
  }
}

Проверка обязательных параметров

Иногда часть конфигурации обязательна.

export default function tokenPlugin(options = {}) {
  const {
    token
  } = options

  if (!token) {
    throw new Error(
      'Token is required'
    )
  }

  return {
    name: 'token-plugin'
  }
}

Объединение пользовательских параметров

Один из самых популярных подходов — merge-конфигурация.

const defaultOptions = {
  enabled: true,
  include: /\.js$/,
  debug: false
}

export default function plugin(userOptions = {}) {
  const options = {
    ...defaultOptions,
    ...userOptions
  }

  return {
    name: 'merge-plugin'
  }
}

Проблема поверхностного merge

Оператор spread работает только на первом уровне.

const defaults = {
  server: {
    host: 'localhost',
    port: 3000
  }
}

const user = {
  server: {
    port: 8080
  }
}

Результат:

{
  server: {
    port: 8080
  }
}

Поле host исчезнет.


Глубокое объединение

Для сложной конфигурации нужен deep merge.

function deepMerge(target, source) {
  const result = { ...target }

  for (const key in source) {
    const sourceValue = source[key]
    const targetValue = target[key]

    if (
      typeof sourceValue === 'object' &&
      sourceValue !== null &&
      !Array.isArray(sourceValue)
    ) {
      result[key] = deepMerge(
        targetValue || {},
        sourceValue
      )
    } else {
      result[key] = sourceValue
    }
  }

  return result
}

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

const options = deepMerge(
  defaults,
  userOptions
)

Конфигурация через режимы

Фабрика может изменять поведение в зависимости от режима сборки.

export default function modePlugin(options = {}) {
  return {
    name: 'mode-plugin',

    config(config, env) {
      if (env.mode === 'production') {
        console.log('Production mode')
      }

      if (env.mode === 'development') {
        console.log('Development mode')
      }
    }
  }
}

Условное включение логики

Опции удобно использовать для активации отдельных возможностей.

export default function featurePlugin(options = {}) {
  const {
    minify = false,
    removeComments = false
  } = options

  return {
    name: 'feature-plugin',

    transform(code) {
      let result = code

      if (removeComments) {
        result = result.replace(
          /\/\*[\s\S]*?\*\//g,
          ''
        )
      }

      if (minify) {
        result = result.replace(/\s+/g, ' ')
      }

      return result
    }
  }
}

Возврат нескольких плагинов из фабрики

Фабричная функция может возвращать массив.

export default function fullPlugin(options = {}) {
  return [
    {
      name: 'plugin-a'
    },

    {
      name: 'plugin-b'
    }
  ]
}

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

plugins: [
  ...fullPlugin()
]

Создание пресетов

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

function createPreset(type) {
  switch (type) {
    case 'development':
      return {
        debug: true,
        minify: false
      }

    case 'production':
      return {
        debug: false,
        minify: true
      }
  }
}

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

plugin(createPreset('production'))

Асинхронная фабричная функция

Vite поддерживает асинхронные плагины.

export default async function plugin(options = {}) {
  const data = await loadConfig()

  return {
    name: 'async-plugin'
  }
}

Использование TypeScript для типизации параметров

В TypeScript фабричная функция особенно удобна.

interface PluginOptions {
  debug?: boolean
  include?: RegExp
  port?: number
}

export default function plugin(
  options: PluginOptions = {}
) {
  return {
    name: 'typed-plugin'
  }
}

Generic-типизация

Для сложных решений применяются generic-типы.

interface BaseOptions<T> {
  data: T
}

function plugin<T>(
  options: BaseOptions<T>
) {
  return {
    name: 'generic-plugin'
  }
}

Поддержка IntelliSense

Типизированная фабрика обеспечивает:

  • автодополнение;
  • проверку типов;
  • предупреждения IDE;
  • удобство использования;
  • документирование API плагина.

Документирование параметров

Даже при наличии TypeScript параметры рекомендуется документировать.

interface PluginOptions {
  /**
   * Включает режим отладки
   */
  debug?: boolean

  /**
   * Регулярное выражение фильтрации
   */
  include?: RegExp
}

Паттерн normalizeOptions

Во многих зрелых плагинах используется отдельная нормализация параметров.

function normalizeOptions(options = {}) {
  return {
    debug: options.debug ?? false,
    include: options.include ?? /\.js$/,
    exclude: options.exclude ?? /node_modules/
  }
}

export default function plugin(userOptions) {
  const options = normalizeOptions(userOptions)

  return {
    name: 'normalized-plugin'
  }
}

Преимущества:

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

Замораживание конфигурации

Иногда конфигурацию делают неизменяемой.

const options = Object.freeze({
  ...defaults,
  ...userOptions
})

Это предотвращает случайное изменение параметров внутри хуков.


Ленивые вычисления

Фабрика может вычислять параметры только при необходимости.

export default function plugin(options = {}) {
  let cache

  function getCache() {
    if (!cache) {
      cache = createCache(options)
    }

    return cache
  }

  return {
    name: 'lazy-plugin',

    buildStart() {
      getCache()
    }
  }
}

Распространённые ошибки

Мутация пользовательского объекта

Плохо:

options.debug = true

Пользовательский объект нельзя изменять напрямую.

Правильно:

const normalized = {
  ...options,
  debug: true
}

Отсутствие дефолтных значений

Плохо:

function plugin(options) {

}

Правильно:

function plugin(options = {}) {

}

Слишком много параметров верхнего уровня

Плохо:

plugin({
  a: true,
  b: true,
  c: true,
  d: true,
  e: true
})

Лучше группировать параметры:

plugin({
  build: {
    minify: true
  },

  debug: {
    enabled: true
  }
})

Архитектура зрелых плагинов

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

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

Пример структуры:

plugins/
└── my-plugin/
    ├── index.js
    ├── options.js
    ├── validator.js
    ├── hooks/
    └── utils/

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

function normalizeOptions(options = {}) {
  return {
    enabled: options.enabled ?? true,
    debug: options.debug ?? false,
    include: options.include ?? /\.js$/
  }
}

export default function customPlugin(userOptions = {}) {
  const options = normalizeOptions(userOptions)

  if (!(options.include instanceof RegExp)) {
    throw new Error(
      '"include" must be RegExp'
    )
  }

  return {
    name: 'custom-plugin',

    transform(code, id) {
      if (!options.enabled) {
        return code
      }

      if (!options.include.test(id)) {
        return code
      }

      if (options.debug) {
        console.log('Transform:', id)
      }

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

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

customPlugin({
  debug: true,
  include: /\.(js|ts)$/
})

Такой подход обеспечивает:

  • масштабируемость;
  • предсказуемость;
  • удобную поддержку;
  • расширяемость API;
  • повторное использование логики;
  • устойчивость к ошибкам конфигурации;
  • удобную интеграцию с TypeScript и IDE.