Создание готовых компонентов

Готовый компонент в контексте Choices.js представляет собой повторно используемый модуль интерфейса, внутри которого инкапсулируются:

  • HTML-разметка
  • конфигурация Choices.js
  • логика загрузки данных
  • обработчики событий
  • методы управления состоянием
  • API взаимодействия с другими частями приложения

Подход с компонентами особенно важен в крупных интерфейсах, где одинаковые селекты, автокомплиты и multi-select элементы используются десятки раз.

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

new Choices('#country', {
  searchEnabled: true,
  shouldSort: false
});

new Choices('#city', {
  searchEnabled: true,
  shouldSort: false
});

new Choices('#category', {
  searchEnabled: true,
  shouldSort: false
});

При росте проекта подобный код быстро становится трудно поддерживаемым.


Создание базового компонента

Инкапсуляция экземпляра Choices.js

Наиболее распространённый подход — создание класса-обёртки.

class SelectComponent {
  constructor(selector, options = {}) {
    this.element = document.querySelector(selector);

    this.defaultOptions = {
      searchEnabled: true,
      shouldSort: false,
      itemSelectText: ''
    };

    this.options = {
      ...this.defaultOptions,
      ...options
    };

    this.instance = null;
  }

  init() {
    this.instance = new Choices(
      this.element,
      this.options
    );
  }

  destroy() {
    if (this.instance) {
      this.instance.destroy();
    }
  }
}

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

const countrySelect = new SelectComponent(
  '#country'
);

countrySelect.init();

Хранение состояния компонента

Готовый компонент обычно хранит:

  • DOM-элемент
  • экземпляр Choices.js
  • текущие данные
  • внутреннее состояние
  • флаги загрузки

Пример:

class AsyncSelectComponent {
  constructor(selector) {
    this.element = document.querySelector(selector);

    this.instance = null;

    this.state = {
      loading: false,
      loaded: false,
      items: []
    };
  }

  async init() {
    this.instance = new Choices(this.element);

    await this.loadData();
  }

  async loadData() {
    this.state.loading = true;

    const response = await fetch('/api/options');

    const data = await response.json();

    this.state.items = data;

    this.instance.setChoices(
      data,
      'value',
      'label',
      true
    );

    this.state.loading = false;
    this.state.loaded = true;
  }
}

Универсальный компонент Select

Построение переиспользуемого API

Полезный компонент не должен быть жёстко привязан к конкретным данным.

Правильнее создавать универсальный API:

class SelectComponent {
  constructor({
    element,
    choices = [],
    placeholder = '',
    multiple = false
  }) {
    this.element = element;

    this.instance = new Choices(element, {
      removeItemButton: multiple,
      placeholderValue: placeholder
    });

    this.setChoices(choices);
  }

  setChoices(data) {
    this.instance.setChoices(
      data,
      'value',
      'label',
      true
    );
  }

  getValue() {
    return this.instance.getValue(true);
  }

  clear() {
    this.instance.clearStore();
  }
}

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

const sel ect = new SelectComponent({
  element: document.querySelector('#roles'),

  multiple: true,

  placeholder: 'Выберите роли',

  choices: [
    { value: 'admin', label: 'Администратор' },
    { value: 'editor', label: 'Редактор' }
  ]
});

Компонент с AJAX-загрузкой

Асинхронные данные

Очень часто готовые компоненты используются для удалённого поиска.

class RemoteSelect {
  constructor(selector, url) {
    this.url = url;

    this.element = document.querySelector(selector);

    this.instance = new Choices(this.element, {
      searchEnabled: true,
      shouldSort: false
    });

    this.bindEvents();
  }

  bindEvents() {
    this.element.addEventListener(
      'search',
      this.handleSearch.bind(this)
    );
  }

  async handleSearch(event) {
    const query = event.detail.value;

    if (query.length < 2) {
      return;
    }

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

    const items = await response.json();

    this.instance.clearChoices();

    this.instance.setChoices(
      items,
      'value',
      'label',
      true
    );
  }
}

Debounce в готовых компонентах

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

Реализация debounce

function debounce(callback, delay = 300) {
  let timeout;

  return (...args) => {
    clearTimeout(timeout);

    timeout = setTimeout(() => {
      callback(...args);
    }, delay);
  };
}

Интеграция:

this.handleSearch = debounce(
  this.handleSearch.bind(this),
  400
);

Компонент Tag Input

Choices.js отлично подходит для создания поля тегов.

Реализация

class TagsComponent {
  constructor(selector) {
    this.element = document.querySelector(selector);

    this.instance = new Choices(this.element, {
      delimiter: ',',
      editItems: true,
      removeItemButton: true
    });
  }

  getTags() {
    return this.instance.getValue(true);
  }

