Перейти к содержанию

Производительность браузерного поиска

Поиск с подсказками выполняет всё больше работы: загружает документы, строит индекс, сравнивает варианты и создаёт карточки. На маленьком наборе паузы могут быть незаметны. Для большой библиотеки нужно понять, где расходуется время и какие вычисления мешают вводу и прокрутке.

В этом уроке перенесём индекс в Web Worker. Основной поток будет управлять формой и DOM, а рабочий поток — хранить данные и выполнять запросы. Это не гарантирует меньшую общую длительность, но отделяет вычисления от интерфейса.

Какие задержки различать

Первая выдача включает сеть, разбор JSON и построение индекса. Следующие запросы могут использовать уже готовую структуру. Поэтому среднее число без указания холодного или повторного запуска мало объясняет.

Отдельно рассматриваем время самого алгоритма и время от действия пользователя до готовых карточек. Второе включает передачу сообщений и отображение. Быстрый поиск внутри Worker не означает столь же быструю полную страницу.

Размер данных тоже относится к наблюдению. Сжатый файл в сети, распакованные строки и структуры Map в памяти — разные величины. Перенос в Worker меняет место хранения, но не делает сам индекс невесомым.

Web Workers выполняют скрипты в отдельном контексте и обмениваются сообщениями с основным потоком. Их устройство описано в руководстве MDN. Worker не управляет обычным DOM страницы.

Рабочий поток

Создайте public/worker.js. Модуль использует полный core.js, полученный после фильтров и пагинации. Ниже полная версия обработчика:

import { buildIndex, searchIndex, normalize } from "./core.js";
let indexPromise;

function loadIndex() {
  if (!indexPromise) {
    const started = performance.now();
    indexPromise = fetch("./documents.json").then(async response => {
      if (!response.ok) throw new Error("Индекс недоступен");
      const payload = await response.json();
      if (payload.schemaVersion !== 1 || !Array.isArray(payload.documents)) {
        throw new Error("Неверная схема");
      }
      const loaded = performance.now();
      const index = buildIndex(payload.documents);
      return { index, version: payload.version,
        loadMs: loaded - started, buildMs: performance.now() - loaded };
    }).catch(error => { indexPromise = undefined; throw error; });
  }
  return indexPromise;
}

self.addEventListener("message", async event => {
  const { id, type, query, options } = event.data;
  try {
    if (typeof query !== "string" || query.length > 200) throw new Error("Неверный запрос");
    const loaded = await loadIndex();
    const started = performance.now();
    let data;
    if (type === "search") data = searchIndex(loaded.index, query, options);
    else if (type === "suggest") {
      const needle = normalize(query);
      data = [...loaded.index.byId.values()]
        .filter(doc => normalize(doc.title).includes(needle))
        .slice(0, 5).map(({ id, title }) => ({ id, title }));
    } else throw new Error("Неизвестная операция");
    self.postMessage({ id, ok: true, data, version: loaded.version,
      timing: { loadMs: loaded.loadMs, buildMs: loaded.buildMs,
        queryMs: performance.now() - started } });
  } catch {
    self.postMessage({ id, ok: false, error: "Поиск недоступен" });
  }
});

Индекс загружается при первой операции. Это может быть отправка формы или появление подсказок. Страница не скачивает весь корпус только из-за открытия главной, где поиск пока не используется.

Один Promise объединяет параллельные первые обращения. Ошибка сбрасывает его, чтобы следующий запрос мог повторить загрузку. Полный корпус остаётся в Worker; основной поток получает только текущие карточки или короткие названия.

Измерения выполняются через performance.now(). Они относятся к одному контексту Worker. Для сравнения абсолютных отметок разных контекстов понадобилось бы учитывать их time origin; здесь передаются уже вычисленные длительности.

Клиент сообщений

Создайте public/worker-client.js. Он связывает номер сообщения с Promise и обрабатывает потерю рабочего потока:

export function createWorkerClient() {
  let worker;
  let sequence = 0;
  const pending = new Map();

  function ensure() {
    if (worker) return worker;
    worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
    worker.addEventListener("message", event => {
      const item = pending.get(event.data.id);
      if (!item) return;
      clearTimeout(item.timer);
      pending.delete(event.data.id);
      if (event.data.ok) item.resolve(event.data);
      else item.reject(new Error(event.data.error));
    });
    worker.addEventListener("error", () => {
      for (const item of pending.values()) {
        clearTimeout(item.timer);
        item.reject(new Error("Worker недоступен"));
      }
      pending.clear();
      worker.terminate();
      worker = undefined;
    });
    return worker;
  }

  function request(type, values) {
    const target = ensure();
    const id = ++sequence;
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        pending.delete(id);
        reject(new Error("Время ожидания истекло"));
      }, 10000);
      pending.set(id, { resolve, reject, timer });
      try { target.postMessage({ id, type, ...values }); }
      catch (error) {
        clearTimeout(timer); pending.delete(id); reject(error);
      }
    });
  }
  return { request };
}

