Адаптеры и обертки

Choices.js проектируется как библиотека, отделяющая ядро управления состоянием выбора от конкретных источников данных и UI-окружений. Центральная идея — возможность подключения внешних слоёв через адаптеры и обёртки, которые преобразуют входные данные, события и взаимодействие DOM в унифицированный формат, понятный ядру.

Внутри системы выделяются три ключевых слоя:

  • Core (ядро) — управляет состоянием выбранных значений, списками опций, фильтрацией и логикой поиска.
  • Adapter layer (слой адаптеров) — преобразует внешние данные в формат, понятный ядру.
  • Wrapper layer (обёртки) — интеграция с фреймворками, DOM или сторонними API.

Такое разделение позволяет использовать Choices.js в разных окружениях без изменения внутренней логики.


Роль адаптеров в архитектуре Choices.js

Адаптеры отвечают за нормализацию входных данных и событий. Основная задача — сделать так, чтобы ядро работало с единым интерфейсом, независимо от того, откуда приходят данные:

  • статические массивы
  • REST API
  • динамические источники (WebSocket, lazy loading)
  • серверные фильтры

Адаптер выступает промежуточным преобразователем:

Внешний формат → унифицированный формат Choices.js

Ключевой принцип — отсутствие прямых зависимостей ядра от источника данных.


Стандартный data-adapter

Наиболее распространённый тип — адаптер данных. Он отвечает за загрузку и преобразование опций.

Типовой интерфейс включает:

  • fetch() — получение данных
  • transform() — приведение к внутреннему формату
  • cache() — кэширование результатов
  • filter() — предварительная фильтрация

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

function transform(items) {
  return items.map(item => ({
    value: item.id,
    label: item.name,
    selected: false,
    disabled: false
  }));
}

Choices.js ожидает строгую структуру:

  • value — уникальный идентификатор
  • label — отображаемый текст
  • selected — состояние выбора
  • disabled — доступность опции

Адаптер гарантирует соответствие этому контракту.


Адаптеры для асинхронных источников

Одной из ключевых возможностей является поддержка асинхронных данных. Для этого используются адаптеры, реализующие стратегию отложенной загрузки.

Типовая схема:

  1. пользователь вводит текст
  2. адаптер перехватывает событие поиска
  3. выполняется запрос к API
  4. результат преобразуется
  5. обновляется список опций

Пример абстракции:

class AsyncAdapter {
  constructor(fetcher) {
    this.fetcher = fetcher;
    this.cache = new Map();
  }

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

    const result = await this.fetcher(query);

    const transformed = result.map(item => ({
      value: item.id,
      label: item.title
    }));

    this.cache.set(query, transformed);

    return transformed;
  }
}

Особенность такого подхода — отделение логики получения данных от логики отображения.


Обёртки над DOM-элементами

Choices.js часто применяется как замена стандартным <select> и <input> элементам. Для этого используется DOM-wrapper, который:

  • скрывает оригинальный элемент
  • создаёт кастомную структуру
  • синхронизирует состояние

Основные задачи обёртки:

  • отслеживание событий input, change, keydown
  • синхронизация значений
  • управление фокусом
  • управление доступностью (ARIA)

Схема взаимодействия:

DOM → Wrapper → Core → Wrapper → DOM

Обёртка не содержит бизнес-логики, она только транслирует события.


React-обёртка над Choices.js

Интеграция с React требует создания контролируемого компонента, который связывает жизненный цикл React с внутренним состоянием Choices.js.

Ключевые элементы:

  • инициализация через useEffect
  • хранение инстанса через useRef
  • синхронизация через props

Пример логики:

function ChoicesSelect({ options, value, onChange }) {
  const ref = useRef(null);
  const instance = useRef(null);

  useEffect(() => {
    instance.current = new Choices(ref.current, {
      choices: options
    });

    instance.current.passedElement.element.addEventListener(
      'change',
      (e) => onChange(e.detail.value)
    );

    return () => instance.current.destroy();
  }, []);

  useEffect(() => {
    if (instance.current) {
      instance.current.setChoices(options, 'value', 'label', true);
    }
  }, [options]);

  useEffect(() => {
    if (instance.current) {
      instance.current.setChoiceByValue(value);
    }
  }, [value]);

  return <select ref={ref} />;
}