  setTags(tags) {
    this.instance.setValue(
      tags.map(tag => ({ value: tag }))
    );
  }
}

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

const tags = new TagsComponent('#tags');

tags.setTags([
  'javascript',
  'frontend',
  'choicesjs'
]);

Компонент зависимых списков

Cascading Selects

Классический пример — выбор страны и города.

class CountryCityComponent {
  constructor(countryEl, cityEl) {
    this.country = new Choices(countryEl);

    this.city = new Choices(cityEl);

    this.bindEvents();
  }

  bindEvents() {
    this.country.passedElement.element
      .addEventListener(
        'change',
        this.handleCountryChange.bind(this)
      );
  }

  async handleCountryChange(event) {
    const country = event.target.value;

    const response = await fetch(
      `/api/cities?country=${country}`
    );

    const cities = await response.json();

    this.city.clearChoices();

    this.city.setChoices(
      cities,
      'value',
      'label',
      true
    );
  }
}

Компонент с локальным кэшированием

Оптимизация повторных запросов

Повторная загрузка одинаковых данных создаёт лишнюю нагрузку.

Компонент может содержать встроенный кэш:

class CachedSelect {
  constructor(selector, url) {
    this.url = url;

    this.cache = new Map();

    this.instance = new Choices(selector);
  }

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

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

    const data = await response.json();

    this.cache.set(query, data);

    return data;
  }
}

Компоненты с шаблонами

Choices.js поддерживает кастомные шаблоны.

Переиспользуемый renderer

class UserSelect {
  constructor(selector, users) {
    this.instance = new Choices(selector, {
      callbackOnCreateTemplates: (
        template
      ) => {
        return {
          choice: (classNames, data) => {
            return template(`
              <div
                class="${classNames.item}"
                data-choice
                data-id="${data.id}"
              >
                <img src="${data.avatar}">
                <span>${data.label}</span>
              </div>
            `);
          }
        };
      }
    });

    this.instance.setChoices(
      users,
      'value',
      'label',
      true
    );
  }
}

Компонент с виртуализацией

При больших объёмах данных стандартный sel ect начинает тормозить.

Частичная загрузка элементов

class LargeDatasetSelect {
  constructor(selector, items) {
    this.items = items;

    this.chunkSize = 100;

    this.currentChunk = 0;

    this.instance = new Choices(selector);

    this.loadNextChunk();
  }

  loadNextChunk() {
    const start =
      this.currentChunk * this.chunkSize;

    const end = start + this.chunkSize;

    const chunk = this.items.slice(start, end);

    this.instance.setChoices(
      chunk,
      'value',
      'label',
      false
    );

    this.currentChunk++;
  }
}

Базовый абстрактный компонент

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

Abstract Component

class BaseChoicesComponent {
  constructor(element, options = {}) {
    this.element = element;

    this.options = options;

    this.instance = null;
  }

  init() {
    this.instance = new Choices(
      this.element,
      this.getOptions()
    );

    this.bindEvents();
  }

  getOptions() {
    return this.options;
  }

  bindEvents() {}

  destroy() {
    if (this.instance) {
      this.instance.destroy();
    }
  }
}

Наследование:

class UserSelect extends BaseChoicesComponent {
  getOptions() {
    return {
      searchEnabled: true,
      removeItemButton: true
    };
  }

  bindEvents() {
    this.element.addEventListener(
      'change',
      this.handleChange.bind(this)
    );
  }

  handleChange(event) {
    console.log(event.target.value);
  }
}

Интеграция компонентов с Framework-архитектурой

Подход Adapter Layer

Choices.js часто используется внутри:

  • React
  • Vue
  • Angular
  • Svelte
  • Alpine.js

Правильная практика — создание адаптера.


React-компонент

import { useEffect, useRef } fr om 'react';
import Choices fr om 'choices.js';

