Готовый компонент в контексте Choices.js представляет собой повторно используемый модуль интерфейса, внутри которого инкапсулируются:
Подход с компонентами особенно важен в крупных интерфейсах, где одинаковые селекты, автокомплиты и 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
});
При росте проекта подобный код быстро становится трудно поддерживаемым.
Наиболее распространённый подход — создание класса-обёртки.
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();
Готовый компонент обычно хранит:
Пример:
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;
}
}
Полезный компонент не должен быть жёстко привязан к конкретным данным.
Правильнее создавать универсальный 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: 'Редактор' }
]
});
Очень часто готовые компоненты используются для удалённого поиска.
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 AJAX-компонент создаёт слишком много запросов.
function debounce(callback, delay = 300) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
callback(...args);
}, delay);
};
}
Интеграция:
this.handleSearch = debounce(
this.handleSearch.bind(this),
400
);
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'
]);
Классический пример — выбор страны и города.
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 поддерживает кастомные шаблоны.
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++;
}
}
В крупных приложениях полезно создавать общий родительский класс.
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);
}
}
Choices.js часто используется внутри:
Правильная практика — создание адаптера.
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>
);
}
export default {
props: ['options'],
mounted() {
this.instance = new Choices(
this.$refs.select
);
this.instance.setChoices(
this.options,
'value',
'label',
true
);
},
beforeUnmount() {
this.instance.destroy();
}
}
Иногда компонент должен поддерживать расширения.
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);
});
}
}
Полноценный компонент обычно содержит стадии:
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();
}
}
Фабрики позволяют стандартизировать настройки.
function createSelectConfig(
custom = {}
) {
return {
searchEnabled: true,
shouldSort: false,
itemSelectText: '',
removeItemButton: true,
...custom
};
}
Использование:
const instance = new Choices(
'#users',
createSelectConfig({
maxItemCount: 5
})
);
В больших приложениях полезно централизованное хранение компонентов.
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();
}
}
}
<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'
});
});
Иногда компонент должен создаваться только при взаимодействии.
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);
Следствие:
Правильный подход:
if (!element.dataset.initialized) {
new Choices(element);
element.dataset.initialized = 'true';
}
Нельзя забывать уничтожать компонент.
component.destroy();
Особенно важно в:
Плохой подход:
fetch('/api/users')
.then(...)
.then(...)
.then(...)
внутри обработчиков интерфейса без разделения ответственности.
Гораздо лучше:
class UserService {}
class UserSelect {}
class UserController {}
Хороший компонент:
Для сложных компонентов можно использовать 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();