Создание плагинов

Библиотека Slim Select не содержит полноценной официальной системы плагинов в стиле крупных UI-фреймворков, однако её внутренняя архитектура позволяет создавать собственные расширения, надстройки и интеграционные модули. Под «плагином» в контексте Slim Select обычно понимаются:

  • расширения поведения;
  • интеграции с API;
  • дополнительные механизмы поиска;
  • кастомные рендереры;
  • обработчики событий;
  • модули асинхронной загрузки;
  • системы валидации;
  • адаптеры для фреймворков;
  • надстройки поверх существующего экземпляра Slim Select.

Плагин строится вокруг экземпляра SlimSelect и использует:

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

Базовая структура экземпляра Slim Select

После инициализации создаётся объект:

const slim = new SlimSelect({
  select: '#users'
})

Экземпляр содержит:

slim.setData(...)
slim.getData()
slim.setSelected()
slim.getSelected()
slim.open()
slim.close()
slim.search()

Именно вокруг этих методов обычно строится плагин.


Подходы к созданию плагинов

Функциональный подход

Самый простой вариант — функция, принимающая экземпляр Slim Select.

function createLoggerPlugin(instance) {
  instance.select.onValueCha nge = (value) => {
    console.log('Новое значение:', value)
  }
}

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

const slim = new SlimSelect({
  select: '#categories'
})

createLoggerPlugin(slim)

Такой подход подходит для:

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

Объектный подход

Более масштабные плагины оформляются в виде класса.

class AutoSavePlugin {
  constructor(instance, options = {}) {
    this.instance = instance
    this.options = options

    this.init()
  }

  init() {
    this.instance.select.onValueCha nge = (value) => {
      this.save(value)
    }
  }

  save(value) {
    localStorage.setItem(
      this.options.key,
      JSON.stringify(value)
    )
  }
}

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

const slim = new SlimSelect({
  select: '#tags'
})

new AutoSavePlugin(slim, {
  key: 'selected-tags'
})

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

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

Архитектура типичного плагина

Большинство расширений строятся по одинаковой схеме.

Этапы инициализации

1. Получение экземпляра

constructor(instance) {
  this.instance = instance
}

2. Проверка API

if (!instance.setData) {
  throw new Error('Slim Select API недоступен')
}

3. Подписка на события

instance.select.onValueCha nge = this.handleChange.bind(this)

4. Создание внутреннего состояния

this.state = {
  loading: false,
  cache: []
}

5. Очистка ресурсов

destroy() {
  clearTimeout(this.timer)
}

Работа с внутренними событиями

onChange

Главное событие выбора.

const slim = new SlimSelect({
  select: '#countries',

  events: {
    afterChange(newVal) {
      console.log(newVal)
    }
  }
})

Плагин может переопределять обработчики:

class ChangeTracker {
  constructor(instance) {
    this.instance = instance

    this.bind()
  }

  bind() {
    const original =
      this.instance.settings.events.afterChange

    this.instance.settings.events.afterChange =
      (newVal) => {

        console.log('Изменение:', newVal)

        if (original) {
          original(newVal)
        }
      }
  }
}

Такой механизм позволяет:

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

Паттерн middleware

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

Реализация middleware

class MiddlewarePlugin {
  constructor(instance) {
    this.instance = instance
    this.middlewares = []

    this.init()
  }

  use(callback) {
    this.middlewares.push(callback)
  }

  init() {
    const original =
      this.instance.settings.events.afterChange

    this.instance.settings.events.afterChange =
      (value) => {

        let current = value

        for (const middleware of this.middlewares) {
          current = middleware(current)
        }

        if (original) {
          original(current)
        }
      }
  }
}

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

const plugin = new MiddlewarePlugin(slim)

plugin.use((value) => {
  console.log('Middleware 1')

  return value
})

plugin.use((value) => {
  console.log('Middleware 2')

  return value
})

Создание плагина поиска

Одно из самых популярных расширений — кастомный поиск.

Локальный поиск

class SearchPlugin {
  constructor(instance) {
    this.instance = instance

    this.overrideSearch()
  }

  overrideSearch() {
    const originalSearch =
      this.instance.search.bind(this.instance)

    this.instance.search = (value) => {

      console.log('Поиск:', value)

      return originalSearch(value)
    }
  }
}

Плагин асинхронной загрузки

Загрузка данных с сервера

class RemoteDataPlugin {
  constructor(instance, options) {
    this.instance = instance
    this.url = options.url

    this.init()
  }

  init() {
    this.instance.settings.events.search =
      this.handleSearch.bind(this)
  }

  async handleSearch(search, currentData) {

    const response = await fetch(
      `${this.url}?q=${search}`
    )

    const items = await response.json()

    this.instance.setData(
      items.map(item => ({
        text: item.name,
        value: item.id
      }))
    )

    return []
  }
}

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

new RemoteDataPlugin(slim, {
  url: '/api/users'
})

Добавление кэширования

Асинхронные плагины часто используют кэш.

class CachedSearchPlugin {
  constructor(instance) {
    this.instance = instance
    this.cache = new Map()
  }

