From 18adfa80b4418a286a726b4159f5ebfe788cbac9 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 10:49:16 +0300 Subject: [PATCH] revise June and August 2019 articles --- editorial/production/README.md | 2 +- editorial/reviews/2019-06-draft.md | 127 ++++ editorial/reviews/2019-08-draft.md | 108 ++++ web/data/editorial-revisions.mjs | 4 + .../2019/frontend-critical-path-2019.svg | 46 ++ .../2019/frontend-loading-profile-2019.svg | 54 ++ .../frontend-performance-diagnosis-2019.svg | 49 ++ .../2019/rest-api-contract-fixture-2019.svg | 49 ++ .../2019/rest-api-contract-map-2019.svg | 44 ++ .../2019/rest-api-response-matrix-2019.svg | 46 ++ web/scripts/upgrade-2019-06.mjs | 612 ++++++++++++++++++ web/scripts/upgrade-2019-08.mjs | 467 +++++++++++++ 12 files changed, 1607 insertions(+), 1 deletion(-) create mode 100644 editorial/reviews/2019-06-draft.md create mode 100644 editorial/reviews/2019-08-draft.md create mode 100644 web/public/assets/editorial/2019/frontend-critical-path-2019.svg create mode 100644 web/public/assets/editorial/2019/frontend-loading-profile-2019.svg create mode 100644 web/public/assets/editorial/2019/frontend-performance-diagnosis-2019.svg create mode 100644 web/public/assets/editorial/2019/rest-api-contract-fixture-2019.svg create mode 100644 web/public/assets/editorial/2019/rest-api-contract-map-2019.svg create mode 100644 web/public/assets/editorial/2019/rest-api-response-matrix-2019.svg create mode 100644 web/scripts/upgrade-2019-06.mjs create mode 100644 web/scripts/upgrade-2019-08.mjs diff --git a/editorial/production/README.md b/editorial/production/README.md index 050a090..77ef9ef 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 52 из 358 созданных материалов. Остальные 306 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 58 из 358 созданных материалов. Остальные 300 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2019-06-draft.md b/editorial/reviews/2019-06-draft.md new file mode 100644 index 0000000..012790e --- /dev/null +++ b/editorial/reviews/2019-06-draft.md @@ -0,0 +1,127 @@ +# Июнь 2019 — тройное ревью чернового пакета П16 «Контракт REST API» + +Статус: **принят в публикационный слой 31 июля 2026 года**. Registry +накладывает три ревизии по стабильным slug и сохраняет дату и автора базового +архива: + +- editorial-2019-06-practice-rest-api; +- editorial-2019-06-mechanism-rest-api; +- editorial-2019-06-field-rest-api. + +Созданы только: + +- web/scripts/upgrade-2019-06.mjs; +- web/public/assets/editorial/2019/rest-api-contract-map-2019.svg; +- web/public/assets/editorial/2019/rest-api-response-matrix-2019.svg; +- web/public/assets/editorial/2019/rest-api-contract-fixture-2019.svg; +- этот файл. + +Модуль экспортирует ровно три ревизии. В ревизиях нет date и +author: это исторические поля исходных публикаций, а не черновика. +При вызове с --print-revisions stdout содержит только JSON. +Отдельный --run-fixture запускает локальную проверку заранее +заданных объектов ответа; он не делает HTTP-запрос, не запускает сервер и не +является проверкой production. + +## Проход 1. Факты и техника — пройдено + +| Утверждение или решение | Первичный источник | Проверенная граница | +| --- | --- | --- | +| HTTP-код и представление ответа — часть результата операции | [IETF RFC 7231, раздел 6](https://www.rfc-editor.org/rfc/rfc7231#section-6) | В текст не введён флаг ошибки внутри 200 как замена HTTP-статуса | +| Problem detail содержит type, title, status, detail, instance; расширения принадлежат API | [IETF RFC 7807](https://www.rfc-editor.org/rfc/rfc7807) | errors помечен как project extension, а не как универсальное поле стандарта | +| OpenAPI 3.0.2 описывает operation, responses, content и schema | [OpenAPI 3.0.2](https://spec.openapis.org/oas/v3.0.2.html) | Версия существовала в 2019 году; не использованы более поздние возможности | +| required-property и nullable-value — разные условия | [OpenAPI 3.0.2, Schema Object](https://spec.openapis.org/oas/v3.0.2.html#schema-object) | page.nextCursor обязателен и допускает null; customer либо отсутствует, либо является объектом | +| HTTP не навязывает конкретную pagination форму | [IETF RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | cursor и object page названы проектным выбором; Link header назван альтернативой, а не проигнорирован | + +Учебные snippets и fixture не ссылаются на настоящий сервис, токен, URL +production или якобы выполненный сетевой сценарий. В CLI с +--run-fixture есть три локальных case: + +1. 200 / JSON с последней страницей и явным nextCursor: null; +2. 400 / problem+json с invalid_cursor; +3. отрицательный 200 / JSON без nextCursor, который обязан быть + отклонён. + +Итог первого прохода: технические утверждения привязаны к HTTP, RFC 7807 и +исторически уместному OpenAPI 3.0.2; тест не объявлен серверным или +production-доказательством. + +## Проход 2. Редактура и голос М2 — пройдено + +| Проверка | Практика | Механизм | Полевой разбор | +| --- | --- | --- | --- | +| Ранняя постановка симптома и цены | Падение, неверная страница и спор слоёв | Тихая поломка при семантически другом JSON | UI не знает, как трактовать распарсенный ответ | +| Рабочая цепочка | Симптом → причина → контракт → fixture → действие | Симптом → Operation/Responses/Schema → совместимость | Симптом → cases → fixture → запрос к стенду | +| Техническая речь | Статус, media type, cursor, optional field | HTTP, OpenAPI 3.0.2, required, nullable | Response objects, negative case, headers, body | +| Объём основного текста | Подтверждается draft gate | Подтверждается draft gate | Подтверждается draft gate | + +Голос соответствует М2 / 2019: автор уже связывает frontend, backend и HTTP, +но не имитирует инструменты и практики 2027 года. Текст не обещает +«универсальный REST», не называет обычный JSON доказательством успеха и не +подменяет конкретные условия общими оценками. В каждой статье есть не менее +пяти смысловых разделов, доступная таблица, код, порядок действий, визуал, +источники и ограничения. + +Итог второго прохода: три текста держат прагматичный формат «симптом → +причина → проверка → действие» и не раздувают тему за счёт общих вступлений. + +## Проход 3. Визуал и выпускная дисциплина — пройдено для автономного пакета + +| Артефакт | Назначение | Проверка доступности и выпуска | +| --- | --- | --- | +| rest-api-contract-map-2019.svg | Вход операции и развилка 200/400 | Есть title, desc, содержательный alt, подпись; SVG без script | +| rest-api-response-matrix-2019.svg | Связь Operation, Responses и Schema | Есть title, desc, содержательный alt, подпись; SVG без script | +| rest-api-contract-fixture-2019.svg | Граница локальной fixture и сетевой проверки | Есть title, desc, содержательный alt, подпись; SVG без script | + +### Выполненные проверки + +~~~text +node --check web/scripts/upgrade-2019-06.mjs +cd web && npm run audit:draft -- scripts/upgrade-2019-06.mjs +node web/scripts/upgrade-2019-06.mjs --run-fixture +xmllint --noout \ + web/public/assets/editorial/2019/rest-api-contract-map-2019.svg \ + web/public/assets/editorial/2019/rest-api-response-matrix-2019.svg \ + web/public/assets/editorial/2019/rest-api-contract-fixture-2019.svg +~~~ + +Результат 31 июля 2026 года: + +- node --check — код 0; +- draft gate — три PASS: практика 9 266, механизм 10 792, полевой разбор + 10 300 знаков основного текста; +- --run-fixture — три PASS: финальная 200-страница с + nextCursor: null, 400 problem detail с + invalid_cursor и обязательное отклонение 200 без + nextCursor; +- xmllint --noout — код 0 для трёх SVG; +- проверка завершающих пробелов не нашла совпадений. + +SVG дополнительно прочитаны как выпускные артефакты: у каждого есть +самодостаточные title и desc, все блоки, стрелки и +подписи размещены внутри viewBox 900×760. У первой схемы длинная итоговая +подпись разбита на две строки. CSS статьи выводит figure-image по ширине +контейнера, а таблицы имеют горизонтальную прокрутку. Это статическая +проверка разметки и геометрии; в журнал не приписывается вымышленный +browser-render, server run или production build. Перед публикацией основной +редактор должен отдельно +подключить ревизии к registry, повторить strict audit вместе с архивом, +построить production-сайт и проверить реальные страницы на широком и узком +экране, сохранив исходные дату и автора. + +После подключения registry основной редактор повторил strict audit: все три +slug прошли объём 9 266 / 10 792 / 10 300 знаков, figure, таблицы, код, +маршруты и источники. npm run build завершился с кодом 0 и +сгенерировал 374 статические страницы. + +Выпусковой вердикт: **принят к публикации**. articles.json не +менялся; registry заменяет только редакционные поля по стабильному slug. + +### Независимый мобильный preflight + +Основной редактор отдельно отрендерил все три SVG через Sharp на ширине 720 и +375 px. Первый вариант не обрезался, но его подписи были слишком мелкими при +375 px. Все три схемы заменены на вертикальные композиции с короткими +подписями; повторный рендер подтвердил читаемые главные метки, отсутствие +обрезания и горизонтального overflow. Это проверка SVG-артефактов, а не +заявление о browser-render или сетевом production-тесте. diff --git a/editorial/reviews/2019-08-draft.md b/editorial/reviews/2019-08-draft.md new file mode 100644 index 0000000..40900c5 --- /dev/null +++ b/editorial/reviews/2019-08-draft.md @@ -0,0 +1,108 @@ +# P18 · 2019-08 · Производительность первой загрузки + +Статус: **принят в публикационный слой 31 июля 2026 года**. Он содержит +ровно три ревизии; registry применяет их по стабильному slug и сохраняет даты +и авторов базового архива. + +- editorial-2019-08-practice-frontend-performance; +- editorial-2019-08-mechanism-frontend-performance; +- editorial-2019-08-field-frontend-performance. + +## Рамка и границы утверждений + +Голос — М2, август 2019 года: автор уже уверенно работает с browser DevTools, +Network, trace и границами модулей, но не выдает поздние платформы +наблюдаемости, RUM-распределения или современные Core Web Vitals за личную +практику того периода. Речь в каждой статье следует маршруту «симптом → +причина → проверка → действие». + +Материал использует Navigation/Resource/User Timing и документацию Chrome +DevTools. Позднейшая web.dev-терминология LCP используется только как +редакторская рамка для полноты диагностики: она не названа сделанным в 2019 +году замером и не подставлена вместо browser trace. + +В модуле есть один контролируемый Node fixture. Его числа +network=180, javascript=165, css=90, +image=45 ms и 210000 bytes проверяют +классификацию четырёх владельцев. Fixture не открывает браузер, не делает +HTTP-запрос и не является production-значением или performance trace. + +## Pass 1 — факты и техника + +- Сверены официальные определения Navigation Timing и + PerformanceResourceTiming: navigation относится к документу, + resource — к отдельным ресурсам; поля cross-origin могут быть ограничены + без Timing-Allow-Origin. +- Утверждение о JavaScript не сведено к весу gzip-файла. Сеть, parse, + execute, DOM, style/layout, decode и paint названы разными участками, + требующими разной проверки. +- Для LCP использована современная официальная разбивка на TTFB, resource + load delay, duration и element render delay. В статьях ясно сказано, что + это способ проверить гипотезу при переиздании, а не подлинный отчёт автора + за август 2019. +- responseEnd, img.complete, + DOMContentLoaded и load нигде не выданы за + универсальный момент полезности экрана. У каждого есть описанное + ограничение. +- fixture проверяет только классификацию. В тексте не заявлены выполненные + trace, мобильный throttling, скриншоты или значения реального проекта. +- Вердикт: технические формулировки проверяемы и отделяют факт API от + лабораторного наблюдения и production-метрики. + +## Pass 2 — редактура и голос + +- В первых абзацах каждой статьи названы наблюдаемый сбой и цена ложной + правки: пустой первый экран, случайное сжатие hero или ложный вывод по + серверному ответу. +- Изложение не обещает «ускорить страницу» общими словами. Для каждой ветви + есть объект проверки: URL в HTML, момент старта на waterfall, участок + scripting, CSSOM/layout, decode и screenshot. +- Тон намеренно сдержан: автор предлагает короткие local experiments, + сохранение условий и обратимые изменения, а не приписывает периоду + SLO-платформу или точность современных отчётов. +- Все статьи проходят диапазон 5 000–15 000 знаков основного текста до + добавления таблиц, кода, SVG и списка источников; это дополнительно + проверяет модуль при импорте. +- Вердикт: текст прагматичен, технически плотен и не превращает сильный + заголовок в скромную заметку без раскрытия. + +## Pass 3 — визуал и выпуск + +- Практика получает схему четырёх дорожек: сеть, JavaScript, CSS и image. + Механизм получает граф зависимостей HTML, CSS, JavaScript и hero. Разбор + получает дерево диагностики от полезного screenshot к одному эксперименту. +- У каждого SVG есть title, desc, содержательный + alt в статье и подпись. Все схемы рассчитаны на вертикальное чтение на + узкой ширине, без скриптов, внешних изображений и raster-заменителей. +- Первый raster-render выявил обрезание длинных нижних подписей и заголовка + диагностики. Текст сокращён и перенесён; повторный рендер схем в 1 000 px + и 375 px подтвердил отсутствие обрезания, наложения или горизонтального + overflow. +- В каждой ревизии есть доступная таблица с thead и + scope="col", код, нумерованный маршрут, ранняя постановка + проблемы и минимум две первичные/официальные ссылки. +- Production build, registry и articles.json намеренно не + запускались и не менялись: они относятся к отдельному интеграционному + ревью. +- Вердикт: пакет готов к независимому draft gate и только после него — к + отдельной интеграции. + +## Выполненные проверки + +| Проверка | Команда | Результат | +| --- | --- | --- | +| Node syntax | node --check scripts/upgrade-2019-08.mjs из web/ | успешно | +| JSON-only CLI и import-safe export | npm run audit:draft -- scripts/upgrade-2019-08.mjs из web/ | успешно: 11 958, 11 919 и 12 638 знаков body по gate | +| Controlled fixture | node scripts/upgrade-2019-08.mjs --run-fixture из web/ | успешно: четыре владельца, 210 000 bytes, 480 ms как сумма самостоятельных fixture-длительностей | +| XML | xmllint --noout public/assets/editorial/2019/frontend-loading-profile-2019.svg public/assets/editorial/2019/frontend-critical-path-2019.svg public/assets/editorial/2019/frontend-performance-diagnosis-2019.svg из web/ | успешно | + +## Выпусковой вердикт + +Черновой пакет прошёл независимые syntax, draft gate, fixture и XML-проверки. +После подключения registry основной редактор повторил strict audit: все три +slug прошли объём 11 958 / 11 919 / 12 638 знаков, figure, таблицы, код, +маршруты и источники. npm run build завершился с кодом 0 и +сгенерировал 374 статические страницы. + +Выпусковой вердикт: **принят к публикации**. articles.json не +менялся; registry заменяет только редакционные поля по стабильному slug. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 87eee5d..57e099a 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -12,7 +12,9 @@ import { revisions as february2019Revisions } from '../scripts/upgrade-2019-02.m import { revisions as march2019Revisions } from '../scripts/upgrade-2019-03.mjs'; import { revisions as april2019Revisions } from '../scripts/upgrade-2019-04.mjs'; import { revisions as may2019Revisions } from '../scripts/upgrade-2019-05.mjs'; +import { revisions as june2019Revisions } from '../scripts/upgrade-2019-06.mjs'; import { revisions as july2019Revisions } from '../scripts/upgrade-2019-07.mjs'; +import { revisions as august2019Revisions } from '../scripts/upgrade-2019-08.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -30,5 +32,7 @@ export const editorialRevisions = [ ...march2019Revisions, ...april2019Revisions, ...may2019Revisions, + ...june2019Revisions, ...july2019Revisions, + ...august2019Revisions, ]; diff --git a/web/public/assets/editorial/2019/frontend-critical-path-2019.svg b/web/public/assets/editorial/2019/frontend-critical-path-2019.svg new file mode 100644 index 0000000..2053a34 --- /dev/null +++ b/web/public/assets/editorial/2019/frontend-critical-path-2019.svg @@ -0,0 +1,46 @@ + + Критический путь первого экрана + Схема показывает, как начальный HTML открывает обнаружение CSS, JavaScript и hero-изображения, а затем свободный главный поток и готовые стили позволяют нарисовать полезный экран. + + + + + + + + + + + + Критический путь полезного экрана + Быстрый ответ HTML полезен, но не завершает визуальную работу браузера. + + HTML + разбор и обнаружение + + + + + CSS + stylesheet и CSSOM + + JavaScript + parse и main thread + + Hero + request, decode, paint + + + + + Готово + для + paint + + + Полезный + первый экран + Проверяем разрыв: URL обнаружен поздно, CSS не готов или main thread занят. + Отдельно: изображение прошло request, decode и paint? + + diff --git a/web/public/assets/editorial/2019/frontend-loading-profile-2019.svg b/web/public/assets/editorial/2019/frontend-loading-profile-2019.svg new file mode 100644 index 0000000..4a42486 --- /dev/null +++ b/web/public/assets/editorial/2019/frontend-loading-profile-2019.svg @@ -0,0 +1,54 @@ + + Профиль первой загрузки с четырьмя владельцами времени + Вертикальная схема показывает документ и сеть, JavaScript на главном потоке, CSS и hero-изображение как отдельные дорожки, которые сходятся в полезный первый экран. + + + + + + + + + + + Первая загрузка — четыре независимые дорожки + Профиль отвечает «кому принадлежит задержка», а не складывает всё в одно число. + + + + + Сеть + HTML и критические запросы + redirect · connect · response · обнаружение URL + + + + + + JS + Главный поток + parse · execute · DOM · сторонний код + + + + + + CSS + Стиль и layout + stylesheet · CSSOM · style · layout + + + + + + Image + Hero-изображение + URL · transfer · decode · paint + + + + + + Полезный экран: проверяем одну границу + + diff --git a/web/public/assets/editorial/2019/frontend-performance-diagnosis-2019.svg b/web/public/assets/editorial/2019/frontend-performance-diagnosis-2019.svg new file mode 100644 index 0000000..d93165e --- /dev/null +++ b/web/public/assets/editorial/2019/frontend-performance-diagnosis-2019.svg @@ -0,0 +1,49 @@ + + Маршрут диагностики позднего полезного экрана + Дерево решения ведёт от позднего полезного screenshot к четырём проверкам: позднее обнаружение ресурса, JavaScript на главном потоке, CSS и layout, а также загрузка и декодирование изображения. + + + + + + + + + + + + Диагностика: поздний экран + Сначала наблюдаемый блок, затем владелец задержки, потом обратимая проверка. + + Полезный экран запоздал + HTML мог уже прийти — это ещё не ответ о причине + + + Критический URL стартовал рано? + сверяем HTML, waterfall и element + + + + + + Позднее + обнаружение + URL создаётся кодом + или скрыт в CSS + Показать URL раньше + + JavaScript + на main thread + scripting до render + или лишний DOM + Отложить один модуль + + CSS и + layout + поздний stylesheet + или повторный layout + Исправить границу CSS + + Изображение: отдельно проверить request, размер, decode и paint + + diff --git a/web/public/assets/editorial/2019/rest-api-contract-fixture-2019.svg b/web/public/assets/editorial/2019/rest-api-contract-fixture-2019.svg new file mode 100644 index 0000000..5b1c446 --- /dev/null +++ b/web/public/assets/editorial/2019/rest-api-contract-fixture-2019.svg @@ -0,0 +1,49 @@ + + Локальная fixture проверяет контракт до сетевого теста + Вертикальная схема показывает три локальных объекта ответа: корректные 200 и 400 проходят контрактную проверку, а 200 без nextCursor отклоняется; отдельный сетевой шаг не выполнен fixture. + + + Fixture ловит нарушение до сети + локальные объекты не заменяют сервер + + + Fixture A · 200 / JSON + nextCursor: null + + + + Fixture B · 400 / problem+json + errors[0].code: invalid_cursor + + + + Fixture C · 200 / JSON + нет обязательного nextCursor + + + + Контрактная проверка + 1. HTTP status + 2. Content-Type + 3. schema body + + + + PASS · A и B + валидная страница и problem detail + + + + REJECT · C + 200 не отменяет обязательную schema + + + Следующий шаг: разрешённый тестовый сервер + fixture его не выполняет + + + + + + + diff --git a/web/public/assets/editorial/2019/rest-api-contract-map-2019.svg b/web/public/assets/editorial/2019/rest-api-contract-map-2019.svg new file mode 100644 index 0000000..c41bf4d --- /dev/null +++ b/web/public/assets/editorial/2019/rest-api-contract-map-2019.svg @@ -0,0 +1,44 @@ + + Контракт одной REST API-операции + Вертикальная схема показывает запрос списка заказов, явную развилку ответов 200 и 400 и проверку клиентом статуса, Content-Type и схемы тела. + + + Контракт одной операции + URL — только начало договора + + + GET /api/v1/orders + limit=20; cursor — строка или отсутствует + + + + проверка входа + + + + 200 OK · application/json + items: Order[] + page.nextCursor: string | null + + + + 400 Bad Request + application/problem+json + type, status, detail, errors[].code + + + + Клиент проверяет три вещи + 1. HTTP status + 2. Content-Type + 3. schema тела + + + JSON сам по себе не равен успеху + + + + + + + diff --git a/web/public/assets/editorial/2019/rest-api-response-matrix-2019.svg b/web/public/assets/editorial/2019/rest-api-response-matrix-2019.svg new file mode 100644 index 0000000..2e111b8 --- /dev/null +++ b/web/public/assets/editorial/2019/rest-api-response-matrix-2019.svg @@ -0,0 +1,46 @@ + + Как OpenAPI связывает операцию с ответом и схемой + Вертикальная схема показывает Operation Object, карту ответов 200 и 400, а затем различие между required полем nextCursor и nullable значением. + + + Операция становится матрицей + вход → HTTP-код → media type → schema + + + Operation Object + GET /orders + query: limit, cursor + + + + Responses Object + "200" · "400" · "500" + код выбирает форму тела + + + + 200 + JSON → OrdersPage + items и page + + + + 400 + problem → Problem + type, status, errors + + + + Schema Object: два разных вопроса + + required + ключ nextCursor есть + + nullable + значение может быть null + отсутствующий ключ и null — не один случай + + + + + + + diff --git a/web/scripts/upgrade-2019-06.mjs b/web/scripts/upgrade-2019-06.mjs new file mode 100644 index 0000000..f4c39ca --- /dev/null +++ b/web/scripts/upgrade-2019-06.mjs @@ -0,0 +1,612 @@ +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function paragraph(text) { + return '

