При работе с динамическими данными интерфейс не всегда способен мгновенно отобразить список элементов. Источником данных могут быть:
Если пользователь не получает визуального подтверждения активности интерфейса, возникают проблемы:
Индикация загрузки решает эту проблему через отображение промежуточного состояния.
Наиболее распространённый сценарий:
<sel ect id="users"></select>
<div id="loader" class="loader hidden">
Загрузка...
</div>
.hidden {
display: none;
}
.loader {
margin-top: 10px;
color: #666;
font-size: 14px;
}
const loader = document.getElementById('loader');
const choices = new Choices('#users', {
searchEnabled: true
});
async function loadUsers() {
loader.classList.remove('hidden');
try {
const response = await fetch('/api/users');
const users = await response.json();
choices.setChoices(
users,
'id',
'name',
true
);
} catch (error) {
console.error(error);
} finally {
loader.classList.add('hidden');
}
}
loadUsers();
Интерфейс загрузки обычно состоит из нескольких состояний:
| Состояние | Описание |
|---|---|
| idle | ожидание |
| loading | загрузка |
| success | успешная загрузка |
| error | ошибка |
| empty | пустой результат |
Choices.js сам по себе не управляет подобной логикой автоматически. Состояния реализуются вручную.
<div class="status-box">
<div id="loadingState" class="hidden">
Загрузка данных...
</div>
<div id="errorState" class="hidden">
Ошибка загрузки
</div>
<div id="emptyState" class="hidden">
Ничего не найдено
</div>
</div>
<select id="products"></select>
const loadingState = document.getElementById('loadingState');
const errorState = document.getElementById('errorState');
const emptyState = document.getElementById('emptyState');
const choices = new Choices('#products');
function resetStates() {
loadingState.classList.add('hidden');
errorState.classList.add('hidden');
emptyState.classList.add('hidden');
}
async function loadProducts() {
resetStates();
loadingState.classList.remove('hidden');
try {
const response = await fetch('/api/products');
if (!response.ok) {
throw new Error('Ошибка сервера');
}
const products = await response.json();
if (!products.length) {
emptyState.classList.remove('hidden');
return;
}
choices.setChoices(
products,
'id',
'title',
true
);
} catch (error) {
errorState.classList.remove('hidden');
} finally {
loadingState.classList.add('hidden');
}
}
loadProducts();
Одна из важнейших возможностей Choices.js — поиск с удалённым API.
При вводе текста запросы могут отправляться постоянно:
a
ap
app
appl
apple
Без индикации пользователь не понимает:
<select id="search-users"></select>
<div id="searchLoader" class="hidden">
Выполняется поиск...
</div>
const searchLoader = document.getElementById('searchLoader');
const choices = new Choices('#search-users', {
searchEnabled: true,
shouldSort: false
});
const input = choices.input.element;
input.addEventListener('input', async (event) => {
const query = event.target.value;
if (query.length < 2) {
return;
}
searchLoader.classList.remove('hidden');
try {
const response = await fetch(
`/api/users?q=${query}`
);
const users = await response.json();
choices.clearChoices();
choices.setChoices(
users,
'id',
'name',
true
);
} catch (error) {
console.error(error);
} finally {
searchLoader.classList.add('hidden');
}
});
При быстром вводе появляются серьёзные проблемы:
Запрос 1 → "a"
Запрос 2 → "ap"
Запрос 3 → "app"
Сервер может ответить в другом порядке:
Ответ 3
Ответ 1
Ответ 2
В результате интерфейс покажет устаревшие данные.
Для корректной индикации загрузки используется
AbortController.
let controller = null;
const choices = new Choices('#users');
const input = choices.input.element;
input.addEventListener('input', async (event) => {
const query = event.target.value;
if (controller) {
controller.abort();
}
controller = new AbortController();
loader.classList.remove('hidden');
try {
const response = await fetch(
`/api/search?q=${query}`,
{
signal: controller.signal
}
);
const data = await response.json();
choices.clearChoices();
choices.setChoices(
data,
'id',
'name',
true
);
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
} finally {
loader.classList.add('hidden');
}
});
Внешний loader подходит не всегда. Часто требуется встроить сообщение прямо в список Choices.js.
choices.clearChoices();
choices.setChoices([
{
value: '',
label: 'Загрузка...',
disabled: true
}
], 'value', 'label', true);
После завершения загрузки временный элемент заменяется настоящими данными.
async function loadCategories() {
choices.clearChoices();
choices.setChoices([
{
value: '',
label: 'Загрузка категорий...',
disabled: true
}
], 'value', 'label', true);
try {
const response = await fetch('/api/categories');
const categories = await response.json();
choices.clearChoices();
choices.setChoices(
categories,
'id',
'title',
true
);
} catch (error) {
choices.clearChoices();
choices.setChoices([
{
value: '',
label: 'Ошибка загрузки',
disabled: true
}
], 'value', 'label', true);
}
}
Текстовая индикация подходит не всегда. Более современным решением является spinner.
.spinner {
width: 20px;
height: 20px;
border: 3px solid #ddd;
border-top: 3px solid #333;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
fr om {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
<div id="spinner" class="spinner hidden"></div>
spinner.classList.remove('hidden');
try {
await loadData();
} finally {
spinner.classList.add('hidden');
}
Некоторые интерфейсы подгружают данные порциями:
В этом случае индикатор появляется внизу списка.
const dropdown = choices.dropdown.element;
dropdown.addEventListener('scroll', async () => {
const reachedBottom =
dropdown.scrollTop + dropdown.clientHeight >=
dropdown.scrollHeight - 10;
if (!reachedBottom) {
return;
}
showLoader();
try {
await loadNextPage();
} finally {
hideLoader();
}
});
Иногда требуется запретить взаимодействие до завершения операции.
choices.disable();
try {
await loadData();
} finally {
choices.enable();
}
choices.disable();
loader.classList.remove('hidden');
try {
await fetchData();
} finally {
loader.classList.add('hidden');
choices.enable();
}
Для крупных файлов или длительных операций можно отображать прогресс.
<div class="progress-bar">
<div id="progress"></div>
</div>
.progress-bar {
width: 300px;
height: 20px;
background: #eee;
}
#progress {
width: 0%;
height: 100%;
background: #333;
}
function updateProgress(percent) {
progress.style.width = `${percent}%`;
}
let percent = 0;
const interval = setInterval(() => {
percent += 10;
updateProgress(percent);
if (percent >= 100) {
clearInterval(interval);
}
}, 300);
Современные интерфейсы часто используют skeleton-анимацию вместо spinner.
.skeleton {
height: 40px;
margin-bottom: 10px;
border-radius: 4px;
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
}
@keyframes skeleton {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
<div id="skeletons">
<div class="skeleton"></div>
<div class="skeleton"></div>
<div class="skeleton"></div>
</div>
skeletons.style.display = 'block';
try {
await loadData();
} finally {
skeletons.style.display = 'none';
}
Первичная загрузка и повторное обновление интерфейса обычно различаются.
Пустой экран + loader
Старые данные + небольшой индикатор обновления
async function refreshData() {
refreshIndicator.style.display = 'block';
try {
const data = await fetchData();
updateChoices(data);
} finally {
refreshIndicator.style.display = 'none';
}
}
Без debounce индикатор может мигать слишком часто.
function debounce(callback, delay) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
callback(...args);
}, delay);
};
}
const debouncedSearch = debounce(async (query) => {
loader.classList.remove('hidden');
try {
const response = await fetch(
`/api/search?q=${query}`
);
const data = await response.json();
choices.clearChoices();
choices.setChoices(
data,
'id',
'name',
true
);
} finally {
loader.classList.add('hidden');
}
}, 400);
Если сервер отвечает мгновенно, loader может «моргать».
Это ухудшает восприятие интерфейса.
async function withMinimumLoader(task) {
const start = Date.now();
loader.classList.remove('hidden');
try {
return await task();
} finally {
const elapsed = Date.now() - start;
const remaining = 500 - elapsed;
if (remaining > 0) {
await new Promise(resolve => {
setTimeout(resolve, remaining);
});
}
loader.classList.add('hidden');
}
}
await withMinimumLoader(async () => {
const response = await fetch('/api/users');
const users = await response.json();
choices.setChoices(
users,
'id',
'name',
true
);
});
Неправильно:
loader.style.display = 'block';
const response = await fetch('/api/users');
loader.style.display = 'none';
Если произойдёт ошибка — loader останется навсегда.
loader.style.display = 'block';
try {
await fetch('/api/users');
} finally {
loader.style.display = 'none';
}
Ошибка:
showLoader();
showLoader();
hideLoader();
Loader исчезнет слишком рано.
let loadingCount = 0;
function startLoading() {
loadingCount++;
loader.style.display = 'block';
}
function stopLoading() {
loadingCount--;
if (loadingCount <= 0) {
loadingCount = 0;
loader.style.display = 'none';
}
}
Ошибка:
choices.setChoices(data);
choices.setChoices(newData);
Старые элементы могут сохраниться.
choices.clearChoices();
choices.setChoices(
newData,
'id',
'name',
true
);
В крупных проектах управление loader выносится в отдельный модуль.
class LoaderManager {
constructor(element) {
this.element = element;
this.count = 0;
}
show() {
this.count++;
this.element.classList.remove('hidden');
}
hide() {
this.count--;
if (this.count <= 0) {
this.count = 0;
this.element.classList.add('hidden');
}
}
}
const manager = new LoaderManager(loader);
async function loadData() {
manager.show();
try {
const response = await fetch('/api/data');
return await response.json();
} finally {
manager.hide();
}
}
Наиболее удобная архитектура строится вокруг:
async/await;try/catch/finally;AbortController;Подобная комбинация обеспечивает: