DarkRiDDeR13 мин

Разбор: зависший фильтр и неожиданное применение async-результата

JavaScriptАсинхронностьРазбор

Представим экран поиска: пользователь быстро меняет строку, а после этого список на мгновение замирает и иногда показывает результат предыдущего запроса. У такой картины минимум две возможные причины. Первая — тяжёлый синхронный разбор или рендер занял главный поток. Вторая — независимый более старый async-результат применился позже нового. Если назвать обе проблемы «event loop тормозит», команда выберет случайный timeout и получит два скрытых дефекта вместо одного явного.

Это учебный полевой разбор, а не отчёт о настоящем продукте и не обещание конкретных цифр. Мы берём один сценарий, добавляем идентификатор запуска и измеряем только свои границы. Затем классифицируем запись: sync, Promise continuation, timer task или сетевой результат. После классификации становится видно, где нужна отмена/актуальность результата, а где — сокращение работы на main thread.

Фиксируем симптом до правки

Перед оптимизацией полезно записать четыре вещи: входную строку фильтра, номер запуска, размер обрабатываемого массива и метки времени вокруг своих функций. Нельзя фиксировать «страница подвисла примерно на секунду» и сразу переносить всё в Promise. Такое наблюдение не говорит, был ли основной расход в JSON.parse, сортировке, DOM-обновлении или ожидании ответа. Номер запуска особенно важен: он отделяет задержку от ситуации, когда поздний результат принадлежит уже не текущей строке поиска.

НаблюдениеКонкурирующая гипотезаЧто добавить в трассуДействие после доказательства
Click/input не отвечает во время фильтраДлинный sync-код или layout на main threadВремя до/после parse, filter, sort, commit; trace главного потокаСократить алгоритм, нарезать CPU-работу или вынести её в worker
Старый список заменяет новыйНезависимый старый запрос завершился позжеНомер запуска в start, response и commitИгнорировать или отменить устаревший результат
Promise callback раньше timer callbackШтатный microtask checkpointИсточник callback и относительный порядок метокВыразить зависимость Promise-цепочкой, не добавлять задержку
Commit происходит поздно при коротком JS-кодеСторонний скрипт, layout, background throttlingPerformance trace и контекст вкладкиЛокализовать реального владельца задержки до переписывания бизнес-кода

Таблица задаёт порядок обсуждения. В ней нет колонки «добавить debounce» специально: debounce может снизить число запусков, но не доказывает ни причину фриза, ни корректность актуального результата. Его можно применить после диагностики как продуктовую политику, но он не заменяет номер запуска и не делает тяжёлую одну операцию дешёвой.

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

Временный helper ниже не подменяет profiler. Его задача — дать каждому событию одинаковую форму и не потерять причинную связь в смешанном логе. В продакшен-аналитику не стоит отправлять сырые строки запроса без согласованной политики данных; для локальной диагностики можно писать в Console. Отдельно помечаем, что функция стартовала синхронно, а Promise callback запустился только после ответа.

let activeRun = 0;

function trace(runId, label, extra) {
  console.log({
    runId,
    label,
    at: performance.now(),
    extra,
  });
}

async function refreshSearch(query) {
  const runId = ++activeRun;
  trace(runId, 'sync: start request', { queryLength: query.length });

  const response = await fetch('/api/search?q=' + encodeURIComponent(query));
  trace(runId, 'microtask: response available', { status: response.status });

  const payload = await response.json();
  trace(runId, 'microtask: parsed payload', { count: payload.items.length });

  if (runId !== activeRun) {
    trace(runId, 'drop: stale result');
    return;
  }

  renderResults(payload.items);
  trace(runId, 'sync: committed current result');
}

Здесь Promise не делает ответы сети упорядоченными по номеру запуска. Два fetch могут завершиться в любом порядке, потому что на них влияют сервер, сеть, кеш и отмена. Условие runId !== activeRun описывает бизнес-правило: экран применяет только актуальный результат. В реальном проекте иногда лучше отменить прошлый запрос через поддерживаемый механизм, но даже при отмене проверка актуальности полезна: отмена может прийти после того, как ответ уже начал обрабатываться.

Не следует читать метку microtask: response available как утверждение, что сама сеть работает в microtask. Она означает, что continuation вашей async-функции после resolved Promise запущена как Promise-реакция. Это точность формулировки важна в ревью: так мы не приписываем браузеру один глобальный порядок и не спорим о словах вместо трассы.

Схема диагностики: лог с номером запуска и performance.now ведёт к классификации sync, microtask, task или долгой работы и затем к целевому действию
Сначала собираем повторяемую трассу, потом выбираем между контролем актуальности результата и устранением блокировки главного потока.

Ищем синхронную границу отдельно от сети

Даже правильный номер запуска не исправит freeze, если после ответа код синхронно сортирует большой массив и строит тысячи DOM-узлов. Поэтому ставим точки не только вокруг fetch, но и вокруг локальных шагов. В учебном фрагменте ниже работа названа явно. В приложении названия должны соответствовать реальным функциям: parseCatalog, normalizeOffer, renderVisibleRows. Тогда trace и стек профиля можно сопоставить без догадки по абстрактному processData.

function measure(label, work) {
  const startedAt = performance.now();
  const value = work();
  const finishedAt = performance.now();

  console.log({
    label,
    duration: finishedAt - startedAt,
  });

  return value;
}

function prepareCurrentItems(items) {
  const filtered = measure('sync: filter', () => filterItems(items));
  const sorted = measure('sync: sort', () => sortItems(filtered));

  return measure('sync: map view-model', () => makeViewModels(sorted));
}

