При работе с удалёнными источниками данных библиотека Choices.js
часто используется совместно с AJAX-запросами, fetch, REST
API и серверными фильтрами. Без механизма кэширования каждое открытие
списка, ввод символа или повторный поиск инициирует новый сетевой
запрос. Это создаёт несколько проблем:
Кэширование решает эти проблемы за счёт сохранения уже загруженных
данных в памяти браузера, localStorage,
sessionStorage или специальных слоях хранения.
Типичный поток загрузки данных без кэша:
При повторном вводе такого же значения процесс повторяется полностью.
С кэшированием схема меняется:
Самый простой вариант — хранение результатов в обычном объекте JavaScript.
<select id="users"></select>
const cache = {};
const choices = new Choices('#users', {
searchEnabled: true,
shouldSort: false
});
async function loadUsers(query) {
if (cache[query]) {
console.log('Данные из кэша');
choices.setChoices(
cache[query],
'value',
'label',
true
);
return;
}
console.log('Запрос к серверу');
const response = await fetch(`/api/users?q=${query}`);
const data = await response.json();
cache[query] = data;
choices.setChoices(
data,
'value',
'label',
true
);
}
Обычно данные сохраняются в формате:
{
"alex": [
{ value: 1, label: "Alex Johnson" },
{ value: 2, label: "Alex Smith" }
],
"john": [
{ value: 3, label: "John Doe" }
]
}
Ключом выступает поисковая строка.
Объект подходит не всегда. Для более управляемого хранения лучше
использовать Map.
size;const cache = new Map();
async function loadCities(query) {
if (cache.has(query)) {
choices.setChoices(
cache.get(query),
'value',
'label',
true
);
return;
}
const response = await fetch(`/api/cities?q=${query}`);
const data = await response.json();
cache.set(query, data);
choices.setChoices(
data,
'value',
'label',
true
);
}
Без ограничения кэш может устаревать. Пользователь продолжит видеть старые данные даже после обновления базы на сервере.
TTL (time to live) — время жизни записи.
const cache = new Map();
const CACHE_TTL = 60000;
async function loadProducts(query) {
const cached = cache.get(query);
if (cached) {
const isExpired =
Date.now() - cached.timestamp > CACHE_TTL;
if (!isExpired) {
choices.setChoices(
cached.data,
'value',
'label',
true
);
return;
}
cache.delete(query);
}
const response = await fetch(`/api/products?q=${query}`);
const data = await response.json();
cache.set(query, {
data,
timestamp: Date.now()
});
choices.setChoices(
data,
'value',
'label',
true
);
}
При долгой работе приложения память постепенно заполняется.
function clearExpiredCache() {
for (const [key, value] of cache.entries()) {
const expired =
Date.now() - value.timestamp > CACHE_TTL;
if (expired) {
cache.delete(key);
}
}
}
setInterval(clearExpiredCache, 30000);
Оперативная память очищается после перезагрузки страницы. Для
постоянного хранения используется localStorage.
async function loadCountries(query) {
const cacheKey = `countries_${query}`;
const cached = localStorage.getItem(cacheKey);
if (cached) {
console.log('localStorage cache');
choices.setChoices(
JSON.parse(cached),
'value',
'label',
true
);
return;
}
const response =
await fetch(`/api/countries?q=${query}`);
const data = await response.json();
localStorage.setItem(
cacheKey,
JSON.stringify(data)
);
choices.setChoices(
data,
'value',
'label',
true
);
}
localStorage блокирует основной поток браузера.
Обычно доступно около 5–10 МБ.
Старые записи необходимо очищать вручную.
function saveCache(key, data) {
const payload = {
data,
expires: Date.now() + 3600000
};
localStorage.setItem(
key,
JSON.stringify(payload)
);
}
function getCache(key) {
const raw = localStorage.getItem(key);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (Date.now() > parsed.expires) {
localStorage.removeItem(key);
return null;
}
return parsed.data;
}
sessionStorage работает аналогично
localStorage, но данные удаляются после закрытия
вкладки.
sessionStorage.setItem(
'search_users',
JSON.stringify(data)
);
Даже при наличии кэша количество запросов может быть слишком большим.
function debounce(callback, delay) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
callback(...args);
}, delay);
};
}
const search = debounce(async (query) => {
await loadUsers(query);
}, 400);
Иногда пользователь быстро вводит одинаковые значения, пока первый запрос ещё не завершён.
const requests = new Map();
async function loadTags(query) {
if (requests.has(query)) {
return requests.get(query);
}
const promise = fetch(`/api/tags?q=${query}`)
.then(response => response.json())
.then(data => {
requests.delete(query);
return data;
});
requests.set(query, promise);
return promise;
}
Без нормализации строки:
Alex
alex
ALEX
будут считаться разными запросами.
function normalizeQuery(query) {
return query
.trim()
.toLowerCase();
}
const key = normalizeQuery(query);
Иногда можно использовать уже загруженные данные для более длинного запроса.
Например:
a
al
ale
alex
Если результаты для alex уже загружены, повторный запрос
не требуется.
function findClosestCache(query) {
for (const key of cache.keys()) {
if (query.startsWith(key)) {
return cache.get(key);
}
}
return null;
}
LRU (Least Recently Used) удаляет самые редко
используемые записи.
class LRUCache {
constructor(limit = 50) {
this.limit = limit;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) {
return null;
}
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key, value) {
if (this.cache.has(key)) {
this.cache.delete(key);
}
this.cache.set(key, value);
if (this.cache.size > this.limit) {
const oldest =
this.cache.keys().next().value;
this.cache.delete(oldest);
}
}
}
const cache = new LRUCache(100);
async function searchUsers(query) {
const cached = cache.get(query);
if (cached) {
choices.setChoices(
cached,
'value',
'label',
true
);
return;
}
const response =
await fetch(`/api/users?q=${query}`);
const data = await response.json();
cache.set(query, data);
choices.setChoices(
data,
'value',
'label',
true
);
}
При постраничной загрузке необходимо учитывать номер страницы.
const key = `${query}_${page}`;
Если поиск зависит от фильтров:
ключ должен учитывать все параметры.
function buildKey(params) {
return JSON.stringify(params);
}
const key = buildKey({
query,
category,
sort
});
Инвалидация — удаление устаревших данных.
cache.clear();
cache.delete(query);
for (const key of cache.keys()) {
if (key.startsWith('users_')) {
cache.delete(key);
}
}
Сначала используется кэш.
if (cache.has(key)) {
return cache.get(key);
}
Подходит для редко изменяемых данных.
Сначала выполняется запрос к серверу.
try {
const data = await fetchData();
cache.set(key, data);
return data;
} catch {
return cache.get(key);
}
Подходит для актуальных данных.
Пользователь мгновенно получает старые данные, а обновление выполняется в фоне.
async function getData(key) {
const cached = cache.get(key);
if (cached) {
fetchFreshData(key);
return cached;
}
return await fetchFreshData(key);
}
Ошибки сети не должны разрушать интерфейс.
async function loadData(query) {
try {
const response =
await fetch(`/api/search?q=${query}`);
const data = await response.json();
cache.set(query, data);
return data;
} catch (error) {
if (cache.has(query)) {
return cache.get(query);
}
throw error;
}
}
Для отладки полезно понимать источник данных.
if (cache.has(query)) {
console.log('cache');
} else {
console.log('network');
}
Слишком большой кэш может вызвать исключение.
try {
localStorage.setItem(
key,
JSON.stringify(data)
);
} catch (error) {
console.error('Storage limit exceeded');
}
Choices.js часто работает с серверным поиском:
fetch(`/api/users?search=${query}`)
Кэш особенно эффективен при:
Некоторые данные можно загрузить заранее.
async function preloadUsers() {
const response =
await fetch('/api/users/popular');
const data = await response.json();
cache.set('popular', data);
}
Для офлайн-режима используется API Cache Storage.
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
return response || fetch(event.request);
})
);
});
Плохой вариант:
{
id,
name,
avatar,
biography,
permissions,
history,
metadata
}
Оптимальный вариант:
{
value: id,
label: name
}
Если элементы содержат аватары:
{
value: 1,
label: 'Alex',
avatar: '/img/alex.jpg'
}
изображения также могут кэшироваться браузером автоматически.
const choices = new Choices('#users');
(async () => {
const cached = getCache('users');
if (cached) {
choices.setChoices(
cached,
'value',
'label',
true
);
return;
}
const response =
await fetch('/api/users');
const data = await response.json();
saveCache('users', data);
choices.setChoices(
data,
'value',
'label',
true
);
})();
Нельзя сохранять невалидный ответ:
if (!response.ok) {
throw new Error('Server error');
}
Бесконечный рост кэша приводит к утечкам памяти.
Данные становятся неактуальными.
Кэш теряет смысл.
Наиболее распространённая схема:
Choices.js
↓
Debounce
↓
Проверка кэша
↓
Fetch API
↓
Обновление кэша
↓
Отображение данных
const cache = new LRUCache(100);
const CACHE_TTL = 300000;
async function searchUsers(query) {
const key =
query.trim().toLowerCase();
const cached = cache.get(key);
if (cached) {
const expired =
Date.now() - cached.timestamp > CACHE_TTL;
if (!expired) {
choices.setChoices(
cached.data,
'value',
'label',
true
);
return;
}
}
try {
const response =
await fetch(`/api/users?q=${key}`);
if (!response.ok) {
throw new Error('Network error');
}
const data = await response.json();
cache.set(key, {
data,
timestamp: Date.now()
});
choices.setChoices(
data,
'value',
'label',
true
);
} catch (error) {
console.error(error);
if (cached) {
choices.setChoices(
cached.data,
'value',
'label',
true
);
}
}
}