{ "index": 316, "slug": "editorial-2019-03-field-event-loop", "title": "Зависший фильтр: как отделить блокировку event loop от гонки ответов", "excerpt": "Фильтр может тормозить по двум разным причинам: тяжёлая работа блокирует главный поток, а старый async-ответ перезаписывает новый. Разбираем обе ветки по трассе, коду и проверяемому критерию готовности.", "contentHtml": "

Пользователь вводит строку в поле поиска. Список перестаёт реагировать, а через мгновение показывает не последний запрос, а предыдущий. Цена ошибки — не только раздражение. Тяжёлый экранный код задерживает ввод, а устаревший ответ может показать неверные данные или вернуть человеку уже отменённый выбор.

У симптома две независимые причины. Синхронный parse, фильтр, сортировка или рендер занимают главный поток. Другой запрос завершается позже и получает право изменить экран, хотя уже не относится к текущему вводу. Если назвать всё «тормозами event loop», команда добавит задержку и не исправит ни блокировку, ни порядок результатов.

Тезис простой: сначала нужно построить трассу, затем разделить время CPU и актуальность результата. Promise не создаёт второй поток и не упорядочивает независимые ответы. Для CPU-работы нужны меньший объём, другой алгоритм, порции или worker. Для ответа нужен явный признак актуальности и, при необходимости, отмена.

Что происходит между вводом и экраном

Обработчик ввода запускает новый поиск. До первого await он работает синхронно. После готовности Promise продолжение функции попадает в очередь Promise jobs, а браузер выполняет его на том же агенте. Сетевой ответ может прийти в любом порядке. Успешный старый запрос не знает, что пользователь уже ввёл другой текст.

В браузерной модели текущая task выполняет JavaScript до конца. После неё выполняется microtask checkpoint. Если функция внутри этого участка сортирует большой массив или создаёт тысячи узлов, интерфейс ждёт. Передача той же функции в Promise.resolve().then() только меняет очередь. Она не прерывает вычисление.

let activeRun = 0;\n\nfunction trace(runId, label, extra = {}) {\n  console.log({\n    runId,\n    label,\n    at: performance.now(),\n    ...extra,\n  });\n}\n\nasync function refreshSearch(query) {\n  const runId = ++activeRun;\n  trace(runId, 'start', { queryLength: query.length });\n\n  const response = await fetch('/api/search?q=' + encodeURIComponent(query));\n  trace(runId, 'response', { status: response.status });\n\n  const payload = await response.json();\n  trace(runId, 'parsed', { count: payload.items.length });\n\n  if (runId !== activeRun) {\n    trace(runId, 'drop-stale');\n    return;\n  }\n\n  renderResults(payload.items);\n  trace(runId, 'commit-current');\n}

Это учебный пример. Он не измеряет реальный продукт и не обещает конкретной задержки. runId задаёт правило: экран принимает только последний запуск. Проверка нужна даже при отмене запроса. Отмена может произойти после того, как ответ уже начал выполняться. В production-логи не следует бездумно отправлять строку поиска; здесь в трассу попадает только её длина.

Симптом → причина → проверка → действие

СимптомПричинаПроверкаДействие
Поле не принимает ввод во время фильтраДлинный синхронный JS, layout или commitPerformance trace и метки вокруг parse, filter, sort, renderСократить работу, изменить алгоритм, нарезать её или перенести CPU-часть в worker
Старый список заменяет новыйПоздний async-ответ не проверяет актуальностьСравнить runId в start, response и commitОтбросить устаревший результат; добавить AbortController, если это поддерживает контракт
Promise callback опережает timerОбычный microtask checkpointЗаписать источник callback и относительный порядокНе добавлять timeout для «исправления порядка»; выразить зависимость через Promise
Короткий JS, но commit запаздываетLayout, сторонний скрипт или ограничение фоновой вкладкиПроверить полный trace и состояние вкладкиНайти владельца времени до переписывания бизнес-кода

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

\"Трасса
Сначала фиксируем порядок событий, затем выбираем действие для конкретной причины.

Измеряем синхронную границу

Трасса запроса не показывает, сколько времени съел локальный код после ответа. Поставьте метки вокруг названных операций. Не используйте одну метку processData: по ней нельзя сопоставить лог с профилем.

function measure(label, work) {\n  const startedAt = performance.now();\n  const value = work();\n  const duration = performance.now() - startedAt;\n\n  console.log({ label, duration });\n  return value;\n}\n\nfunction prepareCurrentItems(items) {\n  const filtered = measure('sync: filter', () => filterItems(items));\n  const sorted = measure('sync: sort', () => sortItems(filtered));\n  return measure('sync: view-model', () => makeViewModels(sorted));\n}

Helper измеряет только тело функции. Он не покажет сборку мусора, перерасчёт стилей или работу виджета между вызовами. Поэтому после локальной оценки нужен Performance trace того же сценария. Если длинный участок принадлежит sortItems, исправляйте алгоритм или объём. Если время уходит в layout, worker не устранит всю задержку.

Порог 50 миллисекунд из Long Tasks API — полезный технический ориентир для длинной задачи, а не готовый бюджет конкретного экрана. Чувствительность зависит от устройства, частоты ввода и сценария. Число в критерии готовности нужно получить на выбранном воспроизводимом входе и согласовать для этого интерфейса.

Почему наивный фикс не работает

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

В этом варианте prepareCurrentItems всё равно выполняется целиком на главном потоке. Promise отложил старт до microtask, но не разрешил браузеру прервать сортировку. Цикл microtask также может задержать следующую task, если постоянно добавляет новые продолжения.

Если алгоритм уже выбран правильно, работу можно разделить на порции. Каждая порция заканчивается, а следующая становится отдельной task. Это учебная конструкция, а не готовая библиотека: она требует правила отмены, контроля памяти и единственного commit.

function refreshWithSlices(items, onDone) {\n  let index = 0;\n  const prepared = [];\n\n  function runSlice() {\n    const deadline = performance.now() + 8;\n\n    while (index < items.length && performance.now() < deadline) {\n      prepared.push(normalizeItem(items[index]));\n      index += 1;\n    }\n\n    if (index < items.length) {\n      setTimeout(runSlice, 0);\n      return;\n    }\n\n    onDone(prepared);\n  }\n\n  runSlice();\n}

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

Порядок действий

  1. Выберите один сценарий: последовательность строк, фиксированный ответ и действие пользователя. Не смешивайте несколько дефектов.
  2. Добавьте runId и performance.now() вокруг запроса, parse, локальной обработки и commit.
  3. Повторите сценарий на одном входе. Запишите относительный порядок start, response, parsed и commit. Не усредняйте разные трассы без объяснения расхождения.
  4. Откройте Performance trace и найдите длинный участок. Отделите ваш JS от layout, GC и стороннего кода.
  5. Если commit устарел, введите проверку актуальности и подходящую отмену. Если блокирует CPU, сократите работу, измените алгоритм, используйте порции или worker.
  6. Повторите тот же сценарий после правки. Проверьте также отрицательный путь: старый запрос завершается последним, а его данные не меняют экран.

Ограничения

Номер запуска защищает экран, но старый запрос всё равно может расходовать сеть и сервер. Для дорогих запросов нужна поддерживаемая отмена и отдельная политика на сервере. Нарезка работы меняет промежуточные состояния. Нельзя показывать частичный список без явного UX-решения. Worker требует сериализации данных и контроля версии результата. Фоновая вкладка, throttling, layout и сторонние скрипты меняют наблюдаемое время. Проверяйте их отдельно.

Не называйте учебные числа production-результатом. В статье нет утверждения об ускорении конкретного продукта. Воспроизводимый ответ, выбранное устройство и trace нужны, чтобы сделать такой вывод в своей среде.

Критерий готовности

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

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

" }