diff --git a/editorial/production/README.md b/editorial/production/README.md index 2a0296e..b87f4c8 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 37 из 358 созданных материалов. Остальные 321 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 49 из 358 созданных материалов. Остальные 309 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2019-02-draft.md b/editorial/reviews/2019-02-draft.md new file mode 100644 index 0000000..1119142 --- /dev/null +++ b/editorial/reviews/2019-02-draft.md @@ -0,0 +1,63 @@ +# П12 · 2019-02 · ES-модули в браузере + +Статус: **принят в публикационный слой 31 июля 2026 года**. Registry +накладывает ревизии только по стабильному slug; дата и автор остаются у +базового архива. + +## Граница пакета + +- Slug: editorial-2019-02-practice-es-modules, editorial-2019-02-mechanism-es-modules, editorial-2019-02-field-es-modules. +- Голос: М2, 2019 год. Автор переносит практику диагностики из PHP/Bitrix на границу браузера, URL и сборщика; не выдаёт себя за владельца большой платформы. +- Три разных вопроса: минимальный native start; отличие браузерной URL-резолвации от этапа Webpack; диагностика двух module identity и ошибочного пути. +- Все примеры помечены как учебные. В тексте нет заявления о настоящем browser trace, чужой production-сборке или измерении задержки. +- Первичные источники: ECMAScript 2019, HTML Living Standard для script/module map и Fetch Standard для CORS. Для исторического ракурса native-примеры намеренно не используют import map, aliases и package resolution. + +## Pass 1 — факты и техника + +- Проверена граница стандартов: ECMAScript задаёт синтаксис и граф import/export, HTML задаёт загрузку, резолвацию URL и время вычисления module script. +- Сверен native start: module script без async загружает граф параллельно разбору и вычисляется после разбора документа; defer не создаёт для module script отдельный режим. +- Сверен путь: URL-подобные ./, ../ и / разрешаются от base URL импортёра. Браузер не обязан подставлять .js и не наследует aliases или package lookup Webpack. +- Сверена identity: module map ключуется URL и типом. Отличный query создаёт отдельную identity; одинаковый нормализованный URL повторно не вычисляется в том же документе. +- В полевой статье status 200 не объявлен достаточным доказательством: отдельно проверяются Response, Content-Type, Initiator и CORS. +- В механической статье порядок зависимость → потребитель показан на учебном графе, а циклические импорты и два entry названы ограничениями. +- Вердикт: соответствует. Bundler-поведение нигде не названо браузерной спецификацией. + +## Pass 2 — редактура и голос + +- Первые одно-два предложения каждого текста называют симптом и цену ошибки: пустой интерфейс, лишняя конфигурация, двойная инициализация или ложный ремонт. +- У каждого материала один главный вопрос и единая цепочка: симптом → причина → проверка → действие → ограничение. +- Тон короткий и предметный: наблюдения привязаны к URL, Network, Content-Type, entry, import.meta.url или конфигурации, а не к общим оценкам. +- М2-словарь ограничен ES-модулем, графом зависимостей, entry, asset, URL, chunk и source-механикой. Поздние рамки уровня SLO, Kubernetes, OpenTelemetry и организационные метрики не добавлены. +- Объём основного текста, который посчитал draft gate: practice — 7 998; mechanism — 7 831; field — 8 306 знаков. Во всех трёх 8–9 разделов, таблица с thead и scope, code, ordered list, figure, ограничения и точный раздел источников. +- Вердикт: соответствует голосу автора 2019 года. Из текста удалены универсальные обещания и неподтверждённый личный опыт. + +## Pass 3 — визуал и выпуск + +- Созданы три разные объясняющие SVG: граф native start, граница browser/Webpack и диагностика URL identity. +- XML каждого SVG валиден. У каждой figure есть осмысленный alt и подпись, которые передают причинную модель без чтения текста внутри картинки. +- SVG открыты в локальном HTTP-preview при ширинах 1280 и 375 px. После узкого прохода схемы переведены в вертикальную композицию: все блоки, стрелки и ключевые метки остаются видимыми, текст не обрезан, горизонтального overflow нет. +- Проверена существующая разметка выпуска: .article-content figure img имеет width: 100%, а .table-scroll включает overflow-x: auto и таблице задан min-width. Пакет использует именно эти контейнеры. +- Передача в архив не производилась; production build не заявляется как выполненная, потому что автономный черновик не меняет data/articles.json. +- Вердикт: готов к интеграционному ревью. + +## Фактические проверки + +| Проверка | Команда или метод | Результат | +| --- | --- | --- | +| Синтаксис модуля | node --check scripts/upgrade-2019-02.mjs из web | PASS | +| Import-safe и JSON-only CLI | npm run audit:draft -- scripts/upgrade-2019-02.mjs из web | PASS: три revision, CLI совпадает с export | +| Контентный gate | та же команда audit:draft | PASS: 7 998 / 7 831 / 8 306 body chars | +| XML | xmllint --noout для трёх SVG из web | PASS | +| Статический preview | curl -I http://127.0.0.1:4173/assets/editorial/2019/es-modules-native-graph-2019.svg | PASS: 200, image/svg+xml | +| Визуал | локальный preview всех трёх SVG в браузере, 1280 и 375 px | PASS: без обрезания и горизонтального overflow | +| Границы фактов | ECMAScript 2019, HTML и Fetch primary sources | PASS: утверждения сверены перед редактурой | + +## Выпусковой вердикт + +После интеграции основной редактор повторно прогнал строгий аудит: все три +slug прошли объём 7 998 / 7 831 / 8 306 знаков, figure, таблицу, код, +маршрут действий и источники. npm run build завершился с +кодом 0 и сгенерировал 374 статические страницы. + +Пакет принят к публикации. articles.json не менялся: registry +подменяет только редакционные поля по стабильному slug. diff --git a/editorial/reviews/2019-03-draft.md b/editorial/reviews/2019-03-draft.md new file mode 100644 index 0000000..0579fce --- /dev/null +++ b/editorial/reviews/2019-03-draft.md @@ -0,0 +1,104 @@ +# П13 · 2019-03 · Event loop и async-поведение + +Статус: **принят в публикационный слой 31 июля 2026 года**. Registry +применяет ревизии по стабильному slug и не заменяет дату или автора базовой +публикации. Внутри пакета три стабильных slug: + +- `editorial-2019-03-practice-event-loop`; +- `editorial-2019-03-mechanism-event-loop`; +- `editorial-2019-03-field-event-loop`. + +## Граница материала + +- Голос: М2, март 2019 года. Автор уже уверенно разбирает JavaScript-сборку и + границы модулей, но не выдаёт современную observability-платформу за опыт того + периода. Речь прагматична: симптом → причина → проверка → действие. +- Практика учит поставить минимальный опыт для относительного порядка sync, + Promise и timer, а затем отдельно измерить синхронную блокировку. +- Механизм разделяет ECMAScript Jobs, browser task и microtask checkpoint. Он + не обещает одинаковый порядок между разными источниками задач и не называет + `await` перерывом на отрисовку. +- Полевой разбор разделяет два независимых дефекта: устаревший async-результат + и CPU/DOM-работу на main thread. Сценарий учебный; результатов реального + продукта, замеров и конкретного профиля здесь нет. +- Визуальные активы: `event-loop-order-2019.svg`, + `event-loop-frame-budget-2019.svg`, `event-loop-trace-2019.svg`. + +## Pass 1 — факты и техника + +- Сверены первичные/официальные источники: HTML Standard для event loop и + microtask checkpoint, ECMAScript для Jobs и host hook Promise-реакций, W3C + High Resolution Time для `performance.now()`, W3C Long Tasks API для связи + длинной работы на UI-потоке с задержкой input и рендеринга. +- Формулировки намеренно разделяют язык и браузер: Promise Job не назван + «вторым потоком», а browser task не обещает точный момент запуска. +- Для `setTimeout(fn, 0)` записано корректное ограничение: это будущая задача + после доступности таймера, а не команда выполнить функцию немедленно или + гарантировать кадр. +- В примерах нет выданных за факт benchmark-результатов. Код показывает метод + измерения и ожидаемый относительный порядок; его числа зависят от окружения. +- Полевой пример не говорит, что Promise упорядочивает независимые сетевые + ответы. Актуальность контролируется явным `runId`; отмена названа отдельной + политикой проекта. +- Ветви решения разделены: явная Promise-зависимость для данных, ограниченные + порции/алгоритм/worker для CPU-работы, trace для проверки владельца времени. +- Вердикт: фактическая модель соответствует источникам и не скрывает границы. + +## Pass 2 — редактура и голос + +- В каждом тексте первые абзацы называют наблюдаемый сбой и цену ошибочной + правки: ложный порядок callback, зависший input или старый результат на + экране. +- Основная композиция сохраняет маршрут «симптом → причина → проверка → + действие». Термины `stack`, `microtask`, `task`, `trace`, `runId` появляются + рядом с проверяемым объектом, а не как украшение. +- Убраны обещания «Promise ускорит код», «таймер гарантирует кадр» и + «одна очередь объясняет всё». В конце каждого текста указаны ограничения. +- Тон остаётся уровнем М2: автор работает с Console, DevTools и небольшими + адаптерами, а не приписывает себе поздние практики SLO-платформы или + наблюдаемости 2025–2027 годов. +- Объём рассчитан как основной прозаический текст без кода, таблиц, рисунков + и источников; граница 5 000–15 000 знаков проверяется модулем при импорте. +- Вердикт: техническая речь краткая и предметная, без общих рекламных фраз. + +## Pass 3 — визуал и выпуск + +- У каждой статьи собственная SVG-схема с содержательным alt и подписью: + порядок stack/microtask/task; длинная работа на main thread; маршрут + диагностики от лога к действию. +- Во всех трёх статьях есть доступная таблица с `thead` и `scope="col"`, + несколько блоков кода, нумерованный план действий и раздел источников с + официальными ссылками. +- SVG используют локальные пути внутри `web/public/assets/editorial/2019/`; + скрипт ссылается только на эти три assets. +- Выполнены Node syntax, JSON-only CLI/import-safe проверка, `npm run + audit:draft`, XML-проверка всех SVG и ручной визуальный просмотр. +- Первый визуальный проход на ширине 375 px выявил слишком мелкие подписи в + горизонтальных вариантах. Все три SVG перестроены в вертикальную композицию; + повторный рендер на 375 px подтвердил, что карточки, стрелки и подписи не + обрезаются и читаются без горизонтальной прокрутки. +- Production build, registry и `articles.json` намеренно находятся вне этого + пакета и не должны упоминаться как выполненные до отдельной интеграции. +- Вердикт: автономный визуальный и выпускной проход принят. Интеграционный + ревью остаётся отдельной операцией. + +## Фактические проверки + +| Проверка | Команда или метод | Результат | +| --- | --- | --- | +| Node syntax | `node --check scripts/upgrade-2019-03.mjs` из `web/` | успешно | +| JSON-only CLI | `node scripts/upgrade-2019-03.mjs --print-revisions` | успешно: stdout распарсен как JSON; export и CLI используют одну ревизию | +| Import-safe и content gate | `npm run audit:draft -- scripts/upgrade-2019-03.mjs` из `web/` | успешно: 9 851, 9 896 и 11 104 знака основного HTML | +| XML | `xmllint --noout public/assets/editorial/2019/event-loop-order-2019.svg public/assets/editorial/2019/event-loop-frame-budget-2019.svg public/assets/editorial/2019/event-loop-trace-2019.svg` из `web/` | успешно | +| Визуал | Sharp-рендер SVG и ручной просмотр на 600 px и 375 px | успешно после вертикальной перестройки | + +## Выпусковой вердикт + +Пакет прошёл автономный тройной review: фактический/технический, редакторский +и визуально-выпускной. После подключения registry основной редактор повторил +строгий аудит всех трёх slug: 9 851 / 9 896 / 11 104 знака, figure, таблицы, +код, маршрут и источники прошли. npm run build завершился с +кодом 0 и сгенерировал 374 статические страницы. + +Выпусковой вердикт: **принят к публикации**. articles.json не +менялся; registry заменяет только редакционные поля по стабильному slug. diff --git a/editorial/reviews/2019-04-draft.md b/editorial/reviews/2019-04-draft.md new file mode 100644 index 0000000..b1cbc6d --- /dev/null +++ b/editorial/reviews/2019-04-draft.md @@ -0,0 +1,130 @@ +# Апрель 2019 — тройное ревью чернового пакета П14 «Валидация форм» + +Статус: **принят в публикационный слой 31 июля 2026 года**. Registry +накладывает три ревизии по стабильным slug, не меняя дату и автора исходных +публикаций: + +- editorial-2019-04-practice-forms-validation; +- editorial-2019-04-mechanism-forms-validation; +- editorial-2019-04-field-forms-validation. + +Созданы только: + +- web/scripts/upgrade-2019-04.mjs; +- web/public/assets/editorial/2019/forms-validation-contract-2019.svg; +- web/public/assets/editorial/2019/forms-validation-state-machine-2019.svg; +- web/public/assets/editorial/2019/forms-validation-late-response-2019.svg; +- этот файл. + +Все три ревизии намеренно не содержат date или author: +эти поля остаются у исходных записей архива. Модуль экспортирует ровно три +ревизии. При прямом вызове с --print-revisions он пишет только JSON; +--run-fixture отдельно запускает учебную модель обратного порядка +ответов. + +## Проход 1. Факты и техника — пройдено + +| Утверждение или решение | Первичный источник | Проверенная граница | +| --- | --- | --- | +| Нативные ограничения формы и validity — ранняя проверка интерфейса | [HTML Standard: Constraint Validation API](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api) | Тексты не называют checkValidity() проверкой занятости, прав или защитой API; сервер остаётся источником бизнес-условия | +| Нативная отправка формы — отдельный алгоритм браузера | [HTML Standard: form submission](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm) | Локальные правила не выдаются за замену серверной проверки и защиты от прямого запроса | +| aria-describedby ссылается на описывающие элементы по ID | [WAI-ARIA 1.1: aria-describedby](https://www.w3.org/TR/wai-aria-1.1/#aria-describedby) | В примерах label остаётся нативным, а подсказка и ошибка имеют устойчивые ID; ARIA не используется вместо input/label | +| aria-errormessage применяется вместе с aria-invalid, а релевантное сообщение не прячется | [WAI-ARIA 1.1: aria-errormessage](https://www.w3.org/TR/wai-aria-1.1/#aria-errormessage) | Примеры выставляют invalid только в ошибке и показывают видимый текст; они не обещают одинаковое озвучивание во всех браузерах и скринридерах | +| AbortController создаёт signal и может сигнализировать abort | [DOM Standard: AbortController](https://dom.spec.whatwg.org/#abortcontroller) | Отмена названа оптимизацией транспорта. Корректность UI обеспечивается сравнением версии, даже если abort не дошёл до транспорта | +| Fetch работает с моделью запроса/ответа и signal при поддержке клиента | [Fetch Standard](https://fetch.spec.whatwg.org/) | В статье нет обещания, что любой адаптер или legacy-клиент отменяется одинаково; запросы проверяются по актуальности после ответа | + +- Главная техническая гипотеза проверена автономным fixture, а не словами: + запрос ivan с requestId: 1 отвечает позже запроса + ivanka с requestId: 2. Результат прогона: + +```json +{ + "results": [ + { "requestId": 1, "applied": false, "ignored": "stale-response" }, + { "requestId": 2, "applied": true, "phase": "valid" } + ], + "finalState": { + "value": "ivanka", + "requestId": 2, + "phase": "valid", + "fieldError": "" + } +} +``` + +- Fixture проверяет только порядок и правило requestId. Он не + подаётся как результат Network, браузерной трассы, screen reader или + production API. Для настоящего экрана статьи оставляют честный маршрут: + проверить клавиатуру и Accessibility tree после интеграции. +- Контракт fields.email/fields.login явно назван + локальным договором приложения, а не обязательным полем HTML или WAI-ARIA. + Неизвестный ключ уходит в безопасный общий путь и требует согласования. +- Асинхронная проверка не объявлена окончательным резервированием логина: + сервер повторяет условие при сохранении, потому что между pre-check и POST + другой запрос может занять значение. + +## Проход 2. Редактура и голос М2 / 2019 — пройдено + +| Ревизия | Симптом и цена в начале | Главный технический вопрос | Артефакт и ограничение | +| --- | --- | --- | --- | +| Практика | Браузер сделал почту зелёной, API отказал; текст может потеряться в общем баннере | Как разделить HTML-ограничение, API-контракт и рендер поля | Карта ответственности, ответ fields, доступная разметка и маршрут внедрения; не обещается единый валидатор для всех форм | +| Механизм | isValid и error не объясняют промежуточную фазу и старый ответ | Какие фазы и переходы принадлежат одному полю | Таблица автомата, version guard, fixture и ожидаемые a11y-инварианты; не заявляется универсальная поддержка всех transport-слоёв | +| Полевой разбор | Медленный ответ «ivan занят» стирает корректное состояние ivanka | Где сохранять право ответа менять UI и куда ставить ошибку | Временная диаграмма, контрпример и requestId; Accessibility tree описано как план проверки, не как проведённый прогон | + +- Автор остаётся на правдоподобном уровне 2019 года: JavaScript-модули, + форма, API-контракт, Fetch и базовая доступность. Нет поздних SLO, + распределённой трассировки, feature flags, продуктовых метрик или позиции + техлида крупной платформы. +- Каждая статья держит цепочку «симптом → причина → проверка → действие → + ожидаемый результат → ограничение». Вместо общих фраз названы объект, + версия, ключ поля, видимый error-контейнер и условие сравнения. +- Автоматический draft gate зафиксировал объём основного тела **11 707**, + **12 082** и **10 685** знаков. Все значения находятся в требуемом + диапазоне 5 000–15 000 без включения title, meta и раздела источников. +- Во всех трёх статьях есть не менее пяти h2, доступная таблица + с caption/thead/scope, собственная + схема с содержательными alt и figcaption, + воспроизводимый код, упорядоченный маршрут и не менее двух первичных + источников. + +## Проход 3. Визуал и выпуск — пройдено в пределах автономного пакета + +- forms-validation-contract-2019.svg отделяет HTML-ограничение, + клиентское состояние и проверку API. Нижняя обратная стрелка явно называет + правило: старый requestId не меняет новое поле. +- forms-validation-state-machine-2019.svg показывает допустимые + фазы одного input и две пунктирные ветви нового input, которые очищают старую + remote-error. Отдельный зачёркнутый путь показывает устаревший ответ. +- forms-validation-late-response-2019.svg фиксирует временной + порядок t0–t3: быстрый requestId 2 применяется, а поздний + requestId 1 отклоняется. Схема не подменяет fixture, а делает + условие render читаемым. +- В каждом SVG есть title, desc, + role="img", единый вертикальный viewBox, нет + JavaScript, внешних URL или растровых вложений. Подписи сокращены до + действия, развёрнутое объяснение вынесено в figcaption статьи. +- Этот проход не утверждает, что SVG открывали в конкретном браузере или что + снимали фактический Accessibility tree. Он проверяет XML, структурную + мобильную пригодность viewBox и связь каждой схемы с объяснением. Браузерный + рендер и проверка на реальном экране остаются выпускными действиями после + интеграции, а не придуманным доказательством черновика. + +### Выполненные проверки + +```text +node --check web/scripts/upgrade-2019-04.mjs +cd web && npm run audit:draft -- scripts/upgrade-2019-04.mjs +node web/scripts/upgrade-2019-04.mjs --run-fixture +xmllint --noout web/public/assets/editorial/2019/forms-validation-contract-2019.svg \ + web/public/assets/editorial/2019/forms-validation-state-machine-2019.svg \ + web/public/assets/editorial/2019/forms-validation-late-response-2019.svg +``` + +Результат: все автономные команды завершились с кодом 0; draft gate прошёл +для трёх стабильных slug. После подключения registry основной редактор +повторил strict audit: 11 707 / 12 082 / 10 685 знаков, figure, таблицы, +код, маршруты и источники прошли. npm run build завершился с +кодом 0 и сгенерировал 374 статические страницы. + +Выпусковой вердикт: **принят к публикации**. articles.json не +менялся; registry заменяет только редакционные поля по стабильному slug. diff --git a/editorial/reviews/2019-05-draft.md b/editorial/reviews/2019-05-draft.md new file mode 100644 index 0000000..2e17d59 --- /dev/null +++ b/editorial/reviews/2019-05-draft.md @@ -0,0 +1,94 @@ +# П15 · 2019-05 · автономное тройное ревью «HTTP-кеширование» + +Статус: **принят в публикационный слой 31 июля 2026 года**. Registry +накладывает ревизии только по стабильному slug; дата и автор остаются в +базовом архиве. +Пакет содержит три стабильных slug: + +- editorial-2019-05-practice-http-caching; +- editorial-2019-05-mechanism-http-caching; +- editorial-2019-05-field-http-caching. + +Созданы только модуль ревизий, этот лист ревью и три SVG в +web/public/assets/editorial/2019/. Скрипт не меняет +articles.json, registry, стандарт, очередь, Git или файлы +агентских партий. Дата ревью: 31 июля 2026 года. + +## Проход 1. Факты и техника — пройдено + +| Утверждение | Первичный источник | Граница утверждения | +| --- | --- | --- | +| Свежесть ответа и повторная проверка — разные состояния | [RFC 7234, 4.2](https://www.rfc-editor.org/rfc/rfc7234#section-4.2) и [RFC 7234, 4.3](https://www.rfc-editor.org/rfc/rfc7234#section-4.3) | Тексты не обещают, что промежуточные кэши одновременно удалят старый ответ после релиза | +| no-cache допускает хранение, но требует проверки перед повторным использованием; no-store — иной запрет | [RFC 7234, 5.2](https://www.rfc-editor.org/rfc/rfc7234#section-5.2) | Директивы объяснены для HTTP-кэшей; они не названы защитой от логов, истории и утечек в произвольных слоях | +| Vary связывает сохранённый вариант с полями запроса, повлиявшими на представление | [RFC 7234, 4.1](https://www.rfc-editor.org/rfc/rfc7234#section-4.1) | Для CDN прямо сохранена оговорка: конкретный cache key нужно сверить с документацией и конфигурацией поставщика | +| ETag и If-None-Match позволяют условную проверку с ответом 304 | [RFC 7234, 4.3](https://www.rfc-editor.org/rfc/rfc7234#section-4.3) и [MDN: HTTP caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching) | Не утверждается, что ETag обязан быть криптографическим хешем или что каждый origin всегда даст 304 | +| add_header — директива nginx, а не готовый контракт приложения | [ngx_http_headers_module](https://nginx.org/en/docs/http/ngx_http_headers_module.html) | Конфигурационный фрагмент отмечен как учебный; наследование заголовков, ошибки и CDN остаются проверкой конкретного проекта | + +- Источники RFC 7234 существовали к 2019 году и подходят историческому + контексту автора. Современная страница MDN использована как поясняющий + источник, но нормативные утверждения привязаны к RFC. +- Практическая статья различает HTML с постоянным URL, fingerprinted asset, + общий короткоживущий API-ответ и персональные данные. Механизм не сводит + cache key к URL. Полевой разбор проверяет origin и edge раздельно. +- Все curl-фрагменты названы командами для безопасного тестового домена. В + материале нет выдуманного ответа CDN, production-замера или выполненного + purge. +- В первом самостоятельном синтаксическом проходе был найден неверно + экранированный перенос строки в учебной curl-команде. Он устранён до + draft-gate: команды сделаны однострочными, а повторный node --check + прошёл. + +Вердикт: **пройдено**. У каждого тезиса есть ограничение, а код показывает +проверку, не выдавая её за измерение в этом workspace. + +## Проход 2. Редактура и голос М2 — пройдено + +| Ревизия | Симптом и цена в начале | Главный вопрос | Проверяемый результат | +| --- | --- | --- | --- | +| Практика | После релиза показывается вчерашняя цена или несогласованный HTML; цена — неверное решение пользователя и слепая правка TTL | Как дать HTML, fingerprinted asset и API разные контракты | Карта URL/заголовков, условный запрос и порядок изменения | +| Механизм | Один браузер уже видит обновление, другой получает старый вариант; цена — поиск виновника в числе секунд вместо key | Почему TTL не заменяет Vary и ETag | Дерево «ключ → свежесть → проверка» и два запроса с разным языком | +| Поле | Публичный URL отдаёт чужой язык при корректном origin; цена — неверный интерфейс и спор между переводом и CDN | Как локализовать расхождение origin и edge | Два безопасных curl-сценария, тела, заголовки и матрица следующего шага | + +- Строгий draft-gate считает 8 068 / 8 174 / 8 949 знаков основного текста; + все значения находятся в диапазоне 5–15 тыс. и не добраны повторами. +- У каждой статьи больше пяти смысловых разделов, таблица с + thead/scope, SVG с alt и подписью, + несколько примеров кода, упорядоченный маршрут и точный раздел + «Проверяемые источники». +- Голос соответствует М2 / 2019: автор связывает знакомые Webpack-assets с + HTTP-договором, говорит короткой цепочкой «симптом → причина → проверка → + действие», но не приписывает себе зрелую практику глобальной платформы, + SLO, массовых инцидентов или универсальных CDN-рецептов. +- Удалены общие оценки. Каждая рекомендация привязана к URL, входу запроса, + заголовку, условному запросу или границе слоя. + +Вердикт: **пройдено**. Тексты расширяют T-shape автора от фронтенд-сборки к +доставке HTTP и не скачут к тону техлида 2025 года. + +## Проход 3. Визуал и выпуск — пройдено для автономного черновика + +- http-cache-response-path-2019.svg показывает три разных + контракта: HTML с проверкой, asset с новым URL и API с ограниченной + свежестью. +- http-cache-key-2019.svg отделяет построение ключа по URL/Vary + от TTL и условной проверки ETag. +- http-cache-variant-check-2019.svg проводит два языковых + варианта origin и edge к проверке одного варианта через ETag. +- xmllint --noout принял все три SVG. Внутри нет + script, inline event handler, внешних ресурсов или растровых + вложений. +- SVG отрендерены через Sharp в PNG на ширине 720 и 375 px. В мобильном + проходе не обнаружено обрезания или горизонтального overflow. На первой + схеме длинная подпись API выходила за границу своей карточки на desktop; + подпись разделена на две строки, после чего повторный рендер прошёл. +- Пройдены node --check scripts/upgrade-2019-05.mjs и + npm run audit:draft -- scripts/upgrade-2019-05.mjs. CLI + печатает только JSON, импорт не имеет побочных эффектов. + +После подключения registry основной редактор повторил строгий аудит: все три +slug прошли объём 8 068 / 8 174 / 8 949 знаков, 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 b4edbf6..3508a17 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -8,6 +8,10 @@ import { revisions as october2018January2019Revisions } from '../scripts/upgrade import { revisions as november2018Revisions } from '../scripts/upgrade-2018-11.mjs'; import { revisions as december2018Revisions } from '../scripts/upgrade-2018-12.mjs'; import { revisions as january2019FieldRevisions } from '../scripts/upgrade-2019-01-field.mjs'; +import { revisions as february2019Revisions } from '../scripts/upgrade-2019-02.mjs'; +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'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -21,4 +25,8 @@ export const editorialRevisions = [ ...november2018Revisions, ...december2018Revisions, ...january2019FieldRevisions, + ...february2019Revisions, + ...march2019Revisions, + ...april2019Revisions, + ...may2019Revisions, ]; diff --git a/web/public/assets/editorial/2019/es-modules-diagnostic-identity-2019.svg b/web/public/assets/editorial/2019/es-modules-diagnostic-identity-2019.svg new file mode 100644 index 0000000..d649a41 --- /dev/null +++ b/web/public/assets/editorial/2019/es-modules-diagnostic-identity-2019.svg @@ -0,0 +1,63 @@ + + + Диагностика идентичности ES-модулей по URL + Верхняя панель показывает два import одного URL init.js и одну module identity. Нижняя панель показывает init.js и init.js с query как две разные identity. Внизу указан порядок проверки. + + + + + + + + + + + + + + + FIELD DIAGNOSTIC · MODULE IDENTITY + Сравниваем URL, а не считаем теги + + + Один разрешённый URL + + catalog/main.js + + admin/main.js + + + + /assets/init.js + одна identity + + URL = 1 + + + Разные разрешённые URL + + catalog/main.js + + + /assets/init.js + + admin/main.js + + + /assets/init.js?demo=2 + + URL = 2 + + + Проверка: import.meta.url → Initiator + → status / MIME → entry-владелец + → один bootstrap + diff --git a/web/public/assets/editorial/2019/es-modules-native-graph-2019.svg b/web/public/assets/editorial/2019/es-modules-native-graph-2019.svg new file mode 100644 index 0000000..2a957e7 --- /dev/null +++ b/web/public/assets/editorial/2019/es-modules-native-graph-2019.svg @@ -0,0 +1,62 @@ + + + Нативный граф ES-модулей в браузере + Вертикальная схема: HTML подключает app.js как модуль, app.js импортирует message.js, после готовности графа app.js обновляет output. Внизу перечислены четыре точки проверки. + + + + + + + + + + + + ES MODULES · 2019 + Нативный запуск: граф модулей + + + + 1 + index.html + <script type="module" + src="./assets/app.js"> + + + entry URL + + + + 2 + assets/app.js + import { message } + from "./message.js" + + + relative URL + + + + 3 + assets/message.js + export function message() { ... } + + + граф готов + + + output#status + модуль app.js получен + + + Проверка: URL entry → URL import + → Content-Type / CORS → DOM-результат + diff --git a/web/public/assets/editorial/2019/es-modules-resolution-boundary-2019.svg b/web/public/assets/editorial/2019/es-modules-resolution-boundary-2019.svg new file mode 100644 index 0000000..e7d2d60 --- /dev/null +++ b/web/public/assets/editorial/2019/es-modules-resolution-boundary-2019.svg @@ -0,0 +1,61 @@ + + + Граница нативного разрешения ES-модуля и Webpack + Верхняя панель показывает: браузер превращает относительный import из main.js в запрос config.js. Нижняя панель показывает: Webpack заранее разрешает имя пакета, строит graph и выпускает asset. У import общий синтаксис, но разные этапы. + + + + + + + + + + + + + + + ES MODULES · RESOLUTION BOUNDARY + Одинаковый import, разные этапы + + + Нативный браузер + + main.js + "./config.js" + + URL + + GET + config.js + + + Проверяем: import → Request URL + → HTTP-ответ. Нет aliases и .js-подбора. + + + Webpack до браузера + + source import + "date-kit" + + + resolve + graph + + + Проверяем: config → resolved module + → emitted asset. Aliases живут здесь. + + + import — общий синтаксис + diff --git a/web/public/assets/editorial/2019/event-loop-frame-budget-2019.svg b/web/public/assets/editorial/2019/event-loop-frame-budget-2019.svg new file mode 100644 index 0000000..f09251b --- /dev/null +++ b/web/public/assets/editorial/2019/event-loop-frame-budget-2019.svg @@ -0,0 +1,35 @@ + + Длинная синхронная задача занимает главный поток + Вертикальная шкала показывает длинный синхронный обработчик, задержанный input, а затем короткие порции работы, между которыми event loop может выбрать input и paint. + + Почему async-экран + всё равно зависает + Promise не выносит вычисление с главного потока. + Пока стек занят, input и render ждут. + + До: один обработчик + + + sync import + parse + render + main thread занят до return + + input уже ждёт, но обработчик не начнётся + После: короткие порции + + + + slice 1 + slice 2 + + + slice 3 + slice 4 + + + input + paint + + + Порции отдают управление event loop. + Проверяем это профилем, а не именем функции. + diff --git a/web/public/assets/editorial/2019/event-loop-order-2019.svg b/web/public/assets/editorial/2019/event-loop-order-2019.svg new file mode 100644 index 0000000..0ca5cc8 --- /dev/null +++ b/web/public/assets/editorial/2019/event-loop-order-2019.svg @@ -0,0 +1,34 @@ + + Очередность синхронного кода, microtask и timer-задачи + Вертикальная схема показывает: синхронный код завершает текущий стек, затем выполняется microtask Promise, после чего event loop может выбрать timer-задачу. + + Порядок одного тика + не угадываем, записываем + Сначала стек, затем microtask checkpoint, + после этого — следующая доступная task. + + + + 1 + Текущий стек + sync: A, затем D + Выполнить функцию до return. + + + + 2 + Microtask checkpoint + Promise: B + Очистить накопленную очередь. + + + + 3 + Будущая task + timer: C + Event loop выбирает доступную работу. + + + Порядок опыта: A → D → B → C. + Timer не обязан запускаться сразу после 0 ms. + diff --git a/web/public/assets/editorial/2019/event-loop-trace-2019.svg b/web/public/assets/editorial/2019/event-loop-trace-2019.svg new file mode 100644 index 0000000..7a3c759 --- /dev/null +++ b/web/public/assets/editorial/2019/event-loop-trace-2019.svg @@ -0,0 +1,34 @@ + + Маршрут диагностики неожиданного порядка async колбэков + Вертикальная схема ведёт от отметок времени и номера запуска через разделение sync, microtask и task к проверке длинной работы в performance trace. + + Маршрут разбора: + от лога к причине + Не называем очередь сломанной, пока не записали + источник callback и sync-границу. + + + + 1 + Записать лог + label, runId, performance.now() + и источник каждого callback. + + + + 2 + Классифицировать + sync / microtask / task + или длинную работу main thread. + + + + 3 + Выбрать действие + явная зависимость, отмена + или работа с CPU/DOM-границей. + + + Порядок и задержка повторяемы. + Один удачный запуск не закрывает проверку. + diff --git a/web/public/assets/editorial/2019/forms-validation-contract-2019.svg b/web/public/assets/editorial/2019/forms-validation-contract-2019.svg new file mode 100644 index 0000000..cd2637e --- /dev/null +++ b/web/public/assets/editorial/2019/forms-validation-contract-2019.svg @@ -0,0 +1,56 @@ + + Контракт валидации формы + Вертикальная схема: HTML проверяет форму ввода, клиент хранит версию и привязывает ошибки к полю, сервер проверяет данные и возвращает ключ поля. Старый ответ не имеет права изменить новое значение. + + + + + + + + + + + + + + + + + Форма: один контракт, три владельца + Симптом: браузер считает поле нормальным, API отказывает. + + + + + 1. HTML-контрол + required · type=email · pattern + Даёт ранний сигнал, но не знает базу. + + + локально корректно + + + + + 2. Клиентское состояние + value + requestId + phase + fieldError + Сравнивает версию до render. + + email-error + aria-invalid=true + + + проверка условия + + + + + 3. API и данные + Занятость, права, нормализация, сохранение. + Возвращает fields.email и код ошибки. + + + + старый requestId → не меняет новое поле + diff --git a/web/public/assets/editorial/2019/forms-validation-late-response-2019.svg b/web/public/assets/editorial/2019/forms-validation-late-response-2019.svg new file mode 100644 index 0000000..5193291 --- /dev/null +++ b/web/public/assets/editorial/2019/forms-validation-late-response-2019.svg @@ -0,0 +1,61 @@ + + Поздний ответ не меняет новое поле + Временная диаграмма проверки логина: пользователь сначала вводит ivan и запускает медленный запрос с requestId 1, затем ivanka с requestId 2. Быстрый новый ответ применён, поздний старый ответ отклонён как устаревший. + + + + + + + + + + + + + + Два ответа, одно актуальное поле + Нельзя доверять времени ответа: сравниваем requestId. + + + Пользователь + Форма + Проверка API + + + + + + 1. Ввод: ivan + + + requestId = 1 + checking: ivan + + медленный ответ + + 2. Ввод: ivanka + + + requestId = 2 + checking: ivanka + + быстрый ответ + + + + apply: requestId 2 + ivanka · valid · error пуст + available + + + + + occupied, но поздно + + ignore: stale-response + 1 !== current requestId 2 + + + Новый input очищает старую ошибку раньше следующего render. + diff --git a/web/public/assets/editorial/2019/forms-validation-state-machine-2019.svg b/web/public/assets/editorial/2019/forms-validation-state-machine-2019.svg new file mode 100644 index 0000000..0c057bb --- /dev/null +++ b/web/public/assets/editorial/2019/forms-validation-state-machine-2019.svg @@ -0,0 +1,61 @@ + + Конечный автомат проверки одного поля + Схема показывает переходы поля: editing, client-invalid, checking, ready и remote-invalid. Каждый новый input увеличивает версию и очищает старый удалённый результат. Устаревший ответ обрывается до изменения состояния. + + + + + + + + + + + + + + Поле как небольшой автомат + Версия — условие, по которому ответ получает право изменить UI. + + + + editing + value изменён · version + 1 + + + локальная ошибка + + client-invalid + сеть не запускаем + + + локально корректно + + checking + запомнить version + + + available + + ready + можно отправлять форму + + + occupied + + remote-invalid + ошибка у этого поля + + + + любой input → очистить + старую remoteError + + + + старая версия + не делает render + + + Ответ применяем, только если checkedVersion === current.version + diff --git a/web/public/assets/editorial/2019/http-cache-key-2019.svg b/web/public/assets/editorial/2019/http-cache-key-2019.svg new file mode 100644 index 0000000..48d1f1d --- /dev/null +++ b/web/public/assets/editorial/2019/http-cache-key-2019.svg @@ -0,0 +1,45 @@ + + Как HTTP-кэш выбирает вариант ответа + Схема показывает, что URL и поля из Vary образуют ключ варианта, затем кэш проверяет свежесть и при истечении срока валидирует ETag у origin. + + + Ответ выбирается по ключу, а не по TTL + URL + входы, перечисленные в Vary + + + GET /catalog + Accept-Language: ru + + + + Ключ варианта + URL + Accept-Language + если origin отдал Vary: Accept-Language + + + + Найдено представление ru + Cache-Control задаёт его свежесть + + + + Свежий + вернуть тело + + Устарел + проверить ETag + + + + If-None-Match → origin + 304 подтверждает тело; 200 даёт новый вариант + + + Неполный ключ может вернуть свежий, но чужой язык + + + + + + + diff --git a/web/public/assets/editorial/2019/http-cache-response-path-2019.svg b/web/public/assets/editorial/2019/http-cache-response-path-2019.svg new file mode 100644 index 0000000..f025087 --- /dev/null +++ b/web/public/assets/editorial/2019/http-cache-response-path-2019.svg @@ -0,0 +1,47 @@ + + Путь HTTP-ответа для HTML, asset и API + Вертикальная схема показывает, как браузер проверяет HTML с no-cache и ETag, получает новый URL версионированного JavaScript и отдельно применяет короткую свежесть к API. + + + Один домен — три контракта ответа + Сначала тип ресурса, потом TTL + + + HTML /catalog + no-cache + ETag + + + + Кэш проверяет сохранённый HTML + свежий — использует; устаревший — + отправляет If-None-Match к origin + + + + 304 + тело остаётся + + 200 + новый HTML + + + + HTML ссылается на новый asset URL + /assets/app.4f91.js вместо app.1120.js + + + + Asset: public, max-age=31536000 + длинный TTL безопасен из-за нового ключа + + + API: короткая свежесть + по допустимой давности + не смешивать с персональным ответом + + + + + + + diff --git a/web/public/assets/editorial/2019/http-cache-variant-check-2019.svg b/web/public/assets/editorial/2019/http-cache-variant-check-2019.svg new file mode 100644 index 0000000..7ba121c --- /dev/null +++ b/web/public/assets/editorial/2019/http-cache-variant-check-2019.svg @@ -0,0 +1,52 @@ + + Проверка cache key для русского и английского варианта + Вертикальная схема показывает два запроса к origin и CDN с разным Accept-Language, отдельные варианты по Vary и проверку ETag только внутри одного языкового варианта. + + + Один URL, два проверяемых варианта + Меняем только Accept-Language + + + GET /catalog + Language: ru + + GET /catalog + Language: en + + + + + Origin возвращает два представления + Vary: Accept-Language; ETag различается + + + + CDN key: /catalog + ru + русское тело + + CDN key: /catalog + en + английское тело + + + + + После срока: проверить тот же вариант + If-None-Match для ru не проверяет en + + + + 304 + вариант не менялся + + 200 + новый ETag и тело + + + Сначала origin, потом edge: не смешивать две границы + + + + + + + diff --git a/web/scripts/upgrade-2019-02.mjs b/web/scripts/upgrade-2019-02.mjs new file mode 100644 index 0000000..1ab0afe --- /dev/null +++ b/web/scripts/upgrade-2019-02.mjs @@ -0,0 +1,418 @@ +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').trim()) + '
'; +} + +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(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 sources are required'); + } + + return { + ...meta, + contentHtml: [ + bodyHtml, + heading('Проверяемые источники'), + sourceList(sources), + ].join('\n'), + proseLength, + }; +} + +const ecmascript2019Modules = { + title: 'ECMAScript 2019: Modules', + url: 'https://262.ecma-international.org/10.0/#sec-modules', + note: 'нормативная модель Module Record, статических import/export и выполнения связанного графа модулей', +}; + +const htmlScriptElement = { + title: 'HTML Living Standard: the script element', + url: 'https://html.spec.whatwg.org/multipage/scripting.html#the-script-element', + note: 'тип module, загрузка графа зависимостей, отличие async и nomodule, CORS для внешних модулей', +}; + +const htmlModuleScripts = { + title: 'HTML Living Standard: JavaScript module scripts', + url: 'https://html.spec.whatwg.org/multipage/webappapis.html#javascript-module-scripts', + note: 'module map, URL-идентичность модуля и разрешение module specifier в браузере', +}; + +const fetchCors = { + title: 'Fetch Standard: CORS protocol and credentials', + url: 'https://fetch.spec.whatwg.org/#cors-protocol-and-credentials', + note: 'ограничения межсайтовой загрузки, которые относятся и к импортам модулей', +}; + +const urlStandard = { + title: 'URL Standard', + url: 'https://url.spec.whatwg.org/', + note: 'модель URL, на которой основано разрешение относительных адресов модулей', +}; + +const practiceArticle = createRevision( + { + slug: 'editorial-2019-02-practice-es-modules', + title: 'ES-модули в браузере: старт без сборщика', + categories: ['JavaScript', 'ES modules', 'Браузер', 'Практика'], + cover: '/assets/editorial/2019/es-modules-native-graph-2019.svg', + excerpt: 'Страница открывается, но модуль не запускается или импорт отвечает 404. Собираем минимальный нативный граф, проверяем URL, MIME и порядок запуска без предположений о Webpack.', + readingMinutes: 11, + }, + [ + paragraph('Страница уже отдала HTML, но кнопка остаётся пустой: модуль не стартовал, а Console показывает ошибку загрузки или разрешения пути. Цена ошибки — не одна сломанная кнопка. Если сразу подменить модуль готовым bundle, причина останется в сервере или адресах и вернётся при следующем экране.'), + paragraph('Разберём один вопрос: как запустить небольшой граф ES-модулей прямо в браузере и доказать, что его загрузил именно браузер, а не логика сборщика. Ниже учебная страница с тремя файлами. Она не является трассой настоящего сайта и не обещает поддержку любого старого браузера.'), + heading('Граница задачи: модульный тег, а не новый bundle'), + paragraph('Нативный модуль начинается с тега <script type="module">. Для браузера это другой вид скрипта: он получает entry-файл, находит его статические import, строит граф и загружает зависимости. Внутри модуля верхнеуровневые объявления не становятся случайными свойствами window; связь между файлами описывается импортом и экспортом.'), + paragraph('Здесь полезно отделить две вещи. Спецификация ECMAScript задаёт синтаксис и связи модулей. Браузерная среда HTML задаёт, как получить файлы по URL, когда начать вычисление и как применить CORS. Webpack может подготовить другой набор файлов заранее, но его правила не появляются в браузере только от слова import.'), + dataTable( + ['Наблюдение', 'Причина в нативном сценарии', 'Проверка', 'Действие'], + [ + ['После загрузки HTML интерфейс пустой', 'entry-модуль не запросился или не вычислился', 'Network: есть ли запрос app.js; Console: есть ли ошибка модуля', 'Проверить тег type="module" и точный URL entry'], + ['Виден 404 на импорт', 'Относительный адрес указывает не в ту папку', 'Открыть в Network фактический Request URL', 'Исправить путь в файле-импортёре, не в HTML наугад'], + ['Вместо JavaScript приходит документ', 'Маршрут сервера вернул HTML fallback', 'Сверить Response и заголовок Content-Type', 'Отделить путь ассета от маршрута приложения'], + ['Импорт с другого origin не загружается', 'У модуля действует CORS-проверка', 'Посмотреть Console и CORS-заголовки ответа', 'Настроить разрешённый origin или отдать модуль с того же origin'], + ], + ), + paragraph('Таблица не заменяет чтение ошибки. Например, 404 и CORS иногда выглядят как одинаковый «модуль не работает». Сначала сохраняем фактический URL и текст Console. Только после этого меняем путь или заголовок.'), + heading('Минимальная учебная страница'), + paragraph('Соберём каталог, который можно отдать любым локальным HTTP-сервером. Открывать index.html через file:// не стоит: у файлового URL другой origin, а диагностика CORS и путей станет не похожа на доставку сайта. Пример использует только относительные URL и не требует пакета из node_modules.'), + codeBlock([ + '', + '', + '', + 'ES modules: учебная страница', + '
', + '

Статус загрузки

', + ' ожидание', + '
', + '', + '', + '// assets/message.js', + 'export function message(name) {', + ' return "модуль " + name + " получен";', + '}', + '', + '// assets/app.js', + 'import { message } from "./message.js";', + '', + 'const status = document.querySelector("#status");', + 'status.textContent = message("app.js");', + 'console.log("Учебный entry:", import.meta.url);', + ]), + paragraph('Проверка воспроизводима: после ответа сервера браузер должен запросить assets/app.js, затем assets/message.js, а в output появится строка из экспорта. В Console выводится URL самого app.js. Это учебная отметка, а не запись Network конкретного проекта.'), + heading('Что браузер делает с type="module"'), + paragraph('У entry без async браузер загружает сам модуль и его зависимости параллельно с разбором HTML, а вычисляет entry после завершения разбора документа. Поэтому в примере main уже существует к моменту обращения к document.querySelector. Атрибут defer для module-скрипта не добавляет отдельного режима: без async модуль уже ведёт себя как отложенный относительно разбора документа.'), + paragraph('Это не значит, что любое действие разрешено писать в верхнем уровне. Модуль зависит от доступности всего его графа: если message.js вернул 404 или неподходящий ответ, app.js не должен считать себя готовым. Поэтому признаком успеха служат и DOM-результат, и два сетевых ответа, и отсутствие ошибки загрузки. Один console.log без Network не доказывает правильный путь.'), + paragraph('Если добавить async к module-скрипту, браузер сможет вычислить граф, как только он готов, потенциально до конца разбора HTML. Для виджета, которому нужен элемент в документе, это меняет условие запуска. Не ставим async как ускоритель, пока не проверили, что код не обращается к ещё не разобранной разметке.'), + heading('Относительный путь читается от файла-импортёра'), + paragraph('В примере ./message.js находится внутри assets/app.js, поэтому браузер ищет assets/message.js. Он не считает путь от адреса страницы index.html и не перебирает расширения. Запись ./message означает URL без добавленного .js; если сервер не имеет такого ресурса, запрос закончится 404 или HTML fallback.'), + codeBlock([ + '// Структура учебного каталога', + '// /demo/index.html', + '// /demo/assets/app.js', + '// /demo/assets/message.js', + '', + '// В /demo/assets/app.js:', + 'import { message } from "./message.js";', + '', + '// Браузер запросит /demo/assets/message.js.', + '// Не /demo/message.js и не /demo/assets/message автоматически.', + ]), + paragraph('Такая проверка полезнее правки с несколькими ../ наугад. Открываем ошибочный Request URL, находим в нём путь entry-файла и вычисляем рядом с ним адрес импорта. Если ожидаемый файл лежит в другом месте, меняем спецификатор или структуру каталога. Если файл есть, но ответ содержит HTML, исправляем правило раздачи статических файлов.'), + heading('Сервер тоже входит в минимальный контракт'), + paragraph('Браузер получает модуль через HTTP. Для своего origin ответ должен быть JavaScript-ресурсом, а для другого origin дополнительно пройти CORS. На практике проверяем статус, итоговый URL после redirect и Content-Type. Ошибка MIME или CORS не лечится перестановкой импортов: это уже граница между модулем и серверной конфигурацией.'), + paragraph('Не подставляем в native import имя пакета вроде date-fns, если сервер не сделал для него URL-маршрут. В учебной странице используем только ./, ../, абсолютный путь или полный URL. Правила пакетов, aliases и автоматических расширений принадлежат сборщику и будут разобраны отдельно.'), + figure('/assets/editorial/2019/es-modules-native-graph-2019.svg', 'Учебный граф нативных ES-модулей: HTML загружает app.js, тот запрашивает message.js, после готовности графа app.js меняет output', 'Нативный запуск проверяем по трём наблюдениям: entry и зависимость запрошены по правильным URL, а код меняет ожидаемый элемент страницы.'), + heading('Порядок ручной проверки'), + orderedList([ + 'Отдать учебный каталог через локальный HTTP-сервер и открыть адрес страницы, а не файл на диске.', + 'В HTML оставить ровно один <script type="module" src="./assets/app.js">; не добавлять bundle «для надёжности».', + 'В Network включить сохранение записей, перезагрузить страницу и записать статус, Request URL и Content-Type для app.js и message.js.', + 'Сверить Console: не должно быть ошибки разрешения, MIME или CORS; затем проверить строку в элементе #status.', + 'Если импорт не найден, считать относительный путь от app.js, а не от адреса вкладки. Исправить один спецификатор и повторить тот же сценарий.', + 'Только после успешного нативного примера переносить схему в страницу приложения и отдельно решать, нужен ли ей fallback для старых браузеров.', + ]), + heading('Совместимость и ограничения'), + bulletList([ + 'Пример рассчитан на браузер с поддержкой module scripts. Для старого браузера можно держать отдельный classic-артефакт с nomodule; этот fallback надо тестировать как отдельный путь.', + 'Код не измеряет скорость и не доказывает порядок в чужом приложении. Он показывает только минимальную форму загрузки и точки наблюдения.', + 'Cross-origin модуль требует корректной CORS-политики. Отключение защиты в браузере не является проверкой production-конфигурации.', + 'Маршрутизатор SPA может возвращать index.html на неизвестный URL. Для модуля это ошибка раздачи ассета, даже если ответ имеет статус 200.', + 'Пример не использует import map, aliases и package resolution. Для 2019 года это намеренное ограничение: путь должен быть виден в самом спецификаторе.', + ]), + heading('Итог'), + paragraph('Нативный ES-модуль стартует не после «подключения JavaScript вообще», а после успешной загрузки графа URL. В первую очередь проверяем тег entry, адреса двух файлов, их HTTP-ответы и DOM-результат. Когда этот маршрут работает без сборщика, становится понятно, где заканчивается браузерный контракт и где начинаются правила конкретного инструмента доставки.'), + ], + [ecmascript2019Modules, htmlScriptElement, htmlModuleScripts, fetchCors], +); + +const mechanismArticle = createRevision( + { + slug: 'editorial-2019-02-mechanism-es-modules', + title: 'ES-модули: почему import в браузере не равен import в Webpack', + categories: ['JavaScript', 'ES modules', 'Webpack', 'Разбор'], + cover: '/assets/editorial/2019/es-modules-resolution-boundary-2019.svg', + excerpt: 'Сборщик принял import по имени пакета, а браузер не знает, куда идти. Разделяем URL-резолвацию, построение графа и дополнительные правила Webpack, не смешивая их в один диагноз.', + readingMinutes: 12, + }, + [ + paragraph('Сборка проходит с import { format } from "date-kit", а страница с тем же исходным файлом отвечает Failed to resolve module specifier. Цена ошибки — двойная конфигурация: разработчик меняет Webpack, хотя браузер в этот момент вообще не получил URL зависимости.'), + paragraph('Разберём один вопрос: почему одинаковый синтаксис import имеет разную доставку в нативном браузере и после Webpack. Учебные файлы ниже показывают причинную модель. Они не являются логом конкретной сборки и не утверждают, что любой bundler работает одинаково.'), + heading('Один синтаксис, две разные границы'), + paragraph('ECMAScript описывает, как модуль объявляет зависимость и что он импортирует из другого модуля. Браузер получает этот спецификатор и должен превратить его в URL ресурса. Webpack читает исходный граф раньше браузера, применяет свою конфигурацию и отдаёт уже созданные assets. В браузере после сборки часто нет исходной строки import "date-kit": есть entry-файл и чанки, адреса которых сгенерировал bundler.'), + paragraph('Поэтому вопрос «почему import не работает» нужно разрезать. Если в исходнике страницы используется native module, проверяем URL-спецификатор, сервер и браузер. Если исходник прошёл через сборку, проверяем resolved-путь в конфигурации и выданный asset. Нельзя объявлять алиас Webpack свойством браузера или считать ошибку URL следствием tree shaking.'), + dataTable( + ['Запись import', 'Что видит браузер без дополнительного отображения', 'Что может сделать Webpack', 'Проверка границы'], + [ + ['import "./format.js"', 'Относительный URL от файла-импортёра', 'Оставить путь или включить файл в bundle', 'Network показывает URL от importer.js'], + ['import "/assets/format.js"', 'URL от origin сайта', 'Обработать как путь проекта по своей конфигурации', 'Сравнить URL в браузере с public path сборщика'], + ['import "date-kit"', 'Не URL-спецификатор для учебного native-сценария', 'Найти пакет, alias или поле package.json', 'Не открывать Network до появления реального URL'], + ['import "./format"', 'Запросить URL без автоматического .js', 'Может подобрать расширение по настройке resolve', 'Проверить точный URI и правило resolve отдельно'], + ], + ), + paragraph('В последней колонке специально нет универсального рецепта. Конфигурация Webpack может подставлять aliases, расширения, loader или путь к пакету; браузер без отдельного механизма таких правил не получает. Для исторического сценария 2019 года в нативных примерах используем URL-подобные спецификаторы с расширением.'), + heading('Учебная папка: URL считается от импортёра'), + paragraph('Посмотрим на дерево, в котором адрес страницы и адрес модуля лежат в разных папках. Главная ошибка здесь — мысленно вычислять ./config.js от index.html. Его базой служит URL файла main.js, потому что именно он содержит import.'), + codeBlock([ + '', + '', + '', + '// /demo/assets/app/main.js', + 'import { apiRoot } from "./config.js";', + 'console.log("API:", apiRoot);', + '', + '// /demo/assets/app/config.js', + 'export const apiRoot = "/api/v1";', + '', + '// Browser request: /demo/assets/app/config.js', + ]), + paragraph('Если заменить строку на import { apiRoot } from "./config", нативный браузер не обязан угадать суффикс. Он идёт за URL /demo/assets/app/config. Сервер может честно вернуть 404 или ошибочно вернуть HTML страницы. Webpack, напротив, может по своей настройке найти config.js ещё на этапе сборки. Это два разных момента времени и два разных набора правил.'), + paragraph('Такую разницу удобно видеть в двух коротких проверках. Для native-страницы открываем Network и копируем Request URL импорта. Для Webpack-сценария смотрим, какой модуль попал в статистику сборки и какой asset выдан в HTML. Результаты не спорят друг с другом: они отвечают на разные вопросы.'), + heading('Порядок вычисления задаёт граф, а не строка после import'), + paragraph('Статический import не является вызовом, который выполняется в середине тела модуля. Сначала среда подготавливает связи графа, затем зависимые модули вычисляются до модуля, который от них зависит. В учебном примере сообщение из config.js появится до сообщения из main.js, даже если строка import записана первой.'), + codeBlock([ + '// config.js', + 'console.log("1. вычисляется config.js");', + 'export const apiRoot = "/api/v1";', + '', + '// main.js', + 'import { apiRoot } from "./config.js";', + 'console.log("2. main.js получил " + apiRoot);', + ]), + paragraph('Это не приглашение строить приложение на побочных эффектах верхнего уровня. Если два независимых entry-модуля меняют один window-объект, порядок их завершения может зависеть от способа подключения и готовности графов. Для общего состояния лучше оставить один entry или передать явную функцию инициализации. Важно другое: строка ниже статического import не может подготовить зависимость, которую тот import уже потребовал.'), + paragraph('У module-скрипта без async вычисление ждёт конца разбора документа и готового графа. С async момент запуска меняется. Webpack может добавить свой runtime, динамические чанки и порядок подключения assets, но это его доставка; стандарт браузера не знает о splitChunks, alias или ProvidePlugin.'), + heading('Что происходит после Webpack'), + paragraph('Сборщик строит граф на машине разработки или CI: разрешает имена пакетов, применяет конфигурацию и записывает результат в один или несколько файлов. Затем браузер загружает уже эти файлы по URL из HTML или runtime. Поэтому диагностировать нужно на правильной стороне границы. Ошибка вида «модуль не найден при сборке» живёт в решении Webpack. Ошибка native module в Console живёт в URL, MIME, CORS или исходном спецификаторе страницы.'), + paragraph('Полезный практический приём — записать в issue четыре поля: исходная строка import, кто её читает первым, какой URL или asset получился и на каком шаге появился сбой. Этого достаточно, чтобы не смешать причины. Фраза «браузер не нашёл пакет в node_modules» почти всегда указывает, что в разговор незаметно попала логика bundler.'), + figure('/assets/editorial/2019/es-modules-resolution-boundary-2019.svg', 'Схема границы: браузер переводит URL-подобный import в сетевой запрос от URL импортёра, а Webpack заранее разрешает пакет и создаёт asset', 'Одинаковая строка import проходит разные этапы: нативная страница идёт к URL, а сборщик сначала строит свой граф и отдаёт готовые assets.'), + heading('Диагностический маршрут без смешения инструментов'), + orderedList([ + 'Зафиксировать, запускается ли файл напрямую как type="module" или входит в production-bundle. Не использовать оба сценария одновременно для одной проверки.', + 'Для native-страницы выписать полный URL entry и каждого failed import из Network. Считать относительный адрес от модуля-импортёра.', + 'Проверить, что спецификатор содержит нужный .js и не является bare-именем пакета, если для него не создан отдельный URL-маршрут.', + 'Для Webpack-сценария получить stats или сообщение компилятора на той же версии lock-файла. Найти resolved-путь, alias и asset, не переносить эти правила в browser Console.', + 'Если порядок важен, сделать учебный лог из зависимого модуля и entry. Затем убрать побочный эффект из верхнего уровня либо оставить один явный bootstrap.', + 'Повторить после одной правки. Готовность: у native-страницы есть корректный URL и ответ, у сборки есть осмысленный resolved-путь и загруженный asset.', + ]), + heading('Ограничения и исторический контекст'), + bulletList([ + 'Текст описывает статические import 2019 года и намеренно не предлагает современные import maps как решение для исторической страницы. В нативном примере путь должен быть виден в исходнике.', + 'Webpack — конкретный пример bundler. Другой инструмент может иначе называть chunks и по-другому разрешать aliases, но это всё равно отдельный этап до браузера.', + 'Порядок зависимость → потребитель не освобождает от ошибок циклического импорта. Если два файла ждут инициализации друг друга, нужно упростить граф или вынести запуск в явную функцию.', + 'CORS, MIME и rewrite сервера способны прервать native-модуль до вычисления. Правильный alias в сборщике их не исправит.', + 'Учебные console-сообщения показывают форму наблюдения. Они не являются измерением задержки или browser trace чужого приложения.', + ]), + heading('Итог'), + paragraph('Синтаксис import общий, но ответственность разная. Браузер резолвит URL от импортёра и загружает модульный граф. Webpack заранее находит пакеты и выпускает assets. Если сначала назвать сторону границы, ошибка перестаёт быть загадочным «ES-модули не работают»: остаётся конкретная проверка URL, графа, конфигурации или порядка инициализации.'), + ], + [ecmascript2019Modules, htmlScriptElement, htmlModuleScripts, urlStandard], +); + +const fieldArticle = createRevision( + { + slug: 'editorial-2019-02-field-es-modules', + title: 'ES-модули в браузере: диагностика двойного запуска и неверного пути', + categories: ['JavaScript', 'ES modules', 'Диагностика', 'Браузер'], + cover: '/assets/editorial/2019/es-modules-diagnostic-identity-2019.svg', + excerpt: 'Инициализация срабатывает дважды или импорт уходит не туда. Учебный диагностический модуль показывает фактический URL, различает две module identity и не выдаёт Console за реальную трассу.', + readingMinutes: 12, + }, + [ + paragraph('Виджет подписывается на событие два раза, либо импорт получает HTML вместо JavaScript по неожиданному адресу. Цена ошибки — дублированные запросы и ложный ремонт: удаляют обработчик, хотя в страницу попали два разных URL модуля.'), + paragraph('Разберём один вопрос: как в браузере отличить повторную инициализацию от двух разных module identity и как связать это с неверным путём. Пример учебный: он добавляет временные отметки в window и Console, но не выдаёт их за Network-трассу реального проекта.'), + heading('Сначала определяем, что именно считается тем же модулем'), + paragraph('Для module script важен разрешённый URL. HTML-среда хранит уже загруженные модули в module map документа; повторный статический импорт одного и того же разрешённого URL обычно использует тот же модульный результат, а не заново вычисляет исходный файл. Поэтому одинаковая строка import "./init.js" в нескольких местах сама по себе ещё не доказывает двойной запуск.'), + paragraph('Но URL с отличающимся путём или query — другой адрес. /assets/init.js и /assets/init.js?entry=admin могут загрузиться как разные ресурсы и выполнить один и тот же код независимо. Так же к двойной инициализации приводят два разных entry, classic bundle рядом с module entry или второй документ в iframe. Диагноз начинается с URL, а не с количества тегов в шаблоне.'), + dataTable( + ['Симптом', 'Факт, который нужно получить', 'Что он означает', 'Следующее действие'], + [ + ['Событие обработано дважды', 'Два лога с разными import.meta.url', 'Страница получила разные module identity', 'Найти, кто добавил query, другой base-путь или второй entry'], + ['Два тега указывают на один URL', 'В логах один URL и одна отметка модуля', 'По тегам нельзя объявить модуль выполненным дважды', 'Искать второй bootstrap или другой документ'], + ['Импорт вернул HTML', 'Request URL, Response preview и MIME-ошибка', 'Путь попал в правило SPA fallback или не существует', 'Исправить спецификатор либо раздачу assets'], + ['Одна страница работает, вложенная — нет', 'Разные base URL у импортёров', 'Относительный путь считается от разных файлов', 'Сделать путь явным в каждом entry и сравнить запросы'], + ], + ), + paragraph('Здесь важно не обещать абсолютную модель кеша одной строкой. Правила module map учитывают тип модуля и настройки загрузки. Для прикладной проверки достаточно увидеть фактические URL и контекст документа, в котором возникла отметка. Это надёжнее, чем угадывать по имени файла на диске.'), + heading('Учебный модуль с отметкой URL'), + paragraph('Сделаем временный файл init.js. Он фиксирует собственный import.meta.url и число запусков именно для этого URL. В проект этот код не нужно оставлять навсегда: его цель — собрать наблюдения в тестовой странице, затем удалить или заменить осмысленной инициализацией.'), + codeBlock([ + '// assets/init.js — учебный диагностический модуль', + 'const url = import.meta.url;', + 'const registry = window.__moduleRuns || (window.__moduleRuns = {});', + 'const count = (registry[url] || 0) + 1;', + '', + 'registry[url] = count;', + 'console.log("[module-check]", { url, count });', + '', + 'export function startWidget(root) {', + ' root.dataset.moduleUrl = url;', + ' root.textContent = "widget started";', + '}', + '', + '// assets/main.js', + 'import { startWidget } from "./init.js";', + 'startWidget(document.querySelector("#widget"));', + ]), + paragraph('При одном entry в учебной странице мы ожидаем одну запись с URL .../assets/init.js и значение count: 1. Если появилось два URL, Console уже даёт гипотезу: один из импортёров считает путь от другой папки либо к URL добавлена версия. Если один URL отмечен дважды, не надо сразу винить module map: проверяем iframe, повторную загрузку документа, classic-код с тем же побочным эффектом и собственный bootstrap.'), + paragraph('Сам код не имитирует браузерную загрузку и не меняет правила кэширования. Он лишь показывает идентификатор, который среда передала текущему модулю. Поэтому в отчёте рядом с этим логом сохраняем вкладку, точный адрес документа и Request URL из Network. Без этого Console остаётся неполным доказательством.'), + heading('Как неверный путь превращается в похожий симптом'), + paragraph('Относительный import разрешается от URL модуля-импортёра. В следующем учебном каталоге два entry лежат в разных папках. Одинаковая строка ./init.js не означает один и тот же запрос: для каждого файла базовый URL свой.'), + codeBlock([ + '', + '', + '', + '', + '// /demo/catalog/main.js', + 'import { startWidget } from "./init.js";', + '', + '// /demo/admin/main.js', + 'import { startWidget } from "./init.js";', + '', + '// Requests are different:', + '// /demo/catalog/init.js', + '// /demo/admin/init.js', + ]), + paragraph('В таком примере две инициализации могут быть ожидаемы, если это два независимых экрана. Они становятся ошибкой, когда оба entry живут в одной странице и пишут в один контейнер. Решение зависит от причины: либо оставить один entry, либо вынести общий код в один путь, либо передать элемент в чистую функцию без верхнеуровневой подписки. Нельзя лечить это удалением query, пока не понятно, служит ли он намеренной версией ресурса.'), + paragraph('Неверный путь иногда маскируется успешным HTTP-статусом. SPA-сервер может ответить 200 и отдать index.html на /demo/admin/init.js. Для module script это всё равно неправильный ресурс: браузер ожидает JavaScript и сообщает о MIME или синтаксисе. Поэтому статус 200 не закрывает проверку; открываем Response и Content-Type.'), + heading('Учебная страница с намеренно разными URL'), + paragraph('Следующая страница нужна только для демонстрации различия URL. Она не должна попадать в production: query в примере показывает отдельную identity, а не рекомендуемый способ запускать виджет дважды. После запуска сравниваем два значения data-module-url у элементов.'), + codeBlock([ + '', + '
', + '
', + '', + ]), + paragraph('Так браузер видит два разных адреса и два отдельных экземпляра учебного модуля. В настоящем расследовании такой результат не означает, что query надо запретить. Он означает, что его владелец должен быть назван: cache busting, вариант entry или ошибочно склеенный путь. Затем проверяем, какая из этих причин допустима для текущей страницы.'), + figure('/assets/editorial/2019/es-modules-diagnostic-identity-2019.svg', 'Схема диагностики: одинаковый URL init.js ведёт к одной module identity, а URL с query или другой папкой ведут к разным отметкам import.meta.url', 'Сначала сравниваем фактические URL модулей, затем решаем, является ли второй entry штатным или дублирует инициализацию.'), + heading('Порядок полевой диагностики'), + orderedList([ + 'Зафиксировать внешний симптом: какой обработчик, запрос или DOM-элемент повторился; сохранить адрес страницы и момент перезагрузки.', + 'В тестовой ветке добавить учебную отметку с import.meta.url в модуль с побочным эффектом. Не помещать в неё данные пользователя или production-логи.', + 'Открыть Network, перезагрузить страницу и для каждого JS-ответа записать Initiator, Request URL, статус, redirect и Content-Type.', + 'Сгруппировать Console-отметки по URL. Разные адреса — повод найти разные importers, query или entry; один адрес — повод проверить второй документ и bootstrap.', + 'Для ответа с HTML или MIME-ошибкой проверить относительный путь от файла-импортёра и правило SPA fallback. Исправить либо import, либо серверный маршрут assets.', + 'Убрать учебную отметку после решения. Критерий готовности: один владелец инициализации, понятный URL каждого модуля и воспроизводимое поведение после чистой перезагрузки.', + ]), + heading('Ограничения и безопасные выводы'), + bulletList([ + 'Результат зависит от документа. Та же module identity в iframe и в основной странице не делает их одним runtime; контекст окна надо записывать отдельно.', + 'Query в URL не является автоматически ошибкой: его может добавлять версия или осознанный вариант entry. Ошибкой является неучтённая вторая инициализация.', + 'Идентичный URL обычно переиспользуется через module map, но не следует использовать это как замену явной архитектуре запуска. Побочный эффект верхнего уровня остаётся сложнее проверять.', + 'CORS, redirect, service worker и серверный rewrite могут менять наблюдаемую доставку. В каждом случае смотрим фактический Network-ответ, а не только исходный import.', + 'Пример создан для учебной страницы и не содержит настоящей browser trace. Его лог нужен для построения гипотезы, а окончательный диагноз закрывают URL, ответ сервера и владелец entry.', + ]), + heading('Итог'), + paragraph('Двойной запуск нельзя надёжно доказать количеством строк в шаблоне. Сначала смотрим, какие URL модулей реально получил документ и кто их импортировал. Затем различаем две identity, второй bootstrap и ошибку раздачи пути. Такой порядок превращает «модуль выполнился дважды» в проверяемую цепочку: URL → entry → побочный эффект → решение.'), + ], + [ecmascript2019Modules, htmlModuleScripts, htmlScriptElement, fetchCors, urlStandard], +); + +export const revisions = [practiceArticle, mechanismArticle, fieldArticle] + .map(({ proseLength, ...revision }) => revision); + +const isDirectRun = process.argv[1] + && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectRun) { + if (process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions, null, 2) + '\n'); + } else { + process.stderr.write('Usage: node web/scripts/upgrade-2019-02.mjs --print-revisions\n'); + process.exitCode = 1; + } +} diff --git a/web/scripts/upgrade-2019-03.mjs b/web/scripts/upgrade-2019-03.mjs new file mode 100644 index 0000000..e15ca52 --- /dev/null +++ b/web/scripts/upgrade-2019-03.mjs @@ -0,0 +1,484 @@ +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(code) { + return '
' + escapeHtml(String(code).trim()) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function bulletList(items) { + return '
    ' + items.map((item) => '
  • ' + item + '
  • ').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 sources are required'); + } + + return { + ...meta, + contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'), + proseLength, + }; +} + +const htmlEventLoops = { + title: 'HTML Standard: Event loops', + url: 'https://html.spec.whatwg.org/multipage/webappapis.html#event-loops', + note: 'модель очередей задач, выбор следующей задачи и microtask checkpoint в браузере', +}; + +const ecmaJobs = { + title: 'ECMAScript: Jobs and Job Queues', + url: 'https://tc39.es/ecma262/multipage/control-abstraction-objects.html#sec-jobs-and-job-queues', + note: 'языковая модель Job и host hook для постановки Promise-реакции', +}; + +const highResolutionTime = { + title: 'W3C High Resolution Time', + url: 'https://www.w3.org/TR/hr-time-3/', + note: 'семантика монотонной временной шкалы и метода performance.now()', +}; + +const longTasks = { + title: 'W3C Long Tasks API', + url: 'https://w3c.github.io/longtasks/', + note: 'почему длинная работа на UI-потоке задерживает input, обработчики и отрисовку', +}; + +const practiceArticle = createRevision( + { + slug: 'editorial-2019-03-practice-event-loop', + title: 'Event loop: как воспроизвести порядок Promise, таймера и синхронного кода', + categories: ['JavaScript', 'Асинхронность', 'Практика'], + cover: '/assets/editorial/2019/event-loop-order-2019.svg', + excerpt: 'Promise-обработчик приходит раньше таймера, а кнопка перестаёт реагировать. Ставим короткий опыт, различаем очередь и долгую синхронную работу, затем выбираем способ исправления.', + readingMinutes: 11, + }, + [ + paragraph('Симптом обычно формулируют неточно: «асинхронность поменяла порядок» или «таймер не сработал вовремя». На странице это выглядит конкретнее: обработчик Promise.then пишет в лог раньше setTimeout, а после импорта данных кнопка несколько мгновений не отвечает. Если начать менять задержки на глаз, можно скрыть один запуск и оставить ту же блокировку на другом устройстве.'), + paragraph('Ниже не «объяснение магии Promise», а маленький воспроизводимый маршрут. Мы сначала записываем порядок синхронных строк, Promise-реакции и timer callback. Потом отдельно создаём длинную синхронную работу и измеряем её границы через performance.now(). Так одна проблема распадается на две: неожиданная очередность и занятый главный поток.'), + heading('Что именно наблюдаем'), + paragraph('В браузерном коде есть как минимум текущий вызов JavaScript, задачи, которые выбирает event loop, и microtask checkpoint. Promise-реакция не прерывает уже исполняющуюся функцию. Она попадает в работу после того, как текущий стек освободится. Callback таймера тоже не появляется в середине этой функции: истекшая задержка делает его кандидатом на будущую задачу. Отсюда первое правило: «через ноль миллисекунд» означает не «немедленно».'), + paragraph('Нельзя сводить всё к одной универсальной очереди. HTML-стандарт оперирует очередями задач и источниками задач; браузер выбирает, что исполнить дальше по собственному алгоритму. Поэтому для реального сбоя важно записать происхождение каждого callback: пользовательское событие, timer, сетевое завершение, Promise-цепочка или ваш прямой вызов. Один только номер строки в Console не доказывает порядок между разными источниками.'), + dataTable( + ['Наблюдаемый фрагмент', 'Куда попадает работа', 'Что не обещает механизм', 'Первая проверка'], + [ + ['Обычный вызов функции', 'Текущий стек JavaScript', 'Что браузер отрисует до возврата из функции', 'Поставить отметки до и после вызова'], + ['Promise.resolve().then(...)', 'Promise Job, который host запускает как microtask', 'Прерывание текущей синхронной функции', 'Сравнить место отметки с концом текущего стека'], + ['setTimeout(fn, 0)', 'Будущая задача после истечения минимальной задержки', 'Точный момент запуска и превосходство над другими источниками задач', 'Записать метку внутри callback, не только перед постановкой'], + ['Тяжёлый цикл или parse JSON', 'Тот же текущий стек', 'Реакцию на input, пока цикл не вернул управление', 'Измерить начало и конец работы, посмотреть performance trace'], + ], + ), + paragraph('Эта таблица не заменяет спецификацию конкретного API. Она нужна для первого разворота расследования. Если в лог попал fetch, сначала устанавливаем, что именно логируется: момент старта запроса, Promise после ответа или собственная функция разбора. Обещание «всё async» здесь бесполезно — важна граница, на которой ваш код вернул управление браузеру.'), + heading('Минимальный опыт с порядком callback'), + paragraph('Откройте чистую вкладку браузера и вставьте пример в Console либо во временный модуль страницы. Он не измеряет скорость сети и не сравнивает браузеры. Он фиксирует только относительный порядок четырёх точек внутри одного сценария. Время сохраняем рядом с названием, но проверяем именно список меток: абсолютные миллисекунды зависят от нагрузки и точности часов.'), + codeBlock(String.raw` +const marks = []; + +function mark(label) { + marks.push({ label, at: performance.now() }); +} + +mark('A: sync start'); + +setTimeout(() => { + mark('C: timer task'); + console.table(marks); +}, 0); + +Promise.resolve().then(() => { + mark('B: Promise microtask'); +}); + +mark('D: sync end'); +`), + paragraph('Для этого опыта ожидаемая причинная запись — A, затем D, затем B, затем C. Сначала заканчивается текущий синхронный фрагмент. После него браузер выполняет microtask checkpoint, в котором может отработать Promise-реакция. Таймерная задача берётся позже, когда event loop выберет следующую доступную работу. Не подменяйте это ожидание тестом вроде «разница всегда ровно 0 или 4 ms»: такого договора у кода нет.'), + paragraph('Полезнее добавить к каждой отметке источник. В проекте через неделю появится ещё один then или debounce, и голый лог 1, 2, 3 перестанет объяснять причину. Название search: parsed response или filter: timer commit делает цепочку пригодной для диффа между двумя запусками. Временную диагностику затем удаляем либо оставляем под локальным флагом, чтобы не слать шум в production-логи.'), + figure('/assets/editorial/2019/event-loop-order-2019.svg', 'Схема порядка: синхронный стек заканчивается первым, затем выполняется Promise microtask, после чего event loop может взять timer-задачу', 'Минимальный опыт проверяет последовательность A → D → B → C; задержка таймера не является обещанием точной секунды запуска.'), + heading('Опыт с зависанием: Promise не выносит вычисление'), + paragraph('Вторая ошибка звучит так: «обернём тяжёлую функцию в Promise — интерфейс перестанет виснуть». Если внутри Promise сразу выполняется большой цикл, всё остаётся на том же главном потоке. Promise.resolve().then(run) лишь переносит начало run в microtask; сама функция всё равно занимает поток целиком, пока не вернёт управление. Пользовательский input и следующая отрисовка ждут эту границу.'), + codeBlock(String.raw` +function blockFor(milliseconds) { + const startedAt = performance.now(); + + while (performance.now() - startedAt < milliseconds) { + // Искусственная нагрузка для опыта. Результат вычисления не важен. + } +} + +document.querySelector('.js-run-check').addEventListener('click', () => { + const startedAt = performance.now(); + blockFor(120); + const finishedAt = performance.now(); + + console.log('sync work duration', finishedAt - startedAt); +}); +`), + paragraph('Число 120 здесь — параметр искусственного опыта, не обещание метрики для сайта. После клика смотрим два факта: длительность, записанную самим сценарием, и виден ли этот участок в профиле производительности. Пока выполняется цикл, другой click handler на этой же странице не начнёт JavaScript-работу. Если нужно сравнить правки, запускайте один и тот же сценарий с той же входной строкой и фиксируйте условия: браузер, профиль CPU и размер данных.'), + paragraph('Не пытайтесь обнаружить это периодическим «пингом таймера» в боевом коде. Он может сам менять картину и не укажет, какой стек занял время. Для расследования достаточно trace в инструментах браузера; для поддерживаемых сред можно отдельно проверить доступность PerformanceObserver с типом longtask. API длинных задач описывает порог 50 ms, но отсутствие записи не оправдывает ощущаемую пользователем задержку и не заменяет trace.'), + heading('Как отделить очередь от блокирующего кода'), + paragraph('Сбой порядка и зависание часто встречаются рядом, но лечатся по-разному. Если лог показывает, что значение из Promise приходит раньше timer callback, это может быть штатный порядок microtask и задачи. Правка состоит в явной зависимости: вызвать следующий шаг в нужном then, вернуть Promise из функции или хранить состояние в одном месте. Добавлять случайную задержку нельзя: она создаёт гонку, а не контракт.'), + paragraph('Если callback начинает работать поздно, а в профиле перед ним виден длинный синхронный участок, причина другая. Находим работу, которую можно сократить, разбить на куски или перенести в worker. Перенос в setTimeout даёт event loop возможность выбрать другие задачи между порциями, но не делает вычисление быстрым и не гарантирует кадр после каждой порции. Сначала измеряем одну порцию, затем выбираем её размер по данным, а не по красивому числу в коде.'), + heading('Последовательность проверки'), + orderedList([ + 'Сформулировать один наблюдаемый симптом: какая кнопка, какой лог или какое состояние оказалось не в том порядке. Сохранить входные данные и шаги воспроизведения.', + 'Поставить именованные отметки до прямого вызова, после него, внутри Promise-реакции и внутри timer callback. К отметке добавить performance.now() и источник события.', + 'Проверить относительный порядок на минимальном примере. Не переносить его автоматически на сетевой ответ, worker или другой tab.', + 'Если есть задержка интерфейса, снять performance trace и найти участок синхронной работы на main thread. Отделить ваш код от layout, GC и стороннего скрипта.', + 'Для зависимости по данным вернуть Promise или передать результат явным аргументом. Для CPU-работы сократить, нарезать или вынести расчёт; не маскировать причину дополнительным timeout.', + 'Повторить сценарий с прежними входными данными. Критерий готовности — понятный порядок меток и измеренная граница тяжёлой работы, а не один случай без ошибки.', + ]), + heading('Практический выбор действия'), + paragraph('Если функция должна начаться строго после HTTP-ответа, пусть вызывающая сторона получает Promise и строит дальнейший шаг в его цепочке. Если пользователь меняет фильтр несколько раз, добавьте номер запроса или отмену там, где это поддерживается, — не рассчитывайте, что Promise упорядочит независимые ответы сети. Если на главном потоке происходит сортировка тысяч записей, сначала проверяем, не нужна ли сортировка полностью, затем рассматриваем порции или worker. Это три разные задачи, хотя в Console они могут выглядеть одинаковым «опозданием».'), + paragraph('Текст намеренно не называет универсальный размер порции и не обещает, что один API лечит каждый freeze. Размер зависит от объёма данных, устройства, текущего DOM и конкурирующей работы. Хорошая техническая заметка оставляет читателю не рецепт «добавить timeout», а инструмент: измерить порядок, обнаружить синхронную границу и выбрать действие, соответствующее именно ей.'), + heading('Ограничения опыта'), + bulletList([ + 'Пример рассчитан на окно браузера. Модель очередей Node.js и поведение worker имеют собственные детали; их нельзя выводить из одного лога в tab.', + 'Временная шкала нужна для сравнения точек одного запуска. Точность и доступное разрешение времени зависят от окружения и политики браузера.', + 'Между разными источниками задач не следует строить бизнес-логику по случайному порядку. Передавайте зависимость явно через данные или Promise.', + 'Длинный участок в trace может включать не только JavaScript. Перед переписыванием цикла надо увидеть, что именно занимает main thread.', + ]), + heading('Итог'), + paragraph('Promise не выполняется «раньше всего», а таймер не запускается «ровно через ноль». Сначала заканчивается текущий JavaScript, затем выполняется доступная microtask-работа, после чего event loop выбирает будущую задачу. Когда экран завис, ищем не слово async, а длинную синхронную границу. Этот порядок делает диагноз проверяемым: лог даёт последовательность, профиль даёт длительность, а исправление привязано к конкретной причине.'), + ], + [htmlEventLoops, ecmaJobs, highResolutionTime, longTasks], +); + +const mechanismArticle = createRevision( + { + slug: 'editorial-2019-03-mechanism-event-loop', + title: 'Event loop под капотом: почему Promise обгоняет таймер, но не прерывает код', + categories: ['JavaScript', 'Асинхронность', 'Механизм'], + cover: '/assets/editorial/2019/event-loop-frame-budget-2019.svg', + excerpt: 'Разделяем стек, browser task и Promise Job. Это помогает не обещать таймеру точность и не пытаться лечить тяжёлый синхронный код лишним await.', + readingMinutes: 12, + }, + [ + paragraph('Сбой начинается с простой фразы в ревью: «поставим await, тогда браузер успеет отрисовать кнопку». Иногда кнопка действительно меняется на конкретной машине, но причина не доказана. Promise-реакция не может вклиниться в середину уже идущей JavaScript-функции. Если перед await был тяжёлый parse или цикл, интерфейс уже ждал; если после await снова идёт тяжёлая работа, он будет ждать следующую границу.'), + paragraph('Разберём механизм без слишком широкой метафоры «у JavaScript одна очередь». В языке есть Jobs и host hooks, а в браузере — event loop, задачи, microtask checkpoint и шаги рендеринга. Для прикладного кода достаточно держать три вопроса: какая работа сейчас на стеке, что поставлено как microtask и какой callback ждёт будущую задачу. Эта тройка объясняет неожиданный порядок Promise и timer без выдуманной точности таймера.'), + heading('Три слоя, которые не стоит смешивать'), + paragraph('Стек исполнения — это то, что выполняется прямо сейчас. Пока синхронная функция не вернулась, браузер не переключит JavaScript на другой callback того же event loop. ECMAScript описывает Jobs как абстрактные единицы работы и определяет host hook для постановки Promise Job. Браузер связывает эту языковую часть с microtask queue: когда он дошёл до checkpoint, накопленные microtasks выполняются до перехода к обычной следующей задаче.'), + paragraph('Task — понятие host-уровня. Timer, пользовательское событие и некоторые другие источники помещают работу в соответствующие очереди задач. Важная техническая оговорка: порядок между разными task source не надо превращать в API вашего приложения. HTML-стандарт описывает выбор задачи, а не обещание «сначала всегда timer, потом всегда input». Поэтому логику сохранения формы нельзя строить на том, что один callback обычно успевал раньше другого на вашем ноутбуке.'), + dataTable( + ['Слой', 'Пример', 'Когда способен начаться', 'Полезная диагностика'], + [ + ['Текущий стек', 'renderRows(data)', 'Сразу при вызове и непрерывно до return', 'Поставить отметку до и после функции; увидеть длительность в trace'], + ['Promise Job / microtask', 'promise.then(commit)', 'После освобождения текущего стека на microtask checkpoint', 'Логировать очередь и не ожидать paint между несколькими microtasks'], + ['Browser task', 'setTimeout(commit, 0)', 'Когда timer стал доступен и event loop выбрал задачу', 'Проверять источник callback и фактическую задержку, не только аргумент timeout'], + ['Обновление rendering', 'style, layout, paint', 'На шаге, который выбирает браузер между задачами', 'Смотреть timeline, а не считать любой timeout гарантией кадра'], + ], + ), + paragraph('Такое разделение полезно ещё и для терминов в команде. Вместо «эта штука асинхронная» можно сказать: «сейчас синхронно собираем 15 тысяч строк; затем Promise continuation записывает state; commit отложен в task». Фраза длиннее на несколько слов, зато сразу сообщает, где возможна блокировка и где можно поставить проверку. Для 2019-проекта это уже лучше, чем прятать систему в общей функции delay.'), + heading('Порядок Promise и таймера на воспроизводимом коде'), + paragraph('Следующий пример усиливает предыдущий опыт: microtask добавляет ещё одну microtask. Он нужен не для запоминания букв, а чтобы увидеть границу checkpoint. Пока очередь microtasks пополняется, browser task с таймером не становится следующим JavaScript-вызовом только потому, что его задержка уже истекла. Наблюдать стоит относительный порядок меток; их числовое время не является ожидаемым результатом теста.'), + codeBlock(String.raw` +const order = []; +const write = (label) => order.push(label); + +write('sync: start'); + +setTimeout(() => { + write('task: timer'); + console.log(order.join(' -> ')); +}, 0); + +Promise.resolve().then(() => { + write('microtask: first'); + + Promise.resolve().then(() => { + write('microtask: nested'); + }); +}); + +write('sync: end'); +`), + paragraph('Семантическая последовательность здесь такая: обе sync-метки появляются до любой Promise-реакции; затем отрабатывает первая и вложенная microtask; после этого доступна timer-задача. Из этого не следует, что любой сетевой callback уступит таймеру или что вкладка всегда будет рисовать между конкретными двумя задачами. Это означает лишь, что собственная цепочка Promise может исчерпать microtask queue до следующего шага event loop.'), + paragraph('Рекурсивно добавлять microtasks опасно. Код, который на каждом then ставит следующий then, может долго не давать браузеру выбрать user input или timer task. Внешне это похоже на обычный цикл, хотя в профиле будут короткие функции подряд. Если работа действительно должна быть дискретной, необходимо определить место, где она отдаёт управление не в новую microtask, а в будущую task либо в другой поток. Без такого места «асинхронность» остаётся лишь дроблением той же блокировки.'), + figure('/assets/editorial/2019/event-loop-frame-budget-2019.svg', 'Длинный синхронный обработчик занимает главный поток и задерживает input, а нарезанные порции оставляют event loop возможность выбрать ожидающие задачи', 'Promise не делает вычисление параллельным: чтобы интерфейс получил шанс на работу, нужно завершить текущую порцию и вернуть управление event loop.'), + heading('Почему await сам по себе не отдаёт кадр'), + paragraph('В async-функции выражение await promise откладывает продолжение до settlement Promise. Если Promise уже resolved, продолжение всё равно не выполняется как обычная синхронная строка: оно ставится в реакцию Promise. Но это именно microtask-граница, а не автоматический «перерыв на rendering». Несколько продолжений могут отработать одна за другой до следующей browser task. Поэтому await Promise.resolve() не стоит применять как договор с UI.'), + codeBlock(String.raw` +async function refreshBad(rawRows) { + const normalized = normalizeAllRows(rawRows); // тяжёлая sync-работа уже блокирует UI + + await Promise.resolve(); + + const ranked = rankAllRows(normalized); // ещё одна тяжёлая sync-работа + renderRows(ranked); +} +`), + paragraph('Функция выше не стала безопасной для кадра из-за одного await. Она разделила процесс на две синхронные части, но не определила бюджет каждой и не доказала, что между ними браузер сможет обработать нужное событие. Правильная следующая проверка — измерить normalizeAllRows и rankAllRows отдельно, увидеть их на main thread и решить, можно ли упростить алгоритм, обрабатывать только видимые элементы или отправить CPU-работу в worker.'), + heading('Как корректно уступать управление'), + paragraph('Иногда полный перенос в worker пока невозможен: код читает DOM или работает с legacy-виджетом. Тогда процесс можно нарезать. Ключевое требование — одна порция имеет ограниченную работу, а планировщик следующей порции создаёт будущую browser task. setTimeout часто используют как простой адаптер, но его задержка не является частотой кадров. Смысл здесь не в числе ноль, а в том, что текущая задача закончилась и у event loop появился выбор.'), + codeBlock(String.raw` +function processInSlices(rows, onDone) { + const result = []; + let index = 0; + + function runSlice() { + const deadline = performance.now() + 8; + + while (index < rows.length && performance.now() < deadline) { + result.push(normalizeRow(rows[index])); + index += 1; + } + + if (index < rows.length) { + setTimeout(runSlice, 0); + return; + } + + onDone(result); + } + + runSlice(); +} +`), + paragraph('В примере восемь миллисекунд — стартовая гипотеза для замера, а не норматив. Он намеренно не делает DOM-обновление на каждой строке: иначе выигрыш от нарезки можно потерять на layout и paint. В реальном коде надо зафиксировать, что считается одной порцией, какие входные данные она берёт и как отменить устаревший процесс. Без отмены пользователь успеет изменить фильтр, а старые порции продолжат занимать main thread уже без пользы.'), + heading('Измерение: что записать до оптимизации'), + paragraph('Для короткого повторяемого опыта достаточно записать начало и конец ваших функций через performance.now(). Спецификация High Resolution Time задаёт подходящую для измерений монотонную шкалу, но не гарантирует одинаковое разрешение в каждом окружении. Запись { label, startedAt, finishedAt, rowCount } полезнее одного console.time: её можно сопоставить с числом строк и с performance trace.'), + paragraph('В DevTools trace ищем не абстрактный «красный участок», а конкретную связь: обработчик input начал выполнение, затем синхронная функция заняла main thread, а нужный commit произошёл позже. Если в середине видны style/layout, не переписывайте немедленно Promise-цепочку — возможно, основной счёт выставляет DOM. Если доминирует ваш JavaScript, сравните алгоритм на одинаковом объёме данных до и после изменения. Это и есть техническое доказательство, а не впечатление от плавности.'), + heading('Порядок проверки механизма'), + orderedList([ + 'Выписать callback и его источник: прямой вызов, Promise reaction, timer, input, сетевой ответ или worker message. Не называть все их одним словом «async».', + 'Поставить метки вокруг текущего синхронного участка и внутри продолжений. Сначала проверить относительный порядок на чистом минимальном сценарии.', + 'Если есть фриз, снять trace и измерить длительность именно тех функций, которые выполняются на main thread. Отделить CPU-код от layout и сторонних скриптов.', + 'Для корректной зависимости по данным вернуть Promise или await результат. Для CPU-нагрузки выбрать оптимизацию алгоритма, ограниченные порции или worker.', + 'Если используются порции, добавить отмену устаревшей работы и проверку, что commit применяет только актуальный результат.', + 'Повторить один входной сценарий и сохранить порядок меток вместе с длительностью. Релизный критерий — конкретно улучшенная граница, а не слово «асинхронно».', + ]), + heading('Границы модели'), + bulletList([ + 'ECMAScript Jobs и browser microtasks связаны host-реализацией. Не переносите детали window в Node.js без проверки его event loop.', + 'Timer API не предоставляет точную плановую гарантию. Вкладка в фоне, нагрузка и политика браузера способны увеличить наблюдаемую задержку.', + 'Нарезка работы помогает отзывчивости, но не уменьшает количество операций. Если алгоритм лишний, первым исправлением будет алгоритм.', + 'Worker отделяет CPU-работу, но требует сериализации данных и явной коммуникации. Он не может напрямую менять DOM окна.', + ]), + heading('Итог'), + paragraph('Механизм проще держать как три разных состояния: работа на стеке, накопленные microtasks и будущие tasks. Promise обгоняет таймер в учебном опыте, потому что checkpoint наступает после освобождения текущего стека; Promise не прерывает уже работающий код. Когда нужен отзывчивый UI, сначала измеряем синхронный участок, затем возвращаем event loop управляемую границу или выносим CPU-задачу. Так выбор между await, порцией и worker перестаёт быть религией.'), + ], + [htmlEventLoops, ecmaJobs, highResolutionTime, longTasks], +); + +const fieldArticle = createRevision( + { + slug: 'editorial-2019-03-field-event-loop', + title: 'Разбор: зависший фильтр и неожиданное применение async-результата', + categories: ['JavaScript', 'Асинхронность', 'Разбор'], + cover: '/assets/editorial/2019/event-loop-trace-2019.svg', + excerpt: 'На фильтре одновременно видны зависание интерфейса и старый ответ, который перезаписывает новый. Сначала строим трассу, затем разделяем CPU-блокировку и гонку результатов.', + readingMinutes: 13, + }, + [ + paragraph('Представим экран поиска: пользователь быстро меняет строку, а после этого список на мгновение замирает и иногда показывает результат предыдущего запроса. У такой картины минимум две возможные причины. Первая — тяжёлый синхронный разбор или рендер занял главный поток. Вторая — независимый более старый async-результат применился позже нового. Если назвать обе проблемы «event loop тормозит», команда выберет случайный timeout и получит два скрытых дефекта вместо одного явного.'), + paragraph('Это учебный полевой разбор, а не отчёт о настоящем продукте и не обещание конкретных цифр. Мы берём один сценарий, добавляем идентификатор запуска и измеряем только свои границы. Затем классифицируем запись: sync, Promise continuation, timer task или сетевой результат. После классификации становится видно, где нужна отмена/актуальность результата, а где — сокращение работы на main thread.'), + heading('Фиксируем симптом до правки'), + paragraph('Перед оптимизацией полезно записать четыре вещи: входную строку фильтра, номер запуска, размер обрабатываемого массива и метки времени вокруг своих функций. Нельзя фиксировать «страница подвисла примерно на секунду» и сразу переносить всё в Promise. Такое наблюдение не говорит, был ли основной расход в JSON.parse, сортировке, DOM-обновлении или ожидании ответа. Номер запуска особенно важен: он отделяет задержку от ситуации, когда поздний результат принадлежит уже не текущей строке поиска.'), + dataTable( + ['Наблюдение', 'Конкурирующая гипотеза', 'Что добавить в трассу', 'Действие после доказательства'], + [ + ['Click/input не отвечает во время фильтра', 'Длинный sync-код или layout на main thread', 'Время до/после parse, filter, sort, commit; trace главного потока', 'Сократить алгоритм, нарезать CPU-работу или вынести её в worker'], + ['Старый список заменяет новый', 'Независимый старый запрос завершился позже', 'Номер запуска в start, response и commit', 'Игнорировать или отменить устаревший результат'], + ['Promise callback раньше timer callback', 'Штатный microtask checkpoint', 'Источник callback и относительный порядок меток', 'Выразить зависимость Promise-цепочкой, не добавлять задержку'], + ['Commit происходит поздно при коротком JS-коде', 'Сторонний скрипт, layout, background throttling', 'Performance trace и контекст вкладки', 'Локализовать реального владельца задержки до переписывания бизнес-кода'], + ], + ), + paragraph('Таблица задаёт порядок обсуждения. В ней нет колонки «добавить debounce» специально: debounce может снизить число запусков, но не доказывает ни причину фриза, ни корректность актуального результата. Его можно применить после диагностики как продуктовую политику, но он не заменяет номер запуска и не делает тяжёлую одну операцию дешёвой.'), + heading('Минимальная трасса с номером запуска'), + paragraph('Временный helper ниже не подменяет profiler. Его задача — дать каждому событию одинаковую форму и не потерять причинную связь в смешанном логе. В продакшен-аналитику не стоит отправлять сырые строки запроса без согласованной политики данных; для локальной диагностики можно писать в Console. Отдельно помечаем, что функция стартовала синхронно, а Promise callback запустился только после ответа.'), + codeBlock(String.raw` +let activeRun = 0; + +function trace(runId, label, extra) { + console.log({ + runId, + label, + at: performance.now(), + extra, + }); +} + +async function refreshSearch(query) { + const runId = ++activeRun; + trace(runId, 'sync: start request', { queryLength: query.length }); + + const response = await fetch('/api/search?q=' + encodeURIComponent(query)); + trace(runId, 'microtask: response available', { status: response.status }); + + const payload = await response.json(); + trace(runId, 'microtask: parsed payload', { count: payload.items.length }); + + if (runId !== activeRun) { + trace(runId, 'drop: stale result'); + return; + } + + renderResults(payload.items); + trace(runId, 'sync: committed current result'); +} +`), + paragraph('Здесь Promise не делает ответы сети упорядоченными по номеру запуска. Два fetch могут завершиться в любом порядке, потому что на них влияют сервер, сеть, кеш и отмена. Условие runId !== activeRun описывает бизнес-правило: экран применяет только актуальный результат. В реальном проекте иногда лучше отменить прошлый запрос через поддерживаемый механизм, но даже при отмене проверка актуальности полезна: отмена может прийти после того, как ответ уже начал обрабатываться.'), + paragraph('Не следует читать метку microtask: response available как утверждение, что сама сеть работает в microtask. Она означает, что continuation вашей async-функции после resolved Promise запущена как Promise-реакция. Это точность формулировки важна в ревью: так мы не приписываем браузеру один глобальный порядок и не спорим о словах вместо трассы.'), + figure('/assets/editorial/2019/event-loop-trace-2019.svg', 'Схема диагностики: лог с номером запуска и performance.now ведёт к классификации sync, microtask, task или долгой работы и затем к целевому действию', 'Сначала собираем повторяемую трассу, потом выбираем между контролем актуальности результата и устранением блокировки главного потока.'), + heading('Ищем синхронную границу отдельно от сети'), + paragraph('Даже правильный номер запуска не исправит freeze, если после ответа код синхронно сортирует большой массив и строит тысячи DOM-узлов. Поэтому ставим точки не только вокруг fetch, но и вокруг локальных шагов. В учебном фрагменте ниже работа названа явно. В приложении названия должны соответствовать реальным функциям: parseCatalog, normalizeOffer, renderVisibleRows. Тогда trace и стек профиля можно сопоставить без догадки по абстрактному processData.'), + codeBlock(String.raw` +function measure(label, work) { + const startedAt = performance.now(); + const value = work(); + const finishedAt = performance.now(); + + console.log({ + label, + duration: finishedAt - startedAt, + }); + + return value; +} + +function prepareCurrentItems(items) { + const filtered = measure('sync: filter', () => filterItems(items)); + const sorted = measure('sync: sort', () => sortItems(filtered)); + + return measure('sync: map view-model', () => makeViewModels(sorted)); +} +`), + paragraph('Такая обвязка годится для временного локального опыта, но не заменяет trace браузера. Она измеряет только тело переданной функции и не покажет, что случилось между двумя вызовами: GC, style recalculation или работа внешнего виджета. После нахождения подозрительной функции открываем performance trace на том же сценарии. Если самый длинный участок — ваш sortItems, можно говорить об алгоритме. Если перед commit доминирует layout, перенос вычисления в worker не решит весь симптом.'), + paragraph('Порог «длинной» работы нельзя выдумывать из воздуха. Спецификация Long Tasks оперирует работой от 50 ms, включая задачу и следующий microtask checkpoint, но пользовательская чувствительность зависит от контекста. В 2019-проекте полезнее сначала получить свои длительности на повторяемом наборе, затем поставить SLO или бюджет для конкретного экрана. Число становится решением команды, а не чужой фразой из статьи.'), + heading('Ошибочный фикс: завернуть весь расчёт в Promise'), + paragraph('Часто код меняют так: Promise.resolve(items).then(prepareCurrentItems). Внешне функция стала «асинхронной», но prepareCurrentItems всё ещё полностью выполняется на главном потоке внутри одной microtask. Более того, если по завершении она сразу ставит ещё несколько Promise-реакций, browser task с input продолжит ждать checkpoint. Такая правка может поменять порядок в тесте, но не уменьшить длительность тяжёлой работы.'), + codeBlock(String.raw` +function refreshBad(items) { + return Promise.resolve(items) + .then(prepareCurrentItems) + .then(renderResults); +} + +function refreshWithSlices(items, onDone) { + let index = 0; + const prepared = []; + + function runSlice() { + const deadline = performance.now() + 8; + + while (index < items.length && performance.now() < deadline) { + prepared.push(normalizeItem(items[index])); + index += 1; + } + + if (index < items.length) { + setTimeout(runSlice, 0); + return; + } + + onDone(prepared); + } + + runSlice(); +} +`), + paragraph('Второй вариант не является готовой заменой первого. Он специально показывает конструкцию проверки: каждая порция завершается, следующая ставится как будущая task, а результат отдаётся один раз. На практике надо решить, где сортировка, как отменить старый runId, сколько памяти допускает промежуточный массив и как не делать полный DOM commit для уже устаревшей строки поиска. Порции дают event loop шанс выбрать другую работу, но не отменяют необходимость измерить пользовательский сценарий.'), + heading('Как собрать доказательство без выдуманного benchmark'), + paragraph('Для первого прохода выбираем искусственный, но повторяемый набор: например, сохранённый JSON-ответ с фиксированным количеством карточек. Записываем браузер, режим CPU throttling если он включён, введённую строку, номер запуска и время трёх локальных функций. Потом повторяем тот же ввод после изменения. Не публикуем «ускорили в два раза», пока нет зафиксированных результатов на той же методике; в статье и в код-ревью достаточно указать, что команда должна собрать этот набор.'), + paragraph('Trace нужен для связи между цифрой и реальным владельцем времени. В нём ищем длительный обработчик, вложенные JS-функции, network callback и момент input. Если trace показывает, что input пришёл после вашего длинного синхронного блока, это подтверждает CPU-ветку. Если всё время ушло до ответа, решаем сетевую/серверную задачу. Если старый runId дошёл до commit, решаем ветку актуальности результата. Эти исходы не конкурируют; один экран может требовать двух отдельных изменений.'), + heading('Последовательность полевого разбора'), + orderedList([ + 'Выбрать один входной сценарий: последовательность строк, сохранённый ответ и действие пользователя. Не смешивать в первой записи несколько багов.', + 'Добавить временные trace-метки с номером запуска и performance.now() вокруг запроса, parse, CPU-обработки и commit.', + 'Запустить сценарий дважды и сравнить относительный порядок. Если записи различаются, сначала найти внешний источник недетерминизма, а не усреднять вывод.', + 'Открыть performance trace и сопоставить длинный участок с собственными именованными функциями. Установить, блокирует ли main thread JS, layout или чужой код.', + 'Для старого результата ввести явное правило актуальности либо отмену. Для CPU-участка снизить объём, заменить алгоритм, нарезать работу или перенести её в worker.', + 'После правки повторить тот же вход. Готовность — устаревший run не коммитится, а синхронная граница стала измеряемой и укладывается в согласованный бюджет.', + ]), + heading('Границы решения'), + bulletList([ + 'Номер запуска защищает отображение от устаревшего результата, но не делает старый запрос бесплатным. Если запросы дороги, добавляется поддерживаемая отмена и серверная политика.', + 'Нарезка CPU-работы меняет порядок наблюдаемых промежуточных состояний. Нельзя коммитить частичный список без отдельного UX-решения.', + 'Timer используется здесь как способ создать будущую task, а не как обещание кадра через ноль миллисекунд. Реальная задержка измеряется на целевом сценарии.', + 'Worker не имеет прямого доступа к DOM. Его внедрение требует контракта сообщений, копирования/передачи данных и проверки актуальности ответа.', + 'Performance trace должен собираться с учётом приватности тестовых данных. В production нельзя бездумно логировать поисковые строки и содержимое ответа.', + ]), + heading('Итог'), + paragraph('У зависшего поиска есть два независимых вопроса: что заняло главный поток и какой результат имеет право менять экран. Event loop помогает сформулировать трассу, но не заменяет её. С номером запуска мы видим гонку результатов; с метками вокруг синхронных функций и trace мы видим блокировку. После этого исправление становится узким: актуальность решается явным правилом, CPU-нагрузка — алгоритмом, порциями или worker. Таймер остаётся диагностическим инструментом, а не пластырем на оба симптома.'), + ], + [htmlEventLoops, ecmaJobs, highResolutionTime, longTasks], +); + +export const revisions = [practiceArticle, mechanismArticle, fieldArticle] + .map(({ proseLength, ...revision }) => revision); + +const isDirectRun = process.argv[1] + && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectRun) { + if (process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions, null, 2) + '\n'); + } else { + process.stderr.write('Usage: node web/scripts/upgrade-2019-03.mjs --print-revisions\n'); + process.exitCode = 1; + } +} diff --git a/web/scripts/upgrade-2019-04.mjs b/web/scripts/upgrade-2019-04.mjs new file mode 100644 index 0000000..b9c2c58 --- /dev/null +++ b/web/scripts/upgrade-2019-04.mjs @@ -0,0 +1,641 @@ +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(code) { + return '
' + escapeHtml(String(code).trim()) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function bulletList(items) { + return '
    ' + items.map((item) => '
  • ' + item + '
  • ').join('') + '
'; +} + +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'); + } + + const contentHtml = [ + bodyHtml, + heading('Проверяемые источники'), + sourceList(sources), + ].join('\n'); + + return { + ...meta, + contentHtml, + proseLength, + }; +} + +const htmlConstraints = { + title: 'HTML Standard: Constraints and Constraint Validation API', + url: 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api', + note: 'модель ограничений формы, validatable-контролы, validity, setCustomValidity(), checkValidity() и reportValidity()', +}; + +const htmlFormSubmission = { + title: 'HTML Standard: Form submission', + url: 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm', + note: 'отправка формы — отдельный алгоритм браузера; клиентские ограничения не подменяют проверку на сервере', +}; + +const ariaDescription = { + title: 'WAI-ARIA 1.1: aria-describedby', + url: 'https://www.w3.org/TR/wai-aria-1.1/#aria-describedby', + note: 'связь контрола с одним или несколькими элементами описания по ID', +}; + +const ariaError = { + title: 'WAI-ARIA 1.1: aria-errormessage', + url: 'https://www.w3.org/TR/wai-aria-1.1/#aria-errormessage', + note: 'aria-errormessage связан с aria-invalid; актуальный текст ошибки должен быть доступен пользователю', +}; + +const ariaAlert = { + title: 'WAI-ARIA 1.1: alert role', + url: 'https://www.w3.org/TR/wai-aria-1.1/#alert', + note: 'семантика срочного, но не переносящего фокус сообщения; применять только к краткому изменению статуса', +}; + +const domAbort = { + title: 'DOM Standard: AbortController', + url: 'https://dom.spec.whatwg.org/#abortcontroller', + note: 'контроллер создаёт AbortSignal и посылает ему abort; это отмена транспорта, а не проверка актуальности состояния сама по себе', +}; + +const fetchSpec = { + title: 'Fetch Standard', + url: 'https://fetch.spec.whatwg.org/', + note: 'модель запроса, ответа и интеграция с abort signal для клиентов, поддерживающих Fetch', +}; + +function delayResult(value, delayMs) { + return new Promise((resolveDelay) => { + setTimeout(() => { + resolveDelay({ + value, + available: value !== 'ivan', + }); + }, delayMs); + }); +} + +async function runDelayedResponseFixture() { + let state = { + value: '', + requestId: 0, + phase: 'editing', + fieldError: '', + }; + + function check(value, delayMs) { + const requestId = state.requestId + 1; + state = { + value, + requestId, + phase: 'checking', + fieldError: '', + }; + + return delayResult(value, delayMs).then((answer) => { + if (state.requestId !== requestId) { + return { requestId, applied: false, ignored: 'stale-response' }; + } + + state = { + value: answer.value, + requestId, + phase: answer.available ? 'valid' : 'invalid', + fieldError: answer.available ? '' : 'Этот логин уже занят', + }; + + return { requestId, applied: true, phase: state.phase }; + }); + } + + const oldRequest = check('ivan', 30); + const newRequest = check('ivanka', 5); + const results = await Promise.all([oldRequest, newRequest]); + + return { results, finalState: state }; +} + +const practiceArticle = createRevision( + { + slug: 'editorial-2019-04-practice-forms-validation', + title: 'Форма без расхождения правил: клиентская проверка, серверная ошибка и поле', + categories: ['JavaScript', 'HTML', 'UX', 'Практика'], + cover: '/assets/editorial/2019/forms-validation-contract-2019.svg', + excerpt: 'Поле проходит проверку в браузере, а сервер возвращает 422. Собираем контракт правил и ошибок так, чтобы сообщение оказалось у нужного поля и поздний ответ не затёр новое значение.', + readingMinutes: 12, + }, + [ + paragraph('Симптом знакомый: почта в форме стала зелёной, пользователь нажал «Сохранить», а сервер вернул ошибку формата или занятости. Ещё хуже, когда ответ приходит, но текст попадает в общий баннер, а не к полю. Человек исправляет значение наугад, повторяет запрос и может создать дубль. Причина обычно не в одном регулярном выражении: у клиента, сервера и представления ошибки разные правила и разные владельцы состояния.'), + paragraph('В апреле 2019 я бы не пытался строить «универсальный валидатор». Для одной формы достаточно зафиксировать короткий контракт: какие ограничения браузер проверяет сразу, какие условия знает только сервер, в каком виде сервер возвращает ошибки и кто имеет право менять состояние поля. Ниже учебный вариант без привязки к фреймворку. Он показывает маршрут проверки; он не является результатом запуска на чужом API или браузерной трассой.'), + heading('Сначала разделяем три вида проверки'), + paragraph('Клиентская проверка нужна, чтобы не отправлять пустую почту или строку с очевидно неверной формой. HTML уже знает часть ограничений: required, type="email", minlength, pattern. У контрола есть validity, а checkValidity() отвечает на конкретный вопрос: проходит ли элемент его ограничения. Это удобный ранний фильтр, но не источник истины о пользователе, правах, занятости логина или правилах, которые меняются на сервере.'), + paragraph('Серверная проверка владеет данными и бизнес-условием. Если API считает, что адрес уже связан с другим аккаунтом, браузер не может опровергнуть это своим input[type=email]. Представление, в свою очередь, владеет тем, где сообщение видно: у поля, у формы или в статусе отправки. Ошибка возникает именно на этой границе, когда JSON от сервера складывают в один текст «Не удалось сохранить», а компонент уже не знает, какой input пометить.'), + dataTable( + 'Короткая карта ответственности для формы регистрации', + ['Слой', 'Что проверяет', 'Чего не обещает', 'Проверяемый результат'], + [ + ['HTML-контрол', 'required, формат email, длину и pattern', 'Занятость адреса, права, транзакцию', 'input.validity.valid и понятная локальная подсказка'], + ['Клиентский код', 'Порядок состояний, актуальность ответа, привязку поля к ошибке', 'Достоверность данных в базе', 'У ошибки есть ключ поля, а старый ответ не меняет новый ввод'], + ['API', 'Нормализацию, занятость, права и правила сохранения', 'Как экран озвучит текст', 'Структурированный ответ с кодом и ключом поля'], + ['Разметка', 'Label, описание, видимость ошибки, семантику invalid', 'Проверку бизнес-правила', 'Ошибка доступна зрительно и связана с нужным контролом'], + ], + ), + paragraph('Такое деление сразу уменьшает расхождение. Не нужно копировать серверное правило в JavaScript, если клиенту достаточно проверить непустое значение и форму строки. Но имя поля и код ошибки должны быть стабильны. Сервер может изменить человеческий текст по локали, а email_taken и ключ email остаются данными, по которым интерфейс выбирает место вывода.'), + heading('Минимальный договор между формой и API'), + paragraph('Практичный ответ ошибки не обязан повторять спецификацию целиком. Важно, чтобы в нём не смешивались поле и общий сбой. В этом примере массив fields хранит ошибки, которые можно показать рядом с input, а form — ошибку, не принадлежащую конкретному полю: например, конфликт состояния формы. HTTP-статус и общая структура ответа — договор API; показанный ключ fields — локальное решение команды, а не поле, навязанное браузером.'), + codeBlock(String.raw` +// Пример ответа API при POST /api/account. +// Это контракт приложения, а не встроенный формат браузера. +{ + "code": "VALIDATION_FAILED", + "fields": { + "email": [ + { "code": "email_taken", "message": "Этот адрес уже используется" } + ] + }, + "form": [] +} +`), + paragraph('На клиенте не стоит искать текстом «адрес» или «занят». Нужен небольшой адаптер, который принимает этот контракт и отдаёт одну карту ошибок. Если сервер прислал неизвестный ключ, адаптер не должен silently приклеивать его к первому полю. Его лучше оставить в form, записать в диагностический лог проекта и добавить явную обработку после согласования контракта. Так опечатка e-mail вместо email не превратится в ложное зелёное состояние.'), + codeBlock(String.raw` +function mapServerErrors(payload, knownFields) { + var mapped = { fields: {}, form: [] }; + var fieldErrors = payload && payload.fields ? payload.fields : {}; + + Object.keys(fieldErrors).forEach(function (name) { + var first = fieldErrors[name] && fieldErrors[name][0]; + var message = first && first.message; + + if (knownFields.indexOf(name) === -1 || !message) { + mapped.form.push('Сервер вернул ошибку без известного поля'); + return; + } + + mapped.fields[name] = message; + }); + + return mapped; +} + +var errors = mapServerErrors(apiPayload, ['email', 'password']); +// errors.fields.email === 'Этот адрес уже используется' +`), + paragraph('У этого кода есть намеренное ограничение: он не решает локализацию, несколько сообщений на поле или вложенные массивы. Для конкретной формы сначала договоритесь, нужен ли один первый текст или список. Если API всегда отдаёт список, не обрезайте его случайно; если продукту нужен один короткий совет, пусть сервер или отдельный formatter выбирает его явно. Главное — не передавать сырое сообщение в HTML как разметку: текст ошибки должен остаться текстом.'), + heading('Разметка: ошибка должна принадлежать полю'), + paragraph('Красная рамка сама по себе не объясняет проблему. У поля должен быть видимый label, постоянная подсказка и отдельный контейнер ошибки. aria-describedby связывает input с описывающими элементами по ID. Когда состояние невалидно, добавляем aria-invalid="true" и ссылку aria-errormessage на видимый текст. В WAI-ARIA эти атрибуты работают вместе: сообщение не нужно прятать от человека, который пользуется ассистивной технологией.'), + codeBlock(String.raw` + + +

Укажем адрес для входа.

+ +`), + paragraph('В валидном состоянии aria-invalid убираем или ставим в false, а контейнер ошибки не оставляем с пустой ролью alert. Если строка ошибки меняется динамически, короткое уведомление может быть живой областью, но не надо превращать каждое нажатие клавиши в срочное объявление. Для проверки формы на отправке достаточно показать текст у поля и, при необходимости, дать краткий общий статус. Фокус переносим только по осознанному правилу интерфейса, обычно на первое невалидное поле после submit.'), + paragraph('Ниже не снимок Accessibility tree из браузера. Это ожидаемая семантическая структура той разметки, которую нужно проверить в DevTools и реальным скринридером проекта. Такой список полезен до запуска: он показывает, какое доказательство искать, и не выдаёт ожидание за измерение.'), + dataTable( + 'Ожидаемая семантика разметки при серверной ошибке', + ['Узел', 'Имя или состояние', 'Откуда берётся', 'Что проверять в реальном браузере'], + [ + ['Текстовое поле', 'Имя «Почта», значение user@example.test, invalid', 'label и aria-invalid', 'Поле доступно по Tab и имеет имя label'], + ['Описание', '«Укажем адрес для входа»', 'aria-describedby', 'Подсказка связана с тем же ID, что указан у input'], + ['Ошибка', '«Этот адрес уже используется»', 'aria-errormessage и видимый контейнер', 'Текст не скрыт и относится к email, а не к соседнему input'], + ['Кнопка', '«Сохранить» и её доступное состояние', 'Нативный button', 'Клавиатурная отправка не блокирует возможность исправить поле'], + ], + ), + heading('Поздний ответ не имеет права менять новый ввод'), + paragraph('Другая частая причина «прыгающей» ошибки — асинхронная проверка. Пользователь ввёл ivan, запрос ушёл на сервер; затем он быстро исправил на ivanka. Если первый ответ, «логин занят», возвращается последним, наивный then() запишет красную ошибку поверх нового значения. Скорость сети не даёт порядка, на который можно опереться. Владельцем актуальности должен быть идентификатор версии поля или запроса.'), + paragraph('Отмена через AbortController полезна, чтобы не тратить работу, когда пользователь продолжил ввод. Но отмена транспорта не заменяет защиту состояния: к моменту abort ответ уже мог разрешиться, библиотека могла не использовать signal, а локальная проверка вообще не имеет сетевого запроса. Поэтому сначала сравниваем номер запроса, а затем при наличии Fetch добавляем abort как оптимизацию.'), + codeBlock(String.raw` +var lastRequestId = 0; + +function checkLogin(value, checkAvailability) { + var requestId = lastRequestId + 1; + lastRequestId = requestId; + render({ value: value, phase: 'checking', error: '' }); + + return checkAvailability(value).then(function (answer) { + if (requestId !== lastRequestId) return; // ответ относится к старому вводу + + render({ + value: value, + phase: answer.available ? 'valid' : 'invalid', + error: answer.available ? '' : 'Этот логин уже занят', + }); + }); +} +`), + paragraph('Номер запроса должен жить рядом с состоянием конкретного поля или формы, а не в глобальной переменной всего сайта. Для нескольких строк таблицы, двух вкладок редактора или нескольких экземпляров компонента глобальный счётчик снова создаст чужое влияние. В простом модуле это замыкание; во фреймворке — состояние экземпляра. Критерий тот же: обработчик ответа проверяет, что он всё ещё работает с текущей версией значения.'), + figure('/assets/editorial/2019/forms-validation-contract-2019.svg', 'Схема договора валидации формы: HTML даёт раннюю проверку, API возвращает ошибку с ключом поля, а клиент привязывает её к доступной разметке и отвергает устаревший ответ', 'Граница проходит не между «клиентом и сервером вообще», а между ограничением, контрактом ошибки и отображением конкретного поля.'), + heading('Маршрут внедрения на одной форме'), + orderedList([ + 'Выписать поля формы и отдельно назвать: проверка браузера, проверка API, общий сбой формы. Не начинаем с копирования всей серверной логики в JavaScript.', + 'Согласовать с API стабильные ключи полей и коды ошибок. Для каждого ключа выбрать место в UI; неизвестный ключ не приклеивать к произвольному input.', + 'Добавить нативные ограничения там, где они честны: required, тип, длина, pattern. Проверить, что disabled не исключает нужное поле из constraint validation по ошибке.', + 'Сделать у каждого поля label, описание и контейнер ошибки с устойчивыми ID. На невалидном состоянии выставлять aria-invalid и показывать текст, а не только менять цвет.', + 'Для асинхронной проверки хранить номер актуального запроса. Создать fixture, где старый ответ приходит позже нового, и ожидать, что он не меняет состояние.', + 'На submit проверить сначала HTML-ограничения, затем ответ API, затем фокус и текст первого поля с ошибкой. Готовность — ошибка видна у правильного поля и исчезает только после новой валидной версии значения.', + ]), + heading('Что проверить до выпуска'), + paragraph('Нужно проверить не один счастливый submit, а границы. Отправьте пустое поле, неверный формат, ошибку API с известным ключом, ошибку API с неизвестным ключом и два ответа в обратном порядке. Проверьте клавиатуру: label не потерян, ошибка не видна только по цвету, а после submit понятно, что именно требует исправления. Если форма живёт в модальном окне, дополнительно проверьте, что фокус не уходит под него при появлении текста ошибки.'), + bulletList([ + 'HTML-валидация может отличаться между браузерами в тексте встроенного сообщения. Если нужен единый текст, используйте свою видимую ошибку, но не отменяйте полезные нативные ограничения без причины.', + 'Сервер всё равно проверяет вход. Нельзя считать checkValidity() защитой API: запрос можно составить вне вашей страницы.', + 'Асинхронную проверку не стоит запускать на каждую букву без порога и задержки. Сначала убедитесь, что локальный формат уже проходит; затем используйте debounce и защиту версии.', + 'Не ставьте role="alert" на целую форму или длинный список. Это даст шум вместо понятного сообщения; у поля нужен конкретный текст.', + 'Ответ API с несколькими ошибками требует решения о порядке. Полезно сохранить порядок полей формы, а не полагаться на порядок ключей объекта.', + ]), + heading('Итог'), + paragraph('Если сервер отказывается сохранять значение, которое клиент уже сделал зелёным, проблема не лечится новой регуляркой. Сначала отделяем локальное ограничение от условия базы, затем фиксируем ключ поля в контракте ошибки, привязываем видимый текст к input и не даём старому ответу менять новый ввод. После этого у формы есть проверяемый результат: известно, кто владеет каждым правилом, и ошибка оказывается у того поля, которое пользователь действительно может исправить.'), + ], + [htmlConstraints, htmlFormSubmission, ariaDescription, ariaError, ariaAlert, domAbort, fetchSpec], +); + +const mechanismArticle = createRevision( + { + slug: 'editorial-2019-04-mechanism-forms-validation', + title: 'Валидация формы как конечный автомат: состояние, версия и ошибка поля', + categories: ['JavaScript', 'HTML', 'Архитектура'], + cover: '/assets/editorial/2019/forms-validation-state-machine-2019.svg', + excerpt: 'Почему boolean isValid не объясняет форму с серверной проверкой. Разбираем состояния editing, checking, invalid и submitting, а также правило, которое не даёт позднему ответу перезаписать новое значение.', + readingMinutes: 13, + }, + [ + paragraph('Симптом: у формы есть isValid, isLoading и строка error, но после двух быстрых изменений поля кнопка становится активной в неправильный момент, а поздний ответ возвращает старый текст. Цена такой ошибки — не только лишний запрос. Пользователь видит состояние, которое относится к уже несуществующему значению, и команда начинает добавлять ещё один флаг вместо объяснения переходов.'), + paragraph('Причина в том, что валидация — это не один boolean. У поля есть значение, локальная проверка, ожидание ответа, серверная ошибка, успешная готовность и попытка отправки. В 2019 для небольшой формы не нужен отдельный автоматный фреймворк. Нужна простая таблица переходов и правило актуальности: обработчик асинхронного результата меняет состояние только тогда, когда его версия совпадает с текущей версией ввода.'), + heading('Какие данные принадлежат состоянию'), + paragraph('Начнём с одного поля логина. Значение value — то, что редактирует человек. touched помогает решить, когда впервые показывать локальную ошибку. localError отвечает за синтаксис и обязательность. remoteError приходит от условия, которое знает сервер, например «логин занят». version растёт при каждом изменении value. Наконец, phase делает состояние читаемым: editing, client-invalid, checking, remote-invalid или ready.'), + paragraph('Не все поля обязаны иметь такую же схему. Пароль может не требовать сетевой проверки, а форма как целое дополнительно имеет submitting, submit-failed и submitted. Важна не одинаковость, а явная граница. Если один флаг одновременно означает «строка соответствует pattern», «запрос уже ушёл» и «сервер согласен», он неизбежно станет неправдой в одном из промежуточных моментов.'), + dataTable( + 'Состояния одного поля и разрешённые действия', + ['Фаза', 'Что видно пользователю', 'Какие данные достоверны', 'Следующий переход'], + [ + ['editing', 'Текущее значение, без окончательного вердикта', 'Значение и его version', 'input → локальная проверка'], + ['client-invalid', 'Ошибка формата или обязательности', 'localError; сетевой запрос не нужен', 'input → editing или проверка'], + ['checking', 'Короткое «Проверяем…», поле всё ещё можно менять', 'version запроса и очищенная старая remoteError', 'ответ той же версии → ready или remote-invalid'], + ['remote-invalid', 'Серверный текст у поля', 'remoteError только для текущего value', 'input → editing'], + ['ready', 'Значение прошло известные проверки', 'Локальная и текущая remote-проверка', 'input → editing; submit → submitting формы'], + ], + ), + paragraph('Эта таблица не требует показывать пользователю слово phase. Она нужна разработчику, чтобы заранее исключить нелепые комбинации: ready и старый remoteError, checking для пустой строки, client-invalid после положительного ответа другой версии. При чтении кода полезно задавать один вопрос: какое событие имеет право перевести поле из этой фазы в следующую?'), + heading('Переход input сначала очищает старый результат'), + paragraph('Когда человек меняет символ, прежний серверный ответ больше не характеризует значение. Поэтому переход input увеличивает version, очищает remoteError и возвращает поле в editing или client-invalid. Ошибка «логин занят» не должна оставаться рядом с ivanka, если она относилась к ivan. Это простое правило часто важнее debounce: даже если запрос ещё не сделан, экран уже не показывает вердикт для старого входа.'), + codeBlock(String.raw` +function localLoginError(value) { + if (!value) return 'Введите логин'; + if (!/^[a-z0-9_]{3,20}$/i.test(value)) { + return 'От 3 до 20 букв, цифр или _'; + } + return ''; +} + +function onInput(state, nextValue) { + var localError = localLoginError(nextValue); + + return { + value: nextValue, + version: state.version + 1, + phase: localError ? 'client-invalid' : 'editing', + localError: localError, + remoteError: '', + }; +} +`), + paragraph('Состояние здесь является обычным объектом. Его легко использовать и с jQuery-формой, и с React, и с собственным рендером. Регулярное выражение — пример локального UX-правила, а не обещание, что сервер принимает те же символы. Если сервер нормализует регистр или допускает Unicode, этот факт должен быть отдельно описан в API-контракте. Клиенту не следует придумывать более строгую форму, которая запрещает корректные серверные данные.'), + heading('Асинхронный переход должен нести версию'), + paragraph('После локально корректного input можно начать асинхронную проверку. Обработчик сохраняет checkedVersion до вызова API. Когда promise завершается, он сравнивает сохранённую версию с текущей. Несовпадение означает не «сервер ошибся», а «результат больше не относится к текущему значению». Его не нужно превращать в ошибку, логировать как отказ или показывать человеку. Его надо молча отбросить как устаревший.'), + codeBlock(String.raw` +function startRemoteCheck(state, checkAvailability) { + if (state.phase === 'client-invalid') return Promise.resolve(state); + + var checkedVersion = state.version; + var checking = { + value: state.value, + version: checkedVersion, + phase: 'checking', + localError: '', + remoteError: '', + }; + + return checkAvailability(checking.value).then(function (answer) { + return { + checkedVersion: checkedVersion, + answer: answer, + }; + }); +} + +function applyRemoteAnswer(current, result) { + if (current.version !== result.checkedVersion) return current; + + return { + value: current.value, + version: current.version, + phase: result.answer.available ? 'ready' : 'remote-invalid', + localError: '', + remoteError: result.answer.available ? '' : 'Этот логин уже занят', + }; +} +`), + paragraph('В рабочем коде current берётся из единственного владельца состояния в момент ответа, а не из замыкания старого рендера. Это различие важно: замыкание может держать объект первой версии, и его сравнение само с собой всегда даст «актуально». Хранилище, экземпляр компонента или reducer должен дать актуальное состояние. Если архитектура уже использует action-ы, полезно передавать checkedVersion прямо в action REMOTE_CHECK_RESOLVED.'), + paragraph('Ниже минимальный fixture. Он намеренно задаёт задержки вручную: проверка ivan отвечает через 30 мс и считает логин занятым, проверка ivanka отвечает через 5 мс и считает его свободным. Это выполняемая модель порядка ответов, а не запись Network или тест настоящего браузера. Правильный результат: первый ответ отмечен как устаревший, а финальное состояние содержит ivanka и фазу valid.'), + codeBlock(String.raw` +// Запускается автономно командой: +// node web/scripts/upgrade-2019-04.mjs --run-fixture +// Ожидаемая форма результата: +{ + "results": [ + { "requestId": 1, "applied": false, "ignored": "stale-response" }, + { "requestId": 2, "applied": true, "phase": "valid" } + ], + "finalState": { + "value": "ivanka", + "requestId": 2, + "phase": "valid", + "fieldError": "" + } +} +`), + paragraph('Fixture проверяет именно правило версии. Он не доказывает поддержку конкретного браузера, не измеряет задержку API и не проверяет доступность разметки. В реальном проекте рядом нужны отдельный тест клиента с настоящим адаптером API и ручная проверка семантики поля. Разделение доказательств важно: хороший результат promise не говорит ничего о том, услышит ли ошибку пользователь со скринридером.'), + figure('/assets/editorial/2019/forms-validation-state-machine-2019.svg', 'Диаграмма конечного автомата поля формы: editing переходит в client-invalid или checking, ответ текущей версии приводит к ready или remote-invalid, а любой новый input очищает старый результат', 'Версия привязана к input: стрелка старого ответа обрывается до изменения состояния, если пользователь уже ввёл новое значение.'), + heading('Где в автомате живёт HTML-валидация'), + paragraph('Нативный Constraint Validation API не обязан быть конкурентом состоянию приложения. Его можно использовать на границе input и submit. Например, input.validity.valid быстро показывает, проходит ли контрол объявленные атрибуты; setCustomValidity() позволяет добавить локальный текст. Но если сервер вернул занятость логина, не подменяйте этим факт HTML-ограничение навсегда. Серверная ошибка относится к версии данных и должна исчезнуть на следующем input, а не жить как искусственный patternMismatch.'), + paragraph('На submit форма собирает состояния полей. Если хоть одно поле находится в client-invalid, отправка не начинается: показываем ошибки и фокусируем первое проблемное поле. Если есть checking, команда должна выбрать правило явно: дождаться, отключить submit на короткое время или повторить серверную проверку в запросе сохранения. Нельзя назвать форму ready только потому, что локальная регулярка прошла, пока ответ проверки ещё не завершился.'), + dataTable( + 'Решения для submit во время remote-check', + ['Политика', 'Когда подходит', 'Плюс', 'Цена и обязательная проверка'], + [ + ['Ждать текущую проверку', 'Короткий запрос и одно поле', 'Меньше дублей запросов', 'Показать доступное «Проверяем…»; убедиться, что интерфейс не завис при ошибке сети'], + ['Отправлять и проверять на сервере', 'Сервер всё равно проверяет условие атомарно', 'Один окончательный ответ для сохранения', 'Клиент не обещает готовность раньше ответа; сервер возвращает field error'], + ['Отключать кнопку до завершения', 'Проверка быстрая и смысл кнопки ясен', 'Простой путь без гонки submit', 'Кнопка не должна быть единственным носителем объяснения; поле всё ещё доступно для правки'], + ], + ), + heading('Доступность — тоже переход состояния'), + paragraph('Для человека, который видит цвет, remote-invalid — это рамка и текст. Для другого пользователя это должно стать доступным состоянием поля. В разметке у input остаётся label; вспомогательный текст и ошибка имеют устойчивые ID; при ошибке есть aria-invalid="true" и ссылка на сообщение. aria-describedby описывает контрол, а aria-errormessage указывает на текст ошибки при невалидном состоянии. Это не повод полностью заменить нативный HTML ARIA-атрибутами: сначала используем нативный input и label.'), + paragraph('Ниже приведено ожидаемое дерево, которое следует сверить в инструментах доступности после интеграции. Это не утверждение, что оно было снято в конкретном браузере. Разные движки и скринридеры по-разному представляют детали, поэтому проверяется не буквальный порядок строк, а инварианты: поле имеет имя, получает invalid при ошибке и связано с видимым описанием.'), + dataTable( + 'Инварианты ожидаемого Accessibility tree для фазы remote-invalid', + ['Инвариант', 'Разметка', 'Наблюдаемый смысл', 'Не является доказательством'], + [ + ['У поля есть имя', '<label for>', 'Пользователь понимает, что правит логин', 'Одинаковое слово в каждом screen reader'], + ['Поле отмечено invalid', 'aria-invalid="true"', 'Ошибка относится к текущему контролу', 'Качество текста ошибки'], + ['Ошибка достижима', 'aria-errormessage и видимый элемент', '«Этот логин уже занят» не спрятан в цвете', 'Автоматическое озвучивание в любой паре браузер/скринридер'], + ['Подсказка не исчезла', 'aria-describedby', 'Правило ввода остаётся доступно рядом с ошибкой', 'Корректность серверного условия'], + ], + ), + heading('Порядок проектирования и проверки'), + orderedList([ + 'Для каждого поля назвать локальное условие, удалённое условие и текст, который видит пользователь. Если условия нет, не создаём искусственный async-check.', + 'Описать фазы и запретить невалидные комбинации: старый remoteError не живёт после input, а checking не означает ready.', + 'Добавить version в действие input и сохранять его при старте запроса. В обработчике ответа сравнить версию с текущим состоянием до любого render.', + 'Запустить fixture с двумя задержками в обратном порядке. Зафиксировать ожидаемое finalState, а не только отсутствие необработанного promise.', + 'Привязать ошибку к input через label, описание, aria-invalid и видимый контейнер сообщения. Проверить клавиатуру и дерево доступности на реальном контуре отдельно.', + 'Прогнать submit при client-invalid, checking, remote-invalid и готовом состоянии. Серверный ответ остаётся окончательным решением для сохранения.', + ]), + heading('Ограничения модели'), + bulletList([ + 'Номер версии защищает только владельца UI-состояния. Он не отменяет запись на сервере и не заменяет идемпотентность операции сохранения.', + 'AbortController можно добавить для экономии ресурсов, если используемый транспорт принимает signal. Даже после abort сравнение версии остаётся обязательным.', + 'Проверка «логин свободен» до submit не гарантирует свободность во время сохранения: другой запрос может занять его между двумя операциями. Сервер должен проверять условие снова.', + 'Фазы в статье описаны для одного поля. Сложная форма с зависимыми полями может хранить отдельный автомат формы и отдельные автоматы полей, но не должна прятать связь между ними.', + 'ARIA-атрибуты не компенсируют отсутствие текста, label или правильного фокуса. Семантика проверяется вместе с интерфейсом, а не строковым поиском по HTML.', + ]), + heading('Итог'), + paragraph('Форма становится предсказуемой не тогда, когда в ней появилось больше флагов, а когда у каждого ответа есть право на переход. Value меняет версию, локальная ошибка не вызывает сеть, async-ответ сравнивает свою версию, а серверная ошибка привязана к полю и доступной разметке. Такой автомат невелик, но он превращает «иногда приходит не та ошибка» в конкретную проверку: старый ответ не может изменить состояние нового значения.'), + ], + [htmlConstraints, htmlFormSubmission, ariaDescription, ariaError, ariaAlert, domAbort, fetchSpec], +); + +const fieldArticle = createRevision( + { + slug: 'editorial-2019-04-field-forms-validation', + title: 'Почему поздняя проверка логина стирает новое состояние формы', + categories: ['JavaScript', 'UX', 'Разбор'], + cover: '/assets/editorial/2019/forms-validation-late-response-2019.svg', + excerpt: 'Разбираем конкретную гонку: ответ «логин занят» для старого значения приходит позже, перезаписывает новое поле и оставляет ошибку без связи с input. Исправляем версией запроса и доступной разметкой.', + readingMinutes: 12, + }, + [ + paragraph('Разбор начинается с симптома, а не с библиотеки. Пользователь вводит логин ivan; форма отправляет проверку. Через мгновение он меняет значение на ivanka. Новый ответ говорит «свободно», экран становится зелёным. Затем приходит старый ответ «занято» и рисует красную строку уже под ivanka. Пользователь видит противоречие, а поддержка получает скриншот, по которому невозможно понять, какое значение проверял сервер.'), + paragraph('Причина — гонка двух корректных по отдельности promise. Код записывает любой завершившийся ответ в одно состояние поля и не хранит, к какому вводу он относится. Вторая проблема обычно рядом: строка ошибки лежит в общем баннере, поэтому даже настоящий серверный отказ нельзя быстро привязать к input. Ниже учебный fixture и маршрут расследования. Он не описывает production-трассу, не заявляет о запуске браузера и не заменяет проверку конкретного API.'), + heading('Реконструкция гонки без настоящей сети'), + paragraph('Для расследования нам не нужен медленный сервер. Достаточно детерминированно задать два ответа в обратном порядке. Функция delayResult в автономном пакете считает ivan занятым и возвращает его спустя 30 мс; ivanka свободен и возвращается спустя 5 мс. Две проверки стартуют одна за другой. Если код применяет всё подряд, первый результат перезапишет второй. Если он сравнивает идентификатор, первый результат станет stale-response и не изменит поле.'), + codeBlock(String.raw` +function delayResult(value, delayMs) { + return new Promise(function (resolve) { + setTimeout(function () { + resolve({ value: value, available: value !== 'ivan' }); + }, delayMs); + }); +} + +var latestRequestId = 0; + +function check(value, delayMs) { + var requestId = latestRequestId + 1; + latestRequestId = requestId; + + return delayResult(value, delayMs).then(function (answer) { + if (requestId !== latestRequestId) { + return { requestId: requestId, applied: false, ignored: 'stale-response' }; + } + + return { + requestId: requestId, + applied: true, + phase: answer.available ? 'valid' : 'invalid', + }; + }); +} +`), + paragraph('В исходном пакете этот fixture запускается отдельной командой и печатает JSON. Он не требует HTTP, DOM или внешней базы: это плюс для проверки правила порядка, но и граница доказательства. Он не отвечает, как конкретный браузер отменяет fetch, как реальный сервер нормализует логин или как экран озвучивает ошибку. Эти вопросы проверяются другими средствами, поэтому в статье они не подменены одним удачным console output.'), + dataTable( + 'Порядок событий в fixture', + ['Момент', 'Действие', 'Текущая версия', 'Ожидаемый эффект'], + [ + ['t0', 'Старт проверки ivan с задержкой 30 мс', '1', 'Поле checking для ivan'], + ['t1', 'Старт проверки ivanka с задержкой 5 мс', '2', 'Поле checking для ivanka; старая ошибка очищена'], + ['t2', 'Ответ ivanka: available', '2', 'Ответ применён, фаза valid, значение ivanka'], + ['t3', 'Ответ ivan: occupied', '2', 'Ответ отклонён как stale-response; текст не меняется'], + ], + ), + paragraph('Проверять нужно не только фразу «старый ответ проигнорирован». Полезно записать финальное состояние целиком: value: "ivanka", requestId: 2, phase: "valid", пустая ошибка поля. Если после исправления тест смотрит лишь на boolean applied, можно пропустить баг, где значение осталось от новой версии, а текст ошибки — от старой. Состояние должно быть согласованным одной версии.'), + heading('Плохой обработчик и минимальная правка'), + paragraph('В наивном варианте callback знает только ответ. Он не знает input, который был актуален на старте запроса. Поэтому последний по времени ответ побеждает независимо от того, что было введено. Отключение кнопки не решает эту гонку: человек всё ещё может менять поле, а ответ может завершиться после повторного открытия формы или смены шага.'), + codeBlock(String.raw` +// Плохо: любой ответ безусловно меняет одно и то же поле. +function applyAnswer(answer) { + state.phase = answer.available ? 'valid' : 'invalid'; + state.error = answer.available ? '' : 'Этот логин уже занят'; + render(state); +} + +// Лучше: requestId закреплён в момент отправки. +function applyAnswerFor(requestId, answer) { + if (requestId !== state.requestId) return; + + state.phase = answer.available ? 'valid' : 'invalid'; + state.error = answer.available ? '' : 'Этот логин уже занят'; + render(state); +} +`), + paragraph('Это не магический token. Он просто превращает неявное допущение «ответы придут по порядку» в явное условие. Текущее состояние — единственный источник версии. Любое событие input увеличивает её до начала следующей проверки. Если форма уничтожается при закрытии модального окна, экземпляр состояния также должен перестать принимать ответы: можно увеличить версию при teardown или проверить, что компонент ещё смонтирован. Выбор зависит от архитектуры, но последний callback не должен оживлять закрытую форму.'), + heading('Где и как показывать серверную ошибку'), + paragraph('Вторая часть диагноза — место ошибки. Ответ «этот логин занят» относится к полю логина, а не к кнопке и не к невидимому тосту. Для формы нужен контракт fields.login → текст. Если API вернул код login_taken, клиент сопоставляет его известному полю. Если же API вернул общий отказ, например закончилась сессия, это уже ошибка формы или маршрута, и её нельзя маскировать под ошибку логина.'), + dataTable( + 'Классификация ответов API до рендера', + ['Ответ', 'Куда идёт', 'Что видит пользователь', 'Что не делать'], + [ + ['fields.login[0]', 'Контейнер login-error', 'Текст под логином, invalid-состояние input', 'Не выводить только общий «Ошибка сохранения»'], + ['fields.email[0]', 'Контейнер email-error', 'Текст под почтой', 'Не приклеивать к текущему активному полю'], + ['form[0]', 'Общий статус формы', 'Краткое сообщение перед кнопкой или заголовком', 'Не ставить aria-invalid на все поля'], + ['Неизвестный ключ', 'Безопасный общий путь и диагностика', 'Нейтральное сообщение без ложного указания', 'Не игнорировать молча и не выбирать первое поле'], + ], + ), + paragraph('Текст ошибки должен быть видимым, но это не значит, что его нужно дублировать по всему экрану. Один контейнер с устойчивым ID остаётся рядом с input. На ошибке input получает aria-invalid="true"; aria-describedby связывает его с постоянной подсказкой, а aria-errormessage — с отдельным текстом ошибки. WAI-ARIA прямо связывает aria-errormessage с состоянием invalid и требует, чтобы релевантное сообщение было доступно пользователю.'), + codeBlock(String.raw` + + +

От 3 до 20 букв, цифр или _.

+ +`), + paragraph('Здесь есть тонкость: пример показывает разметку для фазы remote-invalid, поэтому текст «занят» относится к текущему значению. При следующем input обработчик сначала убирает aria-invalid, очищает login-error и только потом запускает новый запрос. Иначе a11y-семантика тоже будет отставать: зритель увидит новое значение, а ассистивная технология получит старый текст как описание нового поля.'), + heading('Ожидаемое дерево доступности — план проверки, не отчёт'), + paragraph('У этой разметки есть ожидаемая семантика. В дереве должен быть textbox с именем «Логин», текущим значением, состоянием invalid и связью с описанием/ошибкой. Ошибка должна быть видимой и достижимой, а не скрытым span, на который указывает ID. Это не результат снятого Accessibility tree: в этой задаче браузерный прогон не выполнялся. Ниже — чек-лист, который надо подтвердить DevTools и выбранным скринридером после встраивания в реальный экран.'), + dataTable( + 'Ожидаемые признаки дерева доступности', + ['Признак', 'Как создаётся', 'Как подтвердить на контуре', 'Граница вывода'], + [ + ['Имя «Логин»', 'label for="login"', 'Открыть Accessibility tree и пройти поле с клавиатуры', 'Не обещает одинаковую формулировку во всех скринридерах'], + ['Состояние invalid', 'aria-invalid="true" только при ошибке', 'Сменить старое/новое значение и проверить сброс состояния', 'Не заменяет серверную проверку'], + ['Связанный текст ошибки', 'aria-errormessage="login-error"', 'Убедиться, что элемент существует и видим', 'Не гарантирует timing озвучивания без реального прогона'], + ['Постоянная подсказка', 'aria-describedby', 'Проверить ID после рендера формы', 'Не доказывает корректность регулярного выражения'], + ], + ), + paragraph('HTML Constraint Validation API дополняет этот контракт, но не заменяет его. Нативный required и pattern могут остановить очевидно плохой submit. Однако серверная занятость не становится свойством patternMismatch. Храните её отдельно как remoteError, чтобы следующий input мог однозначно очистить результат и запустить проверку актуальной версии. Если нужен единый текст ошибки, setCustomValidity() применяйте к локальному правилу осмысленно и очищайте его на input.'), + figure('/assets/editorial/2019/forms-validation-late-response-2019.svg', 'Временная диаграмма формы: медленный ответ «ivan занят» приходит после быстрого «ivanka свободен», но сравнение requestId 1 и 2 не даёт старому ответу изменить поле', 'Время ответа не равно актуальности. Право обновить UI имеет только ответ, чья версия совпала с текущим вводом.'), + heading('Порядок расследования в реальном проекте'), + orderedList([ + 'Записать два конкретных значения и порядок: что ввели первым, что вторым, какой текст появился в конце. Не начинать с добавления debounce.', + 'Найти единственное место, где меняется состояние поля после promise. Проверить, хранит ли оно значение или requestId, захваченные на старте запроса.', + 'Собрать минимальный fixture с обратными задержками. Ожидаемый результат должен содержать финальное value, phase и error, а не только факт выполнения callback.', + 'При input увеличить версию и очистить remoteError до нового render. Убедиться, что обработчик устаревшего ответа возвращает состояние без изменений.', + 'Проверить контракт API: field error имеет известный ключ, а общий отказ не попадает в произвольное поле. Согласовать неизвестные ключи отдельно.', + 'На реальном экране пройти форму клавиатурой и посмотреть Accessibility tree: label, invalid, описание, видимый error. Этот шаг делает семантику доказательством, а не ожиданием.', + ]), + heading('Почему debounce и abort не закрывают вопрос сами'), + paragraph('Debounce уменьшает число запросов, но не меняет порядок тех запросов, которые уже ушли. Abort может остановить Fetch, если транспорт принимает сигнал, но к моменту отмены ответ уже может быть готов, а отмена не привязывает старый callback к новому value автоматически. Поэтому requestId — условие корректности, debounce — защита API от шума, abort — оптимизация отменяемой работы. Их можно сочетать, но менять одно на другое нельзя.'), + paragraph('Для сохранения действует ещё одно ограничение. Даже если проверка логина сказала «свободен», между check и POST другой пользователь мог занять это имя. Сервер должен повторить правило и вернуть field error при конфликте. Клиент после POST применяет ответ только к версии формы, которая была отправлена; если пользователь уже изменил input, показывать старый ответ над новой формой так же неверно, как в проверке логина.'), + heading('Итог'), + paragraph('В этом случае нет загадочной «нестабильности фронтенда». Есть старый ответ без права менять новый ввод и ошибка без чёткой привязки к полю. Исправление состоит из маленьких проверяемых частей: version при input, сравнение requestId перед render, field-contract API, label и видимый контейнер ошибки. После этого fixture ловит обратный порядок ответов, а реальный экран можно проверить отдельно на клавиатуре и в Accessibility tree без выдуманного отчёта о браузерном прогоне.'), + ], + [htmlConstraints, htmlFormSubmission, ariaDescription, ariaError, ariaAlert, domAbort, fetchSpec], +); + +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')) { + runDelayedResponseFixture() + .then((result) => process.stdout.write(JSON.stringify(result, null, 2) + '\n')) + .catch((error) => { + process.stderr.write(error.stack + '\n'); + process.exitCode = 1; + }); + } else { + process.stderr.write('Usage: node web/scripts/upgrade-2019-04.mjs --print-revisions | --run-fixture\n'); + process.exitCode = 1; + } +} diff --git a/web/scripts/upgrade-2019-05.mjs b/web/scripts/upgrade-2019-05.mjs new file mode 100644 index 0000000..65ef221 --- /dev/null +++ b/web/scripts/upgrade-2019-05.mjs @@ -0,0 +1,359 @@ +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 '
    ' + items.map((item) => '
  • ' + item + '
  • ').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 createRevision(meta, bodyParts, sources) { + if (sources.length < 2) { + throw new Error(meta.slug + ': at least two sources are required'); + } + + return { + ...meta, + contentHtml: [ + bodyParts.join('\n'), + heading('Проверяемые источники'), + sourceList(sources), + ].join('\n'), + }; +} + +const rfcFreshness = { + title: 'RFC 7234, раздел 4.2: freshness', + url: 'https://www.rfc-editor.org/rfc/rfc7234#section-4.2', + note: 'возраст ответа, срок свежести и правило, по которому кэш решает, можно ли использовать сохранённый ответ без повторного запроса', +}; + +const rfcCacheControl = { + title: 'RFC 7234, раздел 5.2: Cache-Control', + url: 'https://www.rfc-editor.org/rfc/rfc7234#section-5.2', + note: 'семантика max-age, s-maxage, private, no-cache и no-store для HTTP/1.1-кэшей', +}; + +const rfcValidation = { + title: 'RFC 7234, раздел 4.3: validation', + url: 'https://www.rfc-editor.org/rfc/rfc7234#section-4.3', + note: 'условные запросы, ETag, If-None-Match и ответ 304 как повторная проверка сохранённого представления', +}; + +const rfcVary = { + title: 'RFC 7234, раздел 4.1: Vary', + url: 'https://www.rfc-editor.org/rfc/rfc7234#section-4.1', + note: 'как кэш выбирает подходящую сохранённую вариацию ответа по полям запроса', +}; + +const mdnCaching = { + title: 'MDN: HTTP caching', + url: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching', + note: 'практическое объяснение свежести, повторной проверки, ETag и разницы между no-cache и no-store', +}; + +const mdnVary = { + title: 'MDN: Vary header', + url: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary', + note: 'Vary как перечисление полей запроса, которые повлияли на представление ресурса', +}; + +const nginxHeaders = { + title: 'nginx: ngx_http_headers_module', + url: 'https://nginx.org/en/docs/http/ngx_http_headers_module.html', + note: 'границы директив add_header и expires в конфигурации nginx', +}; + +const practiceArticle = createRevision( + { + slug: 'editorial-2019-05-practice-http-caching', + title: 'HTTP Cache-Control: как перестать отдавать устаревшие данные', + categories: ['HTTP', 'Производительность', 'Практика'], + cover: '/assets/editorial/2019/http-cache-response-path-2019.svg', + excerpt: 'Пользователь видит старую цену или старый интерфейс после релиза. Разбираем не один TTL, а контракт ресурса: URL, свежесть, повторную проверку и способ измерить результат.', + readingMinutes: 11, + }, + [ + paragraph('После релиза пользователь открывает ту же карточку товара и видит вчерашнюю цену. Команда меняет число в Cache-Control, но часть браузеров продолжает получать старый HTML, а новый JavaScript уже ждёт другие данные. Цена ошибки — не только лишний запрос: пользователь принимает решение по неверному состоянию, а разработчик не может сказать, какой слой сохранил ответ.'), + paragraph('В этой заметке разберём один рабочий вопрос: как назначить кэширование для HTML, версионированных файлов и короткоживущего API-ответа так, чтобы договор можно было проверить заголовками. Это учебный пример HTTP/1.1. Он не заменяет правила конкретного CDN, балансировщика или фреймворка: их настройки нужно сверять отдельно, потому что они могут изменить путь ответа до браузера.'), + heading('Сначала фиксируем ресурс, а не число секунд'), + paragraph('У выражения «поставим час кэша» нет смысла без ресурса. HTML по постоянному URL обычно должен быстро перепроверяться: именно он ссылается на новую версию скрипта и стиля. Файл app.4f91.js можно хранить долго, если изменение содержимого создаёт новый URL. Ответ /api/catalog?category=12 может быть коротко свежим, но только если его тело не зависит от авторизации, языка или другого неучтённого входа.'), + paragraph('RFC 7234 разделяет свежесть и повторную проверку. Пока сохранённый ответ свежий, кэш может использовать его без обращения к origin. Когда срок истёк, это ещё не означает обязательную загрузку всего тела: валидатор может привести к условному запросу и ответу 304 Not Modified. Поэтому первый вопрос к заголовку — не «быстро ли он работает», а «какой старый ответ допустим для этого URL и при каком условии».'), + dataTable( + ['Тип ответа', 'Стабильный URL', 'Практический договор', 'Что проверяем'], + [ + ['HTML документа', 'Да', 'no-cache плюс валидатор, если документ не персонализирован', 'После изменения сервер получает условный запрос или отдаёт новый HTML'], + ['Файл с хешем в имени', 'Нет: URL меняется с содержимым', 'public, max-age=31536000', 'Новый релиз ссылается на новый URL, старый URL может жить отдельно'], + ['Общий краткий API-ответ', 'Да', 'Небольшой max-age; общий кэш только при понятном ключе', 'Два одинаковых запроса дают ожидаемую свежесть и не смешивают варианты'], + ['Персональные данные', 'Да', 'private или no-store по риску хранения', 'Ответ одного пользователя не может стать общим ответом для другого'], + ], + ), + paragraph('Таблица не является готовым набором заголовков для любого сайта. У неё другая цель: перед настройкой выписать свойства ответа. Если HTML содержит имя пользователя или корзину, пример для публичного HTML неприменим. Если asset не имеет fingerprint в имени, годовой max-age создаёт ровно ту проблему, которую команда пытается убрать.'), + heading('Три контракта вместо одного общего правила'), + paragraph('Первый контракт — документ. Для общего HTML полезно разрешить хранение, но требовать повторную проверку перед использованием. Директива no-cache не означает «ничего не хранить»: она требует проверять сохранённый ответ перед повторным использованием. Это даёт браузеру шанс получить 304 вместо повторной передачи всего документа. Если документ персональный, к этому контракту добавляется private; для данных, которые нельзя хранить вообще, нужен более строгий no-store.'), + paragraph('Второй контракт — asset с версией в URL. Здесь cache-busting делается не очисткой кэша, а сменой адреса: содержимое меняется — сборка создаёт новый хеш — HTML начинает ссылаться на новый путь. Длинный срок живёт безопасно только потому, что новый байтовый состав не маскируется старым ключом. Третий контракт — API: значение TTL должно следовать из допустимой давности данных, а не из желания уменьшить нагрузку любой ценой.'), + codeBlock([ + '# nginx: общий HTML, который можно хранить, но надо валидировать', + 'location = /catalog {', + ' add_header Cache-Control "no-cache, public";', + '}', + '', + '# nginx: имя файла меняется вместе с содержимым сборки', + 'location /assets/ {', + ' add_header Cache-Control "public, max-age=31536000";', + '}', + ]), + paragraph('Этот фрагмент показывает форму контракта, а не полный production-конфиг. В реальном nginx нужно проверить наследование add_header, обработку ошибок, существующие заголовки приложения и путь, в котором CDN читает ответ origin. Документация nginx описывает директиву, но не знает, какие именно URL вашего приложения персонализированы или как сборщик формирует имена файлов.'), + figure( + '/assets/editorial/2019/http-cache-response-path-2019.svg', + 'Схема пути ответа: браузер получает HTML с требованием повторной проверки, затем загружает версионированный JavaScript по новому URL; для API отдельно указан короткий срок свежести и валидатор.', + 'Кэш — не один переключатель. Сначала определяется ключ и допустимая давность ответа, затем выбирается путь: повторная проверка, новый URL или запрет общего хранения.', + ), + heading('Проверяем заголовки до изменения конфигурации'), + paragraph('Проверка начинается с одного URL и одного ожидаемого контракта. Не очищаем кэш браузера первым действием: это стирает след, который нужно объяснить. Сохраняем статус, Cache-Control, ETag, Last-Modified, Age при наличии и значения Vary. Затем повторяем запрос с условным заголовком. Если сервер всегда отдаёт полное тело, причина может быть в отсутствии валидатора, в неправильном URL или в том, что промежуточный слой не передаёт условный запрос.'), + codeBlock([ + '# Сначала сохранить заголовки обычного ответа.', + 'curl -sS -D - -o /dev/null https://example.test/catalog', + '', + '# Затем подставить значение ETag из первого ответа.', + 'curl -sS -D - -o /dev/null -H "If-None-Match: catalog-v42" https://example.test/catalog', + ]), + paragraph('Команда выше не доказывает, что ваш CDN использует те же правила, что и браузер. Она делает границу наблюдаемой: на origin или на тестовом домене можно увидеть, поддерживает ли представление условный запрос. Для CDN нужен второй контролируемый путь с той же конфигурацией кэширования. Сравнивать нужно не только код 200 или 304, но и ключевые заголовки на каждом слое.'), + heading('ETag нужен для повторной проверки, а не как украшение'), + paragraph('Когда срок свежести закончился, браузер может отправить If-None-Match со значением предыдущего ETag. Если представление не изменилось, origin отвечает 304, и сохранённое тело остаётся полезным. Если изменилось — отвечает 200 с новым телом и новым валидатором. Такой путь полезен для HTML с постоянным URL: пользователь получает актуальную ссылку на assets, но сеть не передаёт документ повторно, когда он не менялся.'), + paragraph('Не стоит подменять эту механику словом «инвалидация». RFC не обещает, что все кэши исчезнут одновременно после деплоя. Версионированный URL делает старое содержимое отдельным ресурсом; короткий TTL ограничивает допустимую давность; валидатор проверяет конкретное сохранённое представление. Это три разных инструмента. Смешать их в один «кэш выключен» — значит потерять возможность объяснить поведение.'), + heading('Маршрут изменения без слепой очистки'), + orderedList([ + 'Выберите один URL и запишите, какую давность данных пользователь может увидеть без ошибки.', + 'Определите, меняется ли URL вместе с байтовым содержимым. Если нет, не выдавайте долгий max-age за безопасный вариант.', + 'Снимите заголовки origin и публичного адреса; отдельно сохраните Cache-Control, валидаторы, Vary и Age.', + 'Сделайте условный запрос с прежним ETag и зафиксируйте, когда ожидается 304, а когда новый 200.', + 'После одного изменения повторите те же запросы. Проверяйте HTML, asset и API раздельно: общий зелёный экран не доказывает их контракт.', + ]), + heading('Границы решения'), + paragraph('Этот рецепт не обещает немедленную видимость релиза во всех промежуточных кэшах. Поставщик CDN может иметь собственный TTL, собственный cache key или правило, которое обходит заголовок origin. Браузер может использовать навигационную историю иначе, чем обычный reload. Поэтому результатом работы должна быть не фраза «кэш настроен», а короткая карточка: URL, вариант запроса, заголовки, допустимая давность и команда повторной проверки.'), + paragraph('Для автора 2019 года это естественный следующий шаг после Webpack: сборщик уже умеет менять имя asset, теперь нужно связать это с HTTP-ответом и увидеть границу между браузером, origin и общим кэшем. Если в вашем проекте проблема не в свежести, а в разных языках или пользователях по одному URL, сначала разберите ключ варианта — одной настройкой TTL её не исправить.'), + heading('Что унести в проект'), + bulletList([ + 'Длинный TTL безопасен только для ресурса, чей URL меняется вместе с содержимым.', + 'no-cache разрешает хранение, но требует повторной проверки; это не синоним no-store.', + 'Заголовок без снимка ответа не является доказательством: храните запрос, статус и ключевые поля ответа.', + 'HTML, asset и API требуют разных контрактов, даже если проходят через один домен.', + ]), + ], + [rfcFreshness, rfcCacheControl, rfcValidation, mdnCaching, nginxHeaders], +); + +const mechanismArticle = createRevision( + { + slug: 'editorial-2019-05-mechanism-http-caching', + title: 'HTTP-кэш: почему один Cache-Control не управляет всем маршрутом', + categories: ['HTTP', 'Архитектура', 'Разбор'], + cover: '/assets/editorial/2019/http-cache-key-2019.svg', + excerpt: 'Ответ с правильным TTL всё равно может быть неверным: кэш хранит представление по ключу, а browser, proxy и CDN читают его на разных границах. Разбираем свежесть, варианты и повторную проверку.', + readingMinutes: 12, + }, + [ + paragraph('Разработчик видит Cache-Control: max-age=60 и ожидает, что через минуту пользователь обязательно увидит новое значение. Через две минуты один браузер уже получил обновление, другой — нет, а CDN продолжает отвечать старым вариантом. Ошибка здесь не обязательно в числе 60: ответ мог быть сохранён под неполным ключом, промежуточный кэш мог получить иной контракт, а проверка свежести могла произойти не там, где её ищут.'), + paragraph('Разберём механизм на одном вопросе: что именно кэш считает «тем же ответом» и почему срок свежести не заменяет ключ и валидатор. Мы не будем назначать поведение конкретному CDN без его конфигурации. Вместо этого соберём модель HTTP: запрос выбирает представление, кэш оценивает его свежесть, а после истечения срока при необходимости валидирует сохранённую версию у origin.'), + heading('Кэш хранит представление, а не просто URL'), + paragraph('URL — начало ключа, но не всегда конец. Если origin отдаёт русский и английский HTML по одному адресу в зависимости от Accept-Language, для кэша это два представления одного ресурса. Заголовок Vary: Accept-Language говорит, что это поле запроса повлияло на содержимое. При выборе сохранённого ответа кэш должен сопоставить значения перечисленных полей с новым запросом.'), + paragraph('Из этого следует практическая проверка: прежде чем увеличивать TTL, перечислите входы, которые меняют тело. Язык, формат, мобильная версия, авторизация, эксперимент и cookie — это не общая «динамика», а конкретные ветви генерации. Если один из входов влияет на HTML, а cache key его не различает, кэш может честно отдать свежий, но чужой вариант. TTL не исправляет такую ошибку; он только ограничивает, как долго она видна.'), + dataTable( + ['Вход, меняющий тело', 'Какой договор нужен', 'Опасность неверной настройки', 'Наблюдаемая проверка'], + [ + ['URL и query', 'Единый канонический порядок параметров и документированный ключ', 'Один смысл разрастается в много ключей или разные смыслы становятся одним ключом', 'Сравнить заголовки и тело для двух URL'], + ['Accept-Language', 'Vary: Accept-Language при реально разном представлении', 'Русский текст попадает в английский запрос', 'Отправить два запроса с разным языком и сверить Vary'], + ['Cookie/авторизация', 'private либо явное отделение общего ответа от личного', 'Данные одного пользователя попадают в общий кэш', 'Проверить, не меняется ли тело при двух безопасных тестовых сессиях'], + ['Версия asset в имени', 'Новый URL при новом содержимом', 'Длинный TTL держит старый JavaScript по постоянному адресу', 'Сравнить HTML релиза и список URL assets'], + ], + ), + paragraph('RFC 7234 отдельно описывает Vary, но не превращает его в универсальный флаг для каждого управляемого кэша. У CDN могут быть правила, которые определяют cache key заранее или исключают часть ответов. Поэтому Vary — договор HTTP между origin и совместимым кэшем; проверка реального edge-слоя всё равно входит в выпуск. В статье мы не маскируем этот пробел словом «автоматически».'), + heading('Свежий, устаревший и проверенный — разные состояния'), + paragraph('После сохранения ответа кэш вычисляет его текущий возраст и сравнивает с lifetime. Свежий ответ может быть использован сразу. Устаревший ответ не становится мусором: кэш может отправить условный запрос к origin, передав ETag или дату, и получить подтверждение 304 Not Modified. В этом случае тело не скачивается повторно, но новый ответ подтверждает, что сохранённое представление ещё соответствует origin.'), + paragraph('Директивы max-age и s-maxage задают разные границы. Первая влияет на кэши в целом; s-maxage имеет специальное значение для shared cache и может переопределять max-age для него. Это полезно, когда браузер не должен долго хранить ответ, а общий кэш может уменьшить нагрузку origin чуть дольше. Но смысл появляется только после проверки, действительно ли ответ общий и не содержит персональных ветвей.'), + codeBlock([ + 'HTTP/1.1 200 OK', + 'Content-Type: application/json', + 'Cache-Control: public, max-age=30, s-maxage=120', + 'ETag: "catalog-202-17"', + 'Vary: Accept-Language', + '', + '{"items":[{"id":42,"name":"..."}]}', + ]), + paragraph('Этот ответ допустим лишь при конкретных условиях: каталог одинаков для всех пользователей с одним языковым вариантом, а 30 секунд допустимой локальной давности названы бизнесом или продуктом. Если цена зависит от пользователя, промокода или сессии, public в примере становится неправильным. Код не заменяет анализ входов; он фиксирует решение после анализа.'), + heading('no-cache, no-store и private отвечают на разные риски'), + paragraph('Три часто смешиваемые директивы нужны для разных ситуаций. no-cache позволяет сохранить ответ, но требует успешной проверки до повторного использования. no-store запрещает сохранять ответ и его части; он нужен, когда сам факт хранения опасен, а не когда хочется «быстро обновлять страницу». private ограничивает повторное использование shared cache, но не делает страницу автоматически безопасной для всех скриптов, логов и истории браузера.'), + paragraph('Выбор должен начинаться с риска. Для обычной публичной статьи полезна повторная проверка: она поддерживает свежесть без полной передачи тела. Для персонального баланса общий кэш недопустим; команда оценивает, достаточно ли private, или ответ вообще нельзя сохранять. Для версионированного bundle риском является не персонализация, а постоянный URL, поэтому решением будет новый ключ ресурса, а не no-store.'), + figure( + '/assets/editorial/2019/http-cache-key-2019.svg', + 'Схема выбора HTTP-кэша: URL и Vary формируют ключ варианта, затем кэш проверяет свежесть; при истечении срока условный запрос с ETag приводит к 304 или к новому 200.', + 'Диагностика начинается с ключа. Только после этого TTL и валидатор имеют понятный эффект.', + ), + heading('Где искать расхождение между слоями'), + paragraph('У одного ответа может быть несколько наблюдаемых точек: приложение, origin-прокси, CDN и браузер. Нельзя склеивать их в один «сервер». В минимальном журнале укажите время, URL, заголовок запроса, статус, Cache-Control, ETag, Vary и Age, если слой его отдал. Затем выполните тот же сценарий напрямую к origin на тестовом адресе и через публичный путь. Разница показывает, где контракт перестал совпадать с ожиданием.'), + codeBlock([ + '# Запросить один и тот же ресурс в двух вариантах языка.', + 'curl -sS -D /tmp/cache-ru.headers -o /tmp/cache-ru.body -H "Accept-Language: ru" https://example.test/catalog', + 'curl -sS -D /tmp/cache-en.headers -o /tmp/cache-en.body -H "Accept-Language: en" https://example.test/catalog', + '', + '# Сначала сравнить Vary, Cache-Control и ETag, затем уже тела.', + ]), + paragraph('Команды не являются измерением для этой статьи: их нужно запускать на своём тестовом домене, без личных токенов в истории shell. Их польза в другом — они делают явным вход, который раньше был скрыт в браузере. Если два языка дают разное тело и отсутствует ожидаемый Vary, остановитесь здесь. Если тело одинаково, не добавляйте Vary «на всякий случай»: лишний вариант дробит кэш и усложняет проверку.'), + heading('Последовательность проверки механизма'), + orderedList([ + 'Для одного URL выпишите все входы, от которых действительно меняется тело ответа.', + 'Сравните два безопасных варианта запроса и сохраните заголовки вместе с телом или его хешем.', + 'Проверьте, что Vary совпадает с реальными различиями и не содержит случайных полей.', + 'Определите допустимую давность отдельно для browser cache и shared cache; только затем назначайте max-age и s-maxage.', + 'Добавьте валидатор, если постоянный URL должен быстро подтверждать свежесть после истечения срока.', + 'Повторите сценарий через каждый слой, который реально выдаёт ответ пользователю.', + ]), + heading('Границы модели'), + paragraph('HTTP-модель не описывает правила очистки конкретного поставщика CDN, режим offline браузера или историю навигации. Она также не говорит, что ETag должен быть криптографическим хешем: важно, чтобы валидатор корректно отличал представления в выбранном договоре. Если у приложения есть персонализация, эксперименты или геозависимые цены, понадобится отдельная карта вариантов и, возможно, отказ от общего кэша для части URL.'), + paragraph('Главная привычка автора на этом этапе — перестать считать заголовок красивой строкой конфигурации. Cache-Control отвечает на вопрос о повторном использовании, Vary — о соответствии варианта запросу, ETag — о повторной проверке. Когда каждый ответ получает короткую карту этих трёх ролей, дебаг перестаёт начинаться с глобальной очистки CDN.'), + heading('Короткий вывод'), + bulletList([ + 'TTL ограничивает давность сохранённого варианта, но не создаёт правильный ключ.', + 'Если тело зависит от заголовка запроса, это зависимость нужно проверить как часть cache key, а не описать общим словом «динамика».', + 'no-cache, no-store и private выбираются по разным рискам хранения и повторного использования.', + 'Проверка должна сравнивать origin и публичный путь, иначе слой с расхождением останется невидимым.', + ]), + ], + [rfcFreshness, rfcCacheControl, rfcValidation, rfcVary, mdnCaching, mdnVary], +); + +const fieldArticle = createRevision( + { + slug: 'editorial-2019-05-field-http-caching', + title: 'CDN отдаёт старый язык: как проверить cache key, Vary и ETag', + categories: ['HTTP', 'CDN', 'Диагностика'], + cover: '/assets/editorial/2019/http-cache-variant-check-2019.svg', + excerpt: 'Один URL отдаёт разные языки, а пользователь получает вчерашний или чужой вариант. Собираем контролируемый сценарий: два запроса, заголовки, ключ варианта и повторная проверка.', + readingMinutes: 12, + }, + [ + paragraph('На тестовом домене карточка по адресу /catalog должна отвечать на русском и английском в зависимости от Accept-Language. После включения общего кэша часть английских запросов получает русский HTML, хотя origin формирует правильный язык. Цена ошибки — не косметика: пользователь видит чужой интерфейс, а команда может ошибочно списать проблему на перевод или браузер вместо того, чтобы проверить ключ сохранённого ответа.'), + paragraph('Ниже полевой сценарий для одной ветки: один URL, два языка, доступ к безопасному тестовому origin и к публичному адресу через CDN. Он не предполагает, что любой CDN автоматически уважает каждый Vary. Сначала докажем, что origin различает варианты и объявляет это в HTTP, затем проверим тот же договор на публичном пути. Никаких личных cookies и настоящих пользовательских страниц в команду не подставляем.'), + heading('Определяем, что считается разным представлением'), + paragraph('Один URL может иметь несколько представлений. В нашем примере тело меняется из-за Accept-Language: меняются заголовок, подписи и ссылка на локализованный asset. Это не вопрос вкуса, а вход генерации ответа. RFC описывает Vary как список полей запроса, которые повлияли на выбранное представление. Поэтому origin обязан не только вернуть русский текст, но и объявить кэшу, что язык участвовал в выборе.'), + paragraph('Не добавляйте Vary: Cookie по инерции. Если cookie содержит идентификатор сессии, такой ключ может раздробить общий кэш на множество значений и всё равно не решить персональные данные. Сначала ответьте, почему тело меняется. Для личной страницы правильный маршрут часто начинается с private, а для публичной локализации — с ограниченного и проверяемого Vary: Accept-Language.'), + dataTable( + ['Сценарий', 'Ожидаемый заголовок', 'Что считаем ошибкой', 'Следующее действие'], + [ + ['Публичный русский и английский HTML', 'Vary: Accept-Language', 'Тела различаются, а Vary отсутствует или не совпадает', 'Исправить origin и повторить два запроса'], + ['Одинаковый ответ независимо от языка', 'Vary не требуется только ради предположения', 'Добавлен лишний Vary без отличий тела', 'Убрать лишний вариант и измерить ключ заново'], + ['Страница с пользователем', 'private или более строгий запрет хранения', 'Общий кэш способен использовать ответ другой сессии', 'Отделить публичный shell от личных данных'], + ['Вариант устарел после срока', 'ETag и условный запрос при выбранном договоре', 'Сервер всегда отдаёт тело, хотя представление не менялось', 'Проверить генерацию валидатора и путь до origin'], + ], + ), + heading('Собираем контрольный запрос к origin'), + paragraph('Начинаем не с интерфейса, а с заголовков. Два запроса должны отличаться ровно одним входом — языком. Сохраняем заголовки и тело раздельно, чтобы не перепутать вывод curl с данными. В боевом окружении тестовый origin должен быть доступен безопасным способом: отдельный host, allowlist или стенд. Не обходите аутентификацию и не добавляйте служебные адреса в публичные примеры.'), + codeBlock([ + '# Русский вариант.', + 'curl -sS -D /tmp/catalog-ru.headers -o /tmp/catalog-ru.html -H "Accept-Language: ru" https://origin.example.test/catalog', + '', + '# Английский вариант: меняется только один вход.', + 'curl -sS -D /tmp/catalog-en.headers -o /tmp/catalog-en.html -H "Accept-Language: en" https://origin.example.test/catalog', + '', + 'grep -Ei "^(cache-control|vary|etag|last-modified):" /tmp/catalog-ru.headers', + 'grep -Ei "^(cache-control|vary|etag|last-modified):" /tmp/catalog-en.headers', + ]), + paragraph('Ожидаем не конкретный текст ETag, а форму договора. В обоих ответах должно быть одинаковое правило кэширования, если срок свежести одинаков. Vary должен перечислять Accept-Language, если тела различаются по этому полю. ETag может различаться, потому что представления различаются. Если origin уже возвращает неверные заголовки, CDN пока не трогаем: сначала исправляем источник, иначе edge-диагностика будет смешивать две ошибки.'), + figure( + '/assets/editorial/2019/http-cache-variant-check-2019.svg', + 'Вертикальная схема проверки двух языковых вариантов: origin формирует русский и английский ответы с Vary, CDN хранит отдельные ключи, затем условный запрос с ETag подтверждает или обновляет вариант.', + 'Один URL не равен одному телу. В сценарии решающим входом является язык, поэтому его надо увидеть в Vary и в поведении реального кэша.', + ), + heading('Повторяем сценарий через публичный путь'), + paragraph('Когда origin прошёл проверку, повторяем те же два запроса через публичный адрес. Менять одновременно URL, язык, User-Agent и cookie нельзя: тогда результат невозможно объяснить. Сначала сравниваем headers с origin: они могут дополняться, но Cache-Control, Vary и валидатор не должны потерять смысл. Затем сравниваем тела или их безопасные хеши. Если публичный путь отдаёт один вариант на два языка, фиксируем это как расхождение cache key, а не как «кэш иногда глючит».'), + codeBlock([ + 'for lang in ru en; do', + ' curl -sS -D "/tmp/edge-$lang.headers" -o "/tmp/edge-$lang.html" -H "Accept-Language: $lang" https://www.example.test/catalog', + 'done', + '', + 'sha256sum /tmp/edge-ru.html /tmp/edge-en.html', + 'grep -Ei "^(cache-control|vary|etag|age):" /tmp/edge-ru.headers', + 'grep -Ei "^(cache-control|vary|etag|age):" /tmp/edge-en.headers', + ]), + paragraph('Фрагмент предназначен для shell с безопасным доменом; он не является выполненным измерением этой статьи. Важна последовательность: сначала подтверждаем два варианта, потом смотрим, что edge не склеил их в один. Поле Age, если его отдаёт слой, помогает понять, что ответ уже жил в кэше, но его отсутствие не доказывает отсутствие кэширования. Конкретные диагностические заголовки CDN не универсальны и должны быть описаны его документацией.'), + heading('Проверяем повторную проверку после истечения свежести'), + paragraph('Второй тип сбоя выглядит иначе: варианты различаются правильно, но после изменения перевода один из них долго остаётся старым. Здесь проверяем валидатор. Сохраняем ETag русского варианта, ждём или на тестовом стенде настраиваем короткий срок свежести, затем посылаем If-None-Match для того же языка. Если тело не менялось, допустим 304; если менялось — ожидаем новый 200 и новый ETag. Нельзя проверять русский валидатор английским запросом: это уже другой вариант.'), + codeBlock([ + '# Значение взять из ответа русского варианта, не подставлять личные токены.', + 'curl -sS -D - -o /dev/null -H "Accept-Language: ru" -H "If-None-Match: catalog-ru-v18" https://www.example.test/catalog', + ]), + paragraph('Если ответ всегда 200, это не повод отключить кэш. Сначала выясняем, меняется ли ETag на каждом запросе из-за времени, случайного идентификатора или неустойчивого порядка данных. Валидатор должен описывать представление, а не шум вокруг него. Если сервер всегда 304 после реального изменения текста, наоборот, валидатор слишком грубый. Оба случая проверяются на маленьком контролируемом изменении, а не на общей очистке всей зоны.'), + heading('Матрица решения по наблюдению'), + paragraph('Результат сценария удобно зафиксировать как четыре короткие строки: вариант запроса, URL, digest тела и заголовки. В локализации это даёт картину, которую можно показать владельцу CDN или backend: вот два запроса, вот origin, вот edge, вот поле, исчезнувшее на переходе. Такой артефакт ценнее скриншота, потому что его можно повторить после правки правила.'), + dataTable( + ['Наблюдение после двух запросов', 'Граница, где искать', 'Безопасная следующая проверка'], + [ + ['Origin возвращает один и тот же язык', 'Шаблон/роутинг origin', 'Проверить, доходит ли Accept-Language до приложения'], + ['Origin различает, но не ставит Vary', 'HTTP-ответ приложения или proxy', 'Добавить Vary для реального входа и снова снять заголовки'], + ['Origin корректен, edge склеивает варианты', 'Настройка CDN/cache key', 'Сверить правило провайдера с входом языка на тестовом URL'], + ['Варианты разделены, но новый текст не приходит после срока', 'ETag, TTL или маршрут условного запроса', 'Проверить один вариант с If-None-Match'], + ], + ), + heading('Порядок внедрения'), + orderedList([ + 'Выберите публичный тестовый URL без личных данных и один вход, который точно меняет тело.', + 'Снимите два ответа origin, сохранив тело и ключевые заголовки по отдельности.', + 'Проверьте соответствие между различием тела и Vary; не расширяйте key произвольными полями.', + 'Повторите сценарий через CDN с теми же двумя запросами и зафиксируйте расхождение.', + 'Проверьте один язык условным запросом после выбранного срока свежести.', + 'После правки оставьте команды и ожидаемые признаки в репозитории или runbook, чтобы следующий релиз не начинал диагностику заново.', + ]), + heading('Ограничения и следующий шаг'), + paragraph('Сценарий не проверяет все возможные варианты: мобильный рендер, эксперимент, гео и авторизацию нужно добавлять отдельно только если они действительно меняют тело. Он также не разрешает кешировать персональный HTML. Его задача уже: показать, что правильный перевод на origin не равен правильному cache key на edge.'), + paragraph('После этой проверки можно переходить к настройке TTL и purge-процесса конкретного провайдера, но только с зафиксированным ключом. Если команда не может назвать, какие поля запроса создают разные представления, сначала вернитесь к шаблону и данным. Для автора 2019 года это развитие от ручного curl-диагноза к границе между HTTP-договором и инфраструктурой доставки, без притворной уверенности, что один заголовок управляет всей сетью.'), + heading('Короткий вывод'), + bulletList([ + 'Два языка по одному URL требуют проверяемого различия вариантов, а не только двух правильных HTML на origin.', + 'Vary отражает входы, которые изменили представление; он не заменяет проверку реальной настройки CDN.', + 'ETag проверяется внутри одного варианта запроса, иначе 304 и 200 нельзя интерпретировать.', + 'Небольшой повторяемый сценарий с headers и телом быстрее локализует сбой, чем очистка всего кэша.', + ]), + ], + [rfcCacheControl, rfcValidation, rfcVary, mdnCaching, mdnVary], +); + +export const revisions = [practiceArticle, mechanismArticle, fieldArticle]; + +if (process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions)); +}