  async search(query) {

    if (this.cache.has(query)) {
      return this.cache.get(query)
    }

    const response = await fetch(
      `/search?q=${query}`
    )

    const data = await response.json()

    this.cache.set(query, data)

    return data
  }
}

Плагин debounce

Без debounce удалённый поиск создаёт слишком много запросов.

Реализация debounce

class DebouncePlugin {
  constructor(instance, delay = 300) {
    this.instance = instance
    this.delay = delay
    this.timer = null

    this.wrapSearch()
  }

  wrapSearch() {

    const original =
      this.instance.settings.events.search

    this.instance.settings.events.search =
      (...args) => {

        clearTimeout(this.timer)

        this.timer = setTimeout(() => {
          original(...args)
        }, this.delay)
      }
  }
}

DOM-плагины

Некоторые расширения работают напрямую с DOM.

Добавление пользовательской кнопки

class ClearButtonPlugin {
  constructor(instance) {
    this.instance = instance

    this.render()
  }

  render() {

    const button =
      document.createElement('button')

    button.textContent = 'Очистить'

    button.addEventListener('click', () => {
      this.instance.setSelected([])
    })

    this.instance.slim.container
      .appendChild(button)
  }
}

Работа с рендерингом

Slim Select генерирует собственный DOM.

Плагин может:

  • добавлять элементы;
  • изменять классы;
  • внедрять иконки;
  • модифицировать шаблоны.

Плагин кастомных шаблонов

Модификация отображения

class BadgePlugin {
  constructor(instance) {
    this.instance = instance

    this.decorate()
  }

  decorate() {

    const items =
      this.instance.slim.list.querySelectorAll(
        '.ss-option'
      )

    items.forEach(item => {

      const badge =
        document.createElement('span')

      badge.className = 'badge'
      badge.textContent = 'NEW'

      item.appendChild(badge)
    })
  }
}

MutationObserver в плагинах

Slim Select может перерисовывать DOM.

Поэтому прямые изменения иногда исчезают.

Наблюдение за изменениями

class ObserverPlugin {
  constructor(instance) {
    this.instance = instance

    this.observe()
  }

  observe() {

    const observer =
      new MutationObserver(() => {

        console.log('DOM изменён')
      })

    observer.observe(
      this.instance.slim.list,
      {
        childList: true,
        subtree: true
      }
    )
  }
}

Плагин виртуализации

При больших объёмах данных полезна виртуальная прокрутка.

Ограничение DOM-элементов

class VirtualScrollPlugin {
  constructor(instance, limit = 50) {
    this.instance = instance
    this.limit = limit
  }

  render(data) {

    return data.slice(0, this.limit)
  }
}

В реальных проектах виртуализация включает:

  • вычисление видимой области;
  • динамическую подгрузку;
  • переиспользование DOM;
  • оптимизацию scroll-событий.

Плагин аналитики

Сбор пользовательских действий

class AnalyticsPlugin {
  constructor(instance) {
    this.instance = instance

    this.track()
  }

  track() {

    const original =
      this.instance.settings.events.afterChange

    this.instance.settings.events.afterChange =
      (value) => {

        this.send(value)

        if (original) {
          original(value)
        }
      }
  }

  send(value) {

    fetch('/analytics', {
      method: 'POST',

      body: JSON.stringify({
        value,
        timestamp: Date.now()
      })
    })
  }
}

Система хуков

Крупные плагины удобно строить через hooks API.

Реестр хуков

class HookSystem {
  constructor() {
    this.hooks = {}
  }

  on(name, callback) {

    if (!this.hooks[name]) {
      this.hooks[name] = []
    }

    this.hooks[name].push(callback)
  }

  emit(name, payload) {

    if (!this.hooks[name]) {
      return
    }

    for (const callback of this.hooks[name]) {
      callback(payload)
    }
  }
}

Композиция плагинов

Несколько плагинов могут работать одновременно.

PluginManager

class PluginManager {
  constructor(instance) {
    this.instance = instance
    this.plugins = []
  }

  register(Plugin, options) {

    const plugin =
      new Plugin(this.instance, options)

    this.plugins.push(plugin)

    return plugin
  }

  destroy() {

    this.plugins.forEach(plugin => {

      if (plugin.destroy) {
        plugin.destroy()
      }
    })
  }
}

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

const manager =
  new PluginManager(slim)

manager.register(RemoteDataPlugin, {
  url: '/api/tags'
})

manager.register(AnalyticsPlugin)
manager.register(DebouncePlugin)

Предотвращение конфликтов

Несколько плагинов могут:

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

Безопасное расширение методов

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

instance.search = customSearch

Лучший вариант:

const original = instance.search.bind(instance)

instance.search = (...args) => {

  console.log('before')

  const result = original(...args)

  console.log('after')

  return result
}

Namespacing

Плагин не должен загрязнять экземпляр.

Плохой пример:

instance.cache = {}

Лучший вариант:

instance.plugins = instance.plugins || {}

instance.plugins.remoteCache = {}

Плагин валидации

Проверка выбора

class ValidationPlugin {
  constructor(instance) {
    this.instance = instance

    this.init()
  }

