revise June and August 2019 articles
Build and deploy / deploy (push) Successful in 13s

This commit is contained in:
2026-07-31 10:49:16 +03:00
parent bbedf1ae87
commit 18adfa80b4
12 changed files with 1607 additions and 1 deletions
+467
View File
@@ -0,0 +1,467 @@
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(lines) {
return '<pre><code>' + escapeHtml(lines.join('\n')) + '</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 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 or official sources are required');
}
return {
...meta,
contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'),
proseLength,
};
}
const webVitals = {
title: 'web.dev: Web Vitals',
url: 'https://web.dev/articles/vitals?hl=en',
note: 'современная рамка пользовательских метрик; в переиздании она помогает не смешивать скорость отображения с одним сетевым числом',
};
const lcpGuide = {
title: 'web.dev: Optimize Largest Contentful Paint',
url: 'https://web.dev/articles/optimize-lcp?hl=en',
note: 'разделяет TTFB, задержку старта критического ресурса, его загрузку и задержку отрисовки; это позднейшая терминология для проверки гипотезы, а не выданная за отчёт 2019 года',
};
const navigationTiming = {
title: 'MDN: Navigation Timing',
url: 'https://developer.mozilla.org/en-US/docs/Web/API/Performance_API/Navigation_timing',
note: 'объект navigation entry и границы загрузки документа, DOM и обработчиков события загрузки',
};
const resourceTiming = {
title: 'MDN: PerformanceResourceTiming',
url: 'https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming',
note: 'состав времён и размеров отдельных ресурсов, включая transferSize, encodedBodySize и ограничения кросс-доменных записей',
};
const performanceData = {
title: 'MDN: Performance data',
url: 'https://developer.mozilla.org/en-US/docs/Web/API/Performance_API/Performance_data',
note: 'типы записей Performance API и смысл developer marks и measures',
};
const chromePerformance = {
title: 'Chrome DevTools: Performance features reference',
url: 'https://developer.chrome.com/docs/devtools/performance/reference',
note: 'как trace показывает работу loading, scripting, rendering и painting, а также custom marks',
};
const chromeNetwork = {
title: 'Chrome DevTools: Inspect network activity',
url: 'https://developer.chrome.com/docs/devtools/network/',
note: 'разделение запросов по типам, фильтры и проверка водопада без догадки по одному общему времени загрузки',
};
function summarizeControlledProfile() {
const fixture = [
{ owner: 'network', label: 'document response', milliseconds: 180, bytes: 18000 },
{ owner: 'javascript', label: 'parse and execute app.js', milliseconds: 165, bytes: 96000 },
{ owner: 'css', label: 'fetch and build CSSOM', milliseconds: 90, bytes: 24000 },
{ owner: 'image', label: 'fetch, decode and paint hero', milliseconds: 45, bytes: 72000 },
];
const owners = fixture.reduce((result, item) => {
result[item.owner] = {
milliseconds: item.milliseconds,
bytes: item.bytes,
label: item.label,
};
return result;
}, {});
const totalBytes = fixture.reduce((sum, item) => sum + item.bytes, 0);
const totalStandaloneMilliseconds = fixture.reduce((sum, item) => sum + item.milliseconds, 0);
if (Object.keys(owners).length !== 4 || totalBytes !== 210000 || totalStandaloneMilliseconds !== 480) {
throw new Error('controlled profile fixture no longer describes four independent owners');
}
return {
fixture: 'Детерминированный Node fixture: это проверка классификации, не browser trace и не production-замер.',
owners,
totalBytes,
totalStandaloneMilliseconds,
conclusion: 'В fixture четыре независимых владельца времени; складывать их в один waterfall нельзя, потому что часть работы может перекрываться.',
};
}
const practiceArticle = createRevision(
{
slug: 'editorial-2019-08-practice-frontend-performance',
title: 'Производительность первой загрузки: как собрать профиль вместо слова «тяжёлая»',
categories: ['JavaScript', 'Производительность', 'Практика'],
cover: '/assets/editorial/2019/frontend-loading-profile-2019.svg',
excerpt: 'Страница кажется тяжёлой, но сетевой запрос может быть быстрым. Собираем один воспроизводимый профиль и отдельно проверяем сеть, JavaScript, CSS и изображения.',
readingMinutes: 13,
},
[
paragraph('Проблема начинается с фразы «страница тяжёлая». В ней нет владельца задержки: сервер мог ответить быстро, но браузер ждёт таблицу стилей; картинка уже пришла, но её не дают нарисовать длинные скрипты; файл JavaScript маленький в gzip, зато его разбор занимает главный поток. Пока все эти случаи называют одним числом «load», команда сжимает не тот ресурс и получает тот же пустой первый экран. Цена ошибки — ещё один релиз без понятного результата и пользователи, которые уходят до полезного содержимого.'),
paragraph('Для первой загрузки я не начинаю с набора оптимизаций. Сначала выбираю один сценарий и делаю профиль, в котором четыре владельца времени видны отдельно: документ и сеть, JavaScript на главном потоке, CSS до готового стиля, изображение до декодирования и отрисовки. Это не обещание, что четыре полосы складываются в одну честную сумму. Их работа может пересекаться. Цель профиля проще: назвать следующий проверяемый вопрос, а не угадать виновника по размеру бандла.'),
heading('Что именно считаем медленной первой загрузкой'),
paragraph('У экрана есть несколько разных моментов: браузер получил начало HTML, увидел структуру, получил стили, нарисовал первый полезный контент и смог обработать действие. Между ними нет одной универсальной границы. <code>DOMContentLoaded</code> говорит о завершении разбора документа и defer-скриптов, а не о том, что главный блок уже нарисован. Событие <code>load</code> может ждать второстепенные картинки, которые не помогают пользователю начать работу. Поэтому запись «load за две секунды» не отвечает, почему кнопка или заголовок появились поздно.'),
paragraph('В августе 2019 для расследования достаточно открыть DevTools и увидеть водопад, main thread и скриншоты загрузки. При переиздании можно соотнести результат с более поздним словарём LCP: он описывает момент, когда крупное содержание в viewport отрисовано. Но не стоит подменять этим словарём старую проверку и тем более писать вымышленное значение метрики. Если конкретный trace не записан, в заметке остаётся гипотеза и маршрут её проверки, а не число с точностью до миллисекунды.'),
dataTable(
['Наблюдение', 'Возможный владелец', 'Чего оно не доказывает', 'Первый запрос к данным'],
[
['HTML быстро пришёл, экран пустой', 'CSS, JavaScript или скрытый критический ресурс', 'Что origin медленный', 'Сверить responseStart документа с первым screenshot и полосой main thread'],
['Водопад длинный', 'Один критический запрос, очередь приоритетов или несколько независимых ресурсов', 'Что самый большой файл всегда виноват', 'Найти ресурс, без которого не появляется полезный блок'],
['app.js небольшой после сжатия', 'Parse, compile и execute JavaScript', 'Что код дёшев на слабом устройстве', 'Записать trace и посмотреть scripting на main thread'],
['Hero уже скачан', 'Decode, style, layout или занятый main thread', 'Что изображение стало видимым', 'Связать URL ресурса со screenshot и событием paint'],
],
),
paragraph('Профиль не требует сразу добавлять RUM или менять CDN. В первом проходе достаточно одной локальной страницы, одного пути и одной версии сборки. Он полезен именно потому, что ограничен: позже другой инженер может повторить условия и увидеть, какая полоса изменилась. Если смешать мобильный эмулятор, тёплый кэш, авторизованную сессию и три разных URL, сравнение превратится в набор впечатлений.'),
heading('Фиксируем условия до нажатия Reload'),
paragraph('Сначала записываю URL, действие пользователя и что считается полезным экраном. Не «страница открылась», а, например, «заголовок товара, цена и кнопка заказа видимы без прокрутки». Затем фиксирую, очищается ли кэш, есть ли Service Worker, какой viewport и какое ограничение CPU или сети выбрано. Эти параметры не делают лабораторный запуск похожим на каждого реального пользователя, но делают его повторяемым для сравнения двух веток.'),
paragraph('Нельзя делать вывод о production только по одной локальной записи. Лабораторный профиль отвечает на вопрос «какой путь браузер прошёл в этих условиях». Полевая телеметрия отвечает на другой вопрос: «какой распределённый опыт получили пользователи». Для исправления конкретного регресса сначала нужен владелец из профиля; для приоритета работы нужна отдельная выборка. Смешивать эти доказательства — значит выдать диагностический опыт за статистику.'),
codeBlock([
'function collectLoadingEntries() {',
' const navigation = performance.getEntriesByType("navigation")[0];',
' const resources = performance.getEntriesByType("resource").map((entry) => ({',
' name: new URL(entry.name).pathname,',
' type: entry.initiatorType,',
' duration: Math.round(entry.duration),',
' transferSize: entry.transferSize,',
' encodedBodySize: entry.encodedBodySize,',
' }));',
'',
' return {',
' navigation: navigation && {',
' responseStart: Math.round(navigation.responseStart),',
' domInteractive: Math.round(navigation.domInteractive),',
' domContentLoadedEnd: Math.round(navigation.domContentLoadedEventEnd),',
' },',
' resources,',
' };',
'}',
'',
'console.table(collectLoadingEntries().resources);',
]),
paragraph('Этот код не измеряет CSSOM или время выполнения скрипта: он берёт только записи navigation и resource. Это намеренное ограничение. Из него можно увидеть тип ресурса, длительность и размеры там, где браузер имеет право раскрыть их. Для ресурсов с другого origin подробные поля могут быть нулевыми без <code>Timing-Allow-Origin</code>. Нулевое DNS или connect время также не доказывает, что сети не было: соединение могло быть переиспользовано или значение ограничено политикой доступа.'),
paragraph('Сохраняйте результат с версией сборки и условиями, но не отправляйте в общий лог полный URL с пользовательскими параметрами. Для локальной диагностики обычно хватает пути, типа инициатора и округлённых времён. Если нужен отчёт для команды, приложите screenshot с моментом появления полезного блока и короткое пояснение: какая гипотеза проверялась, что действительно измерено и что пока неизвестно.'),
heading('Разводим сеть, JavaScript, CSS и изображение'),
paragraph('У документа и ресурса есть свой водопад: redirect, DNS, connect, запрос и ответ. Это сетевой слой, но даже его нельзя сократить до transferSize. Два одинаковых файла получают разную задержку из-за origin, очереди, приоритета, повторного соединения или кэша. Если критическая картинка обнаруживается только после выполнения скрипта, её поздний старт выглядит сетевой проблемой, хотя сначала надо проверить путь обнаружения в HTML и CSS.'),
paragraph('JavaScript проверяю не размером файла, а временем на main thread после его прихода. В trace ищу длинные фрагменты scripting и связываю их с конкретным ресурсом или функцией через source map, если она доступна. CSS проверяю отдельно: таблица стилей может блокировать расчёт стиля и первый рендер, а большой DOM может удлинить style и layout. У изображения два шага: загрузка байтов и декодирование с отрисовкой. Сжатие файла полезно только если оно попало в доказанный критический участок.'),
figure('/assets/editorial/2019/frontend-loading-profile-2019.svg', 'Профиль первой загрузки с четырьмя самостоятельными дорожками: документ и сеть, JavaScript на main thread, CSS до готового стиля и изображение до decode и paint', 'Одна фраза «тяжёлая страница» раскладывается на четыре владельца времени. Дорожки могут пересекаться, поэтому их нельзя бездумно суммировать.'),
heading('Контролируемый fixture проверяет классификацию, а не скорость сайта'),
paragraph('Чтобы не спорить о том, как отчёт группирует данные, в этом модуле есть детерминированный fixture. В нём четыре записи: response документа, работа JavaScript, построение CSSOM и decode hero-изображения. Скрипт складывает байты и длительности по владельцу и проверяет, что в результате действительно четыре независимые группы. Fixture не открывает браузер, не делает HTTP-запрос и не измеряет этот сайт. Его доказательство узкое: классификатор не потерял CSS внутри JavaScript и не выдал картинку за сеть.'),
codeBlock([
'// Из каталога web/:',
'// node scripts/upgrade-2019-08.mjs --run-fixture',
'',
'{',
' "owners": {',
' "network": { "milliseconds": 180, "bytes": 18000 },',
' "javascript": { "milliseconds": 165, "bytes": 96000 },',
' "css": { "milliseconds": 90, "bytes": 24000 },',
' "image": { "milliseconds": 45, "bytes": 72000 }',
' },',
' "totalBytes": 210000',
'}',
]),
paragraph('Числа fixture не являются бюджетом и не являются результатом trace. Они выбраны так, чтобы тест ловил ошибку группировки. Например, если код отнесёт decode картинки к JavaScript, у результата исчезнет владелец <code>image</code>, и fixture упадёт. Для реальной страницы после этого всё равно нужен отдельный Reload в DevTools: только он покажет, перекрывались ли операции, какой URL был критическим и где браузер действительно потратил время.'),
heading('Собираем короткий отчёт, пригодный для следующего запуска'),
paragraph('Полезный отчёт помещается в несколько строк. Первая строка — условия: путь, кэш, viewport, throttle, хэш сборки. Вторая — наблюдение: «HTML получил ответ до первого screenshot, а полезный блок появился после scripting». Третья — конкретный владелец и ссылка на дорожку: «main thread: модуль checkout.js, участок parse плюс execute». Четвёртая — одна гипотеза изменения и критерий проверки. Если отчёт не называет владельца, он не помогает выбрать работу.'),
paragraph('Не добавляйте в него слово «ускорили», пока не повторили профиль при тех же условиях. Например, перенос второстепенного виджета за событие пользователя может уменьшить scripting до полезного блока, но одновременно увеличить network idle. Это хороший обмен, если экран стал полезен раньше; это плохой аргумент, если измерен только размер одного чанка. Важно заранее зафиксировать, какой момент загрузки должен сдвинуться и какой вторичный эффект допустим.'),
dataTable(
['Фрагмент отчёта', 'Плохая запись', 'Проверяемая запись'],
[
['Симптом', 'Долго грузится', 'Карточка появляется после длинного scripting, хотя ответ HTML уже получен'],
['Причина', 'Много JavaScript', 'В trace участок main thread привязан к модулю фильтров; это гипотеза до source-map проверки'],
['Действие', 'Оптимизировать бандл', 'Не загружать модуль подсказок до первого взаимодействия и оставить проверку fallback'],
['Критерий', 'Стало лучше', 'Повторить тот же Reload и сравнить момент полезного блока и длительность участка scripting'],
],
),
heading('Меняем один критический путь за раз'),
paragraph('Когда владелец назван, действие становится обычной инженерной работой. Для сети это может быть устранение лишнего redirect, раннее обнаружение ресурса или перенос критического файла на подходящий origin. Для JavaScript — разделение entry, удаление неиспользуемой ветки, откладывание виджета или уменьшение синхронной инициализации. Для CSS — критичный минимум, порядок подключения и уменьшение селекторов или DOM там, где trace показал style и layout. Для изображения — правильный размер, формат, ранний URL и отказ от lazy loading именно у первого значимого изображения.'),
paragraph('Ни одна из этих мер не универсальна. <code>preload</code> помогает только ресурсу, который действительно нужен первому экрану; лишние preload конкурируют за сеть. Code splitting помогает, если код перестал быть частью начального пути; если модуль сразу нужен для рендера, дополнительный запрос может ухудшить ситуацию. Оптимизация картинки не поможет, если она уже скачана и ждёт занятый main thread. Поэтому после каждого изменения возвращаемся к тому же профилю, а не переносим удачную технику на все ресурсы подряд.'),
orderedList([
'Сформулировать полезный первый экран и зафиксировать URL, кэш, viewport, throttle и хэш сборки.',
'Записать один Reload в DevTools с Network, Performance и screenshot, не называя результат production-метрикой.',
'Разметить в отчёте документ, критический ресурс, участки scripting, style или layout и момент появления полезного блока.',
'Выбрать одного владельца и одно изменение, которое должно сдвинуть конкретную полосу, а не весь мир сразу.',
'Повторить тот же сценарий, сравнить только заявленный критерий и записать побочный эффект.',
'Лишь после устойчивого лабораторного результата решать, нужна ли полевая метрика или выпускная проверка на реальных устройствах.',
]),
heading('Итог: профиль превращает жалобу в маршрут'),
paragraph('Первая загрузка не становится понятной от одного Lighthouse-числа, веса JavaScript или события <code>load</code>. Нужна короткая карта: что браузер получил, что обнаружил поздно, что заняло главный поток и что не успело появиться на экране. Такая карта не требует большой платформы наблюдаемости, но запрещает прятать разные причины под слово «тяжёлая».'),
paragraph('В этом упражнении нет заявленного trace конкретного сайта и нет обещания универсального порога. Есть воспроизводимый fixture для классификации и маршрут для настоящего профиля в браузере. Следующая статья разберёт, почему документ, CSS, JavaScript и изображение образуют зависимую критическую цепочку даже тогда, когда отдельные запросы выглядят быстрыми.'),
],
[navigationTiming, resourceTiming, performanceData, chromeNetwork, chromePerformance, webVitals, lcpGuide],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2019-08-mechanism-frontend-performance',
title: 'Под капотом первой загрузки: где теряется время между HTML и полезным экраном',
categories: ['JavaScript', 'Производительность', 'Браузер'],
cover: '/assets/editorial/2019/frontend-critical-path-2019.svg',
excerpt: 'Быстрый ответ origin не равен быстрому экрану. Разбираем зависимую цепочку HTML, CSS, JavaScript и изображения и проверяем, кому принадлежит задержка.',
readingMinutes: 14,
},
[
paragraph('Симптом выглядит противоречиво: backend показывает короткое время ответа, Network не содержит гигабайтных файлов, а пользователь всё равно ждёт пустой или нерабочий первый экран. Ошибка расследования в том, что серверный ответ принимают за завершение загрузки. Браузер после первого байта ещё должен разобрать HTML, обнаружить зависимости, получить стили, выполнить синхронный код, построить дерево рендера, декодировать нужные изображения и выделить время на paint. Быстрый origin закрывает только один участок этой цепочки.'),
paragraph('Здесь не нужен мифический «браузер тормозит». Нужна модель зависимостей. Одни ресурсы можно качать параллельно, но некоторые работы ждут предыдущей границы: нельзя применить внешний stylesheet до его прихода; JavaScript без <code>defer</code> может остановить разбор HTML; картинка, добавленная только после выполнения приложения, не будет обнаружена preload scanner из начального документа. Критический путь — не список всех файлов, а цепочка того, без чего выбранный полезный экран не может появиться.'),
heading('Документ задаёт не только разметку, но и момент обнаружения'),
paragraph('HTML приходит потоково. Пока браузер читает начальный документ, он может обнаружить <code>link</code>, <code>script</code>, <code>img</code> и начать работу с ними раньше, чем весь ответ будет получен. Поэтому важен не только размер HTML, но и место, где расположен критический URL. Если hero-изображение или основной stylesheet скрыт за JavaScript-конфигурацией, браузер узнает о нём только после новой работы; лишняя задержка возникает до реальной загрузки байтов.'),
paragraph('Это не аргумент за то, чтобы сделать весь HTML огромным. Начальный ответ должен содержать то, что позволяет браузеру увидеть и запросить первый экран: семантический каркас, нужный CSS и прямой адрес критического изображения или шрифта, если он действительно нужен. Второстепенные карточки, рекламные виджеты и модальные окна могут быть отложены. Решение принимают по роли на первом экране, а не по тому, какой компонент проще перенести в шаблон.'),
dataTable(
['Граница', 'Что открывает работу', 'Типичная ошибка', 'Как проверить'],
[
['Ответ HTML', 'Парсер и preload scanner видят URL из начальной разметки', 'Критический URL появляется только после boot приложения', 'Посмотреть документ и момент старта ресурса на waterfall'],
['CSS', 'Стиль становится доступен для расчёта и рендера', 'Считать stylesheet обычной второстепенной картинкой', 'Сопоставить окончание CSS с моментом первого полезного paint'],
['JavaScript', 'Parse, compile, execute и создание DOM', 'Смотреть только gzip-размер чанка', 'Выделить scripting и функцию в main-thread trace'],
['Изображение', 'Запрос, байты, decode и paint', 'После responseEnd считать hero видимым', 'Связать URL с decode, screenshot и LCP-кандидатом, если метрика доступна'],
],
),
paragraph('Navigation Timing описывает путь самого документа, а Resource Timing — путь отдельных ресурсов. Эти записи полезны, но не содержат полный причинный граф. Например, высокий <code>responseEnd</code> изображения говорит, когда закончилась передача, но не говорит, что оно было критическим или что его разрешили отрисовать. Поэтому запись из API нужно всегда читать рядом с DOM, сетевым водопадом и trace главного потока.'),
heading('CSS — часть визуальной готовности, а не украшение после HTML'),
paragraph('Для первого экрана CSS определяет, какие элементы видны, какие шрифты и размеры участвуют в layout и может ли браузер собрать корректное дерево рендера. Внешний stylesheet обычно имеет приоритетную роль: пока нет необходимых правил, браузер старается не показывать нестабильный или неверно стилизованный результат. Если приложение выводит разметку, но затем прячет её классом до окончания инициализации, пользователю всё равно: HTML существует, а полезного экрана нет.'),
paragraph('Проверка начинается с конкретного stylesheet, а не с общего правила «инлайнить critical CSS». В trace смотрим, был ли CSS завершён до первого screenshot и не идут ли затем длинные style или layout. В Network смотрим, когда браузер обнаружил файл, насколько он конкурирует с другими ранними запросами и нет ли import-цепочки, которая откладывает правила. В DOM смотрим, не создаёт ли JavaScript огромное дерево или не меняет ли классы в несколько проходов. Каждая из этих причин требует другого изменения.'),
codeBlock([
'<link rel="stylesheet" href="/assets/app.css">',
'<link rel="preload" as="image" href="/assets/hero-960.webp" type="image/webp">',
'',
'<main class="product-page">',
' <h1>Название товара</h1>',
' <img',
' src="/assets/hero-960.webp"',
' width="960"',
' height="640"',
' alt="Товар на нейтральном фоне"',
' >',
'</main>',
]),
paragraph('Этот фрагмент не является рецептом для всех страниц. Он показывает проверяемую идею: критический URL виден в начальном HTML, а размер изображения известен разметке. Preload оправдан только после доказательства, что именно этот ресурс нужен выбранному первому экрану. Добавить его ко всем картинкам — значит забрать пропускную способность у стиля, документа или другого важного ресурса. <code>loading="lazy"</code> у hero также нельзя ставить по привычке: оно намеренно откладывает старт загрузки.'),
heading('JavaScript создаёт два разных вида задержки'),
paragraph('Первый вид — сетевой и поисковый: browser должен обнаружить, запросить и получить скрипт. Второй — вычислительный: после прихода байтов браузер разбирает и выполняет код на главном потоке. Эти этапы имеют разный диагноз. Убрать десять килобайт из чанка полезно, если они были на критическом пути передачи. Но если задержку создаёт синхронная инициализация большого списка, форматирование данных или повторный layout, тот же файл может прийти быстро и всё равно задержать paint.'),
paragraph('Особенно опасна инициализация, которая выглядит маленькой в diff: импорт добавляет polyfill, компонент при старте строит сотни строк таблицы, сторонний код измеряет каждый DOM-узел, аналитика синхронно проходит по странице. В Network это может быть один обычный JS-запрос. В trace будет длинная работа на main thread, иногда с несколькими зелёными и фиолетовыми участками style/layout после неё. Пока функция не названа, правило «сделаем code split» остаётся предположением.'),
dataTable(
['Наблюдение в trace', 'Рабочая гипотеза', 'Необязательный вывод', 'Проверяемое действие'],
[
['Длинный scripting сразу после app.js', 'Критический код выполняет лишнюю работу до первого экрана', 'Что весь app.js надо вынести в отдельный чанк', 'Найти функцию, отложить второстепенный путь и повторить тот же профиль'],
['Несколько style/layout после одного обработчика', 'Код чередует чтение геометрии и запись классов', 'Что CSS-файл слишком большой', 'Сгруппировать измерения и изменения DOM, проверить число layout-проходов'],
['Пустой экран до завершения JS', 'Разметка или критический ресурс создаётся только приложением', 'Что сервер обязан немедленно перейти на новый стек', 'Вывести минимальный каркас и критический URL раньше либо доказать иной путь'],
['JS пришёл поздно', 'Ресурс поздно обнаружен или конкурирует в сети', 'Что выполнение кода дорогое', 'Сравнить startTime скрипта с HTML и приоритетом на waterfall'],
],
),
heading('Изображение имеет жизнь после responseEnd'),
paragraph('У изображения есть размер на диске, фактические пиксели, место в layout, момент декодирования и момент paint. Сетевой водопад честно покажет transfer и responseEnd, но пользователь увидит файл позже, если браузер занят скриптом, ждёт нужный стиль или декодирует слишком большое изображение. У hero также важен выбор варианта: нет смысла передавать desktop-оригинал на маленький экран, если разметка знает реальный размер контейнера.'),
paragraph('Не следует объявлять каждую картинку LCP-кандидатом. Сначала на выбранном первом экране определяем, какой визуальный элемент действительно самый крупный и полезный. В современных инструментах это можно сопоставить с LCP, но статья не превращает этот термин в фальшивый замер 2019 года. Если доступен только screenshot, пишем честнее: «проверяем появление hero-изображения» и сохраняем условия. Если есть trace и metric marker, прикладываем его к конкретному URL.'),
figure('/assets/editorial/2019/frontend-critical-path-2019.svg', 'Критическая цепочка первой загрузки: HTML открывает обнаружение CSS, JavaScript и hero-изображения; стили и свободный main thread нужны до полезного paint', 'Запросы могут идти параллельно, но полезный экран ждёт зависимые границы: обнаружение, нужный ресурс, доступный main thread и paint.'),
heading('Четыре временных слоя нельзя заменить одной суммой'),
paragraph('Полезно держать четыре вопроса. Первый: когда браузер получил первый байт HTML? Второй: когда начались и закончились критические запросы? Третий: чем был занят main thread между приходом ресурсов и первым полезным paint? Четвёртый: какой элемент на экране ещё ждал decode, стиль или layout? Даже если все числа сохранены в миллисекундах, они не образуют последовательность без перекрытий. Сложение длительностей может показать 900 ms там, где реальное окно загрузки 500 ms, и направить усилия в неверный участок.'),
paragraph('Позднейшая разбивка LCP на TTFB, resource load delay, resource load duration и element render delay удобна как проверка полноты вопросов. Она не отменяет различий браузеров, не доказывает причину сама по себе и не позволяет пересчитать пользовательский опыт по одному тёплому запуску. Её ценность здесь практическая: если после сокращения байтов изображение не появилось раньше, проверяем render delay, а не повторяем ту же оптимизацию сильнее.'),
codeBlock([
'performance.mark("catalog: render-start");',
'renderCatalog(shellData);',
'performance.mark("catalog: render-end");',
'performance.measure("catalog: initial-render",',
' "catalog: render-start",',
' "catalog: render-end");',
'',
'const measures = performance.getEntriesByType("measure");',
'console.table(measures.map(({ name, duration }) => ({',
' name,',
' duration: Math.round(duration),',
'})));',
]),
paragraph('User Timing не заменяет trace, но даёт приложению именованную границу: где начался и закончился его собственный render. Эту метку стоит ставить вокруг конкретной операции, а не вокруг всей загрузки. Иначе она будет включать сеть, таймеры и чужие скрипты, а название <code>initial-render</code> перестанет соответствовать измеряемому участку. На production такие marks требуют отдельного решения о сборе данных и приватности; в локальном профиле они помогают читать дорожку.'),
heading('Выбираем действие по разрыву в цепочке'),
paragraph('Если критическое изображение начинается заметно позже документа, сначала ищем его URL: оно в начальном HTML, в CSS или создаётся кодом? Если CSS завершён поздно, проверяем import-цепочку, объём и конкуренцию, а не переносим весь stylesheet inline. Если до первого полезного screenshot занята основная нить, идём в функцию, DOM или сторонний код. Если ресурс уже пришёл, а элемент не нарисован, проверяем доступность main thread, скрывающий класс, размер контейнера и decode.'),
paragraph('Действие должно иметь обратимое доказательство. Например, временно убрать второстепенный виджет из начального пути и повторить профиль. Если scripting ушёл, а полезный блок появился раньше, гипотеза получила опору; затем решение оформляют аккуратно с fallback и проверкой функциональности. Если ничего не изменилось, не держим feature-ветку ради надежды. Возвращаемся к предыдущей границе и смотрим, какой ресурс или работа всё ещё ждёт.'),
orderedList([
'Определить полезный первый экран и назвать один визуальный элемент или интеракцию, которая должна быть готова.',
'Проследить его назад: нужен ли ему HTML, stylesheet, скрипт, изображение, шрифт или данные.',
'На waterfall проверить момент обнаружения и завершения каждого критического запроса.',
'На main thread найти блоки scripting, style, layout, paint между готовностью ресурса и экраном.',
'Сделать одно минимальное изменение на ранней разорванной границе и повторить условия профиля.',
'Зафиксировать результат как лабораторное наблюдение; полевая метрика и выпускной бюджет остаются следующей отдельной работой.',
]),
heading('Итог: критический путь — это зависимость, а не рейтинг файлов'),
paragraph('Сервер, сеть, JavaScript, CSS и картинка не соревнуются за один титул виновника. Они образуют путь, на котором ранняя задержка может спрятать более позднюю, а быстрый файл может ждать занятый main thread. Когда этот путь нарисован, команда перестаёт спорить о «самом тяжёлом» ресурсе и начинает проверять, какая граница действительно удерживает полезный экран.'),
paragraph('Практический результат механизма — небольшой словарь для trace: обнаружен поздно, ждёт CSS, занял main thread, ждёт decode, нарисован позже. В следующем разборе этот словарь применяется к учебной карточке: ответ HTML быстрый, но экран остаётся пустым. Сценарий будет явно помечен как controlled fixture, чтобы не выдать иллюстрацию за измерение чужого продукта.'),
],
[navigationTiming, resourceTiming, performanceData, chromeNetwork, chromePerformance, webVitals, lcpGuide],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2019-08-field-frontend-performance',
title: 'Разбор первой загрузки: быстрый HTML, пустой экран и неверный фикс',
categories: ['JavaScript', 'Производительность', 'Разбор'],
cover: '/assets/editorial/2019/frontend-performance-diagnosis-2019.svg',
excerpt: 'Учебный профиль показывает быстрый ответ HTML и поздний полезный экран. Разбираем, как отличить поздний hero, блокирующий JavaScript и CSS без выдуманного production-замера.',
readingMinutes: 14,
},
[
paragraph('Симптом: карточка товара открывается, серверный лог показывает короткий ответ HTML, но пользователь несколько секунд видит фон и каркас без товара. Первое поспешное решение — сжать hero-изображение. Оно может не изменить экран вообще, если изображение уже скачано и ждёт выполнения стартового JavaScript. Обратная ошибка тоже частая: вынести код в другой чанк, хотя картинка вообще не была обнаружена до запуска приложения. Цена такого поиска — серия случайных правок, которые нельзя объяснить следующему разработчику.'),
paragraph('Ниже учебный разбор, а не trace реального сайта. Для контролируемого профиля мы задаём четыре независимые полосы: документ и сеть — 180 ms, JavaScript — 165 ms, CSS — 90 ms, hero после загрузки — 45 ms. Эти числа существуют только в fixture модуля и проверяют, что отчёт не смешивает владельцев. Они не складываются в «настоящие 480 ms», не описывают устройство пользователя и не дают права писать о production-результате. На их основе можно честно отрепетировать порядок расследования.'),
heading('Фиксируем учебный сценарий и границу полезности'),
paragraph('Полезным экраном в этом случае считаем три вещи: название товара, цену и hero-изображение рядом с кнопкой заказа. Spinner не считается результатом: он говорит только, что код начал работу. Это определение важно, потому что команда иначе может улучшить момент появления skeleton и объявить победу, хотя покупатель всё ещё не знает, что покупает. До любых изменений фиксируем тот же URL, тот же вариант страницы, состояние кэша и viewport.'),
paragraph('В controlled fixture документ уже получил ответ, CSS имеет отдельный этап, JavaScript — отдельную работу, изображение — отдельные байты и decode. Так мы заранее не объявляем один слой главным. Если после настоящего Reload окажется, что hero вообще не на первом экране или содержимое текстовое, сценарий меняется: диагностика всегда начинается с фактического визуального критерия, а не с названия файла <code>hero.webp</code>.'),
dataTable(
['Полоса fixture', 'Данные fixture', 'Что можно утверждать', 'Что утверждать нельзя'],
[
['Документ и сеть', '180 ms, 18 000 bytes', 'Классификатор выделил ответ документа отдельным владельцем', 'Что origin конкретного сайта отвечает за 180 ms'],
['JavaScript', '165 ms, 96 000 bytes', 'Отдельно учтён parse и execute app.js в учебном профиле', 'Что любой bundle такого размера блокирует ровно 165 ms'],
['CSS', '90 ms, 24 000 bytes', 'CSS не потерян среди сетевых или JS-данных', 'Что stylesheet в реальном браузере всегда блокирует весь этот интервал'],
['Hero', '45 ms, 72 000 bytes', 'Загрузка и decode изображения имеют свой владелец', 'Что responseEnd равен моменту видимости изображения'],
],
),
paragraph('Такая таблица полезна именно своей скромностью. Она не говорит, какая полоса длиннее на устройстве пользователя, и не добавляет несуществующий waterfall. Она даёт контракт для инструмента и для автора статьи: в дальнейших фразах сеть означает сетевые записи, JavaScript — работу main thread, CSS — готовность стилей, изображение — путь до paint. Если фактический trace позже покажет перекрытие, модель не сломается: полосы всё равно остаются разными владельцами.'),
heading('Первый вопрос: какой ресурс открывает полезный экран'),
paragraph('В настоящем проекте я бы начал не с главного чанка, а с DOM и screenshot. Есть ли title и price в исходном HTML? Есть ли у hero прямой <code>src</code> или URL появляется в состоянии приложения? Не скрывает ли контейнер класс <code>is-loading</code> до завершения bootstrap? Эти вопросы часто дают результат быстрее, чем сортировка Network по размеру: если полезный блок создаётся только после <code>renderProduct()</code>, его картинка физически не могла стартовать раньше выполнения этого кода.'),
paragraph('После этого проверяем waterfall в двух направлениях. От документа вперёд: когда открылись CSS, app.js и hero. От полезного элемента назад: какой URL, стиль и код нужны именно ему. Если hero начинает запрос после <code>app.js</code>, фиксируем не «медленную сеть», а позднее обнаружение. Если hero стартует рано, но screenshot меняется поздно, сеть перестаёт быть первой гипотезой: смотрим main thread, decode и скрывающую логику.'),
codeBlock([
'const hero = document.querySelector("[data-product-hero]");',
'const css = document.querySelector("link[href*=app.css]");',
'',
'console.table({',
' heroSrc: hero && hero.currentSrc,',
' heroComplete: hero && hero.complete,',
' heroNaturalWidth: hero && hero.naturalWidth,',
' stylesheetLoaded: css && css.sheet !== null,',
' productHidden: document.querySelector(".product.is-loading") !== null,',
'});',
]),
paragraph('Этот фрагмент — локальная проверка состояния после загрузки, не автоматический benchmark. <code>img.complete</code> не доказывает, что изображение уже показано пользователю, а <code>link.sheet</code> не отвечает на вопрос о стоимости layout. Зато он быстро отделяет ситуацию «URL отсутствует или изображение не готово» от ситуации «ресурс уже доступен, ищем работу рендера». Если запускать его поздно вручную, фиксируйте это в заметке: console-проверка после события не воспроизводит точный момент первого paint.'),
heading('Вторая проверка: не держит ли экран стартовый JavaScript'),
paragraph('Представим, что waterfall показывает ранний hero и завершённый CSS, но полезный screenshot всё равно появляется после блока scripting. Тогда цель — не «разбить всё на чанки», а найти работу, которая происходит до <code>renderProduct</code>. Это может быть инициализация фильтров, формирование рекомендаций, синхронный разбор большой конфигурации или сторонний виджет. Любая из этих функций имеет другой безопасный момент запуска и другой риск для поведения страницы.'),
paragraph('В Chrome DevTools выбираем участок main thread между окончанием критического запроса и появлением нужного screenshot. Затем смотрим Bottom-up или Call Tree, если source map позволяет увидеть исходные функции. Без source map не подставляем название модуля из фантазии: пишем путь скомпилированного ресурса и оставляем задачу на сопоставление. Отсутствие точного имени — не повод вернуться к догадке по весу бандла.'),
dataTable(
['Факт после Reload', 'Следующая гипотеза', 'Минимальный эксперимент', 'Критерий'],
[
['Hero и CSS стартовали рано, перед экраном длинный scripting', 'Bootstrap выполняет некритичную работу', 'Временно убрать второстепенный виджет из initial path', 'Сдвигается момент полезного screenshot и сокращается участок scripting'],
['Hero стартовал после app.js', 'URL создаётся приложением', 'Показать URL в HTML или проверить preload только для hero', 'На waterfall запрос hero начинается раньше при тех же условиях'],
['Hero завершился, но экран меняется после style/layout', 'DOM или классы создают поздний render', 'Сгруппировать записи DOM и убрать лишний ранний layout', 'Уменьшается work между ресурсом и paint'],
['CSS заканчивается поздно', 'Стиль найден поздно или конкурирует за сеть', 'Проверить порядок link и import-цепочку', 'CSS приходит до нужного визуального этапа без новых ошибок стиля'],
],
),
paragraph('Важно оставить эксперимент узким и обратимым. Не удаляйте сразу половину приложения. Отключите один реально второстепенный блок под локальным флагом или в отдельной ветке и повторите сценарий. Если экрана это не коснулось, фикс возвращают и не рекламируют «оптимизацию». Если сдвиг есть, следующий шаг — безопасно изменить загрузочный контракт: отложить модуль, оставить заглушку, проверить ошибку загрузки и убедиться, что пользовательская функция не исчезла навсегда.'),
heading('Третья проверка: CSS и изображение не завершаются в один момент'),
paragraph('Даже в аккуратном водопаде нельзя считать <code>responseEnd</code> финишем визуальной работы. Браузер должен применить стиль, рассчитать геометрию, при необходимости декодировать изображение и отрисовать кадр. Если JavaScript несколько раз читает размеры и тут же меняет классы, он может создавать повторные layout между готовым hero и paint. В этом случае перекодировка картинки даст небольшой сетевой выигрыш, но проблема полезного экрана останется в main thread.'),
paragraph('С другой стороны, картинка может быть действительно слишком поздней: URL находится в background-image внешнего CSS, viewport получает неподходящий большой вариант или первый элемент помечен lazy loading. Проверка должна назвать один из этих фактов. У URL в CSS нет автоматического права на preload: сначала убедитесь, что это тот элемент, который нужен без прокрутки. Когда это доказано, раннее объявление ресурса или изменение разметки становятся осмысленным действием, а не массовой настройкой.'),
figure('/assets/editorial/2019/frontend-performance-diagnosis-2019.svg', 'Дерево диагностики первого экрана: от полезного screenshot к четырём ветвям — позднее обнаружение в сети, JavaScript на main thread, CSS и layout, изображение с decode и paint', 'Диагностика начинается от наблюдаемого полезного экрана. Каждая ветвь задаёт свой минимальный эксперимент и не выдаёт учебный fixture за production-трассу.'),
heading('Проверяем изменение тем же маршрутом, а не одним размером файла'),
paragraph('Допустим, trace подтвердил, что блок рекомендаций синхронно строится до карточки. После переноса его за первую отрисовку нельзя останавливаться на уменьшении initial chunk. Повторяем Reload в тех же условиях и смотрим четыре вещи: появился ли полезный screenshot раньше, исчезла ли конкретная работа scripting, не стартовал ли hero позже из-за нового порядка, не сломалась ли карточка без рекомендаций. Только такой набор позволяет отличить улучшение критического пути от перемещения задержки в соседнюю полосу.'),
paragraph('Если изменение касается CSS, дополнительно проверяем отсутствие скачка layout и доступность контента без внешнего файла. Если касается изображения — его фактические размеры, корректный alt и fallback. Если касается загрузки модуля — состояние ошибки и медленную сеть. Производительность первой загрузки не освобождает от корректности: пустой, но быстрый экран не считается результатом. В 2019 это особенно важно для постепенных улучшений поверх существующего интерфейса, а не для демонстрации красивого измерения.'),
codeBlock([
'performance.mark("product: shell-visible");',
'showProductShell();',
'',
'loadRecommendationsLater().catch(() => {',
' // Карточка уже полезна; ошибка второстепенного блока не скрывает цену и заказ.',
' showRecommendationFallback();',
'});',
'',
'performance.mark("product: recommendations-scheduled");',
'const navigation = performance.getEntriesByType("navigation")[0];',
'const shellVisible = performance.getEntriesByName("product: shell-visible")[0];',
'console.log("shell after navigation", Math.round(',
' shellVisible.startTime - navigation.startTime,',
'));',
]),
paragraph('Название <code>initial-shell</code> здесь важно: измеряется момент, когда показали каркас продукта, а не вся страница и не подтверждённая пользовательская метрика. Для trace можно использовать <code>performance.mark</code> как ориентир рядом с Network и main thread. Но значение mark не становится доказательством, что контент полезен, пока это не подтверждено заранее определённым DOM и screenshot-критерием. Так команда не подменяет результат измерением удобной, но пустой стадии.'),
heading('Маршрут выпуска без ложного production-отчёта'),
paragraph('Перед выпуском инженер может написать честный итог: «В лабораторном Reload при таких-то условиях полезный блок появился раньше после переноса второстепенного модуля; отдельно проверены Network, main thread и fallback». Он не должен писать «все пользователи получили минус 300 ms», если полевая выборка не собиралась. Для поля нужны отдельные согласованные метрики, сегменты устройств и правила приватности. Лабораторный результат остаётся основанием для merge конкретной правки, а не заменой аналитики.'),
paragraph('Если профиль не дал однозначной причины, это тоже нормальный итог. Сохраняем trace, условия и исключённые гипотезы: hero стартует рано, CSS готов до первого screenshot, но source map отсутствует для долгого scripting. Следующая задача тогда конкретна — восстановить карту исходников или изолировать функцию, а не повторять сжатие изображений. Неопределённость уменьшается фактами, а не более ярким заголовком оптимизации.'),
orderedList([
'Назвать полезный первый экран и записать его DOM-признаки до открытия DevTools.',
'Снять один повторяемый Reload с фиксированными условиями, сохранив Network, screenshot и main-thread trace.',
'Проверить, когда стартовали HTML, CSS, app.js и нужное изображение, не используя размер файла как приговор.',
'Найти раннюю разорванную границу: позднее обнаружение, long scripting, style/layout или decode и paint.',
'Сделать один обратимый эксперимент и проверить конкретный ожидаемый сдвиг, а также fallback и визуальную корректность.',
'Сформулировать лабораторный вывод с его пределами; production-числа добавлять только после отдельного сбора полевых данных.',
]),
heading('Итог: быстрый ответ — это только начало расследования'),
paragraph('Учебный fixture показывает важный навык: даже когда каждая полоса имеет число, не надо строить из них фальшивую общую скорость. Первая загрузка становится полезной после зависимой работы HTML, сети, CSS, JavaScript, изображения и paint. У каждой части есть собственный инструмент проверки и собственный способ сломаться.'),
paragraph('Поэтому хороший разбор начинается с видимого критерия, идёт назад по зависимостям и заканчивается одним воспроизводимым изменением. В нём есть точные ограничения: fixture не является browser trace, trace не является полевой статистикой, а маленький бандл не равен быстрому экрану. Такая дисциплина делает следующую оптимизацию короче, потому что она отвечает на конкретный вопрос, а не на жалобу «страница тяжёлая».'),
],
[navigationTiming, resourceTiming, performanceData, chromeNetwork, chromePerformance, webVitals, lcpGuide],
);
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 if (process.argv.includes('--run-fixture')) {
process.stdout.write(JSON.stringify(summarizeControlledProfile(), null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2019-08.mjs --print-revisions | --run-fixture\n');
process.exitCode = 1;
}
}