Здесь обёртка решает проблему синхронизации двух моделей состояния: React state и Choices state.


Vue-обёртка и реактивность

Во Vue адаптация строится вокруг реактивных данных и lifecycle hooks.

Основная проблема — предотвращение циклических обновлений между Vue и Choices.js.

Стратегия:

  • инициализация в mounted
  • очистка в beforeUnmount
  • watch-наблюдатели для синхронизации

Пример логики:

export default {
  props: ['options', 'value'],
  mounted() {
    this.instance = new Choices(this.$refs.select);

    this.syncOptions();
    this.syncValue();
  },
  watch: {
    options() {
      this.syncOptions();
    },
    value() {
      this.syncValue();
    }
  },
  methods: {
    syncOptions() {
      this.instance.setChoices(this.options, 'value', 'label', true);
    },
    syncValue() {
      this.instance.setChoiceByValue(this.value);
    }
  }
};

Angular-обёртка через директивы и сервисы

В Angular подход отличается строгой типизацией и DI-контейнером.

Часто используется комбинация:

  • директива для DOM
  • сервис для управления инстансом
  • ControlValueAccessor для формы

Схема:

Angular Form → ControlValueAccessor → Choices Instance → DOM

Контракт ControlValueAccessor позволяет интегрировать Choices.js в reactive forms.

Ключевые методы:

  • writeValue
  • registerOnChange
  • registerOnTouched

Унификация интерфейса через фасады

Фасад (Facade) используется для упрощения работы с несколькими адаптерами одновременно.

Он скрывает:

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

Фасад предоставляет единый API:

  • setOptions()
  • setValue()
  • getValue()
  • search()

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

class ChoicesFacade {
  constructor(instance, adapter) {
    this.instance = instance;
    this.adapter = adapter;
  }

  async search(query) {
    const results = await this.adapter.search(query);
    this.instance.setChoices(results, 'value', 'label', true);
  }
}

Адаптация кастомных форматов данных

В реальных системах данные редко приходят в формате, совместимом с Choices.js. Поэтому адаптеры часто выполняют сложные преобразования:

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

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

function normalize(data) {
  return data.items.map(item => ({
    value: item.meta.uuid,
    label: `${item.profile.firstName} ${item.profile.lastName}`
  }));
}

Адаптер становится слоем бизнес-логики, но не должен влиять на UI-логику.


Кэширование в адаптерах

Для асинхронных источников критично снижение количества запросов. В адаптерах используется кэширование:

Стратегии:

  • memory cache (Map)
  • TTL-кэш
  • LRU-кэш

Пример TTL-логики:

class Cache {
  constructor(ttl = 30000) {
    this.ttl = ttl;
    this.store = new Map();
  }

  set(key, value) {
    this.store.set(key, {
      value,
      time: Date.now()
    });
  }

  get(key) {
    const entry = this.store.get(key);
    if (!entry) return null;

    if (Date.now() - entry.time > this.ttl) {
      this.store.delete(key);
      return null;
    }

    return entry.value;
  }
}

Событийная модель адаптеров

Адаптеры часто подписываются на события ядра:

  • addItem
  • removeItem
  • search
  • highlight

И наоборот — транслируют внешние события внутрь системы.

Это создаёт двусторонний поток данных:

UI → Adapter → Core → Adapter → UI

Основная сложность — предотвращение рекурсивных обновлений.


Расширяемость через кастомные адаптеры

Choices.js допускает создание полностью кастомных адаптеров, если стандартные механизмы недостаточны.

Кастомный адаптер может:

  • подключаться к GraphQL
  • работать с IndexedDB
  • использовать Web Workers
  • агрегировать несколько API

Пример комбинированного адаптера:

class MultiSourceAdapter {
  constructor(sources) {
    this.sources = sources;
  }

  async search(query) {
    const results = await Promise.all(
      this.sources.map(s => s.search(query))
    );

    return results.flat();
  }
}

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

Несмотря на гибкость, существуют ограничения:

  • рост сложности при большом количестве адаптеров
  • риск дублирования логики трансформации
  • необходимость строгого соблюдения контрактов
  • потенциальные утечки памяти при неправильном кэшировании

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