function Select({
  options,
  onChange
}) {
  const ref = useRef(null);

  const choicesRef = useRef(null);

  useEffect(() => {
    choicesRef.current = new Choices(ref.current);

    choicesRef.current.setChoices(
      options,
      'value',
      'label',
      true
    );

    ref.current.addEventListener(
      'change',
      onChange
    );

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

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

Vue-компонент

export default {
  props: ['options'],

  mounted() {
    this.instance = new Choices(
      this.$refs.select
    );

    this.instance.setChoices(
      this.options,
      'value',
      'label',
      true
    );
  },

  beforeUnmount() {
    this.instance.destroy();
  }
}

Компоненты с плагинной архитектурой

Иногда компонент должен поддерживать расширения.

Middleware-подход

class ExtensibleSelect {
  constructor(selector) {
    this.plugins = [];

    this.instance = new Choices(selector);
  }

  use(plugin) {
    plugin(this);

    this.plugins.push(plugin);
  }
}

Плагин:

function LoggerPlugin(component) {
  component.instance.passedElement.element
    .addEventListener('change', e => {
      console.log(e.target.value);
    });
}

Компонент с системой событий

Компонент может иметь собственный Event Bus.

class EventedSelect {
  constructor(selector) {
    this.events = {};

    this.instance = new Choices(selector);
  }

  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }

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

  emit(event, payload) {
    if (!this.events[event]) {
      return;
    }

    this.events[event].forEach(cb => {
      cb(payload);
    });
  }
}

Управление жизненным циклом

Полноценный компонент обычно содержит стадии:

  • создание
  • инициализация
  • обновление
  • уничтожение

Реализация lifecycle

class LifecycleSelect {
  constructor(selector) {
    this.selector = selector;
  }

  created() {}

  mounted() {}

  upd ated() {}

  destroyed() {}

  init() {
    this.created();

    this.instance = new Choices(
      this.selector
    );

    this.mounted();
  }

  refresh(data) {
    this.instance.setChoices(
      data,
      'value',
      'label',
      true
    );

    this.updated();
  }

  destroy() {
    this.instance.destroy();

    this.destroyed();
  }
}

Конфигурационные фабрики компонентов

Фабрики позволяют стандартизировать настройки.

Factory Function

function createSelectConfig(
  custom = {}
) {
  return {
    searchEnabled: true,
    shouldSort: false,
    itemSelectText: '',
    removeItemButton: true,

    ...custom
  };
}

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

const instance = new Choices(
  '#users',
  createSelectConfig({
    maxItemCount: 5
  })
);

Registry компонентов

В больших приложениях полезно централизованное хранение компонентов.

class ComponentRegistry {
  constructor() {
    this.components = new Map();
  }

  register(name, component) {
    this.components.se t(name, component);
  }

  get(name) {
    return this.components.get(name);
  }

  destroy(name) {
    const component = this.get(name);

    if (component) {
      component.destroy();
    }
  }
}

Автоматическая инициализация компонентов

Data Attributes

<select
  data-choice
  data-search="true"
  data-remove-button="true"
></select>

Автоинициализация:

document
  .querySelectorAll('[data-choice]')
  .forEach(element => {
    new Choices(element, {
      searchEnabled:
        element.dataset.search === 'true',

      removeItemButton:
        element.dataset.removeButton === 'true'
    });
  });

Lazy Initialization

Иногда компонент должен создаваться только при взаимодействии.

class LazySelect {
  constructor(selector) {
    this.element =
      document.querySelector(selector);

    this.instance = null;

    this.bindEvents();
  }

  bindEvents() {
    this.element.addEventListener(
      'focus',
      () => this.initialize(),
      { once: true }
    );
  }

  initialize() {
    this.instance = new Choices(
      this.element
    );
  }
}

Композиция компонентов

Компоненты могут объединяться друг с другом.

class FilterPanel {
  constructor() {
    this.categorySelect =
      new SelectComponent({
        element:
          document.querySelector('#category')
      });

    this.tagsSelect =
      new TagsComponent('#tags');
  }

  getFilters() {
    return {
      category:
        this.categorySelect.getValue(),

      tags:
        this.tagsSelect.getTags()
    };
  }
}

Типичные ошибки при создании компонентов

Повторная инициализация

Ошибка:

new Choices(element);
new Choices(element);

Следствие:

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

Правильный подход:

if (!element.dataset.initialized) {
  new Choices(element);

  element.dataset.initialized = 'true';
}

Утечки памяти

Нельзя забывать уничтожать компонент.

component.destroy();

Особенно важно в:

  • SPA
  • модальных окнах
  • динамических страницах
  • tab-интерфейсах

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

Плохой подход:

fetch('/api/users')
  .then(...)
  .then(...)
  .then(...)

внутри обработчиков интерфейса без разделения ответственности.

Гораздо лучше:

class UserService {}

class UserSelect {}

class UserController {}

Переиспользуемость компонентов

Хороший компонент:

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

Паттерн Builder

Для сложных компонентов можно использовать Builder.

class SelectBuilder {
  constructor(selector) {
    this.selector = selector;

    this.options = {};
  }

  searchable() {
    this.options.searchEnabled = true;

    return this;
  }

  multiple() {
    this.options.removeItemButton = true;

    return this;
  }

  placeholder(text) {
    this.options.placeholderValue = text;

    return this;
  }

  build() {
    return new Choices(
      this.selector,
      this.options
    );
  }
}

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

const select = new SelectBuilder('#users')
  .searchable()
  .multiple()
  .placeholder('Выберите пользователей')
  .build();