Динамическая загрузка данных

Статический массив значений подходит только для небольших списков, которые известны заранее. В реальных приложениях автодополнение чаще всего получает данные с сервера: из базы данных, REST API, поискового индекса, CRM-системы или внешнего сервиса.

Динамическая загрузка позволяет:

  • получать актуальные данные;
  • работать с тысячами записей;
  • уменьшать объём загружаемого JavaScript;
  • выполнять поиск на сервере;
  • обновлять список без перезагрузки страницы;
  • учитывать права доступа и пользовательский контекст.

Awesomplete не содержит встроенного AJAX-механизма, однако библиотека легко интегрируется с fetch, XMLHttpRequest, axios и любыми другими средствами HTTP-запросов.


Базовая схема динамической загрузки

Общий алгоритм работы выглядит следующим образом:

  1. Пользователь вводит текст.
  2. Срабатывает событие input.
  3. Выполняется HTTP-запрос.
  4. Сервер возвращает массив данных.
  5. Массив передаётся в awesomplete.list.
  6. Awesomplete отображает подсказки.

Простая загрузка через fetch

HTML

<input id="cities">

JavaScript

const input = document.querySelector("#cities");

const awesomplete = new Awesomplete(input);

input.addEventListener("input", async () => {

    const query = input.value;

    if (query.length < 2) {
        awesomplete.list = [];
        return;
    }

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

    const data = await response.json();

    awesomplete.list = data;
});

Что происходит в этом примере

Получение текущего текста

const query = input.value;

Считывается текущее содержимое поля ввода.


Проверка минимальной длины

if (query.length < 2)

Минимальная длина запроса уменьшает:

  • нагрузку на сервер;
  • количество HTTP-запросов;
  • бессмысленные поисковые операции.

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


Выполнение HTTP-запроса

fetch(`/api/cities?q=${encodeURIComponent(query)}`)

На сервер отправляется поисковая строка.

Пример запроса:

/api/cities?q=mos

Преобразование ответа

const data = await response.json();

Обычно сервер возвращает JSON-массив:

[
    "Moscow",
    "Mostar",
    "Mosul"
]

Обновление списка

awesomplete.list = data;

После присвоения нового массива Awesomplete автоматически перестраивает список подсказок.


Серверный ответ в формате JSON

Пример на Node.js

app.get("/api/cities", (req, res) => {

    const query = req.query.q.toLowerCase();

    const cities = [
        "Moscow",
        "Madrid",
        "Milan",
        "Munich",
        "Mostar"
    ];

    const result = cities.filter(city =>
        city.toLowerCase().includes(query)
    );

    res.json(result);
});

Работа с объектами

Сервер редко возвращает простые строки. Обычно используются объекты.

Пример ответа сервера

[
    {
        "id": 1,
        "name": "JavaScript"
    },
    {
        "id": 2,
        "name": "Java"
    }
]

Отображение объектов

const awesomplete = new Awesomplete(input, {

    list: [],

    replace(item) {
        this.input.value = item.name;
    }
});

input.addEventListener("input", async () => {

    const response = await fetch(
        `/api/tags?q=${input.value}`
    );

    const data = await response.json();

    awesomplete.list = data;
});

Настройка text и label

Awesomplete должен понимать:

  • что отображать;
  • что вставлять в input;
  • как фильтровать данные.

Для этого используются функции data, item, replace.


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

const awesomplete = new Awesomplete(input, {

    data(item) {
        return {
            label: item.name,
            value: item
        };
    },

    replace(item) {
        this.input.value = item.value.name;
    }
});

Что делает data()

Функция преобразует объект сервера во внутренний формат Awesomplete.

Возвращаемая структура

{
    label: item.name,
    value: item
}

label

Текст подсказки.

value

Исходный объект.


Использование дополнительных полей

Сервер может возвращать:

[
    {
        "id": 10,
        "name": "JavaScript",
        "category": "Programming",
        "icon": "js.png"
    }
]

Эти данные можно использовать при рендеринге.


Кастомное отображение результатов

const awesomplete = new Awesomplete(input, {

    item(item, input) {

        const element = document.createElement("li");

        element.innerHTML = `
            <strong>${item.value.name}</strong>
            <small>${item.value.category}</small>
        `;

        return element;
    }
});