' + text + '

'; +} + +function heading(text) { + return '

' + text + '

'; +} + +function codeBlock(lines) { + return '
' + escapeHtml(lines.join('\n')) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function bulletList(items) { + return ''; +} + +function dataTable(caption, headers, rows) { + const head = '' + headers.map((header) => '' + header + '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; + return '
' + head + body + '
' + caption + '
'; +} + +function sourceList(items) { + return ''; +} + +function visibleText(html) { + return html + .replace(/<[^>]*>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function proseText(html) { + return visibleText( + html + .replace(/
[\s\S]*?<\/code><\/pre>/g, '')
+      .replace(/
[\s\S]*?<\/figure>/g, '') + .replace(/
[\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'), + }; +} + +const rfcHttp = { + title: 'IETF RFC 7231, HTTP/1.1 Semantics and Content', + url: 'https://www.rfc-editor.org/rfc/rfc7231', + note: 'семантика методов, представлений, Content-Type и кодов ответа, действовавшая в 2019 году', +}; + +const rfcStatus = { + title: 'IETF RFC 7231, раздел 6: Response Status Codes', + url: 'https://www.rfc-editor.org/rfc/rfc7231#section-6', + note: 'коды 2xx, 4xx и 5xx сообщают результат конкретного HTTP-запроса; их смысл нельзя заменять произвольным полем JSON', +}; + +const rfcProblem = { + title: 'IETF RFC 7807, Problem Details for HTTP APIs', + url: 'https://www.rfc-editor.org/rfc/rfc7807', + note: 'стандартизированная форма problem detail с type, title, status, detail и instance; расширения остаются контрактом API', +}; + +const rfcLink = { + title: 'IETF RFC 8288, Web Linking', + url: 'https://www.rfc-editor.org/rfc/rfc8288', + note: 'модель ссылочных отношений HTTP; конкретная форма пагинации должна быть явно выбрана командой', +}; + +const openApi = { + title: 'OpenAPI Specification 3.0.2', + url: 'https://spec.openapis.org/oas/v3.0.2.html', + note: 'версия спецификации, доступная в 2019 году; описывает пути, операции, ответы, content и Schema Object', +}; + +const openApiOperation = { + title: 'OpenAPI 3.0.2, Operation Object', + url: 'https://spec.openapis.org/oas/v3.0.2.html#operation-object', + note: 'каждая операция объявляет параметры и ожидаемые ответы, а не только URL и метод', +}; + +const openApiResponse = { + title: 'OpenAPI 3.0.2, Responses Object', + url: 'https://spec.openapis.org/oas/v3.0.2.html#responses-object', + note: 'ответы задаются по HTTP-коду или default; у каждого можно описать content и схему тела', +}; + +const openApiSchema = { + title: 'OpenAPI 3.0.2, Schema Object', + url: 'https://spec.openapis.org/oas/v3.0.2.html#schema-object', + note: 'required относится к свойствам объекта, а nullable разрешает null только при явно заданном type', +}; + +const practiceArticle = createRevision( + { + slug: 'editorial-2019-06-practice-rest-api', + title: 'REST API без угадывания: фиксируем статусы, ошибки и страницу списка', + categories: ['HTTP', 'API', 'Практика'], + cover: '/assets/editorial/2019/rest-api-contract-map-2019.svg', + excerpt: 'Клиент падает не из-за адреса, а когда 200 содержит ошибку, следующая страница исчезает или необязательное поле внезапно становится null. Собираем короткий контракт операции и проверяем его на локальных фикстурах.', + readingMinutes: 12, + }, + [ + paragraph('Симптом обычно выглядит как ошибка интерфейса: список заказов перестал листаться, карточка падает на customer.name, а форма показывает «неизвестную ошибку». URL /api/orders при этом не менялся. Цена такого сбоя выше одной красной строки в консоли: клиент повторяет запрос, пользователь не понимает, сохранено ли действие, а backend и frontend спорят о том, кто «сломал API». Причина почти всегда в незафиксированном ответе: статус говорит одно, тело другое, а пагинация или необязательное поле существуют только в чьей-то памяти.'), + paragraph('В июне 2019 я бы начал не с генератора клиента и не с большой документации. Для одной операции нужен короткий договор, который можно прочитать за несколько минут и прогнать на данных. Возьмём GET /api/v1/orders: он возвращает страницу заказов, принимает limit и непрозрачный cursor, а при плохом курсоре отдаёт problem document. Пример учебный: он не выполняет запрос к серверу и не доказывает поведение production. Его задача — показать, какие признаки должны совпасть до интеграции.'), + heading('Сначала описываем наблюдаемый сбой и стоимость'), + paragraph('Плохой договор часто начинается с фразы «успешный ответ — JSON». Она не отвечает на четыре вопроса. Что считать успехом: только 200 или ещё 204? Как клиент узнаёт о неправильном параметре? Чем последняя страница отличается от временно пустого списка? И допустимо ли отсутствие поля customer, либо оно должно быть null? Если эти решения не записаны, каждая библиотека подставляет собственное: fetch не считает 400 исключением, сериализатор может опустить ключ, а UI делает доступ к вложенному свойству без проверки.'), + paragraph('HTTP уже задаёт язык для результата операции. RFC 7231 описывает метод, целевой ресурс, представление и коды статуса; 200 означает успешный ответ, а 4xx и 5xx сообщают разные классы ошибки запроса и сервера. Наш проектный контракт не должен переопределять этот язык флагом ok: false внутри ответа с 200. Он должен уточнять его: для какого статуса какое представление приходит, какой Content-Type ожидается и какие поля клиент вправе читать.'), + dataTable( + 'Карта рисков для одной операции списка заказов', + ['Наблюдение у клиента', 'Незакрытая граница', 'Что фиксируем в контракте', 'Цена, если не зафиксировать'], + [ + ['Кнопка «ещё» исчезла раньше времени', 'Последняя страница смешана с пустым результатом', 'Обязательный объект page и явный nextCursor: null', 'Пользователь не видит часть заказов'], + ['Экран падает на вложенном свойстве', 'Неизвестно, обязательны ли customer и его поля', 'required для ядра заказа; правило для отсутствующего customer', 'Падение или ложная пустая карточка'], + ['Форма показывает общий баннер', 'Ошибка параметра не имеет стабильной формы', 'application/problem+json, type и расширение errors', 'Нельзя привязать действие к полю'], + ['Клиент продолжает парсить 200', 'Статус и тело противоречат друг другу', 'Список допустимых статусов на операцию', 'Сбой маскируется как «пустые данные»'], + ], + ), + heading('Выбираем маленький, но полный контракт операции'), + paragraph('Для начала достаточно одного пути, одного метода и нескольких ответов. Наша операция читает коллекцию, поэтому договор включает параметры, а не только тело 200. limit имеет диапазон; cursor либо отсутствует, либо является строкой, которую клиент не разбирает; ответ всегда содержит items и page. В page.nextCursor строка означает, что следующий запрос возможен, а null означает конец снимка. Мы не используем отсутствие ключа как отдельный сигнал.'), + paragraph('Это проектное решение, а не требование REST или HTTP. Пагинация не задана RFC 7231: команда могла бы использовать offset, Link header или отдельный объект links. Важно выбрать одну форму и описать её до кода. Cursor здесь непрозрачен намеренно. Если UI начинает вырезать из него дату или ID, сервер уже не сможет изменить кодирование без поломки клиента. Клиент должен только передать полученную строку в следующий запрос.'), + codeBlock([ + 'GET /api/v1/orders?limit=2 HTTP/1.1', + 'Accept: application/json', + '', + 'HTTP/1.1 200 OK', + 'Content-Type: application/json', + '', + '{', + ' "items": [', + ' {', + ' "id": "ord_1042",', + ' "status": "paid",', + ' "total": { "amount": 9900, "currency": "RUB" },', + ' "customer": { "id": "cus_17", "name": "Ирина" }', + ' }', + ' ],', + ' "page": { "limit": 2, "nextCursor": "ord_1042" }', + '}', + ]), + paragraph('В примере id, status, total и page — обязательное ядро. customer — необязательное поле: если его нет, это не ошибка транспорта и не строка null. Если поле присутствует, оно обязано быть объектом с теми свойствами, которые нужны текущему экрану. Это важнее, чем кажется: «может прийти всё что угодно» делает любой клиент вынужденным угадывать, а строгий контракт позволяет обработать отсутствие ровно в одном месте.'), + heading('Статус и ошибка образуют один результат'), + paragraph('Для неправильного cursor не нужно возвращать 200 с массивом errors. Запрос не выполнен как запрос списка, поэтому выбираем клиентскую ошибку 400 Bad Request. RFC 7807 задаёт переносимую оболочку problem detail: поля type, title, status, detail и instance; дополнительные поля разрешены как extension members. В договоре ниже errors — именно расширение приложения, а не тайный стандарт поля.'), + codeBlock([ + 'HTTP/1.1 400 Bad Request', + 'Content-Type: application/problem+json', + '', + '{', + ' "type": "https://api.example.test/problems/invalid-cursor",', + ' "title": "Параметр cursor недействителен",', + ' "status": 400,', + ' "detail": "Курсор не принадлежит этому списку заказов",', + ' "instance": "/api/v1/orders?limit=2&cursor=broken",', + ' "errors": [', + ' { "path": "query.cursor", "code": "invalid_cursor" }', + ' ]', + '}', + ]), + paragraph('Клиенту не следует сопоставлять логику с русским title или английским detail. Текст пригодится человеку и журналу, но стабильным ключом решения становится type или наш errors[0].code. Например, invalid_cursor означает: очистить сохранённый курсор, загрузить первую страницу и не повторять тот же запрос в цикле. Нераспознанный type должен показать общий сбой и оставить диагностический след, а не притвориться пустым списком.'), + figure( + '/assets/editorial/2019/rest-api-contract-map-2019.svg', + 'Вертикальная схема контракта GET списка заказов: параметры limit и cursor ведут к двум развилкам 200 application/json и 400 application/problem+json; у успеха выделены items и page.nextCursor, у ошибки type и errors.', + 'Контракт начинается на входе запроса и заканчивается тем, что клиент может проверить в статусе, Content-Type и схеме тела. Один URL не покрывает эти границы.', + ), + heading('Записываем операцию в OpenAPI, а не в комментарий'), + paragraph('OpenAPI 3.0.2 уже позволяет записать этот договор рядом с API. Operation Object связывает путь, метод, параметры и responses. Responses Object, в свою очередь, привязывает конкретный HTTP-код к content и Schema Object. Это не гарантирует, что сервер исполняет YAML автоматически. Зато файл становится единым местом, где видно: 200 — страница, 400 — problem document, а тело не описывается абстрактным словом object.'), + codeBlock([ + '/api/v1/orders:', + ' get:', + ' parameters:', + ' - in: query', + ' name: cursor', + ' schema: { type: string }', + ' - in: query', + ' name: limit', + ' schema: { type: integer, minimum: 1, maximum: 100 }', + ' responses:', + ' "200":', + ' description: Страница заказов', + ' content:', + ' application/json:', + ' schema: { $ref: "#/components/schemas/OrdersPage" }', + ' "400":', + ' description: Неподходящий cursor или limit', + ' content:', + ' application/problem+json:', + ' schema: { $ref: "#/components/schemas/Problem" }', + ]), + paragraph('Не надо делать OpenAPI файлом «на потом». В ревью к изменению операции должны попасть одновременно: изменение схемы, пример ответа и правило для клиента. Если backend добавляет поле, которое может отсутствовать, это чаще всего обратно совместимо для терпимого клиента, но только после проверки его потребления. Если он удаляет required-поле, меняет тип или переносит ошибку из 400 в 200, это уже изменение поведения, для которого нужен согласованный переход.'), + heading('Проверяем договор на локальных данных'), + paragraph('До доступа к стенду можно поймать часть расхождений на фикстурах. В пакете есть небольшой запуск node web/scripts/upgrade-2019-06.mjs --run-fixture. Он не открывает сеть: берёт три заранее заданных response-объекта и проверяет обязательные поля страницы, явный конец пагинации, форму problem document и отрицательный случай с потерянным nextCursor. Такой тест не заменяет интеграционный: он не знает о роутинге, авторизации или сериализаторе сервера. Но он делает документированную границу исполнимой до подключения реального API.'), + orderedList([ + 'Выберите одну операцию и запишите её ожидаемое действие, а не общий список «API должно быть RESTful».', + 'Назовите допустимые HTTP-статусы, Content-Type и форму тела для каждого статуса.', + 'Для коллекции отдельно зафиксируйте первый запрос, окончание страницы и правило передачи cursor или offset.', + 'Отметьте required-поля и одно точное правило для необязательного поля: отсутствует, null или объект; не оставляйте все три варианта одновременно.', + 'Добавьте пример успеха, пример ошибки и минимальную фикстуру, которая ломается при изменении этих признаков.', + 'Перед выпуском выполните такой же запрос к тестовому серверу и сравните статус, заголовок и тело со спецификацией.', + ]), + heading('Границы решения и короткий вывод'), + paragraph('Этот контракт не решает авторизацию, повторную доставку команд, лимиты нагрузки и версионирование всех ресурсов. Он также не утверждает, что cursor безопасен как токен доступа: его формат и срок жизни остаются отдельной задачей. Зато он убирает базовую неопределённость на границе frontend и backend. Когда 200, 400, items, nextCursor и optional-поля можно прочитать и проверить, ошибка перестаёт выглядеть как мистический «сломанный REST».'), + bulletList([ + 'URL и метод идентифицируют операцию, но не описывают все её успешные и ошибочные представления.', + 'Статус, Content-Type и схема тела проверяются вместе; флаг ошибки внутри 200 не заменяет HTTP-семантику.', + 'Пагинация и optional-поля — явные проектные решения, которым нужен один проверяемый вариант.', + 'Локальная фикстура полезна как ранняя проверка контракта, но не является отчётом о работе production-сервера.', + ]), + ], + [rfcHttp, rfcStatus, rfcProblem, rfcLink, openApi, openApiOperation, openApiResponse, openApiSchema], +); + +const mechanismArticle = createRevision( + { + slug: 'editorial-2019-06-mechanism-rest-api', + title: 'OpenAPI 3.0.2 под капотом: операция, статус, схема и совместимость клиента', + categories: ['HTTP', 'OpenAPI', 'Архитектура'], + cover: '/assets/editorial/2019/rest-api-response-matrix-2019.svg', + excerpt: 'Схема API полезна не как каталог URL. Разбираем, как в OpenAPI 3.0.2 связать операцию с кодами ответов, problem details, required-полями и окончанием cursor-пагинации.', + readingMinutes: 13, + }, + [ + paragraph('Симптом более опасный, чем опечатка в URL: backend выкатывает «небольшое» изменение, и старый клиент получает ответ, который синтаксически остаётся JSON, но семантически стал другим. status превратился из строки в объект, пустая страница потеряла nextCursor, а ошибка валидации стала 200. Цена — тихая поломка: мониторинг видит успешные HTTP-запросы, а пользователь видит пустой экран или повторную отправку формы. Причина — у операции нет границы совместимости, есть только маршрут и пример из happy path.'), + paragraph('Разберём механизм на той же коллекции заказов, но не как рецепт одного контроллера. Нам нужно понять, что именно фиксирует спецификация и что остаётся проектным решением. В 2019 году для этого подходит OpenAPI 3.0.2: она описывает Path Item, Operation, параметры, Responses, content и Schema Object. HTTP остаётся транспортным контрактом, а OpenAPI собирает выбранный договор операции в один документ. Ни одна YAML-схема сама не проверит живой сервер, если команда не подключит её к тесту или ревью.'), + heading('Операция — это не только path и метод'), + paragraph('Когда в описании есть только GET /orders, человек всё ещё не знает, какие параметры разрешены и что приходит при каждом результате. Operation Object в OpenAPI объединяет эти части. У cursor фиксируется расположение in: query, тип и условная необязательность; у limit — числовые границы. В responses фиксируется не один пример, а карта статусов. Клиент тогда строит ветвление не по догадке «если в JSON есть error», а по документированному результату HTTP.'), + paragraph('Практический минимум: 200 для страницы, 400 для недопустимого запроса, 401 для отсутствующего или недействительного контекста доступа, 500 для непредвиденного сбоя. Не нужно объявлять все коды, которые может вернуть любой proxy в мире. Но каждый код, который сознательно производит приложение, должен иметь форму ответа или честную пометку, что тела нет. Иначе мобильный клиент может ждать JSON от 401, а gateway отправит HTML, который парсер примет за сетевую ошибку.'), + dataTable( + 'Матрица результата одной операции GET /api/v1/orders', + ['HTTP-статус', 'Content-Type', 'Обязательная форма', 'Действие клиента', 'Чего не делать'], + [ + ['200', 'application/json', 'items, page.limit, page.nextCursor', 'Показать элементы; передать строковый cursor дальше только при наличии', 'Не считать пустой items концом без проверки page'], + ['400', 'application/problem+json', 'type, title, status и project errors', 'Сбросить только проблемный параметр или показать объяснение', 'Не парсить ошибку как страницу'], + ['401', 'Описывается отдельно', 'Статус и согласованный ответ/заголовок', 'Запустить известный поток авторизации', 'Не повторять запрос бесконечно'], + ['500', 'application/problem+json либо общий ответ', 'Без внутренних деталей и стека', 'Показать общий сбой и сохранить trace identifier', 'Не выдавать пользователю SQL или stack trace'], + ], + ), + heading('Responses Object связывает код и представление'), + paragraph('В OpenAPI ответ задан ключом HTTP-кода или default. У него есть description, headers, links и content. Самая полезная часть для клиента — content: она связывает media type с schema. Если 200 объявлен как application/json, а 400 как application/problem+json, граница становится наблюдаемой даже до чтения каждого поля. Серверу не стоит отдавать HTML-страницу ошибки под тем же публичным API-путём молча: это нарушает ожидание парсера и скрывает источник проблемы.'), + codeBlock([ + 'responses:', + ' "200":', + ' description: Страница заказов', + ' content:', + ' application/json:', + ' schema:', + ' $ref: "#/components/schemas/OrdersPage"', + ' "400":', + ' description: Параметры списка не проходят проверку', + ' content:', + ' application/problem+json:', + ' schema:', + ' $ref: "#/components/schemas/Problem"', + ' "500":', + ' description: Непредвиденная ошибка обработки', + ' content:', + ' application/problem+json:', + ' schema:', + ' $ref: "#/components/schemas/Problem"', + ]), + paragraph('Файл не обязан делать все ошибки одинаковыми. Например, ошибка авторизации может прийти с заголовком, который понятен используемому механизму доступа, а у асинхронной команды может быть другой ожидаемый успешный статус. Важно не прятать различие. Если две операции возвращают разные формы ошибки, это надо назвать в их responses. Если команда сознательно выбирает один Problem schema для нескольких операций, то её расширения — errors, traceId, code полей — тоже становятся частью совместимого контракта.'), + heading('Schema Object: required и nullable решают разные вопросы'), + paragraph('Самая частая ловушка — назвать поле «необязательным», не указав, что это означает на проводе. В OpenAPI 3.0.2 массив required принадлежит объекту: в нём перечислены имена свойств, которые должны присутствовать. nullable: true отвечает на другой вопрос: можно ли передать значение null, если у schema явно указан type. Отсутствующий ключ и ключ со значением null — разные состояния; клиенту нельзя считать их одинаковыми, если это не записано в договоре.'), + paragraph('Для страницы заказов выберем строгую форму. items и page обязательны, потому что клиент всегда должен отличить ответ коллекции от произвольного объекта. В page обязательны limit и nextCursor; последний имеет тип string и nullable, поэтому конец списка выражается null, а не пропущенным ключом. customer у заказа не входит в required: его может не быть, но если он есть, он — object, не null. Такое решение можно поменять, но менять его надо как изменение контракта, а не как побочный эффект ORM.'), + codeBlock([ + 'OrdersPage:', + ' type: object', + ' required: [items, page]', + ' properties:', + ' items:', + ' type: array', + ' items: { $ref: "#/components/schemas/Order" }', + ' page:', + ' type: object', + ' required: [limit, nextCursor]', + ' properties:', + ' limit: { type: integer, minimum: 1 }', + ' nextCursor: { type: string, nullable: true }', + 'Order:', + ' type: object', + ' required: [id, status, total]', + ' properties:', + ' id: { type: string }', + ' customer: { $ref: "#/components/schemas/Customer" }', + ]), + paragraph('Эта схема не говорит, что JSON Schema валидатор в проекте обязан полностью понимать любую возможность JSON Schema. OpenAPI 3.0.2 определяет собственный Schema Object с расширенным подмножеством. Поэтому до выбора генератора или validator надо сверить, какую версию и какую часть спецификации он реально поддерживает. В противном случае на бумаге появится nullable, а в рантайме проверка пропустит другой вариант или, наоборот, отвергнет законный ответ.'), + figure( + '/assets/editorial/2019/rest-api-response-matrix-2019.svg', + 'Схема слева направо: один GET с параметрами limit и cursor входит в Operation Object, затем ветвится в Responses 200 application/json и 400 application/problem+json; внизу показано, как Schema Object различает required property и nullable value.', + 'У операции есть два слоя: HTTP сообщает, какой результат пришёл, а schema определяет, какие данные разрешено читать внутри выбранного представления.', + ), + heading('Problem Details не отменяет проектную ошибку'), + paragraph('RFC 7807 полезен тем, что проблема перестаёт быть бесформенным { "error": "..." }. Поле type является URI reference, title — кратким названием, status отражает HTTP-код, detail поясняет конкретный случай, instance помогает различать экземпляры. Но RFC не выдаёт команде готовые коды полей. Если в UI важно выделить query.cursor, это безопаснее сделать явным расширением errors с документированными path и code, чем извлекать смысл из локализованного текста.'), + paragraph('Не называйте каждый бизнес-конфликт «400» только потому, что клиент передал JSON. Нужный статус зависит от семантики операции; его стоит сверять с HTTP и договором продукта. В этой статье мы ограничили пример недопустимым cursor, поэтому 400 понятен: сообщение запроса нельзя обработать как корректную страницу. Для конфликта версии ресурса команда может выбрать другой документированный путь. Главное — не менять статус между релизами без проверки клиентов и не посылать известную ошибку как успешный JSON.'), + heading('Пагинация — часть представления, а не свойство базы'), + paragraph('Наличие LIMIT 20 в SQL ещё не создаёт API-пагинацию. Клиенту нужно знать порядок, размер страницы, признак конца и поведение cursor после изменения данных. В нашем компактном договоре сервер возвращает текущий limit и opaque nextCursor. Мы не обещаем стабильный total и не выводим его из длины items: короткая страница может быть последней, но это решение подтверждает именно nextCursor: null. Если продукту нужен total, он становится отдельным полем с отдельной стоимостью и условиями точности.'), + paragraph('RFC 8288 описывает Web Linking, и команда может выбрать Link header для relation next. Это допустимый, но другой контракт: тогда нужно зафиксировать relation, относительность URL, порядок параметров и способ, которым клиент читает header. Не смешивайте Link и page.nextCursor наполовину. Один доступный путь быстрее тестируется и не заставляет frontend искать несколько несогласованных признаков конца списка.'), + dataTable( + 'Совместимость изменений тела 200 для терпимого клиента', + ['Изменение', 'Почему риск есть', 'Что проверить до выпуска', 'Безопасный переход'], + [ + ['Добавить необязательное поле', 'Старый клиент может игнорировать его, новый — ошибочно ожидать', 'Парсер не требует поле до согласованного релиза', 'Сначала добавить и наблюдать, затем использовать'], + ['Удалить required-поле', 'Старый клиент делает прямой доступ', 'Все поддерживаемые клиенты и контрактные фикстуры', 'Новая версия или период двух полей'], + ['Изменить string на object', 'JSON парсится, но логика ломается позже', 'Потребители, schema и примеры', 'Новое поле или новая операция'], + ['Заменить null отсутствием', 'Это два разных состояния schema', 'Проверки terminal page и UI ветвления', 'Сохранить один вариант до миграции клиентов'], + ], + ), + heading('Делаем изменение проверяемым'), + paragraph('Техническая ценность OpenAPI начинается, когда на её основе появляется проверка. В минимальном варианте это ревью diff: изменились ли status, media type, required или nullable? Затем — пример каждого ответа и локальная fixture, которая намеренно отвергает потерянный nextCursor или problem document с 200. После подключения тестового сервера те же случаи становятся запросами к живому endpoint. Так документ не обещает автоматически совместимость, а даёт список точек, где она может быть нарушена.'), + orderedList([ + 'Опишите Operation Object вместе с параметрами, а не добавляйте responses после реализации контроллера.', + 'Для каждого сознательно возвращаемого статуса укажите description, Content-Type и schema тела или явное отсутствие тела.', + 'Разведите отсутствующее свойство и null через required и nullable; зафиксируйте выбор примером.', + 'Опишите окончание пагинации как часть 200, не выводите его из случайной длины массива.', + 'Добавьте локальные positive и negative fixtures, затем перенесите те же ожидания на тестовый сервер.', + 'При изменении schema оцените поддержку старых клиентов до слияния, а не после первых ошибок пользователя.', + ]), + heading('Границы механизма и короткий вывод'), + paragraph('OpenAPI не заменяет авторизацию, миграцию данных или проверку таймаутов. Она также не делает любое изменение YAML обратно совместимым. Но спецификация даёт инженерный язык для спора: не «у нас же JSON», а «операция больше не возвращает required page.nextCursor на 200». В 2019 это уже достаточный шаг от договорённостей в чате к T-shaped работе на границе frontend, backend и HTTP.'), + bulletList([ + 'Операция состоит из параметров, HTTP-кодов, media types и schemas; URL — только вход в этот договор.', + 'Responses Object связывает код с представлением, а Schema Object делает форму тела проверяемой.', + 'required и nullable не взаимозаменяемы; отсутствие ключа и null надо выбирать осознанно.', + 'Пагинация и extension-поля problem detail принадлежат проектному контракту и требуют теста.', + ]), + ], + [rfcHttp, rfcStatus, rfcProblem, rfcLink, openApi, openApiOperation, openApiResponse, openApiSchema], +); + +function hasOwn(object, key) { + return Object.prototype.hasOwnProperty.call(object, key); +} + +function mediaType(headerValue) { + return typeof headerValue === 'string' + ? headerValue.split(';', 1)[0].trim().toLowerCase() + : ''; +} + +function assertFixture(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function assertProblem(response, expectedStatus, expectedCode) { + const contentType = response.headers['content-type']; + const body = response.body; + + assertFixture(response.status === expectedStatus, 'ожидался HTTP ' + expectedStatus); + assertFixture(mediaType(contentType) === 'application/problem+json', 'ошибка должна иметь application/problem+json'); + assertFixture(body && typeof body === 'object', 'problem body должен быть объектом'); + assertFixture(typeof body.type === 'string' && body.type.indexOf('https://') === 0, 'problem.type должен быть URI'); + assertFixture(body.status === expectedStatus, 'problem.status должен совпадать с HTTP-статусом'); + assertFixture(Array.isArray(body.errors) && body.errors.length > 0, 'problem.errors должен быть непустым массивом'); + assertFixture(body.errors[0].code === expectedCode, 'ожидался project error code ' + expectedCode); +} + +function assertOrdersPage(response) { + const contentType = response.headers['content-type']; + const body = response.body; + + assertFixture(response.status === 200, 'страница списка должна иметь HTTP 200'); + assertFixture(mediaType(contentType) === 'application/json', 'страница должна иметь application/json'); + assertFixture(body && typeof body === 'object', 'body страницы должен быть объектом'); + assertFixture(Array.isArray(body.items), 'items должен быть массивом'); + assertFixture(body.page && typeof body.page === 'object', 'page должен быть объектом'); + assertFixture(Number.isInteger(body.page.limit) && body.page.limit > 0, 'page.limit должен быть положительным целым'); + assertFixture(hasOwn(body.page, 'nextCursor'), 'page.nextCursor должен присутствовать даже на последней странице'); + assertFixture( + body.page.nextCursor === null || typeof body.page.nextCursor === 'string', + 'page.nextCursor должен быть строкой или null', + ); + + body.items.forEach((order, index) => { + assertFixture(order && typeof order === 'object', 'items[' + index + '] должен быть объектом'); + assertFixture(typeof order.id === 'string' && order.id.length > 0, 'items[' + index + '].id обязателен'); + assertFixture(typeof order.status === 'string' && order.status.length > 0, 'items[' + index + '].status обязателен'); + assertFixture(order.total && Number.isInteger(order.total.amount), 'items[' + index + '].total.amount обязателен'); + assertFixture(order.total && typeof order.total.currency === 'string', 'items[' + index + '].total.currency обязателен'); + + if (hasOwn(order, 'customer')) { + assertFixture(order.customer && typeof order.customer === 'object', 'customer при наличии должен быть объектом, не null'); + assertFixture(typeof order.customer.id === 'string', 'customer.id обязателен при наличии customer'); + assertFixture(typeof order.customer.name === 'string', 'customer.name обязателен при наличии customer'); + } + }); +} + +function expectFixtureFailure(action, expectedText) { + try { + action(); + } catch (error) { + assertFixture(String(error.message).indexOf(expectedText) !== -1, 'fixture должен упасть по ожидаемой причине'); + return; + } + + throw new Error('fixture должен был обнаружить нарушение: ' + expectedText); +} + +function runContractFixture() { + const finalPage = { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { + items: [ + { + id: 'ord_1043', + status: 'paid', + total: { amount: 9900, currency: 'RUB' }, + }, + ], + page: { limit: 2, nextCursor: null }, + }, + }; + + const invalidCursor = { + status: 400, + headers: { 'content-type': 'application/problem+json' }, + body: { + type: 'https://api.example.test/problems/invalid-cursor', + title: 'Параметр cursor недействителен', + status: 400, + detail: 'Курсор не принадлежит этому списку заказов', + instance: '/api/v1/orders?cursor=broken', + errors: [{ path: 'query.cursor', code: 'invalid_cursor' }], + }, + }; + + const brokenTerminalPage = { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { + items: [], + page: { limit: 2 }, + }, + }; + + assertOrdersPage(finalPage); + assertProblem(invalidCursor, 400, 'invalid_cursor'); + expectFixtureFailure( + () => assertOrdersPage(brokenTerminalPage), + 'page.nextCursor должен присутствовать', + ); + + return { + fixture: 'rest-api-contract-v1', + transport: 'local response objects only; no server request was made', + cases: [ + { name: '200 final page with explicit null cursor', result: 'pass' }, + { name: '400 invalid cursor with problem details', result: 'pass' }, + { name: '200 page without nextCursor is rejected', result: 'pass' }, + ], + }; +} + +const fieldArticle = createRevision( + { + slug: 'editorial-2019-06-field-rest-api', + title: 'Полевой разбор: контрактный тест REST API без подмены его production-проверкой', + categories: ['HTTP', 'Тестирование', 'Диагностика'], + cover: '/assets/editorial/2019/rest-api-contract-fixture-2019.svg', + excerpt: 'В API «всё работает», пока UI не получает 200 без nextCursor или 400 в чужом формате. Собираем три воспроизводимых response-фикстуры, отделяем их от запроса к стенду и готовим маршрут интеграционной проверки.', + readingMinutes: 13, + }, + [ + paragraph('Симптом на интеграции прост: frontend получает ответ, JSON успешно распарсился, но следующий экран уже не знает, что делать. В одном релизе последняя страница приходит без nextCursor, в другом backend отдаёт HTML от proxy вместо problem document, в третьем optional customer становится null. Цена — не только падение компонента. Клиент может считать данные окончательными, показать неверный текст ошибки или повторять запрос, который никогда не станет успешным.'), + paragraph('Ниже — полевой сценарий для маленького контракта GET /api/v1/orders. Он строится вокруг двух вещей: воспроизводимого HTTP-запроса, который следует выполнить на разрешённом тестовом URL, и локальной fixture, которую можно прогнать без сети. Важно не перепутать их. Фикстура доказывает, что наши правила отличают допустимое тело от недопустимого. Она не доказывает, что сервер, gateway, авторизация и production уже ведут себя так же.'), + heading('Собираем симптомы в проверяемые случаи'), + paragraph('Сначала вырезаем из инцидента общие слова. «Пагинация сломалась» превращается в утверждение: у 200 application/json обязан быть объект page, а у него — ключ nextCursor, равный строке или null. «Ошибка непонятна» превращается в другое утверждение: у 400 ожидается application/problem+json, поле status совпадает с HTTP-кодом, а локальный errors[0].code даёт UI стабильный повод для действия.'), + paragraph('Такая декомпозиция позволяет тестировать не весь сервис, а границу, которая уже известна из сбоя. Для выборки заказов хватит трёх cases: корректная последняя страница с nextCursor: null; корректный problem document для плохого cursor; намеренно испорченная страница, где cursor пропал. Третий case особенно полезен: если проверка его принимает, тест на деле проверяет лишь наличие JSON и не защищает договор.'), + dataTable( + 'Минимальная матрица контрактной fixture', + ['Case', 'Статус и Content-Type', 'Ключевое ожидание', 'Ожидаемый итог'], + [ + ['Последняя страница', '200 / application/json', 'page.nextCursor присутствует и равен null', 'Принять ответ'], + ['Недопустимый cursor', '400 / application/problem+json', 'status совпадает; errors[0].code равен invalid_cursor', 'Принять управляемую ошибку'], + ['Регрессия пагинации', '200 / application/json', 'Ключ nextCursor отсутствует', 'Отклонить ответ с понятной причиной'], + ['Случай customer', '200 / application/json', 'customer отсутствует либо является объектом, но не null', 'Принять или отклонить строго по схеме'], + ], + ), + heading('Фикстура должна проверять статус до тела'), + paragraph('Порядок проверок важен. Нельзя сначала читать body.items, а затем мимоходом заметить, что статус был 400. Так код начинает парсить ошибку как список и рождает вторичную ошибку вроде «map is not a function». В fixture сначала сравниваются статус и content-type, потом только форма соответствующего тела. Для success нужен 200 и JSON. Для known validation error нужен 400 и problem+json. Неизвестный статус оставляем нераспознанным, чтобы интерфейс показал общий сбой и команда увидела новый случай.'), + codeBlock([ + 'function assertOrdersPage(response) {', + ' assert(response.status === 200, "ожидался HTTP 200");', + ' assert(mediaType(response.headers["content-type"]) === "application/json", "ожидался JSON");', + ' assert(Array.isArray(response.body.items), "items должен быть массивом");', + ' assert(response.body.page, "page обязателен");', + ' assert(Object.prototype.hasOwnProperty.call(response.body.page, "nextCursor"),', + ' "page.nextCursor должен присутствовать");', + ' assert(response.body.page.nextCursor === null ||', + ' typeof response.body.page.nextCursor === "string",', + ' "nextCursor должен быть строкой или null");', + '}', + ]), + paragraph('Этот фрагмент намеренно не делает сетевой запрос. response — обычный объект с status, headers и body. В полном пакете запускается та же идея: проверка принимает локальную финальную страницу, принимает 400 problem detail и убеждается, что сама отвергает 200 без nextCursor. Если такой negative case вдруг проходит, мы знаем, что защита ослабла до подключения API. Это корректный результат unit-level fixture, а не отчёт об endpoint.'), + heading('Различаем optional, null и неизвестное поле'), + paragraph('В реальной выдаче часто спорят о customer: пользователю без привязанного профиля объект не нужен, но UI может захотеть написать «клиент не указан». Это не повод разрешить все представления сразу. В выбранном договоре customer необязателен. Если ключ отсутствует, экран выбирает запасной текст. Если ключ присутствует, он обязан быть объектом с id и name. Значение null считается нарушением, потому что добавляет третью ветку без продукта и без причины.'), + paragraph('Это правило не универсально. Другая команда может сделать customer обязательным и nullable, если null имеет отдельный бизнес-смысл. Тогда schema и fixture должны принять null, а интерфейс — назвать его. Плохой вариант один: backend меняет отсутствие на null «потому что так сериализатор отдал», а frontend должен догадаться. Contract test ценен тем, что такое изменение становится красным до того, как попадёт в карточку.'), + codeBlock([ + 'function assertCustomer(order) {', + ' var hasCustomer = Object.prototype.hasOwnProperty.call(order, "customer");', + ' if (!hasCustomer) return;', + '', + ' assert(order.customer && typeof order.customer === "object",', + ' "customer при наличии должен быть объектом, не null");', + ' assert(typeof order.customer.id === "string", "customer.id обязателен");', + ' assert(typeof order.customer.name === "string", "customer.name обязателен");', + '}', + ]), + paragraph('Проверка не обязана быть сложной библиотекой schema validation. В 2019 маленькая функция на assert часто полезнее, когда она живёт рядом с тремя fixtures и легко читается разработчиком обоих слоёв. Позже её можно заменить валидатором на основе OpenAPI, но только после сравнения поддержки версии Schema Object. Цель текущего теста скромнее: удержать реально важные условия — статус, media type, required-поля и смысл optional-поля.'), + figure( + '/assets/editorial/2019/rest-api-contract-fixture-2019.svg', + 'Вертикальная схема контрактной проверки: локальные response fixtures проходят сначала через проверку HTTP-статуса и Content-Type, затем через схему success или problem; отдельная красная ветка показывает 200 без nextCursor, который должен быть отвергнут. Справа отмечен отдельный последующий запрос к тестовому стенду.', + 'Фикстура проверяет договор на заранее заданных данных. Реальный HTTP-запрос — следующий независимый этап, поэтому диаграмма не выдаёт локальный тест за production-проверку.', + ), + heading('Добавляем воспроизводимый запрос, но не выдумываем его результат'), + paragraph('После fixture берём разрешённый тестовый host и выполняем один запрос к первой странице. Команда ниже сохраняет заголовки и тело отдельно. Это важно: status и Content-Type видны в headers, а body можно показать в ревью без шума curl. В примере нет токена, cookies и адреса production. Подставлять их в статью, коммит или CI-лог нельзя; для закрытого API команда должна использовать безопасный тестовый способ аутентификации и скрытие секретов.'), + codeBlock([ + '# Выполнять только на разрешённом тестовом URL и с безопасной авторизацией.', + 'curl -sS -D /tmp/orders.headers -o /tmp/orders.json \\', + ' -H "Accept: application/json" \\', + ' "https://api.example.test/api/v1/orders?limit=2"', + '', + 'grep -Ei "^(HTTP/|content-type:)" /tmp/orders.headers', + 'node -e "const fs=require(\\"fs\\"); const body=JSON.parse(fs.readFileSync(\\"/tmp/orders.json\\")); console.log(body.page)"', + ]), + paragraph('Этот запрос надо читать по шагам. Сначала сверяем фактический HTTP-код. Затем Content-Type; заголовок application/json; charset=utf-8 может содержать параметры, поэтому production-парсер должен сравнивать media type корректно, а не полную строку, если сервер его допускает. Потом смотрим наличие items, page и nextCursor. Если ответ — 400, мы не запускаем проверку success, а сравниваем problem document с отдельной веткой. Результат фиксируем как запись факта, не как «API работает».'), + heading('Связываем fixture с OpenAPI-описанием'), + paragraph('У теста не должно быть второго тайного контракта. Перед запуском сверяем его условия с OpenAPI: 200 указывает на OrdersPage, 400 — на Problem, required содержит items и page, а nullable у nextCursor разрешает только null помимо string. Если fixture и YAML расходятся, сначала решаем, какой из них описывает продукт, и исправляем один источник. Нельзя чинить тест под случайный текущий ответ сервера и оставить спецификацию прежней: это вернёт спор в следующем релизе.'), + dataTable( + 'Порядок диагностики при расхождении теста и сервера', + ['Наблюдение', 'На что указывает', 'Проверка', 'Действие'], + [ + ['Fixture падает на локальном положительном case', 'Ошибка в тесте или собственном примере', 'Сверить fixture с зафиксированным schema', 'Исправить тест/пример до сетевого запуска'], + ['Fixture принимает отрицательный case', 'Контракт не защищён от известной регрессии', 'Добавить конкретное assertion', 'Не продолжать с зелёным, но пустым тестом'], + ['Тестовый сервер отдаёт другой status', 'Нарушен Responses contract или выбран иной сценарий', 'Сохранить headers и запрос без секретов', 'Согласовать изменение или исправить endpoint'], + ['Status верный, тело другое', 'Сериализация/schema не совпали', 'Сравнить required, nullable и Content-Type', 'Исправить schema или mapper и повторить запрос'], + ], + ), + heading('Маршрут от локального случая к интеграционной проверке'), + orderedList([ + 'Возьмите один пользовательский сбой и выразите его в одном проверяемом условии ответа.', + 'Добавьте успешную fixture, управляемую ошибку и отрицательный case, который обязан завершиться с ошибкой проверки.', + 'Сначала проверяйте HTTP-статус и media type, затем schema соответствующего тела.', + 'Зафиксируйте одно правило optional-поля и добавьте его в fixture и OpenAPI одновременно.', + 'Выполните идентичный запрос на разрешённом тестовом сервере, сохранив headers и body без секретов.', + 'Если результат расходится, не подгоняйте UI: сначала укажите, какая строчка контракта изменилась, и согласуйте переход.', + ]), + heading('Что этот сценарий не обещает'), + paragraph('Фикстура не измеряет latency, не проверяет права, не запускает gateway и не подтверждает, что cursor защищён от перебора. Она также не заменяет end-to-end сценарий, где UI действительно нажимает «ещё». Это сознательная граница: один быстрый локальный тест должен ловить разрыв контракта раньше, а серверный и браузерный уровни подтверждают другие свойства. Объявить fixture production-тестом означало бы скрыть эти пробелы, а не уменьшить риск.'), + paragraph('Зато сценарий даёт команде чёткий предметный артефакт. Когда следующий change удалит page.nextCursor, поставит другой Content-Type или заменит optional object на null, можно показать конкретный case и конкретный пункт спецификации. Для автора 2019 года это уже не «проверим руками после релиза», а аккуратный мост от фронтенд-обработки ответа к договору backend и HTTP.'), + heading('Короткий вывод'), + bulletList([ + 'Contract fixture должна принимать ожидаемые cases и обязательно отвергать известный плохой ответ.', + 'Проверка начинается со status и Content-Type; одинаковый JSON не делает 200 и 400 взаимозаменяемыми.', + 'Отсутствующее optional-поле и null — разные данные, если команда не зафиксировала обратное.', + 'Локальные response objects полезны до сети, но тестовый запрос к серверу остаётся отдельным и честно названным этапом.', + ]), + ], + [rfcHttp, rfcStatus, rfcProblem, rfcLink, openApi, openApiOperation, openApiResponse, openApiSchema], +); + +export const revisions = [practiceArticle, mechanismArticle, fieldArticle]; + +if (process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions)); +} else if (process.argv.includes('--run-fixture')) { + process.stdout.write(JSON.stringify(runContractFixture(), null, 2) + '\n'); +} diff --git a/web/scripts/upgrade-2019-08.mjs b/web/scripts/upgrade-2019-08.mjs new file mode 100644 index 0000000..d4eb818 --- /dev/null +++ b/web/scripts/upgrade-2019-08.mjs @@ -0,0 +1,467 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function paragraph(text) { + return '

' + text + '

'; +} + +function heading(text) { + return '

' + text + '

'; +} + +function codeBlock(lines) { + return '
' + escapeHtml(lines.join('\n')) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function dataTable(headers, rows) { + const head = '' + headers.map((header) => '' + header + '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; + return '
' + head + body + '
'; +} + +function sourceList(items) { + return ''; +} + +function visibleText(html) { + return html + .replace(/<[^>]*>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function proseText(html) { + return visibleText( + html + .replace(/
[\s\S]*?<\/code><\/pre>/g, '')
+      .replace(/
[\s\S]*?<\/figure>/g, '') + .replace(/
[\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, увидел структуру, получил стили, нарисовал первый полезный контент и смог обработать действие. Между ними нет одной универсальной границы. DOMContentLoaded говорит о завершении разбора документа и defer-скриптов, а не о том, что главный блок уже нарисован. Событие load может ждать второстепенные картинки, которые не помогают пользователю начать работу. Поэтому запись «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 подробные поля могут быть нулевыми без Timing-Allow-Origin. Нулевое 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, у результата исчезнет владелец image, и 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('Ни одна из этих мер не универсальна. preload помогает только ресурсу, который действительно нужен первому экрану; лишние preload конкурируют за сеть. Code splitting помогает, если код перестал быть частью начального пути; если модуль сразу нужен для рендера, дополнительный запрос может ухудшить ситуацию. Оптимизация картинки не поможет, если она уже скачана и ждёт занятый main thread. Поэтому после каждого изменения возвращаемся к тому же профилю, а не переносим удачную технику на все ресурсы подряд.'), + orderedList([ + 'Сформулировать полезный первый экран и зафиксировать URL, кэш, viewport, throttle и хэш сборки.', + 'Записать один Reload в DevTools с Network, Performance и screenshot, не называя результат production-метрикой.', + 'Разметить в отчёте документ, критический ресурс, участки scripting, style или layout и момент появления полезного блока.', + 'Выбрать одного владельца и одно изменение, которое должно сдвинуть конкретную полосу, а не весь мир сразу.', + 'Повторить тот же сценарий, сравнить только заявленный критерий и записать побочный эффект.', + 'Лишь после устойчивого лабораторного результата решать, нужна ли полевая метрика или выпускная проверка на реальных устройствах.', + ]), + heading('Итог: профиль превращает жалобу в маршрут'), + paragraph('Первая загрузка не становится понятной от одного Lighthouse-числа, веса JavaScript или события load. Нужна короткая карта: что браузер получил, что обнаружил поздно, что заняло главный поток и что не успело появиться на экране. Такая карта не требует большой платформы наблюдаемости, но запрещает прятать разные причины под слово «тяжёлая».'), + 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 без defer может остановить разбор HTML; картинка, добавленная только после выполнения приложения, не будет обнаружена preload scanner из начального документа. Критический путь — не список всех файлов, а цепочка того, без чего выбранный полезный экран не может появиться.'), + heading('Документ задаёт не только разметку, но и момент обнаружения'), + paragraph('HTML приходит потоково. Пока браузер читает начальный документ, он может обнаружить link, script, img и начать работу с ними раньше, чем весь ответ будет получен. Поэтому важен не только размер 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 — путь отдельных ресурсов. Эти записи полезны, но не содержат полный причинный граф. Например, высокий responseEnd изображения говорит, когда закончилась передача, но не говорит, что оно было критическим или что его разрешили отрисовать. Поэтому запись из 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([ + '', + '', + '', + '
', + '

Название товара

', + ' ', + '
', + ]), + paragraph('Этот фрагмент не является рецептом для всех страниц. Он показывает проверяемую идею: критический URL виден в начальном HTML, а размер изображения известен разметке. Preload оправдан только после доказательства, что именно этот ресурс нужен выбранному первому экрану. Добавить его ко всем картинкам — значит забрать пропускную способность у стиля, документа или другого важного ресурса. loading="lazy" у 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. Эту метку стоит ставить вокруг конкретной операции, а не вокруг всей загрузки. Иначе она будет включать сеть, таймеры и чужие скрипты, а название initial-render перестанет соответствовать измеряемому участку. На 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 вообще не на первом экране или содержимое текстовое, сценарий меняется: диагностика всегда начинается с фактического визуального критерия, а не с названия файла hero.webp.'), + 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 прямой src или URL появляется в состоянии приложения? Не скрывает ли контейнер класс is-loading до завершения bootstrap? Эти вопросы часто дают результат быстрее, чем сортировка Network по размеру: если полезный блок создаётся только после renderProduct(), его картинка физически не могла стартовать раньше выполнения этого кода.'), + paragraph('После этого проверяем waterfall в двух направлениях. От документа вперёд: когда открылись CSS, app.js и hero. От полезного элемента назад: какой URL, стиль и код нужны именно ему. Если hero начинает запрос после app.js, фиксируем не «медленную сеть», а позднее обнаружение. Если 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. img.complete не доказывает, что изображение уже показано пользователю, а link.sheet не отвечает на вопрос о стоимости layout. Зато он быстро отделяет ситуацию «URL отсутствует или изображение не готово» от ситуации «ресурс уже доступен, ищем работу рендера». Если запускать его поздно вручную, фиксируйте это в заметке: console-проверка после события не воспроизводит точный момент первого paint.'), + heading('Вторая проверка: не держит ли экран стартовый JavaScript'), + paragraph('Представим, что waterfall показывает ранний hero и завершённый CSS, но полезный screenshot всё равно появляется после блока scripting. Тогда цель — не «разбить всё на чанки», а найти работу, которая происходит до renderProduct. Это может быть инициализация фильтров, формирование рекомендаций, синхронный разбор большой конфигурации или сторонний виджет. Любая из этих функций имеет другой безопасный момент запуска и другой риск для поведения страницы.'), + 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('Даже в аккуратном водопаде нельзя считать responseEnd финишем визуальной работы. Браузер должен применить стиль, рассчитать геометрию, при необходимости декодировать изображение и отрисовать кадр. Если 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('Название initial-shell здесь важно: измеряется момент, когда показали каркас продукта, а не вся страница и не подтверждённая пользовательская метрика. Для trace можно использовать performance.mark как ориентир рядом с 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; + } +}