Такая обвязка годится для временного локального опыта, но не заменяет trace браузера. Она измеряет только тело переданной функции и не покажет, что случилось между двумя вызовами: GC, style recalculation или работа внешнего виджета. После нахождения подозрительной функции открываем performance trace на том же сценарии. Если самый длинный участок — ваш sortItems, можно говорить об алгоритме. Если перед commit доминирует layout, перенос вычисления в worker не решит весь симптом.

Порог «длинной» работы нельзя выдумывать из воздуха. Спецификация Long Tasks оперирует работой от 50 ms, включая задачу и следующий microtask checkpoint, но пользовательская чувствительность зависит от контекста. В 2019-проекте полезнее сначала получить свои длительности на повторяемом наборе, затем поставить SLO или бюджет для конкретного экрана. Число становится решением команды, а не чужой фразой из статьи.

Ошибочный фикс: завернуть весь расчёт в Promise

Часто код меняют так: Promise.resolve(items).then(prepareCurrentItems). Внешне функция стала «асинхронной», но prepareCurrentItems всё ещё полностью выполняется на главном потоке внутри одной microtask. Более того, если по завершении она сразу ставит ещё несколько Promise-реакций, browser task с input продолжит ждать checkpoint. Такая правка может поменять порядок в тесте, но не уменьшить длительность тяжёлой работы.

function refreshBad(items) {
  return Promise.resolve(items)
    .then(prepareCurrentItems)
    .then(renderResults);
}

function refreshWithSlices(items, onDone) {
  let index = 0;
  const prepared = [];

  function runSlice() {
    const deadline = performance.now() + 8;

    while (index < items.length && performance.now() < deadline) {
      prepared.push(normalizeItem(items[index]));
      index += 1;
    }

    if (index < items.length) {
      setTimeout(runSlice, 0);
      return;
    }

    onDone(prepared);
  }

  runSlice();
}

Второй вариант не является готовой заменой первого. Он специально показывает конструкцию проверки: каждая порция завершается, следующая ставится как будущая task, а результат отдаётся один раз. На практике надо решить, где сортировка, как отменить старый runId, сколько памяти допускает промежуточный массив и как не делать полный DOM commit для уже устаревшей строки поиска. Порции дают event loop шанс выбрать другую работу, но не отменяют необходимость измерить пользовательский сценарий.

Как собрать доказательство без выдуманного benchmark

Для первого прохода выбираем искусственный, но повторяемый набор: например, сохранённый JSON-ответ с фиксированным количеством карточек. Записываем браузер, режим CPU throttling если он включён, введённую строку, номер запуска и время трёх локальных функций. Потом повторяем тот же ввод после изменения. Не публикуем «ускорили в два раза», пока нет зафиксированных результатов на той же методике; в статье и в код-ревью достаточно указать, что команда должна собрать этот набор.

Trace нужен для связи между цифрой и реальным владельцем времени. В нём ищем длительный обработчик, вложенные JS-функции, network callback и момент input. Если trace показывает, что input пришёл после вашего длинного синхронного блока, это подтверждает CPU-ветку. Если всё время ушло до ответа, решаем сетевую/серверную задачу. Если старый runId дошёл до commit, решаем ветку актуальности результата. Эти исходы не конкурируют; один экран может требовать двух отдельных изменений.

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

  1. Выбрать один входной сценарий: последовательность строк, сохранённый ответ и действие пользователя. Не смешивать в первой записи несколько багов.
  2. Добавить временные trace-метки с номером запуска и performance.now() вокруг запроса, parse, CPU-обработки и commit.
  3. Запустить сценарий дважды и сравнить относительный порядок. Если записи различаются, сначала найти внешний источник недетерминизма, а не усреднять вывод.
  4. Открыть performance trace и сопоставить длинный участок с собственными именованными функциями. Установить, блокирует ли main thread JS, layout или чужой код.
  5. Для старого результата ввести явное правило актуальности либо отмену. Для CPU-участка снизить объём, заменить алгоритм, нарезать работу или перенести её в worker.
  6. После правки повторить тот же вход. Готовность — устаревший run не коммитится, а синхронная граница стала измеряемой и укладывается в согласованный бюджет.

Границы решения

  • Номер запуска защищает отображение от устаревшего результата, но не делает старый запрос бесплатным. Если запросы дороги, добавляется поддерживаемая отмена и серверная политика.
  • Нарезка CPU-работы меняет порядок наблюдаемых промежуточных состояний. Нельзя коммитить частичный список без отдельного UX-решения.
  • Timer используется здесь как способ создать будущую task, а не как обещание кадра через ноль миллисекунд. Реальная задержка измеряется на целевом сценарии.
  • Worker не имеет прямого доступа к DOM. Его внедрение требует контракта сообщений, копирования/передачи данных и проверки актуальности ответа.
  • Performance trace должен собираться с учётом приватности тестовых данных. В production нельзя бездумно логировать поисковые строки и содержимое ответа.

Итог

У зависшего поиска есть два независимых вопроса: что заняло главный поток и какой результат имеет право менять экран. Event loop помогает сформулировать трассу, но не заменяет её. С номером запуска мы видим гонку результатов; с метками вокруг синхронных функций и trace мы видим блокировку. После этого исправление становится узким: актуальность решается явным правилом, CPU-нагрузка — алгоритмом, порциями или worker. Таймер остаётся диагностическим инструментом, а не пластырем на оба симптома.

Проверяемые источники

  • HTML Standard: Event loops — модель очередей задач, выбор следующей задачи и microtask checkpoint в браузере
  • ECMAScript: Jobs and Job Queues — языковая модель Job и host hook для постановки Promise-реакции
  • W3C High Resolution Time — семантика монотонной временной шкалы и метода performance.now()
  • W3C Long Tasks API — почему длинная работа на UI-потоке задерживает input, обработчики и отрисовку