Проблема большого количества запросов

При вводе текста событие input вызывается после каждого символа.

Например:

m
mo
mos
mosc

Это может привести к десяткам запросов в секунду.


Debounce

Debounce ограничивает частоту вызовов функции.


Реализация debounce

function debounce(callback, delay) {

    let timeout;

    return (...args) => {

        clearTimeout(timeout);

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

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

const loadData = debounce(async () => {

    const query = input.value;

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

    const response = await fetch(
        `/api/search?q=${query}`
    );

    const data = await response.json();

    awesomplete.list = data;

}, 300);

input.addEventListener("input", loadData);

Как работает debounce

Если пользователь продолжает ввод:

j
ja
jav
java

таймер постоянно перезапускается.

Запрос выполняется только после паузы.


Отмена предыдущих запросов

Без отмены может возникнуть проблема гонки запросов.

Пример проблемы

  1. Отправлен запрос "ja".
  2. Затем отправлен "java".
  3. "java" приходит быстрее.
  4. Позже приходит "ja" и перезаписывает список.

В результате отображаются устаревшие данные.


AbortController

Современный способ отмены запросов.


Пример отмены запросов

let controller;

input.addEventListener("input", async () => {

    if (controller) {
        controller.abort();
    }

    controller = new AbortController();

    try {

        const response = await fetch(
            `/api/search?q=${input.value}`,
            {
                signal: controller.signal
            }
        );

        const data = await response.json();

        awesomplete.list = data;

    } catch (error) {

        if (error.name !== "AbortError") {
            console.error(error);
        }
    }
});

Зачем нужна отмена

Отмена запросов:

  • предотвращает гонки;
  • снижает нагрузку;
  • экономит трафик;
  • делает интерфейс стабильнее.

Индикатор загрузки

Во время загрузки полезно отображать состояние интерфейса.


Пример с loader

const loader = document.querySelector(".loader");

input.addEventListener("input", async () => {

    loader.style.display = "block";

    try {

        const response = await fetch(
            `/api/search?q=${input.value}`
        );

        const data = await response.json();

        awesomplete.list = data;

    } finally {

        loader.style.display = "none";
    }
});

Обработка ошибок

Сетевые ошибки возникают регулярно:

  • сервер недоступен;
  • истёк timeout;
  • проблемы с интернетом;
  • ошибка API;
  • неверный JSON.

Обработка try/catch

input.addEventListener("input", async () => {

    try {

        const response = await fetch(
            `/api/search?q=${input.value}`
        );

        if (!response.ok) {
            throw new Error("Server error");
        }

        const data = await response.json();

        awesomplete.list = data;

    } catch (error) {

        console.error(error);

        awesomplete.list = [];
    }
});

Кэширование результатов

Повторные запросы можно сохранять локально.


Пример кэша

const cache = {};

input.addEventListener("input", async () => {

    const query = input.value;

    if (cache[query]) {

        awesomplete.list = cache[query];
        return;
    }

    const response = await fetch(
        `/api/search?q=${query}`
    );

    const data = await response.json();

    cache[query] = data;

    awesomplete.list = data;
});

Преимущества кэширования

Кэш:

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

Минимальная архитектура autocomplete API

Типичная серверная схема:

Client
   ↓
HTTP Request
   ↓
Backend API
   ↓
Database / Search Engine
   ↓
JSON Response

Серверная фильтрация

При больших объёмах данных фильтрация должна выполняться на сервере.

Неправильно:

fetch("/api/all-users")

с последующей фильтрацией в браузере.

Правильно:

fetch(`/api/users?q=${query}`)

Ограничение количества результатов

Большие ответы замедляют интерфейс.

Хорошая практика

[
    "Java",
    "JavaScript",
    "JavaFX",
    "Java EE",
    "Java ME"
]

Плохая практика

Возврат нескольких тысяч записей.


Ограничение на сервере

const result = users
    .filter(user =>
        user.name.includes(query)
    )
    .slice(0, 10);

Работа с задержкой сети

Иногда сервер отвечает медленно.

Awesomplete может открывать старый список, пока новые данные ещё не получены.

Для повышения UX часто используется:

  • очистка списка перед запросом;
  • индикатор загрузки;
  • временная блокировка dropdown;
  • skeleton-интерфейс.

Очистка списка перед загрузкой

awesomplete.list = [];

Пример полного решения

const input = document.querySelector("#search");

const awesomplete = new Awesomplete(input);

let controller;

const search = debounce(async () => {

    const query = input.value.trim();

    if (query.length < 2) {

        awesomplete.list = [];
        return;
    }

    if (controller) {
        controller.abort();
    }

    controller = new AbortController();

    try {

        const response = await fetch(
            `/api/search?q=${encodeURIComponent(query)}`,
            {
                signal: controller.signal
            }
        );

        if (!response.ok) {
            throw new Error("Request failed");
        }

        const data = await response.json();

        awesomplete.list = data;

    } catch (error) {

        if (error.name !== "AbortError") {

            console.error(error);

            awesomplete.list = [];
        }
    }

}, 300);

input.addEventListener("input", search);

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

Многие проекты используют axios.

Пример

input.addEventListener("input", async () => {

    const response = await axios.get("/api/search", {
        params: {
            q: input.value
        }
    });

    awesomplete.list = response.data;
});

Динамическая подгрузка при фокусе

Иногда данные нужно загружать только при открытии поля.


Пример

input.addEventListener("focus", async () => {

    const response = await fetch("/api/popular");

    const data = await response.json();

    awesomplete.list = data;

    awesomplete.evaluate();
});

evaluate()

Метод:

awesomplete.evaluate()

принудительно запускает построение списка.

Это полезно, когда данные были изменены программно.


Комбинация локальных и удалённых данных

Часть данных может храниться локально.


Пример гибридного подхода

const localData = [
    "HTML",
    "CSS",
    "JavaScript"
];

input.addEventListener("input", async () => {

    const response = await fetch(
        `/api/search?q=${input.value}`
    );

    const remoteData = await response.json();

    awesomplete.list = [
        ...localData,
        ...remoteData
    ];
});

Динамическое обновление list

Свойство list можно менять неограниченное количество раз.

awesomplete.list = data;

Awesomplete автоматически:

  • фильтрует элементы;
  • пересоздаёт dropdown;
  • обновляет навигацию;
  • синхронизирует состояние списка.

Оптимизация производительности

При работе с динамическими данными важны:

Debounce

Снижает количество запросов.

Кэш

Убирает повторные обращения.

Серверная фильтрация

Не перегружает браузер.

Ограничение результатов

Ускоряет рендеринг.

Отмена запросов

Устраняет race condition.

Минимальная длина запроса

Снижает нагрузку на backend.


Типичные ошибки

Отсутствие debounce

Приводит к лавинообразным запросам.


Загрузка всего массива

fetch("/api/users")

Плохо масштабируется.


Игнорирование ошибок

Может полностью сломать интерфейс.


Отсутствие отмены запросов

Вызывает отображение устаревших данных.


Слишком большой список

Dropdown становится медленным и неудобным.


Практический сценарий: поиск пользователей

Ответ сервера

[
    {
        "id": 15,
        "name": "John Smith",
        "email": "john@example.com"
    }
]

Конфигурация Awesomplete

const awesomplete = new Awesomplete(input, {

    data(item) {

        return {
            label: `${item.name} (${item.email})`,
            value: item
        };
    },

    replace(item) {

        this.input.value = item.value.name;

        this.input.dataset.userId = item.value.id;
    }
});

Что получает приложение

После выбора элемента:

<input
    value="John Smith"
    data-user-id="15"
>

В поле отображается имя пользователя, а ID сохраняется отдельно.


Использование async/await

Современный стандарт для асинхронного кода.

Преимущества

  • читаемость;
  • линейный стиль;
  • удобный try/catch;
  • простая обработка ошибок;
  • меньше вложенности.

Альтернатива через Promise

fetch("/api/search")
    .then(response => response.json())
    .then(data => {
        awesomplete.list = data;
    });

Почему async/await удобнее

Код проще масштабировать:

try {

    const response = await fetch(url);

    const data = await response.json();

    awesomplete.list = data;

} catch (error) {

    console.error(error);
}

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