revise February to May 2019 articles
Build and deploy / deploy (push) Successful in 13s

This commit is contained in:
2026-07-31 10:32:34 +03:00
parent e8afc8454a
commit 238c1f7688
22 changed files with 2913 additions and 1 deletions
+484
View File
@@ -0,0 +1,484 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(code) {
return '<pre><code>' + escapeHtml(String(code).trim()) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function dataTable(headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function visibleText(html) {
return html
.replace(/<[^>]*>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function proseText(html) {
return visibleText(
html
.replace(/<pre><code>[\s\S]*?<\/code><\/pre>/g, '')
.replace(/<figure>[\s\S]*?<\/figure>/g, '')
.replace(/<div class="table-scroll">[\s\S]*?<\/div>/g, ''),
);
}
function createRevision(meta, bodyParts, sources) {
const bodyHtml = bodyParts.join('\n');
const proseLength = proseText(bodyHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength);
}
if (sources.length < 2) {
throw new Error(meta.slug + ': at least two primary sources are required');
}
return {
...meta,
contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'),
proseLength,
};
}
const htmlEventLoops = {
title: 'HTML Standard: Event loops',
url: 'https://html.spec.whatwg.org/multipage/webappapis.html#event-loops',
note: 'модель очередей задач, выбор следующей задачи и microtask checkpoint в браузере',
};
const ecmaJobs = {
title: 'ECMAScript: Jobs and Job Queues',
url: 'https://tc39.es/ecma262/multipage/control-abstraction-objects.html#sec-jobs-and-job-queues',
note: 'языковая модель Job и host hook для постановки Promise-реакции',
};
const highResolutionTime = {
title: 'W3C High Resolution Time',
url: 'https://www.w3.org/TR/hr-time-3/',
note: 'семантика монотонной временной шкалы и метода performance.now()',
};
const longTasks = {
title: 'W3C Long Tasks API',
url: 'https://w3c.github.io/longtasks/',
note: 'почему длинная работа на UI-потоке задерживает input, обработчики и отрисовку',
};
const practiceArticle = createRevision(
{
slug: 'editorial-2019-03-practice-event-loop',
title: 'Event loop: как воспроизвести порядок Promise, таймера и синхронного кода',
categories: ['JavaScript', 'Асинхронность', 'Практика'],
cover: '/assets/editorial/2019/event-loop-order-2019.svg',
excerpt: 'Promise-обработчик приходит раньше таймера, а кнопка перестаёт реагировать. Ставим короткий опыт, различаем очередь и долгую синхронную работу, затем выбираем способ исправления.',
readingMinutes: 11,
},
[
paragraph('Симптом обычно формулируют неточно: «асинхронность поменяла порядок» или «таймер не сработал вовремя». На странице это выглядит конкретнее: обработчик <code>Promise.then</code> пишет в лог раньше <code>setTimeout</code>, а после импорта данных кнопка несколько мгновений не отвечает. Если начать менять задержки на глаз, можно скрыть один запуск и оставить ту же блокировку на другом устройстве.'),
paragraph('Ниже не «объяснение магии Promise», а маленький воспроизводимый маршрут. Мы сначала записываем порядок синхронных строк, Promise-реакции и timer callback. Потом отдельно создаём длинную синхронную работу и измеряем её границы через <code>performance.now()</code>. Так одна проблема распадается на две: неожиданная очередность и занятый главный поток.'),
heading('Что именно наблюдаем'),
paragraph('В браузерном коде есть как минимум текущий вызов JavaScript, задачи, которые выбирает event loop, и microtask checkpoint. Promise-реакция не прерывает уже исполняющуюся функцию. Она попадает в работу после того, как текущий стек освободится. Callback таймера тоже не появляется в середине этой функции: истекшая задержка делает его кандидатом на будущую задачу. Отсюда первое правило: «через ноль миллисекунд» означает не «немедленно».'),
paragraph('Нельзя сводить всё к одной универсальной очереди. HTML-стандарт оперирует очередями задач и источниками задач; браузер выбирает, что исполнить дальше по собственному алгоритму. Поэтому для реального сбоя важно записать происхождение каждого callback: пользовательское событие, timer, сетевое завершение, Promise-цепочка или ваш прямой вызов. Один только номер строки в Console не доказывает порядок между разными источниками.'),
dataTable(
['Наблюдаемый фрагмент', 'Куда попадает работа', 'Что не обещает механизм', 'Первая проверка'],
[
['Обычный вызов функции', 'Текущий стек JavaScript', 'Что браузер отрисует до возврата из функции', 'Поставить отметки до и после вызова'],
['<code>Promise.resolve().then(...)</code>', 'Promise Job, который host запускает как microtask', 'Прерывание текущей синхронной функции', 'Сравнить место отметки с концом текущего стека'],
['<code>setTimeout(fn, 0)</code>', 'Будущая задача после истечения минимальной задержки', 'Точный момент запуска и превосходство над другими источниками задач', 'Записать метку внутри callback, не только перед постановкой'],
['Тяжёлый цикл или parse JSON', 'Тот же текущий стек', 'Реакцию на input, пока цикл не вернул управление', 'Измерить начало и конец работы, посмотреть performance trace'],
],
),
paragraph('Эта таблица не заменяет спецификацию конкретного API. Она нужна для первого разворота расследования. Если в лог попал <code>fetch</code>, сначала устанавливаем, что именно логируется: момент старта запроса, Promise после ответа или собственная функция разбора. Обещание «всё async» здесь бесполезно — важна граница, на которой ваш код вернул управление браузеру.'),
heading('Минимальный опыт с порядком callback'),
paragraph('Откройте чистую вкладку браузера и вставьте пример в Console либо во временный модуль страницы. Он не измеряет скорость сети и не сравнивает браузеры. Он фиксирует только относительный порядок четырёх точек внутри одного сценария. Время сохраняем рядом с названием, но проверяем именно список меток: абсолютные миллисекунды зависят от нагрузки и точности часов.'),
codeBlock(String.raw`
const marks = [];
function mark(label) {
marks.push({ label, at: performance.now() });
}
mark('A: sync start');
setTimeout(() => {
mark('C: timer task');
console.table(marks);
}, 0);
Promise.resolve().then(() => {
mark('B: Promise microtask');
});
mark('D: sync end');
`),
paragraph('Для этого опыта ожидаемая причинная запись — <code>A</code>, затем <code>D</code>, затем <code>B</code>, затем <code>C</code>. Сначала заканчивается текущий синхронный фрагмент. После него браузер выполняет microtask checkpoint, в котором может отработать Promise-реакция. Таймерная задача берётся позже, когда event loop выберет следующую доступную работу. Не подменяйте это ожидание тестом вроде «разница всегда ровно 0 или 4 ms»: такого договора у кода нет.'),
paragraph('Полезнее добавить к каждой отметке источник. В проекте через неделю появится ещё один <code>then</code> или debounce, и голый лог <code>1, 2, 3</code> перестанет объяснять причину. Название <code>search: parsed response</code> или <code>filter: timer commit</code> делает цепочку пригодной для диффа между двумя запусками. Временную диагностику затем удаляем либо оставляем под локальным флагом, чтобы не слать шум в production-логи.'),
figure('/assets/editorial/2019/event-loop-order-2019.svg', 'Схема порядка: синхронный стек заканчивается первым, затем выполняется Promise microtask, после чего event loop может взять timer-задачу', 'Минимальный опыт проверяет последовательность A → D → B → C; задержка таймера не является обещанием точной секунды запуска.'),
heading('Опыт с зависанием: Promise не выносит вычисление'),
paragraph('Вторая ошибка звучит так: «обернём тяжёлую функцию в Promise — интерфейс перестанет виснуть». Если внутри Promise сразу выполняется большой цикл, всё остаётся на том же главном потоке. <code>Promise.resolve().then(run)</code> лишь переносит начало <code>run</code> в microtask; сама функция всё равно занимает поток целиком, пока не вернёт управление. Пользовательский input и следующая отрисовка ждут эту границу.'),
codeBlock(String.raw`
function blockFor(milliseconds) {
const startedAt = performance.now();
while (performance.now() - startedAt < milliseconds) {
// Искусственная нагрузка для опыта. Результат вычисления не важен.
}
}
document.querySelector('.js-run-check').addEventListener('click', () => {
const startedAt = performance.now();
blockFor(120);
const finishedAt = performance.now();
console.log('sync work duration', finishedAt - startedAt);
});
`),
paragraph('Число <code>120</code> здесь — параметр искусственного опыта, не обещание метрики для сайта. После клика смотрим два факта: длительность, записанную самим сценарием, и виден ли этот участок в профиле производительности. Пока выполняется цикл, другой click handler на этой же странице не начнёт JavaScript-работу. Если нужно сравнить правки, запускайте один и тот же сценарий с той же входной строкой и фиксируйте условия: браузер, профиль CPU и размер данных.'),
paragraph('Не пытайтесь обнаружить это периодическим «пингом таймера» в боевом коде. Он может сам менять картину и не укажет, какой стек занял время. Для расследования достаточно trace в инструментах браузера; для поддерживаемых сред можно отдельно проверить доступность <code>PerformanceObserver</code> с типом <code>longtask</code>. API длинных задач описывает порог 50 ms, но отсутствие записи не оправдывает ощущаемую пользователем задержку и не заменяет trace.'),
heading('Как отделить очередь от блокирующего кода'),
paragraph('Сбой порядка и зависание часто встречаются рядом, но лечатся по-разному. Если лог показывает, что значение из Promise приходит раньше timer callback, это может быть штатный порядок microtask и задачи. Правка состоит в явной зависимости: вызвать следующий шаг в нужном <code>then</code>, вернуть Promise из функции или хранить состояние в одном месте. Добавлять случайную задержку нельзя: она создаёт гонку, а не контракт.'),
paragraph('Если callback начинает работать поздно, а в профиле перед ним виден длинный синхронный участок, причина другая. Находим работу, которую можно сократить, разбить на куски или перенести в worker. Перенос в <code>setTimeout</code> даёт event loop возможность выбрать другие задачи между порциями, но не делает вычисление быстрым и не гарантирует кадр после каждой порции. Сначала измеряем одну порцию, затем выбираем её размер по данным, а не по красивому числу в коде.'),
heading('Последовательность проверки'),
orderedList([
'Сформулировать один наблюдаемый симптом: какая кнопка, какой лог или какое состояние оказалось не в том порядке. Сохранить входные данные и шаги воспроизведения.',
'Поставить именованные отметки до прямого вызова, после него, внутри Promise-реакции и внутри timer callback. К отметке добавить <code>performance.now()</code> и источник события.',
'Проверить относительный порядок на минимальном примере. Не переносить его автоматически на сетевой ответ, worker или другой tab.',
'Если есть задержка интерфейса, снять performance trace и найти участок синхронной работы на main thread. Отделить ваш код от layout, GC и стороннего скрипта.',
'Для зависимости по данным вернуть Promise или передать результат явным аргументом. Для CPU-работы сократить, нарезать или вынести расчёт; не маскировать причину дополнительным timeout.',
'Повторить сценарий с прежними входными данными. Критерий готовности — понятный порядок меток и измеренная граница тяжёлой работы, а не один случай без ошибки.',
]),
heading('Практический выбор действия'),
paragraph('Если функция должна начаться строго после HTTP-ответа, пусть вызывающая сторона получает Promise и строит дальнейший шаг в его цепочке. Если пользователь меняет фильтр несколько раз, добавьте номер запроса или отмену там, где это поддерживается, — не рассчитывайте, что Promise упорядочит независимые ответы сети. Если на главном потоке происходит сортировка тысяч записей, сначала проверяем, не нужна ли сортировка полностью, затем рассматриваем порции или worker. Это три разные задачи, хотя в Console они могут выглядеть одинаковым «опозданием».'),
paragraph('Текст намеренно не называет универсальный размер порции и не обещает, что один API лечит каждый freeze. Размер зависит от объёма данных, устройства, текущего DOM и конкурирующей работы. Хорошая техническая заметка оставляет читателю не рецепт «добавить timeout», а инструмент: измерить порядок, обнаружить синхронную границу и выбрать действие, соответствующее именно ей.'),
heading('Ограничения опыта'),
bulletList([
'Пример рассчитан на окно браузера. Модель очередей Node.js и поведение worker имеют собственные детали; их нельзя выводить из одного лога в tab.',
'Временная шкала нужна для сравнения точек одного запуска. Точность и доступное разрешение времени зависят от окружения и политики браузера.',
'Между разными источниками задач не следует строить бизнес-логику по случайному порядку. Передавайте зависимость явно через данные или Promise.',
'Длинный участок в trace может включать не только JavaScript. Перед переписыванием цикла надо увидеть, что именно занимает main thread.',
]),
heading('Итог'),
paragraph('Promise не выполняется «раньше всего», а таймер не запускается «ровно через ноль». Сначала заканчивается текущий JavaScript, затем выполняется доступная microtask-работа, после чего event loop выбирает будущую задачу. Когда экран завис, ищем не слово async, а длинную синхронную границу. Этот порядок делает диагноз проверяемым: лог даёт последовательность, профиль даёт длительность, а исправление привязано к конкретной причине.'),
],
[htmlEventLoops, ecmaJobs, highResolutionTime, longTasks],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2019-03-mechanism-event-loop',
title: 'Event loop под капотом: почему Promise обгоняет таймер, но не прерывает код',
categories: ['JavaScript', 'Асинхронность', 'Механизм'],
cover: '/assets/editorial/2019/event-loop-frame-budget-2019.svg',
excerpt: 'Разделяем стек, browser task и Promise Job. Это помогает не обещать таймеру точность и не пытаться лечить тяжёлый синхронный код лишним await.',
readingMinutes: 12,
},
[
paragraph('Сбой начинается с простой фразы в ревью: «поставим <code>await</code>, тогда браузер успеет отрисовать кнопку». Иногда кнопка действительно меняется на конкретной машине, но причина не доказана. Promise-реакция не может вклиниться в середину уже идущей JavaScript-функции. Если перед <code>await</code> был тяжёлый parse или цикл, интерфейс уже ждал; если после <code>await</code> снова идёт тяжёлая работа, он будет ждать следующую границу.'),
paragraph('Разберём механизм без слишком широкой метафоры «у JavaScript одна очередь». В языке есть Jobs и host hooks, а в браузере — event loop, задачи, microtask checkpoint и шаги рендеринга. Для прикладного кода достаточно держать три вопроса: какая работа сейчас на стеке, что поставлено как microtask и какой callback ждёт будущую задачу. Эта тройка объясняет неожиданный порядок Promise и timer без выдуманной точности таймера.'),
heading('Три слоя, которые не стоит смешивать'),
paragraph('Стек исполнения — это то, что выполняется прямо сейчас. Пока синхронная функция не вернулась, браузер не переключит JavaScript на другой callback того же event loop. ECMAScript описывает Jobs как абстрактные единицы работы и определяет host hook для постановки Promise Job. Браузер связывает эту языковую часть с microtask queue: когда он дошёл до checkpoint, накопленные microtasks выполняются до перехода к обычной следующей задаче.'),
paragraph('Task — понятие host-уровня. Timer, пользовательское событие и некоторые другие источники помещают работу в соответствующие очереди задач. Важная техническая оговорка: порядок между разными task source не надо превращать в API вашего приложения. HTML-стандарт описывает выбор задачи, а не обещание «сначала всегда timer, потом всегда input». Поэтому логику сохранения формы нельзя строить на том, что один callback обычно успевал раньше другого на вашем ноутбуке.'),
dataTable(
['Слой', 'Пример', 'Когда способен начаться', 'Полезная диагностика'],
[
['Текущий стек', '<code>renderRows(data)</code>', 'Сразу при вызове и непрерывно до return', 'Поставить отметку до и после функции; увидеть длительность в trace'],
['Promise Job / microtask', '<code>promise.then(commit)</code>', 'После освобождения текущего стека на microtask checkpoint', 'Логировать очередь и не ожидать paint между несколькими microtasks'],
['Browser task', '<code>setTimeout(commit, 0)</code>', 'Когда timer стал доступен и event loop выбрал задачу', 'Проверять источник callback и фактическую задержку, не только аргумент timeout'],
['Обновление rendering', 'style, layout, paint', 'На шаге, который выбирает браузер между задачами', 'Смотреть timeline, а не считать любой timeout гарантией кадра'],
],
),
paragraph('Такое разделение полезно ещё и для терминов в команде. Вместо «эта штука асинхронная» можно сказать: «сейчас синхронно собираем 15 тысяч строк; затем Promise continuation записывает state; commit отложен в task». Фраза длиннее на несколько слов, зато сразу сообщает, где возможна блокировка и где можно поставить проверку. Для 2019-проекта это уже лучше, чем прятать систему в общей функции <code>delay</code>.'),
heading('Порядок Promise и таймера на воспроизводимом коде'),
paragraph('Следующий пример усиливает предыдущий опыт: microtask добавляет ещё одну microtask. Он нужен не для запоминания букв, а чтобы увидеть границу checkpoint. Пока очередь microtasks пополняется, browser task с таймером не становится следующим JavaScript-вызовом только потому, что его задержка уже истекла. Наблюдать стоит относительный порядок меток; их числовое время не является ожидаемым результатом теста.'),
codeBlock(String.raw`
const order = [];
const write = (label) => order.push(label);
write('sync: start');
setTimeout(() => {
write('task: timer');
console.log(order.join(' -> '));
}, 0);
Promise.resolve().then(() => {
write('microtask: first');
Promise.resolve().then(() => {
write('microtask: nested');
});
});
write('sync: end');
`),
paragraph('Семантическая последовательность здесь такая: обе sync-метки появляются до любой Promise-реакции; затем отрабатывает первая и вложенная microtask; после этого доступна timer-задача. Из этого не следует, что любой сетевой callback уступит таймеру или что вкладка всегда будет рисовать между конкретными двумя задачами. Это означает лишь, что собственная цепочка Promise может исчерпать microtask queue до следующего шага event loop.'),
paragraph('Рекурсивно добавлять microtasks опасно. Код, который на каждом <code>then</code> ставит следующий <code>then</code>, может долго не давать браузеру выбрать user input или timer task. Внешне это похоже на обычный цикл, хотя в профиле будут короткие функции подряд. Если работа действительно должна быть дискретной, необходимо определить место, где она отдаёт управление не в новую microtask, а в будущую task либо в другой поток. Без такого места «асинхронность» остаётся лишь дроблением той же блокировки.'),
figure('/assets/editorial/2019/event-loop-frame-budget-2019.svg', 'Длинный синхронный обработчик занимает главный поток и задерживает input, а нарезанные порции оставляют event loop возможность выбрать ожидающие задачи', 'Promise не делает вычисление параллельным: чтобы интерфейс получил шанс на работу, нужно завершить текущую порцию и вернуть управление event loop.'),
heading('Почему await сам по себе не отдаёт кадр'),
paragraph('В <code>async</code>-функции выражение <code>await promise</code> откладывает продолжение до settlement Promise. Если Promise уже resolved, продолжение всё равно не выполняется как обычная синхронная строка: оно ставится в реакцию Promise. Но это именно microtask-граница, а не автоматический «перерыв на rendering». Несколько продолжений могут отработать одна за другой до следующей browser task. Поэтому <code>await Promise.resolve()</code> не стоит применять как договор с UI.'),
codeBlock(String.raw`
async function refreshBad(rawRows) {
const normalized = normalizeAllRows(rawRows); // тяжёлая sync-работа уже блокирует UI
await Promise.resolve();
const ranked = rankAllRows(normalized); // ещё одна тяжёлая sync-работа
renderRows(ranked);
}
`),
paragraph('Функция выше не стала безопасной для кадра из-за одного <code>await</code>. Она разделила процесс на две синхронные части, но не определила бюджет каждой и не доказала, что между ними браузер сможет обработать нужное событие. Правильная следующая проверка — измерить <code>normalizeAllRows</code> и <code>rankAllRows</code> отдельно, увидеть их на main thread и решить, можно ли упростить алгоритм, обрабатывать только видимые элементы или отправить CPU-работу в worker.'),
heading('Как корректно уступать управление'),
paragraph('Иногда полный перенос в worker пока невозможен: код читает DOM или работает с legacy-виджетом. Тогда процесс можно нарезать. Ключевое требование — одна порция имеет ограниченную работу, а планировщик следующей порции создаёт будущую browser task. <code>setTimeout</code> часто используют как простой адаптер, но его задержка не является частотой кадров. Смысл здесь не в числе ноль, а в том, что текущая задача закончилась и у event loop появился выбор.'),
codeBlock(String.raw`
function processInSlices(rows, onDone) {
const result = [];
let index = 0;
function runSlice() {
const deadline = performance.now() + 8;
while (index < rows.length && performance.now() < deadline) {
result.push(normalizeRow(rows[index]));
index += 1;
}
if (index < rows.length) {
setTimeout(runSlice, 0);
return;
}
onDone(result);
}
runSlice();
}
`),
paragraph('В примере восемь миллисекунд — стартовая гипотеза для замера, а не норматив. Он намеренно не делает DOM-обновление на каждой строке: иначе выигрыш от нарезки можно потерять на layout и paint. В реальном коде надо зафиксировать, что считается одной порцией, какие входные данные она берёт и как отменить устаревший процесс. Без отмены пользователь успеет изменить фильтр, а старые порции продолжат занимать main thread уже без пользы.'),
heading('Измерение: что записать до оптимизации'),
paragraph('Для короткого повторяемого опыта достаточно записать начало и конец ваших функций через <code>performance.now()</code>. Спецификация High Resolution Time задаёт подходящую для измерений монотонную шкалу, но не гарантирует одинаковое разрешение в каждом окружении. Запись <code>{ label, startedAt, finishedAt, rowCount }</code> полезнее одного <code>console.time</code>: её можно сопоставить с числом строк и с performance trace.'),
paragraph('В DevTools trace ищем не абстрактный «красный участок», а конкретную связь: обработчик input начал выполнение, затем синхронная функция заняла main thread, а нужный commit произошёл позже. Если в середине видны style/layout, не переписывайте немедленно Promise-цепочку — возможно, основной счёт выставляет DOM. Если доминирует ваш JavaScript, сравните алгоритм на одинаковом объёме данных до и после изменения. Это и есть техническое доказательство, а не впечатление от плавности.'),
heading('Порядок проверки механизма'),
orderedList([
'Выписать callback и его источник: прямой вызов, Promise reaction, timer, input, сетевой ответ или worker message. Не называть все их одним словом «async».',
'Поставить метки вокруг текущего синхронного участка и внутри продолжений. Сначала проверить относительный порядок на чистом минимальном сценарии.',
'Если есть фриз, снять trace и измерить длительность именно тех функций, которые выполняются на main thread. Отделить CPU-код от layout и сторонних скриптов.',
'Для корректной зависимости по данным вернуть Promise или await результат. Для CPU-нагрузки выбрать оптимизацию алгоритма, ограниченные порции или worker.',
'Если используются порции, добавить отмену устаревшей работы и проверку, что commit применяет только актуальный результат.',
'Повторить один входной сценарий и сохранить порядок меток вместе с длительностью. Релизный критерий — конкретно улучшенная граница, а не слово «асинхронно».',
]),
heading('Границы модели'),
bulletList([
'ECMAScript Jobs и browser microtasks связаны host-реализацией. Не переносите детали window в Node.js без проверки его event loop.',
'Timer API не предоставляет точную плановую гарантию. Вкладка в фоне, нагрузка и политика браузера способны увеличить наблюдаемую задержку.',
'Нарезка работы помогает отзывчивости, но не уменьшает количество операций. Если алгоритм лишний, первым исправлением будет алгоритм.',
'Worker отделяет CPU-работу, но требует сериализации данных и явной коммуникации. Он не может напрямую менять DOM окна.',
]),
heading('Итог'),
paragraph('Механизм проще держать как три разных состояния: работа на стеке, накопленные microtasks и будущие tasks. Promise обгоняет таймер в учебном опыте, потому что checkpoint наступает после освобождения текущего стека; Promise не прерывает уже работающий код. Когда нужен отзывчивый UI, сначала измеряем синхронный участок, затем возвращаем event loop управляемую границу или выносим CPU-задачу. Так выбор между <code>await</code>, порцией и worker перестаёт быть религией.'),
],
[htmlEventLoops, ecmaJobs, highResolutionTime, longTasks],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2019-03-field-event-loop',
title: 'Разбор: зависший фильтр и неожиданное применение async-результата',
categories: ['JavaScript', 'Асинхронность', 'Разбор'],
cover: '/assets/editorial/2019/event-loop-trace-2019.svg',
excerpt: 'На фильтре одновременно видны зависание интерфейса и старый ответ, который перезаписывает новый. Сначала строим трассу, затем разделяем CPU-блокировку и гонку результатов.',
readingMinutes: 13,
},
[
paragraph('Представим экран поиска: пользователь быстро меняет строку, а после этого список на мгновение замирает и иногда показывает результат предыдущего запроса. У такой картины минимум две возможные причины. Первая — тяжёлый синхронный разбор или рендер занял главный поток. Вторая — независимый более старый async-результат применился позже нового. Если назвать обе проблемы «event loop тормозит», команда выберет случайный timeout и получит два скрытых дефекта вместо одного явного.'),
paragraph('Это учебный полевой разбор, а не отчёт о настоящем продукте и не обещание конкретных цифр. Мы берём один сценарий, добавляем идентификатор запуска и измеряем только свои границы. Затем классифицируем запись: sync, Promise continuation, timer task или сетевой результат. После классификации становится видно, где нужна отмена/актуальность результата, а где — сокращение работы на main thread.'),
heading('Фиксируем симптом до правки'),
paragraph('Перед оптимизацией полезно записать четыре вещи: входную строку фильтра, номер запуска, размер обрабатываемого массива и метки времени вокруг своих функций. Нельзя фиксировать «страница подвисла примерно на секунду» и сразу переносить всё в Promise. Такое наблюдение не говорит, был ли основной расход в <code>JSON.parse</code>, сортировке, DOM-обновлении или ожидании ответа. Номер запуска особенно важен: он отделяет задержку от ситуации, когда поздний результат принадлежит уже не текущей строке поиска.'),
dataTable(
['Наблюдение', 'Конкурирующая гипотеза', 'Что добавить в трассу', 'Действие после доказательства'],
[
['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 throttling', 'Performance trace и контекст вкладки', 'Локализовать реального владельца задержки до переписывания бизнес-кода'],
],
),
paragraph('Таблица задаёт порядок обсуждения. В ней нет колонки «добавить debounce» специально: debounce может снизить число запусков, но не доказывает ни причину фриза, ни корректность актуального результата. Его можно применить после диагностики как продуктовую политику, но он не заменяет номер запуска и не делает тяжёлую одну операцию дешёвой.'),
heading('Минимальная трасса с номером запуска'),
paragraph('Временный helper ниже не подменяет profiler. Его задача — дать каждому событию одинаковую форму и не потерять причинную связь в смешанном логе. В продакшен-аналитику не стоит отправлять сырые строки запроса без согласованной политики данных; для локальной диагностики можно писать в Console. Отдельно помечаем, что функция стартовала синхронно, а Promise callback запустился только после ответа.'),
codeBlock(String.raw`
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');
}
`),
paragraph('Здесь Promise не делает ответы сети упорядоченными по номеру запуска. Два <code>fetch</code> могут завершиться в любом порядке, потому что на них влияют сервер, сеть, кеш и отмена. Условие <code>runId !== activeRun</code> описывает бизнес-правило: экран применяет только актуальный результат. В реальном проекте иногда лучше отменить прошлый запрос через поддерживаемый механизм, но даже при отмене проверка актуальности полезна: отмена может прийти после того, как ответ уже начал обрабатываться.'),
paragraph('Не следует читать метку <code>microtask: response available</code> как утверждение, что сама сеть работает в microtask. Она означает, что continuation вашей <code>async</code>-функции после resolved Promise запущена как Promise-реакция. Это точность формулировки важна в ревью: так мы не приписываем браузеру один глобальный порядок и не спорим о словах вместо трассы.'),
figure('/assets/editorial/2019/event-loop-trace-2019.svg', 'Схема диагностики: лог с номером запуска и performance.now ведёт к классификации sync, microtask, task или долгой работы и затем к целевому действию', 'Сначала собираем повторяемую трассу, потом выбираем между контролем актуальности результата и устранением блокировки главного потока.'),
heading('Ищем синхронную границу отдельно от сети'),
paragraph('Даже правильный номер запуска не исправит freeze, если после ответа код синхронно сортирует большой массив и строит тысячи DOM-узлов. Поэтому ставим точки не только вокруг <code>fetch</code>, но и вокруг локальных шагов. В учебном фрагменте ниже работа названа явно. В приложении названия должны соответствовать реальным функциям: <code>parseCatalog</code>, <code>normalizeOffer</code>, <code>renderVisibleRows</code>. Тогда trace и стек профиля можно сопоставить без догадки по абстрактному <code>processData</code>.'),
codeBlock(String.raw`
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));
}
`),
paragraph('Такая обвязка годится для временного локального опыта, но не заменяет trace браузера. Она измеряет только тело переданной функции и не покажет, что случилось между двумя вызовами: GC, style recalculation или работа внешнего виджета. После нахождения подозрительной функции открываем performance trace на том же сценарии. Если самый длинный участок — ваш <code>sortItems</code>, можно говорить об алгоритме. Если перед commit доминирует layout, перенос вычисления в worker не решит весь симптом.'),
paragraph('Порог «длинной» работы нельзя выдумывать из воздуха. Спецификация Long Tasks оперирует работой от 50 ms, включая задачу и следующий microtask checkpoint, но пользовательская чувствительность зависит от контекста. В 2019-проекте полезнее сначала получить свои длительности на повторяемом наборе, затем поставить SLO или бюджет для конкретного экрана. Число становится решением команды, а не чужой фразой из статьи.'),
heading('Ошибочный фикс: завернуть весь расчёт в Promise'),
paragraph('Часто код меняют так: <code>Promise.resolve(items).then(prepareCurrentItems)</code>. Внешне функция стала «асинхронной», но <code>prepareCurrentItems</code> всё ещё полностью выполняется на главном потоке внутри одной microtask. Более того, если по завершении она сразу ставит ещё несколько Promise-реакций, browser task с input продолжит ждать checkpoint. Такая правка может поменять порядок в тесте, но не уменьшить длительность тяжёлой работы.'),
codeBlock(String.raw`
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();
}
`),
paragraph('Второй вариант не является готовой заменой первого. Он специально показывает конструкцию проверки: каждая порция завершается, следующая ставится как будущая task, а результат отдаётся один раз. На практике надо решить, где сортировка, как отменить старый <code>runId</code>, сколько памяти допускает промежуточный массив и как не делать полный DOM commit для уже устаревшей строки поиска. Порции дают event loop шанс выбрать другую работу, но не отменяют необходимость измерить пользовательский сценарий.'),
heading('Как собрать доказательство без выдуманного benchmark'),
paragraph('Для первого прохода выбираем искусственный, но повторяемый набор: например, сохранённый JSON-ответ с фиксированным количеством карточек. Записываем браузер, режим CPU throttling если он включён, введённую строку, номер запуска и время трёх локальных функций. Потом повторяем тот же ввод после изменения. Не публикуем «ускорили в два раза», пока нет зафиксированных результатов на той же методике; в статье и в код-ревью достаточно указать, что команда должна собрать этот набор.'),
paragraph('Trace нужен для связи между цифрой и реальным владельцем времени. В нём ищем длительный обработчик, вложенные JS-функции, network callback и момент input. Если trace показывает, что input пришёл после вашего длинного синхронного блока, это подтверждает CPU-ветку. Если всё время ушло до ответа, решаем сетевую/серверную задачу. Если старый <code>runId</code> дошёл до commit, решаем ветку актуальности результата. Эти исходы не конкурируют; один экран может требовать двух отдельных изменений.'),
heading('Последовательность полевого разбора'),
orderedList([
'Выбрать один входной сценарий: последовательность строк, сохранённый ответ и действие пользователя. Не смешивать в первой записи несколько багов.',
'Добавить временные trace-метки с номером запуска и <code>performance.now()</code> вокруг запроса, parse, CPU-обработки и commit.',
'Запустить сценарий дважды и сравнить относительный порядок. Если записи различаются, сначала найти внешний источник недетерминизма, а не усреднять вывод.',
'Открыть performance trace и сопоставить длинный участок с собственными именованными функциями. Установить, блокирует ли main thread JS, layout или чужой код.',
'Для старого результата ввести явное правило актуальности либо отмену. Для CPU-участка снизить объём, заменить алгоритм, нарезать работу или перенести её в worker.',
'После правки повторить тот же вход. Готовность — устаревший run не коммитится, а синхронная граница стала измеряемой и укладывается в согласованный бюджет.',
]),
heading('Границы решения'),
bulletList([
'Номер запуска защищает отображение от устаревшего результата, но не делает старый запрос бесплатным. Если запросы дороги, добавляется поддерживаемая отмена и серверная политика.',
'Нарезка CPU-работы меняет порядок наблюдаемых промежуточных состояний. Нельзя коммитить частичный список без отдельного UX-решения.',
'Timer используется здесь как способ создать будущую task, а не как обещание кадра через ноль миллисекунд. Реальная задержка измеряется на целевом сценарии.',
'Worker не имеет прямого доступа к DOM. Его внедрение требует контракта сообщений, копирования/передачи данных и проверки актуальности ответа.',
'Performance trace должен собираться с учётом приватности тестовых данных. В production нельзя бездумно логировать поисковые строки и содержимое ответа.',
]),
heading('Итог'),
paragraph('У зависшего поиска есть два независимых вопроса: что заняло главный поток и какой результат имеет право менять экран. Event loop помогает сформулировать трассу, но не заменяет её. С номером запуска мы видим гонку результатов; с метками вокруг синхронных функций и trace мы видим блокировку. После этого исправление становится узким: актуальность решается явным правилом, CPU-нагрузка — алгоритмом, порциями или worker. Таймер остаётся диагностическим инструментом, а не пластырем на оба симптома.'),
],
[htmlEventLoops, ecmaJobs, highResolutionTime, longTasks],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
.map(({ proseLength, ...revision }) => revision);
const isDirectRun = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectRun) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2019-03.mjs --print-revisions\n');
process.exitCode = 1;
}
}