  init() {

    const original =
      this.instance.settings.events.beforeChange

    this.instance.settings.events.beforeChange =
      (newValue, oldValue) => {

        const valid =
          this.validate(newValue)

        if (!valid) {
          return false
        }

        if (original) {
          return original(newValue, oldValue)
        }

        return true
      }
  }

  validate(values) {
    return values.length <= 3
  }
}

Интеграция с React

Плагин может использоваться внутри адаптера.

useEffect(() => {

  const slim = new SlimSelect({
    select: ref.current
  })

  const plugin =
    new AnalyticsPlugin(slim)

  return () => {
    plugin.destroy?.()
    slim.destroy()
  }

}, [])

Интеграция с Vue

onMounted(() => {

  slim.value = new SlimSelect({
    select: selectRef.value
  })

  new RemoteDataPlugin(slim.value, {
    url: '/api/users'
  })
})

Создание системы событий плагина

Иногда плагину нужен собственный EventBus.

EventEmitter

class EventEmitter {
  constructor() {
    this.events = {}
  }

  on(event, callback) {

    if (!this.events[event]) {
      this.events[event] = []
    }

    this.events[event].push(callback)
  }

  emit(event, payload) {

    const listeners =
      this.events[event] || []

    listeners.forEach(listener => {
      listener(payload)
    })
  }
}

Плагин drag-and-drop сортировки

Сортировка выбранных элементов

class SortPlugin {
  constructor(instance) {
    this.instance = instance

    this.enable()
  }

  enable() {

    const container =
      this.instance.slim.values

    container.addEventListener(
      'dragstart',
      this.handleDrag.bind(this)
    )
  }

  handleDrag(event) {
    console.log(event)
  }
}

Плагин синхронизации

Связь нескольких select

class SyncPlugin {
  constructor(master, slave) {

    this.master = master
    this.slave = slave

    this.bind()
  }

  bind() {

    this.master.settings.events.afterChange =
      (values) => {

        this.slave.setSelected(values)
      }
  }
}

Lazy initialization

Тяжёлые плагины можно загружать отложенно.

class LazyPlugin {
  constructor(instance) {
    this.instance = instance

    this.initialized = false

    this.bind()
  }

  bind() {

    this.instance.slim.main
      .addEventListener('click', () => {

        if (!this.initialized) {
          this.init()
        }
      })
  }

  init() {
    this.initialized = true

    console.log('Плагин загружен')
  }
}

Уничтожение плагинов

Корректная очистка особенно важна в SPA.

Полный destroy

destroy() {

  window.removeEventListener(
    'resize',
    this.handleResize
  )

  clearTimeout(this.timer)

  this.observer?.disconnect()

  this.instance = null
}

Без очистки возникают:

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

Структура production-плагина

Типичный production-ready плагин содержит:

plugin/
├── core/
├── dom/
├── api/
├── events/
├── utils/
├── styles/
├── index.js
└── types.d.ts

TypeScript-типизация плагинов

Типизация экземпляра

interface SlimPlugin {
  init(): void
  destroy(): void
}

Типизация параметров

interface RemotePluginOptions {
  url: string
  debounce?: number
}

Класс плагина

class RemotePlugin
implements SlimPlugin {

  constructor(
    private instance: any,
    private options:
      RemotePluginOptions
  ) {}

  init(): void {}

  destroy(): void {}
}

Публикация плагинов

Распространённые форматы:

  • npm-пакет;
  • ES Module;
  • UMD-сборка;
  • CDN-файл;
  • внутренний корпоративный модуль.

Пример экспорта

export default RemoteDataPlugin

Автоматическая регистрация

Некоторые системы используют глобальный реестр.

window.SlimPlugins = {
  RemoteDataPlugin,
  AnalyticsPlugin
}

Поддержка нескольких версий Slim Select

Плагин может зависеть от внутреннего API.

Поэтому важно проверять:

if (!instance.version) {
  console.warn('Версия не определена')
}

Иногда требуется адаптер:

if (instance.version.startsWith('2')) {
  this.initV2()
} else {
  this.initLegacy()
}

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

Проверяются:

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

Пример теста

test('plugin saves values', () => {

  const slim = createInstance()

  const plugin =
    new AutoSavePlugin(slim)

  slim.setSelected('admin')

  expect(
    localStorage.getItem('selected')
  ).not.toBeNull()
})

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

Основные проблемы плагинов:

  • частые re-render;
  • избыточные DOM-операции;
  • множественные fetch-запросы;
  • тяжёлые observers;
  • каскадные события.

Методы оптимизации:

  • debounce;
  • throttle;
  • memoization;
  • batching;
  • document fragments;
  • lazy loading;
  • виртуализация.

Универсальный шаблон production-плагина

class SlimPlugin {

  constructor(instance, options = {}) {

    this.instance = instance
    this.options = options

    this.initialized = false

    this.init()
  }

  init() {

    if (this.initialized) {
      return
    }

    this.initialized = true

    this.bindEvents()
  }

  bindEvents() {}

  destroy() {

    this.initialized = false
  }
}