Номер сообщения отличается от номера текущего интерфейсного запроса. Первый связывает ответ с вызовом, второй помогает игнорировать устаревшую выдачу. Оба механизма сохраняются: корректно доставленный ответ всё ещё может относиться к прежней строке ввода.

Таймер в 10 секунд — учебное ограничение ожидания, а не измеренный норматив производительности. Он освобождает ожидающий вызов, но не отменяет уже выполняющийся алгоритм в Worker. Поздний ответ будет проигнорирован после удаления номера из Map.

Если Worker завершился с ошибкой, ожидающие операции получают отказ, а следующий вызов создаёт новый экземпляр. Не следует оставлять форму в бесконечном состоянии загрузки из-за потерянного обработчика.

Изменения основного модуля

В app.js уберите импорт построения индекса и локальную функцию loadIndex. Подключите клиент:

import { createWorkerClient } from "./worker-client.js";
const client = createWorkerClient();

В refresh замените ожидание индекса и вызов searchIndex следующим фрагментом. Проверка revision остаётся между ответом и отображением:

const response = await client.request("search", {
  query: state.query,
  options: state
});
if (requestId !== revision) return;
last = response.data;
render(last.hits);

В обработчике подсказок вместо чтения byId используется другая операция:

const response = await client.request("suggest", { query });
if (requestId !== suggestionRevision) return;
const found = response.data;

Оставшиеся создание кнопок, подсветка и история браузера сохраняются. Полные согласованные файлы находятся в контрольной версии после урока 12. В ней основной модуль не держит вторую копию всего корпуса ради подсказок.

Наблюдение производительности

Сначала рассмотрите холодное открытие: данные ещё не загружены. Затем выполните несколько запросов на том же индексе. В ответах loadMs и buildMs описывают первоначальную подготовку, а queryMs — конкретную текущую операцию.

Не складывайте время подготовки заново для каждого повторного запроса. Оно передаётся как характеристика загруженной версии. Для оценки полного ожидания измерьте интервал в основном потоке вокруг client.request и отображения.

Метки и длительности можно записывать через Performance API. Назначение измерений описано в справке Performance.measure. В нашей версии достаточно чисел в отладочном наблюдении; отправка статистики внешнему сервису не требуется.

Для синтетической нагрузки можно увеличить число документов, присвоив им уникальные ID. Такой набор помогает увидеть рост структуры, но не измеряет качество поиска реальной библиотеки. Повторение одинакового текста также плохо воспроизводит рост разнообразного словаря.

Кеширование и границы

Здесь есть кеш в памяти Worker. После перезагрузки страницы он создаётся заново. HTTP-кеш браузера — другой механизм, а хранение подготовленного индекса в IndexedDB — ещё один отдельный вариант.

Кеш обязан учитывать версию корпуса и алгоритма. Иначе новая статья может отсутствовать в поиске даже при успешной загрузке интерфейса. Следующий блок разберёт обновление данных и согласованность версии.

Передача большого результата через сообщения тоже имеет стоимость. Мы ограничиваем страницу и число подсказок, но отдельная карточка может содержать длинный основной текст. В дальнейшем полезно передавать только необходимые поля и готовый фрагмент.

Worker не делает браузерный поиск подходящим для любой библиотеки. Если индекс слишком велик для устройства или его загрузка занимает неприемлемое время, понадобится другое устройство сервиса. Сначала сохраняем наблюдения, затем принимаем архитектурное решение.

Сравнивая две версии, сохраняйте условия наблюдения: один корпус, одинаковый запрос, устройство и состояние загрузки. Холодный запрос одной версии нельзя напрямую сопоставить с повторным запросом другой. Запишите несколько наблюдений отдельно для подготовки и обращения к готовому индексу; единичное удачное число плохо описывает поведение посетителя.

Обратите внимание и на саму форму во время построения индекса. Worker может сохранить возможность печатать и переключать элементы, хотя итоговая выдача появляется не раньше прежнего. Это самостоятельное улучшение отзывчивости. Оно не доказывает уменьшение вычислительной работы, поэтому в редакционном выводе полезно отдельно описывать ожидание результата и способность взаимодействовать со страницей.

Теперь готова вторая контрольная версия: форма, подсказки, фрагменты и индекс в рабочем потоке. Следующий урок оценит, помогают ли все эти улучшения находить нужные материалы, а не только выполнять запросы.