This commit is contained in:
@@ -12,7 +12,9 @@ import { revisions as february2019Revisions } from '../scripts/upgrade-2019-02.m
|
||||
import { revisions as march2019Revisions } from '../scripts/upgrade-2019-03.mjs';
|
||||
import { revisions as april2019Revisions } from '../scripts/upgrade-2019-04.mjs';
|
||||
import { revisions as may2019Revisions } from '../scripts/upgrade-2019-05.mjs';
|
||||
import { revisions as june2019Revisions } from '../scripts/upgrade-2019-06.mjs';
|
||||
import { revisions as july2019Revisions } from '../scripts/upgrade-2019-07.mjs';
|
||||
import { revisions as august2019Revisions } from '../scripts/upgrade-2019-08.mjs';
|
||||
|
||||
// This layer replaces archived source entries without losing their stable slug and date.
|
||||
export const editorialRevisions = [
|
||||
@@ -30,5 +32,7 @@ export const editorialRevisions = [
|
||||
...march2019Revisions,
|
||||
...april2019Revisions,
|
||||
...may2019Revisions,
|
||||
...june2019Revisions,
|
||||
...july2019Revisions,
|
||||
...august2019Revisions,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 760" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Критический путь первого экрана</title>
|
||||
<desc id="desc">Схема показывает, как начальный HTML открывает обнаружение CSS, JavaScript и hero-изображения, а затем свободный главный поток и готовые стили позволяют нарисовать полезный экран.</desc>
|
||||
<defs>
|
||||
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#111b30"/>
|
||||
<stop offset="1" stop-color="#243350"/>
|
||||
</linearGradient>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#a7bad7"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="1000" height="760" fill="url(#background)"/>
|
||||
<g font-family="Arial, sans-serif">
|
||||
<text x="70" y="68" fill="#f8fbff" font-size="34" font-weight="700">Критический путь полезного экрана</text>
|
||||
<text x="70" y="104" fill="#c1cee2" font-size="20">Быстрый ответ HTML полезен, но не завершает визуальную работу браузера.</text>
|
||||
<rect x="70" y="156" width="260" height="110" rx="20" fill="#4a93e9"/>
|
||||
<text x="200" y="202" text-anchor="middle" fill="#0e2038" font-size="25" font-weight="700">HTML</text>
|
||||
<text x="200" y="234" text-anchor="middle" fill="#16304e" font-size="18">разбор и обнаружение</text>
|
||||
<path d="M 330 211 L 426 211" stroke="#a7bad7" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<path d="M 330 223 C 380 285 420 300 480 316" fill="none" stroke="#a7bad7" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<path d="M 330 223 C 400 385 450 450 510 474" fill="none" stroke="#a7bad7" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<rect x="450" y="156" width="260" height="110" rx="20" fill="#5ed2bb"/>
|
||||
<text x="580" y="202" text-anchor="middle" fill="#09362d" font-size="25" font-weight="700">CSS</text>
|
||||
<text x="580" y="234" text-anchor="middle" fill="#154f43" font-size="18">stylesheet и CSSOM</text>
|
||||
<rect x="450" y="292" width="260" height="110" rx="20" fill="#ffb45c"/>
|
||||
<text x="580" y="338" text-anchor="middle" fill="#351d00" font-size="25" font-weight="700">JavaScript</text>
|
||||
<text x="580" y="370" text-anchor="middle" fill="#523000" font-size="18">parse и main thread</text>
|
||||
<rect x="450" y="448" width="260" height="110" rx="20" fill="#d79bf7"/>
|
||||
<text x="580" y="494" text-anchor="middle" fill="#361442" font-size="25" font-weight="700">Hero</text>
|
||||
<text x="580" y="526" text-anchor="middle" fill="#512466" font-size="18">request, decode, paint</text>
|
||||
<path d="M 710 211 C 770 211 755 330 815 330" fill="none" stroke="#a7bad7" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<path d="M 710 347 L 815 347" stroke="#a7bad7" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<path d="M 710 503 C 770 503 758 366 815 366" fill="none" stroke="#a7bad7" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<rect x="830" y="272" width="120" height="150" rx="24" fill="#f5f8ff"/>
|
||||
<text x="890" y="321" text-anchor="middle" fill="#1b2840" font-size="19" font-weight="700">Готово</text>
|
||||
<text x="890" y="350" text-anchor="middle" fill="#1b2840" font-size="19" font-weight="700">для</text>
|
||||
<text x="890" y="379" text-anchor="middle" fill="#1b2840" font-size="19" font-weight="700">paint</text>
|
||||
<path d="M 890 422 L 890 514" stroke="#a7bad7" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<rect x="745" y="545" width="210" height="100" rx="22" fill="#f5f8ff"/>
|
||||
<text x="850" y="586" text-anchor="middle" fill="#1b2840" font-size="22" font-weight="700">Полезный</text>
|
||||
<text x="850" y="617" text-anchor="middle" fill="#1b2840" font-size="22" font-weight="700">первый экран</text>
|
||||
<text x="70" y="682" fill="#c1cee2" font-size="18">Проверяем разрыв: URL обнаружен поздно, CSS не готов или main thread занят.</text>
|
||||
<text x="70" y="714" fill="#c1cee2" font-size="18">Отдельно: изображение прошло request, decode и paint?</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,54 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 780" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Профиль первой загрузки с четырьмя владельцами времени</title>
|
||||
<desc id="desc">Вертикальная схема показывает документ и сеть, JavaScript на главном потоке, CSS и hero-изображение как отдельные дорожки, которые сходятся в полезный первый экран.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#101a32"/>
|
||||
<stop offset="1" stop-color="#172a45"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-10%" y="-10%" width="120%" height="130%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#07101f" flood-opacity=".28"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="1000" height="780" fill="url(#bg)"/>
|
||||
<text x="70" y="70" fill="#f6f8fc" font-family="Arial, sans-serif" font-size="34" font-weight="700">Первая загрузка — четыре независимые дорожки</text>
|
||||
<text x="70" y="106" fill="#b8c8e3" font-family="Arial, sans-serif" font-size="20">Профиль отвечает «кому принадлежит задержка», а не складывает всё в одно число.</text>
|
||||
<g font-family="Arial, sans-serif">
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="70" y="152" width="860" height="100" rx="18" fill="#1e3558"/>
|
||||
<rect x="96" y="178" width="154" height="48" rx="24" fill="#4e9af1"/>
|
||||
<text x="173" y="209" text-anchor="middle" fill="#09192d" font-size="20" font-weight="700">Сеть</text>
|
||||
<text x="282" y="193" fill="#f6f8fc" font-size="23" font-weight="700">HTML и критические запросы</text>
|
||||
<text x="282" y="220" fill="#b8c8e3" font-size="18">redirect · connect · response · обнаружение URL</text>
|
||||
<line x1="655" y1="202" x2="875" y2="202" stroke="#4e9af1" stroke-width="14" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="70" y="276" width="860" height="100" rx="18" fill="#27365b"/>
|
||||
<rect x="96" y="302" width="154" height="48" rx="24" fill="#ffb45c"/>
|
||||
<text x="173" y="333" text-anchor="middle" fill="#2a1700" font-size="20" font-weight="700">JS</text>
|
||||
<text x="282" y="317" fill="#f6f8fc" font-size="23" font-weight="700">Главный поток</text>
|
||||
<text x="282" y="344" fill="#c8d2e7" font-size="18">parse · execute · DOM · сторонний код</text>
|
||||
<line x1="574" y1="326" x2="822" y2="326" stroke="#ffb45c" stroke-width="14" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="70" y="400" width="860" height="100" rx="18" fill="#1c4551"/>
|
||||
<rect x="96" y="426" width="154" height="48" rx="24" fill="#5dd6ba"/>
|
||||
<text x="173" y="457" text-anchor="middle" fill="#06342d" font-size="20" font-weight="700">CSS</text>
|
||||
<text x="282" y="441" fill="#f6f8fc" font-size="23" font-weight="700">Стиль и layout</text>
|
||||
<text x="282" y="468" fill="#c3e6df" font-size="18">stylesheet · CSSOM · style · layout</text>
|
||||
<line x1="612" y1="450" x2="760" y2="450" stroke="#5dd6ba" stroke-width="14" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="70" y="524" width="860" height="100" rx="18" fill="#4b355d"/>
|
||||
<rect x="96" y="550" width="154" height="48" rx="24" fill="#d59afa"/>
|
||||
<text x="173" y="581" text-anchor="middle" fill="#321343" font-size="20" font-weight="700">Image</text>
|
||||
<text x="282" y="565" fill="#f6f8fc" font-size="23" font-weight="700">Hero-изображение</text>
|
||||
<text x="282" y="592" fill="#ead8f7" font-size="18">URL · transfer · decode · paint</text>
|
||||
<line x1="600" y1="574" x2="846" y2="574" stroke="#d59afa" stroke-width="14" stroke-linecap="round"/>
|
||||
</g>
|
||||
<path d="M 500 640 L 500 681" stroke="#9bb0d3" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="M 500 681 L 488 665 M 500 681 L 512 665" fill="none" stroke="#9bb0d3" stroke-width="5" stroke-linecap="round"/>
|
||||
<rect x="185" y="695" width="630" height="60" rx="30" fill="#f4f7fc"/>
|
||||
<text x="500" y="733" text-anchor="middle" fill="#16233a" font-size="21" font-weight="700">Полезный экран: проверяем одну границу</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 800" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Маршрут диагностики позднего полезного экрана</title>
|
||||
<desc id="desc">Дерево решения ведёт от позднего полезного screenshot к четырём проверкам: позднее обнаружение ресурса, JavaScript на главном потоке, CSS и layout, а также загрузка и декодирование изображения.</desc>
|
||||
<defs>
|
||||
<linearGradient id="fieldbg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#152039"/>
|
||||
<stop offset="1" stop-color="#263756"/>
|
||||
</linearGradient>
|
||||
<marker id="branchArrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#b8c8e2"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="1000" height="800" fill="url(#fieldbg)"/>
|
||||
<g font-family="Arial, sans-serif">
|
||||
<text x="70" y="66" fill="#f8fbff" font-size="34" font-weight="700">Диагностика: поздний экран</text>
|
||||
<text x="70" y="102" fill="#c6d3e7" font-size="20">Сначала наблюдаемый блок, затем владелец задержки, потом обратимая проверка.</text>
|
||||
<rect x="280" y="145" width="440" height="92" rx="22" fill="#f5f8ff"/>
|
||||
<text x="500" y="184" text-anchor="middle" fill="#17243b" font-size="24" font-weight="700">Полезный экран запоздал</text>
|
||||
<text x="500" y="214" text-anchor="middle" fill="#394962" font-size="17">HTML мог уже прийти — это ещё не ответ о причине</text>
|
||||
<path d="M 500 237 L 500 292" stroke="#b8c8e2" stroke-width="5" marker-end="url(#branchArrow)"/>
|
||||
<rect x="322" y="305" width="356" height="74" rx="18" fill="#31476d"/>
|
||||
<text x="500" y="336" text-anchor="middle" fill="#f8fbff" font-size="21" font-weight="700">Критический URL стартовал рано?</text>
|
||||
<text x="500" y="361" text-anchor="middle" fill="#d5e0f0" font-size="17">сверяем HTML, waterfall и element</text>
|
||||
<path d="M 350 379 C 255 415 210 434 185 478" fill="none" stroke="#b8c8e2" stroke-width="5" marker-end="url(#branchArrow)"/>
|
||||
<path d="M 455 379 C 430 420 410 432 402 478" fill="none" stroke="#b8c8e2" stroke-width="5" marker-end="url(#branchArrow)"/>
|
||||
<path d="M 545 379 C 570 420 590 432 598 478" fill="none" stroke="#b8c8e2" stroke-width="5" marker-end="url(#branchArrow)"/>
|
||||
<path d="M 650 379 C 745 415 790 434 815 478" fill="none" stroke="#b8c8e2" stroke-width="5" marker-end="url(#branchArrow)"/>
|
||||
<rect x="55" y="495" width="260" height="158" rx="20" fill="#4d94ea"/>
|
||||
<text x="185" y="534" text-anchor="middle" fill="#10233c" font-size="22" font-weight="700">Позднее</text>
|
||||
<text x="185" y="562" text-anchor="middle" fill="#10233c" font-size="22" font-weight="700">обнаружение</text>
|
||||
<text x="185" y="594" text-anchor="middle" fill="#16304e" font-size="17">URL создаётся кодом</text>
|
||||
<text x="185" y="620" text-anchor="middle" fill="#16304e" font-size="17">или скрыт в CSS</text>
|
||||
<text x="185" y="642" text-anchor="middle" fill="#173454" font-size="15" font-weight="700">Показать URL раньше</text>
|
||||
<rect x="345" y="495" width="260" height="158" rx="20" fill="#ffb45c"/>
|
||||
<text x="475" y="534" text-anchor="middle" fill="#352000" font-size="22" font-weight="700">JavaScript</text>
|
||||
<text x="475" y="562" text-anchor="middle" fill="#352000" font-size="22" font-weight="700">на main thread</text>
|
||||
<text x="475" y="594" text-anchor="middle" fill="#4b2c00" font-size="17">scripting до render</text>
|
||||
<text x="475" y="620" text-anchor="middle" fill="#4b2c00" font-size="17">или лишний DOM</text>
|
||||
<text x="475" y="642" text-anchor="middle" fill="#4b2c00" font-size="15" font-weight="700">Отложить один модуль</text>
|
||||
<rect x="635" y="495" width="260" height="158" rx="20" fill="#5ed1b8"/>
|
||||
<text x="765" y="534" text-anchor="middle" fill="#07372e" font-size="22" font-weight="700">CSS и</text>
|
||||
<text x="765" y="562" text-anchor="middle" fill="#07372e" font-size="22" font-weight="700">layout</text>
|
||||
<text x="765" y="594" text-anchor="middle" fill="#164e44" font-size="17">поздний stylesheet</text>
|
||||
<text x="765" y="620" text-anchor="middle" fill="#164e44" font-size="17">или повторный layout</text>
|
||||
<text x="765" y="642" text-anchor="middle" fill="#164e44" font-size="15" font-weight="700">Исправить границу CSS</text>
|
||||
<rect x="55" y="690" width="840" height="58" rx="29" fill="#d99af9"/>
|
||||
<text x="475" y="726" text-anchor="middle" fill="#351442" font-size="21" font-weight="700">Изображение: отдельно проверить request, размер, decode и paint</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1280" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Локальная fixture проверяет контракт до сетевого теста</title>
|
||||
<desc id="desc">Вертикальная схема показывает три локальных объекта ответа: корректные 200 и 400 проходят контрактную проверку, а 200 без nextCursor отклоняется; отдельный сетевой шаг не выполнен fixture.</desc>
|
||||
<rect width="720" height="1280" fill="#0f172a"/>
|
||||
<rect x="42" y="34" width="636" height="112" rx="18" fill="#1e3a8a"/>
|
||||
<text x="360" y="80" text-anchor="middle" fill="#ffffff" font-size="28" font-family="Arial, sans-serif" font-weight="700">Fixture ловит нарушение до сети</text>
|
||||
<text x="360" y="116" text-anchor="middle" fill="#bfdbfe" font-size="21" font-family="Arial, sans-serif">локальные объекты не заменяют сервер</text>
|
||||
|
||||
<rect x="85" y="190" width="550" height="108" rx="18" fill="#14532d" stroke="#86efac" stroke-width="3"/>
|
||||
<text x="360" y="232" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">Fixture A · 200 / JSON</text>
|
||||
<text x="360" y="268" text-anchor="middle" fill="#dcfce7" font-size="22" font-family="Arial, sans-serif">nextCursor: null</text>
|
||||
<path d="M360 298v34" stroke="#bfdbfe" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="85" y="338" width="550" height="108" rx="18" fill="#7f1d1d" stroke="#fca5a5" stroke-width="3"/>
|
||||
<text x="360" y="380" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">Fixture B · 400 / problem+json</text>
|
||||
<text x="360" y="416" text-anchor="middle" fill="#fee2e2" font-size="22" font-family="Arial, sans-serif">errors[0].code: invalid_cursor</text>
|
||||
<path d="M360 446v34" stroke="#bfdbfe" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="85" y="486" width="550" height="108" rx="18" fill="#7f1d1d" stroke="#fca5a5" stroke-width="3"/>
|
||||
<text x="360" y="528" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">Fixture C · 200 / JSON</text>
|
||||
<text x="360" y="564" text-anchor="middle" fill="#fee2e2" font-size="22" font-family="Arial, sans-serif">нет обязательного nextCursor</text>
|
||||
<path d="M360 594v44" stroke="#bfdbfe" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="75" y="644" width="570" height="184" rx="18" fill="#1d4ed8" stroke="#93c5fd" stroke-width="3"/>
|
||||
<text x="360" y="690" text-anchor="middle" fill="#ffffff" font-size="28" font-family="Arial, sans-serif" font-weight="700">Контрактная проверка</text>
|
||||
<text x="360" y="734" text-anchor="middle" fill="#dbeafe" font-size="23" font-family="Arial, sans-serif">1. HTTP status</text>
|
||||
<text x="360" y="768" text-anchor="middle" fill="#dbeafe" font-size="23" font-family="Arial, sans-serif">2. Content-Type</text>
|
||||
<text x="360" y="802" text-anchor="middle" fill="#dbeafe" font-size="23" font-family="Arial, sans-serif">3. schema body</text>
|
||||
<path d="M360 828v42" stroke="#bfdbfe" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="85" y="876" width="550" height="96" rx="18" fill="#14532d" stroke="#86efac" stroke-width="3"/>
|
||||
<text x="360" y="918" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">PASS · A и B</text>
|
||||
<text x="360" y="950" text-anchor="middle" fill="#dcfce7" font-size="21" font-family="Arial, sans-serif">валидная страница и problem detail</text>
|
||||
<path d="M360 972v34" stroke="#bfdbfe" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="85" y="1012" width="550" height="96" rx="18" fill="#991b1b" stroke="#fca5a5" stroke-width="3"/>
|
||||
<text x="360" y="1054" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">REJECT · C</text>
|
||||
<text x="360" y="1086" text-anchor="middle" fill="#fee2e2" font-size="21" font-family="Arial, sans-serif">200 не отменяет обязательную schema</text>
|
||||
|
||||
<rect x="70" y="1152" width="580" height="72" rx="16" fill="#334155" stroke="#facc15" stroke-width="3"/>
|
||||
<text x="360" y="1184" text-anchor="middle" fill="#f8fafc" font-size="23" font-family="Arial, sans-serif" font-weight="700">Следующий шаг: разрешённый тестовый сервер</text>
|
||||
<text x="360" y="1214" text-anchor="middle" fill="#cbd5e1" font-size="19" font-family="Arial, sans-serif">fixture его не выполняет</text>
|
||||
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M0 0L10 5L0 10z" fill="#bfdbfe"/>
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,44 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1160" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Контракт одной REST API-операции</title>
|
||||
<desc id="desc">Вертикальная схема показывает запрос списка заказов, явную развилку ответов 200 и 400 и проверку клиентом статуса, Content-Type и схемы тела.</desc>
|
||||
<rect width="720" height="1160" fill="#0f172a"/>
|
||||
<rect x="42" y="34" width="636" height="112" rx="18" fill="#172554"/>
|
||||
<text x="360" y="80" text-anchor="middle" fill="#ffffff" font-size="29" font-family="Arial, sans-serif" font-weight="700">Контракт одной операции</text>
|
||||
<text x="360" y="116" text-anchor="middle" fill="#cbd5e1" font-size="22" font-family="Arial, sans-serif">URL — только начало договора</text>
|
||||
|
||||
<rect x="85" y="190" width="550" height="132" rx="18" fill="#0f766e" stroke="#5eead4" stroke-width="3"/>
|
||||
<text x="360" y="234" text-anchor="middle" fill="#ffffff" font-size="28" font-family="Arial, sans-serif" font-weight="700">GET /api/v1/orders</text>
|
||||
<text x="360" y="270" text-anchor="middle" fill="#ccfbf1" font-size="23" font-family="Arial, sans-serif">limit=20; cursor — строка или отсутствует</text>
|
||||
<path d="M360 322v48" stroke="#cbd5e1" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="175" y="376" width="370" height="58" rx="29" fill="#fef3c7"/>
|
||||
<text x="360" y="414" text-anchor="middle" fill="#1e293b" font-size="22" font-family="Arial, sans-serif" font-weight="700">проверка входа</text>
|
||||
<path d="M360 434v40" stroke="#cbd5e1" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="85" y="480" width="550" height="148" rx="18" fill="#14532d" stroke="#86efac" stroke-width="3"/>
|
||||
<text x="360" y="526" text-anchor="middle" fill="#ffffff" font-size="29" font-family="Arial, sans-serif" font-weight="700">200 OK · application/json</text>
|
||||
<text x="360" y="564" text-anchor="middle" fill="#dcfce7" font-size="23" font-family="Arial, sans-serif">items: Order[]</text>
|
||||
<text x="360" y="598" text-anchor="middle" fill="#dcfce7" font-size="23" font-family="Arial, sans-serif">page.nextCursor: string | null</text>
|
||||
<path d="M360 628v44" stroke="#cbd5e1" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="85" y="678" width="550" height="148" rx="18" fill="#7f1d1d" stroke="#fca5a5" stroke-width="3"/>
|
||||
<text x="360" y="724" text-anchor="middle" fill="#ffffff" font-size="29" font-family="Arial, sans-serif" font-weight="700">400 Bad Request</text>
|
||||
<text x="360" y="762" text-anchor="middle" fill="#fee2e2" font-size="23" font-family="Arial, sans-serif">application/problem+json</text>
|
||||
<text x="360" y="796" text-anchor="middle" fill="#fee2e2" font-size="23" font-family="Arial, sans-serif">type, status, detail, errors[].code</text>
|
||||
<path d="M360 826v44" stroke="#cbd5e1" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="70" y="876" width="580" height="170" rx="18" fill="#1e293b" stroke="#64748b" stroke-width="3"/>
|
||||
<text x="360" y="924" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">Клиент проверяет три вещи</text>
|
||||
<text x="360" y="962" text-anchor="middle" fill="#cbd5e1" font-size="23" font-family="Arial, sans-serif">1. HTTP status</text>
|
||||
<text x="360" y="994" text-anchor="middle" fill="#cbd5e1" font-size="23" font-family="Arial, sans-serif">2. Content-Type</text>
|
||||
<text x="360" y="1026" text-anchor="middle" fill="#cbd5e1" font-size="23" font-family="Arial, sans-serif">3. schema тела</text>
|
||||
|
||||
<rect x="70" y="1080" width="580" height="46" rx="14" fill="#334155"/>
|
||||
<text x="360" y="1111" text-anchor="middle" fill="#e2e8f0" font-size="21" font-family="Arial, sans-serif">JSON сам по себе не равен успеху</text>
|
||||
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M0 0L10 5L0 10z" fill="#cbd5e1"/>
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1180" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Как OpenAPI связывает операцию с ответом и схемой</title>
|
||||
<desc id="desc">Вертикальная схема показывает Operation Object, карту ответов 200 и 400, а затем различие между required полем nextCursor и nullable значением.</desc>
|
||||
<rect width="720" height="1180" fill="#172554"/>
|
||||
<rect x="42" y="34" width="636" height="112" rx="18" fill="#312e81"/>
|
||||
<text x="360" y="80" text-anchor="middle" fill="#ffffff" font-size="28" font-family="Arial, sans-serif" font-weight="700">Операция становится матрицей</text>
|
||||
<text x="360" y="116" text-anchor="middle" fill="#ddd6fe" font-size="21" font-family="Arial, sans-serif">вход → HTTP-код → media type → schema</text>
|
||||
|
||||
<rect x="85" y="190" width="550" height="140" rx="18" fill="#3730a3" stroke="#a5b4fc" stroke-width="3"/>
|
||||
<text x="360" y="236" text-anchor="middle" fill="#ffffff" font-size="28" font-family="Arial, sans-serif" font-weight="700">Operation Object</text>
|
||||
<text x="360" y="274" text-anchor="middle" fill="#e0e7ff" font-size="23" font-family="Arial, sans-serif">GET /orders</text>
|
||||
<text x="360" y="306" text-anchor="middle" fill="#e0e7ff" font-size="22" font-family="Arial, sans-serif">query: limit, cursor</text>
|
||||
<path d="M360 330v48" stroke="#c7d2fe" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="140" y="384" width="440" height="128" rx="18" fill="#0f766e" stroke="#5eead4" stroke-width="3"/>
|
||||
<text x="360" y="430" text-anchor="middle" fill="#ffffff" font-size="28" font-family="Arial, sans-serif" font-weight="700">Responses Object</text>
|
||||
<text x="360" y="468" text-anchor="middle" fill="#ccfbf1" font-size="23" font-family="Arial, sans-serif">"200" · "400" · "500"</text>
|
||||
<text x="360" y="496" text-anchor="middle" fill="#ccfbf1" font-size="20" font-family="Arial, sans-serif">код выбирает форму тела</text>
|
||||
<path d="M360 512v48" stroke="#c7d2fe" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="85" y="566" width="550" height="116" rx="18" fill="#14532d" stroke="#86efac" stroke-width="3"/>
|
||||
<text x="360" y="610" text-anchor="middle" fill="#ffffff" font-size="28" font-family="Arial, sans-serif" font-weight="700">200 + JSON → OrdersPage</text>
|
||||
<text x="360" y="648" text-anchor="middle" fill="#dcfce7" font-size="22" font-family="Arial, sans-serif">items и page</text>
|
||||
<path d="M360 682v36" stroke="#c7d2fe" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="85" y="724" width="550" height="116" rx="18" fill="#7f1d1d" stroke="#fca5a5" stroke-width="3"/>
|
||||
<text x="360" y="768" text-anchor="middle" fill="#ffffff" font-size="28" font-family="Arial, sans-serif" font-weight="700">400 + problem → Problem</text>
|
||||
<text x="360" y="806" text-anchor="middle" fill="#fee2e2" font-size="22" font-family="Arial, sans-serif">type, status, errors</text>
|
||||
<path d="M360 840v42" stroke="#c7d2fe" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="55" y="888" width="610" height="214" rx="18" fill="#1e293b" stroke="#64748b" stroke-width="3"/>
|
||||
<text x="360" y="934" text-anchor="middle" fill="#ffffff" font-size="27" font-family="Arial, sans-serif" font-weight="700">Schema Object: два разных вопроса</text>
|
||||
<rect x="86" y="966" width="250" height="92" rx="14" fill="#334155"/>
|
||||
<text x="211" y="1002" text-anchor="middle" fill="#f8fafc" font-size="23" font-family="Arial, sans-serif" font-weight="700">required</text>
|
||||
<text x="211" y="1033" text-anchor="middle" fill="#cbd5e1" font-size="20" font-family="Arial, sans-serif">ключ nextCursor есть</text>
|
||||
<rect x="384" y="966" width="250" height="92" rx="14" fill="#334155"/>
|
||||
<text x="509" y="1002" text-anchor="middle" fill="#f8fafc" font-size="23" font-family="Arial, sans-serif" font-weight="700">nullable</text>
|
||||
<text x="509" y="1033" text-anchor="middle" fill="#cbd5e1" font-size="20" font-family="Arial, sans-serif">значение может быть null</text>
|
||||
<text x="360" y="1088" text-anchor="middle" fill="#cbd5e1" font-size="20" font-family="Arial, sans-serif">отсутствующий ключ и null — не один случай</text>
|
||||
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M0 0L10 5L0 10z" fill="#c7d2fe"/>
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,612 @@
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function paragraph(text) {
|
||||
return '<p>' + text + '</p>';
|
||||
}
|
||||
|
||||
function heading(text) {
|
||||
return '<h2>' + text + '</h2>';
|
||||
}
|
||||
|
||||
function codeBlock(lines) {
|
||||
return '<pre><code>' + escapeHtml(lines.join('\n')) + '</code></pre>';
|
||||
}
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function bulletList(items) {
|
||||
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function dataTable(caption, headers, rows) {
|
||||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||||
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function visibleText(html) {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function proseText(html) {
|
||||
return visibleText(
|
||||
html
|
||||
.replace(/<pre><code>[\s\S]*?<\/code><\/pre>/g, '')
|
||||
.replace(/<figure>[\s\S]*?<\/figure>/g, '')
|
||||
.replace(/<div class="table-scroll">[\s\S]*?<\/div>/g, ''),
|
||||
);
|
||||
}
|
||||
|
||||
function createRevision(meta, bodyParts, sources) {
|
||||
const bodyHtml = bodyParts.join('\n');
|
||||
const proseLength = proseText(bodyHtml).length;
|
||||
|
||||
if (proseLength < 5000 || proseLength > 15000) {
|
||||
throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength);
|
||||
}
|
||||
|
||||
if (sources.length < 2) {
|
||||
throw new Error(meta.slug + ': at least two primary sources are required');
|
||||
}
|
||||
|
||||
return {
|
||||
...meta,
|
||||
contentHtml: [
|
||||
bodyHtml,
|
||||
heading('Проверяемые источники'),
|
||||
sourceList(sources),
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const rfcHttp = {
|
||||
title: 'IETF RFC 7231, HTTP/1.1 Semantics and Content',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc7231',
|
||||
note: 'семантика методов, представлений, Content-Type и кодов ответа, действовавшая в 2019 году',
|
||||
};
|
||||
|
||||
const rfcStatus = {
|
||||
title: 'IETF RFC 7231, раздел 6: Response Status Codes',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc7231#section-6',
|
||||
note: 'коды 2xx, 4xx и 5xx сообщают результат конкретного HTTP-запроса; их смысл нельзя заменять произвольным полем JSON',
|
||||
};
|
||||
|
||||
const rfcProblem = {
|
||||
title: 'IETF RFC 7807, Problem Details for HTTP APIs',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc7807',
|
||||
note: 'стандартизированная форма problem detail с type, title, status, detail и instance; расширения остаются контрактом API',
|
||||
};
|
||||
|
||||
const rfcLink = {
|
||||
title: 'IETF RFC 8288, Web Linking',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc8288',
|
||||
note: 'модель ссылочных отношений HTTP; конкретная форма пагинации должна быть явно выбрана командой',
|
||||
};
|
||||
|
||||
const openApi = {
|
||||
title: 'OpenAPI Specification 3.0.2',
|
||||
url: 'https://spec.openapis.org/oas/v3.0.2.html',
|
||||
note: 'версия спецификации, доступная в 2019 году; описывает пути, операции, ответы, content и Schema Object',
|
||||
};
|
||||
|
||||
const openApiOperation = {
|
||||
title: 'OpenAPI 3.0.2, Operation Object',
|
||||
url: 'https://spec.openapis.org/oas/v3.0.2.html#operation-object',
|
||||
note: 'каждая операция объявляет параметры и ожидаемые ответы, а не только URL и метод',
|
||||
};
|
||||
|
||||
const openApiResponse = {
|
||||
title: 'OpenAPI 3.0.2, Responses Object',
|
||||
url: 'https://spec.openapis.org/oas/v3.0.2.html#responses-object',
|
||||
note: 'ответы задаются по HTTP-коду или default; у каждого можно описать content и схему тела',
|
||||
};
|
||||
|
||||
const openApiSchema = {
|
||||
title: 'OpenAPI 3.0.2, Schema Object',
|
||||
url: 'https://spec.openapis.org/oas/v3.0.2.html#schema-object',
|
||||
note: 'required относится к свойствам объекта, а nullable разрешает null только при явно заданном type',
|
||||
};
|
||||
|
||||
const practiceArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2019-06-practice-rest-api',
|
||||
title: 'REST API без угадывания: фиксируем статусы, ошибки и страницу списка',
|
||||
categories: ['HTTP', 'API', 'Практика'],
|
||||
cover: '/assets/editorial/2019/rest-api-contract-map-2019.svg',
|
||||
excerpt: 'Клиент падает не из-за адреса, а когда 200 содержит ошибку, следующая страница исчезает или необязательное поле внезапно становится null. Собираем короткий контракт операции и проверяем его на локальных фикстурах.',
|
||||
readingMinutes: 12,
|
||||
},
|
||||
[
|
||||
paragraph('Симптом обычно выглядит как ошибка интерфейса: список заказов перестал листаться, карточка падает на <code>customer.name</code>, а форма показывает «неизвестную ошибку». URL <code>/api/orders</code> при этом не менялся. Цена такого сбоя выше одной красной строки в консоли: клиент повторяет запрос, пользователь не понимает, сохранено ли действие, а backend и frontend спорят о том, кто «сломал API». Причина почти всегда в незафиксированном ответе: статус говорит одно, тело другое, а пагинация или необязательное поле существуют только в чьей-то памяти.'),
|
||||
paragraph('В июне 2019 я бы начал не с генератора клиента и не с большой документации. Для одной операции нужен короткий договор, который можно прочитать за несколько минут и прогнать на данных. Возьмём <code>GET /api/v1/orders</code>: он возвращает страницу заказов, принимает <code>limit</code> и непрозрачный <code>cursor</code>, а при плохом курсоре отдаёт problem document. Пример учебный: он не выполняет запрос к серверу и не доказывает поведение production. Его задача — показать, какие признаки должны совпасть до интеграции.'),
|
||||
heading('Сначала описываем наблюдаемый сбой и стоимость'),
|
||||
paragraph('Плохой договор часто начинается с фразы «успешный ответ — JSON». Она не отвечает на четыре вопроса. Что считать успехом: только <code>200</code> или ещё <code>204</code>? Как клиент узнаёт о неправильном параметре? Чем последняя страница отличается от временно пустого списка? И допустимо ли отсутствие поля <code>customer</code>, либо оно должно быть <code>null</code>? Если эти решения не записаны, каждая библиотека подставляет собственное: fetch не считает 400 исключением, сериализатор может опустить ключ, а UI делает доступ к вложенному свойству без проверки.'),
|
||||
paragraph('HTTP уже задаёт язык для результата операции. RFC 7231 описывает метод, целевой ресурс, представление и коды статуса; <code>200</code> означает успешный ответ, а 4xx и 5xx сообщают разные классы ошибки запроса и сервера. Наш проектный контракт не должен переопределять этот язык флагом <code>ok: false</code> внутри ответа с <code>200</code>. Он должен уточнять его: для какого статуса какое представление приходит, какой <code>Content-Type</code> ожидается и какие поля клиент вправе читать.'),
|
||||
dataTable(
|
||||
'Карта рисков для одной операции списка заказов',
|
||||
['Наблюдение у клиента', 'Незакрытая граница', 'Что фиксируем в контракте', 'Цена, если не зафиксировать'],
|
||||
[
|
||||
['Кнопка «ещё» исчезла раньше времени', 'Последняя страница смешана с пустым результатом', 'Обязательный объект page и явный <code>nextCursor: null</code>', 'Пользователь не видит часть заказов'],
|
||||
['Экран падает на вложенном свойстве', 'Неизвестно, обязательны ли customer и его поля', 'required для ядра заказа; правило для отсутствующего customer', 'Падение или ложная пустая карточка'],
|
||||
['Форма показывает общий баннер', 'Ошибка параметра не имеет стабильной формы', '<code>application/problem+json</code>, type и расширение errors', 'Нельзя привязать действие к полю'],
|
||||
['Клиент продолжает парсить 200', 'Статус и тело противоречат друг другу', 'Список допустимых статусов на операцию', 'Сбой маскируется как «пустые данные»'],
|
||||
],
|
||||
),
|
||||
heading('Выбираем маленький, но полный контракт операции'),
|
||||
paragraph('Для начала достаточно одного пути, одного метода и нескольких ответов. Наша операция читает коллекцию, поэтому договор включает параметры, а не только тело <code>200</code>. <code>limit</code> имеет диапазон; <code>cursor</code> либо отсутствует, либо является строкой, которую клиент не разбирает; ответ всегда содержит <code>items</code> и <code>page</code>. В <code>page.nextCursor</code> строка означает, что следующий запрос возможен, а <code>null</code> означает конец снимка. Мы не используем отсутствие ключа как отдельный сигнал.'),
|
||||
paragraph('Это проектное решение, а не требование REST или HTTP. Пагинация не задана RFC 7231: команда могла бы использовать offset, Link header или отдельный объект links. Важно выбрать одну форму и описать её до кода. Cursor здесь непрозрачен намеренно. Если UI начинает вырезать из него дату или ID, сервер уже не сможет изменить кодирование без поломки клиента. Клиент должен только передать полученную строку в следующий запрос.'),
|
||||
codeBlock([
|
||||
'GET /api/v1/orders?limit=2 HTTP/1.1',
|
||||
'Accept: application/json',
|
||||
'',
|
||||
'HTTP/1.1 200 OK',
|
||||
'Content-Type: application/json',
|
||||
'',
|
||||
'{',
|
||||
' "items": [',
|
||||
' {',
|
||||
' "id": "ord_1042",',
|
||||
' "status": "paid",',
|
||||
' "total": { "amount": 9900, "currency": "RUB" },',
|
||||
' "customer": { "id": "cus_17", "name": "Ирина" }',
|
||||
' }',
|
||||
' ],',
|
||||
' "page": { "limit": 2, "nextCursor": "ord_1042" }',
|
||||
'}',
|
||||
]),
|
||||
paragraph('В примере <code>id</code>, <code>status</code>, <code>total</code> и <code>page</code> — обязательное ядро. <code>customer</code> — необязательное поле: если его нет, это не ошибка транспорта и не строка <code>null</code>. Если поле присутствует, оно обязано быть объектом с теми свойствами, которые нужны текущему экрану. Это важнее, чем кажется: «может прийти всё что угодно» делает любой клиент вынужденным угадывать, а строгий контракт позволяет обработать отсутствие ровно в одном месте.'),
|
||||
heading('Статус и ошибка образуют один результат'),
|
||||
paragraph('Для неправильного cursor не нужно возвращать <code>200</code> с массивом <code>errors</code>. Запрос не выполнен как запрос списка, поэтому выбираем клиентскую ошибку <code>400 Bad Request</code>. RFC 7807 задаёт переносимую оболочку problem detail: поля <code>type</code>, <code>title</code>, <code>status</code>, <code>detail</code> и <code>instance</code>; дополнительные поля разрешены как extension members. В договоре ниже <code>errors</code> — именно расширение приложения, а не тайный стандарт поля.'),
|
||||
codeBlock([
|
||||
'HTTP/1.1 400 Bad Request',
|
||||
'Content-Type: application/problem+json',
|
||||
'',
|
||||
'{',
|
||||
' "type": "https://api.example.test/problems/invalid-cursor",',
|
||||
' "title": "Параметр cursor недействителен",',
|
||||
' "status": 400,',
|
||||
' "detail": "Курсор не принадлежит этому списку заказов",',
|
||||
' "instance": "/api/v1/orders?limit=2&cursor=broken",',
|
||||
' "errors": [',
|
||||
' { "path": "query.cursor", "code": "invalid_cursor" }',
|
||||
' ]',
|
||||
'}',
|
||||
]),
|
||||
paragraph('Клиенту не следует сопоставлять логику с русским <code>title</code> или английским <code>detail</code>. Текст пригодится человеку и журналу, но стабильным ключом решения становится <code>type</code> или наш <code>errors[0].code</code>. Например, <code>invalid_cursor</code> означает: очистить сохранённый курсор, загрузить первую страницу и не повторять тот же запрос в цикле. Нераспознанный <code>type</code> должен показать общий сбой и оставить диагностический след, а не притвориться пустым списком.'),
|
||||
figure(
|
||||
'/assets/editorial/2019/rest-api-contract-map-2019.svg',
|
||||
'Вертикальная схема контракта GET списка заказов: параметры limit и cursor ведут к двум развилкам 200 application/json и 400 application/problem+json; у успеха выделены items и page.nextCursor, у ошибки type и errors.',
|
||||
'Контракт начинается на входе запроса и заканчивается тем, что клиент может проверить в статусе, Content-Type и схеме тела. Один URL не покрывает эти границы.',
|
||||
),
|
||||
heading('Записываем операцию в OpenAPI, а не в комментарий'),
|
||||
paragraph('OpenAPI 3.0.2 уже позволяет записать этот договор рядом с API. Operation Object связывает путь, метод, параметры и responses. Responses Object, в свою очередь, привязывает конкретный HTTP-код к content и Schema Object. Это не гарантирует, что сервер исполняет YAML автоматически. Зато файл становится единым местом, где видно: <code>200</code> — страница, <code>400</code> — problem document, а тело не описывается абстрактным словом object.'),
|
||||
codeBlock([
|
||||
'/api/v1/orders:',
|
||||
' get:',
|
||||
' parameters:',
|
||||
' - in: query',
|
||||
' name: cursor',
|
||||
' schema: { type: string }',
|
||||
' - in: query',
|
||||
' name: limit',
|
||||
' schema: { type: integer, minimum: 1, maximum: 100 }',
|
||||
' responses:',
|
||||
' "200":',
|
||||
' description: Страница заказов',
|
||||
' content:',
|
||||
' application/json:',
|
||||
' schema: { $ref: "#/components/schemas/OrdersPage" }',
|
||||
' "400":',
|
||||
' description: Неподходящий cursor или limit',
|
||||
' content:',
|
||||
' application/problem+json:',
|
||||
' schema: { $ref: "#/components/schemas/Problem" }',
|
||||
]),
|
||||
paragraph('Не надо делать OpenAPI файлом «на потом». В ревью к изменению операции должны попасть одновременно: изменение схемы, пример ответа и правило для клиента. Если backend добавляет поле, которое может отсутствовать, это чаще всего обратно совместимо для терпимого клиента, но только после проверки его потребления. Если он удаляет required-поле, меняет тип или переносит ошибку из 400 в 200, это уже изменение поведения, для которого нужен согласованный переход.'),
|
||||
heading('Проверяем договор на локальных данных'),
|
||||
paragraph('До доступа к стенду можно поймать часть расхождений на фикстурах. В пакете есть небольшой запуск <code>node web/scripts/upgrade-2019-06.mjs --run-fixture</code>. Он не открывает сеть: берёт три заранее заданных response-объекта и проверяет обязательные поля страницы, явный конец пагинации, форму problem document и отрицательный случай с потерянным <code>nextCursor</code>. Такой тест не заменяет интеграционный: он не знает о роутинге, авторизации или сериализаторе сервера. Но он делает документированную границу исполнимой до подключения реального API.'),
|
||||
orderedList([
|
||||
'Выберите одну операцию и запишите её ожидаемое действие, а не общий список «API должно быть RESTful».',
|
||||
'Назовите допустимые HTTP-статусы, Content-Type и форму тела для каждого статуса.',
|
||||
'Для коллекции отдельно зафиксируйте первый запрос, окончание страницы и правило передачи cursor или offset.',
|
||||
'Отметьте required-поля и одно точное правило для необязательного поля: отсутствует, null или объект; не оставляйте все три варианта одновременно.',
|
||||
'Добавьте пример успеха, пример ошибки и минимальную фикстуру, которая ломается при изменении этих признаков.',
|
||||
'Перед выпуском выполните такой же запрос к тестовому серверу и сравните статус, заголовок и тело со спецификацией.',
|
||||
]),
|
||||
heading('Границы решения и короткий вывод'),
|
||||
paragraph('Этот контракт не решает авторизацию, повторную доставку команд, лимиты нагрузки и версионирование всех ресурсов. Он также не утверждает, что cursor безопасен как токен доступа: его формат и срок жизни остаются отдельной задачей. Зато он убирает базовую неопределённость на границе frontend и backend. Когда <code>200</code>, <code>400</code>, <code>items</code>, <code>nextCursor</code> и optional-поля можно прочитать и проверить, ошибка перестаёт выглядеть как мистический «сломанный REST».'),
|
||||
bulletList([
|
||||
'URL и метод идентифицируют операцию, но не описывают все её успешные и ошибочные представления.',
|
||||
'Статус, Content-Type и схема тела проверяются вместе; флаг ошибки внутри 200 не заменяет HTTP-семантику.',
|
||||
'Пагинация и optional-поля — явные проектные решения, которым нужен один проверяемый вариант.',
|
||||
'Локальная фикстура полезна как ранняя проверка контракта, но не является отчётом о работе production-сервера.',
|
||||
]),
|
||||
],
|
||||
[rfcHttp, rfcStatus, rfcProblem, rfcLink, openApi, openApiOperation, openApiResponse, openApiSchema],
|
||||
);
|
||||
|
||||
const mechanismArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2019-06-mechanism-rest-api',
|
||||
title: 'OpenAPI 3.0.2 под капотом: операция, статус, схема и совместимость клиента',
|
||||
categories: ['HTTP', 'OpenAPI', 'Архитектура'],
|
||||
cover: '/assets/editorial/2019/rest-api-response-matrix-2019.svg',
|
||||
excerpt: 'Схема API полезна не как каталог URL. Разбираем, как в OpenAPI 3.0.2 связать операцию с кодами ответов, problem details, required-полями и окончанием cursor-пагинации.',
|
||||
readingMinutes: 13,
|
||||
},
|
||||
[
|
||||
paragraph('Симптом более опасный, чем опечатка в URL: backend выкатывает «небольшое» изменение, и старый клиент получает ответ, который синтаксически остаётся JSON, но семантически стал другим. <code>status</code> превратился из строки в объект, пустая страница потеряла <code>nextCursor</code>, а ошибка валидации стала <code>200</code>. Цена — тихая поломка: мониторинг видит успешные HTTP-запросы, а пользователь видит пустой экран или повторную отправку формы. Причина — у операции нет границы совместимости, есть только маршрут и пример из happy path.'),
|
||||
paragraph('Разберём механизм на той же коллекции заказов, но не как рецепт одного контроллера. Нам нужно понять, что именно фиксирует спецификация и что остаётся проектным решением. В 2019 году для этого подходит OpenAPI 3.0.2: она описывает Path Item, Operation, параметры, Responses, content и Schema Object. HTTP остаётся транспортным контрактом, а OpenAPI собирает выбранный договор операции в один документ. Ни одна YAML-схема сама не проверит живой сервер, если команда не подключит её к тесту или ревью.'),
|
||||
heading('Операция — это не только path и метод'),
|
||||
paragraph('Когда в описании есть только <code>GET /orders</code>, человек всё ещё не знает, какие параметры разрешены и что приходит при каждом результате. Operation Object в OpenAPI объединяет эти части. У <code>cursor</code> фиксируется расположение <code>in: query</code>, тип и условная необязательность; у <code>limit</code> — числовые границы. В responses фиксируется не один пример, а карта статусов. Клиент тогда строит ветвление не по догадке «если в JSON есть error», а по документированному результату HTTP.'),
|
||||
paragraph('Практический минимум: <code>200</code> для страницы, <code>400</code> для недопустимого запроса, <code>401</code> для отсутствующего или недействительного контекста доступа, <code>500</code> для непредвиденного сбоя. Не нужно объявлять все коды, которые может вернуть любой proxy в мире. Но каждый код, который сознательно производит приложение, должен иметь форму ответа или честную пометку, что тела нет. Иначе мобильный клиент может ждать JSON от 401, а gateway отправит HTML, который парсер примет за сетевую ошибку.'),
|
||||
dataTable(
|
||||
'Матрица результата одной операции GET /api/v1/orders',
|
||||
['HTTP-статус', 'Content-Type', 'Обязательная форма', 'Действие клиента', 'Чего не делать'],
|
||||
[
|
||||
['200', '<code>application/json</code>', 'items, page.limit, page.nextCursor', 'Показать элементы; передать строковый cursor дальше только при наличии', 'Не считать пустой items концом без проверки page'],
|
||||
['400', '<code>application/problem+json</code>', 'type, title, status и project errors', 'Сбросить только проблемный параметр или показать объяснение', 'Не парсить ошибку как страницу'],
|
||||
['401', 'Описывается отдельно', 'Статус и согласованный ответ/заголовок', 'Запустить известный поток авторизации', 'Не повторять запрос бесконечно'],
|
||||
['500', '<code>application/problem+json</code> либо общий ответ', 'Без внутренних деталей и стека', 'Показать общий сбой и сохранить trace identifier', 'Не выдавать пользователю SQL или stack trace'],
|
||||
],
|
||||
),
|
||||
heading('Responses Object связывает код и представление'),
|
||||
paragraph('В OpenAPI ответ задан ключом HTTP-кода или <code>default</code>. У него есть <code>description</code>, headers, links и content. Самая полезная часть для клиента — <code>content</code>: она связывает media type с schema. Если <code>200</code> объявлен как <code>application/json</code>, а <code>400</code> как <code>application/problem+json</code>, граница становится наблюдаемой даже до чтения каждого поля. Серверу не стоит отдавать HTML-страницу ошибки под тем же публичным API-путём молча: это нарушает ожидание парсера и скрывает источник проблемы.'),
|
||||
codeBlock([
|
||||
'responses:',
|
||||
' "200":',
|
||||
' description: Страница заказов',
|
||||
' content:',
|
||||
' application/json:',
|
||||
' schema:',
|
||||
' $ref: "#/components/schemas/OrdersPage"',
|
||||
' "400":',
|
||||
' description: Параметры списка не проходят проверку',
|
||||
' content:',
|
||||
' application/problem+json:',
|
||||
' schema:',
|
||||
' $ref: "#/components/schemas/Problem"',
|
||||
' "500":',
|
||||
' description: Непредвиденная ошибка обработки',
|
||||
' content:',
|
||||
' application/problem+json:',
|
||||
' schema:',
|
||||
' $ref: "#/components/schemas/Problem"',
|
||||
]),
|
||||
paragraph('Файл не обязан делать все ошибки одинаковыми. Например, ошибка авторизации может прийти с заголовком, который понятен используемому механизму доступа, а у асинхронной команды может быть другой ожидаемый успешный статус. Важно не прятать различие. Если две операции возвращают разные формы ошибки, это надо назвать в их responses. Если команда сознательно выбирает один Problem schema для нескольких операций, то её расширения — <code>errors</code>, <code>traceId</code>, code полей — тоже становятся частью совместимого контракта.'),
|
||||
heading('Schema Object: required и nullable решают разные вопросы'),
|
||||
paragraph('Самая частая ловушка — назвать поле «необязательным», не указав, что это означает на проводе. В OpenAPI 3.0.2 массив <code>required</code> принадлежит объекту: в нём перечислены имена свойств, которые должны присутствовать. <code>nullable: true</code> отвечает на другой вопрос: можно ли передать значение <code>null</code>, если у schema явно указан type. Отсутствующий ключ и ключ со значением null — разные состояния; клиенту нельзя считать их одинаковыми, если это не записано в договоре.'),
|
||||
paragraph('Для страницы заказов выберем строгую форму. <code>items</code> и <code>page</code> обязательны, потому что клиент всегда должен отличить ответ коллекции от произвольного объекта. В <code>page</code> обязательны <code>limit</code> и <code>nextCursor</code>; последний имеет тип string и nullable, поэтому конец списка выражается <code>null</code>, а не пропущенным ключом. <code>customer</code> у заказа не входит в required: его может не быть, но если он есть, он — object, не null. Такое решение можно поменять, но менять его надо как изменение контракта, а не как побочный эффект ORM.'),
|
||||
codeBlock([
|
||||
'OrdersPage:',
|
||||
' type: object',
|
||||
' required: [items, page]',
|
||||
' properties:',
|
||||
' items:',
|
||||
' type: array',
|
||||
' items: { $ref: "#/components/schemas/Order" }',
|
||||
' page:',
|
||||
' type: object',
|
||||
' required: [limit, nextCursor]',
|
||||
' properties:',
|
||||
' limit: { type: integer, minimum: 1 }',
|
||||
' nextCursor: { type: string, nullable: true }',
|
||||
'Order:',
|
||||
' type: object',
|
||||
' required: [id, status, total]',
|
||||
' properties:',
|
||||
' id: { type: string }',
|
||||
' customer: { $ref: "#/components/schemas/Customer" }',
|
||||
]),
|
||||
paragraph('Эта схема не говорит, что JSON Schema валидатор в проекте обязан полностью понимать любую возможность JSON Schema. OpenAPI 3.0.2 определяет собственный Schema Object с расширенным подмножеством. Поэтому до выбора генератора или validator надо сверить, какую версию и какую часть спецификации он реально поддерживает. В противном случае на бумаге появится <code>nullable</code>, а в рантайме проверка пропустит другой вариант или, наоборот, отвергнет законный ответ.'),
|
||||
figure(
|
||||
'/assets/editorial/2019/rest-api-response-matrix-2019.svg',
|
||||
'Схема слева направо: один GET с параметрами limit и cursor входит в Operation Object, затем ветвится в Responses 200 application/json и 400 application/problem+json; внизу показано, как Schema Object различает required property и nullable value.',
|
||||
'У операции есть два слоя: HTTP сообщает, какой результат пришёл, а schema определяет, какие данные разрешено читать внутри выбранного представления.',
|
||||
),
|
||||
heading('Problem Details не отменяет проектную ошибку'),
|
||||
paragraph('RFC 7807 полезен тем, что проблема перестаёт быть бесформенным <code>{ "error": "..." }</code>. Поле <code>type</code> является URI reference, <code>title</code> — кратким названием, <code>status</code> отражает HTTP-код, <code>detail</code> поясняет конкретный случай, <code>instance</code> помогает различать экземпляры. Но RFC не выдаёт команде готовые коды полей. Если в UI важно выделить <code>query.cursor</code>, это безопаснее сделать явным расширением <code>errors</code> с документированными <code>path</code> и <code>code</code>, чем извлекать смысл из локализованного текста.'),
|
||||
paragraph('Не называйте каждый бизнес-конфликт «400» только потому, что клиент передал JSON. Нужный статус зависит от семантики операции; его стоит сверять с HTTP и договором продукта. В этой статье мы ограничили пример недопустимым cursor, поэтому <code>400</code> понятен: сообщение запроса нельзя обработать как корректную страницу. Для конфликта версии ресурса команда может выбрать другой документированный путь. Главное — не менять статус между релизами без проверки клиентов и не посылать известную ошибку как успешный JSON.'),
|
||||
heading('Пагинация — часть представления, а не свойство базы'),
|
||||
paragraph('Наличие <code>LIMIT 20</code> в SQL ещё не создаёт API-пагинацию. Клиенту нужно знать порядок, размер страницы, признак конца и поведение cursor после изменения данных. В нашем компактном договоре сервер возвращает текущий limit и opaque nextCursor. Мы не обещаем стабильный total и не выводим его из длины <code>items</code>: короткая страница может быть последней, но это решение подтверждает именно <code>nextCursor: null</code>. Если продукту нужен total, он становится отдельным полем с отдельной стоимостью и условиями точности.'),
|
||||
paragraph('RFC 8288 описывает Web Linking, и команда может выбрать Link header для relation next. Это допустимый, но другой контракт: тогда нужно зафиксировать relation, относительность URL, порядок параметров и способ, которым клиент читает header. Не смешивайте Link и page.nextCursor наполовину. Один доступный путь быстрее тестируется и не заставляет frontend искать несколько несогласованных признаков конца списка.'),
|
||||
dataTable(
|
||||
'Совместимость изменений тела 200 для терпимого клиента',
|
||||
['Изменение', 'Почему риск есть', 'Что проверить до выпуска', 'Безопасный переход'],
|
||||
[
|
||||
['Добавить необязательное поле', 'Старый клиент может игнорировать его, новый — ошибочно ожидать', 'Парсер не требует поле до согласованного релиза', 'Сначала добавить и наблюдать, затем использовать'],
|
||||
['Удалить required-поле', 'Старый клиент делает прямой доступ', 'Все поддерживаемые клиенты и контрактные фикстуры', 'Новая версия или период двух полей'],
|
||||
['Изменить string на object', 'JSON парсится, но логика ломается позже', 'Потребители, schema и примеры', 'Новое поле или новая операция'],
|
||||
['Заменить null отсутствием', 'Это два разных состояния schema', 'Проверки terminal page и UI ветвления', 'Сохранить один вариант до миграции клиентов'],
|
||||
],
|
||||
),
|
||||
heading('Делаем изменение проверяемым'),
|
||||
paragraph('Техническая ценность OpenAPI начинается, когда на её основе появляется проверка. В минимальном варианте это ревью diff: изменились ли status, media type, required или nullable? Затем — пример каждого ответа и локальная fixture, которая намеренно отвергает потерянный <code>nextCursor</code> или problem document с <code>200</code>. После подключения тестового сервера те же случаи становятся запросами к живому endpoint. Так документ не обещает автоматически совместимость, а даёт список точек, где она может быть нарушена.'),
|
||||
orderedList([
|
||||
'Опишите Operation Object вместе с параметрами, а не добавляйте responses после реализации контроллера.',
|
||||
'Для каждого сознательно возвращаемого статуса укажите description, Content-Type и schema тела или явное отсутствие тела.',
|
||||
'Разведите отсутствующее свойство и null через required и nullable; зафиксируйте выбор примером.',
|
||||
'Опишите окончание пагинации как часть 200, не выводите его из случайной длины массива.',
|
||||
'Добавьте локальные positive и negative fixtures, затем перенесите те же ожидания на тестовый сервер.',
|
||||
'При изменении schema оцените поддержку старых клиентов до слияния, а не после первых ошибок пользователя.',
|
||||
]),
|
||||
heading('Границы механизма и короткий вывод'),
|
||||
paragraph('OpenAPI не заменяет авторизацию, миграцию данных или проверку таймаутов. Она также не делает любое изменение YAML обратно совместимым. Но спецификация даёт инженерный язык для спора: не «у нас же JSON», а «операция больше не возвращает required page.nextCursor на 200». В 2019 это уже достаточный шаг от договорённостей в чате к T-shaped работе на границе frontend, backend и HTTP.'),
|
||||
bulletList([
|
||||
'Операция состоит из параметров, HTTP-кодов, media types и schemas; URL — только вход в этот договор.',
|
||||
'Responses Object связывает код с представлением, а Schema Object делает форму тела проверяемой.',
|
||||
'required и nullable не взаимозаменяемы; отсутствие ключа и null надо выбирать осознанно.',
|
||||
'Пагинация и extension-поля problem detail принадлежат проектному контракту и требуют теста.',
|
||||
]),
|
||||
],
|
||||
[rfcHttp, rfcStatus, rfcProblem, rfcLink, openApi, openApiOperation, openApiResponse, openApiSchema],
|
||||
);
|
||||
|
||||
function hasOwn(object, key) {
|
||||
return Object.prototype.hasOwnProperty.call(object, key);
|
||||
}
|
||||
|
||||
function mediaType(headerValue) {
|
||||
return typeof headerValue === 'string'
|
||||
? headerValue.split(';', 1)[0].trim().toLowerCase()
|
||||
: '';
|
||||
}
|
||||
|
||||
function assertFixture(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertProblem(response, expectedStatus, expectedCode) {
|
||||
const contentType = response.headers['content-type'];
|
||||
const body = response.body;
|
||||
|
||||
assertFixture(response.status === expectedStatus, 'ожидался HTTP ' + expectedStatus);
|
||||
assertFixture(mediaType(contentType) === 'application/problem+json', 'ошибка должна иметь application/problem+json');
|
||||
assertFixture(body && typeof body === 'object', 'problem body должен быть объектом');
|
||||
assertFixture(typeof body.type === 'string' && body.type.indexOf('https://') === 0, 'problem.type должен быть URI');
|
||||
assertFixture(body.status === expectedStatus, 'problem.status должен совпадать с HTTP-статусом');
|
||||
assertFixture(Array.isArray(body.errors) && body.errors.length > 0, 'problem.errors должен быть непустым массивом');
|
||||
assertFixture(body.errors[0].code === expectedCode, 'ожидался project error code ' + expectedCode);
|
||||
}
|
||||
|
||||
function assertOrdersPage(response) {
|
||||
const contentType = response.headers['content-type'];
|
||||
const body = response.body;
|
||||
|
||||
assertFixture(response.status === 200, 'страница списка должна иметь HTTP 200');
|
||||
assertFixture(mediaType(contentType) === 'application/json', 'страница должна иметь application/json');
|
||||
assertFixture(body && typeof body === 'object', 'body страницы должен быть объектом');
|
||||
assertFixture(Array.isArray(body.items), 'items должен быть массивом');
|
||||
assertFixture(body.page && typeof body.page === 'object', 'page должен быть объектом');
|
||||
assertFixture(Number.isInteger(body.page.limit) && body.page.limit > 0, 'page.limit должен быть положительным целым');
|
||||
assertFixture(hasOwn(body.page, 'nextCursor'), 'page.nextCursor должен присутствовать даже на последней странице');
|
||||
assertFixture(
|
||||
body.page.nextCursor === null || typeof body.page.nextCursor === 'string',
|
||||
'page.nextCursor должен быть строкой или null',
|
||||
);
|
||||
|
||||
body.items.forEach((order, index) => {
|
||||
assertFixture(order && typeof order === 'object', 'items[' + index + '] должен быть объектом');
|
||||
assertFixture(typeof order.id === 'string' && order.id.length > 0, 'items[' + index + '].id обязателен');
|
||||
assertFixture(typeof order.status === 'string' && order.status.length > 0, 'items[' + index + '].status обязателен');
|
||||
assertFixture(order.total && Number.isInteger(order.total.amount), 'items[' + index + '].total.amount обязателен');
|
||||
assertFixture(order.total && typeof order.total.currency === 'string', 'items[' + index + '].total.currency обязателен');
|
||||
|
||||
if (hasOwn(order, 'customer')) {
|
||||
assertFixture(order.customer && typeof order.customer === 'object', 'customer при наличии должен быть объектом, не null');
|
||||
assertFixture(typeof order.customer.id === 'string', 'customer.id обязателен при наличии customer');
|
||||
assertFixture(typeof order.customer.name === 'string', 'customer.name обязателен при наличии customer');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function expectFixtureFailure(action, expectedText) {
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
assertFixture(String(error.message).indexOf(expectedText) !== -1, 'fixture должен упасть по ожидаемой причине');
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('fixture должен был обнаружить нарушение: ' + expectedText);
|
||||
}
|
||||
|
||||
function runContractFixture() {
|
||||
const finalPage = {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: {
|
||||
items: [
|
||||
{
|
||||
id: 'ord_1043',
|
||||
status: 'paid',
|
||||
total: { amount: 9900, currency: 'RUB' },
|
||||
},
|
||||
],
|
||||
page: { limit: 2, nextCursor: null },
|
||||
},
|
||||
};
|
||||
|
||||
const invalidCursor = {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/problem+json' },
|
||||
body: {
|
||||
type: 'https://api.example.test/problems/invalid-cursor',
|
||||
title: 'Параметр cursor недействителен',
|
||||
status: 400,
|
||||
detail: 'Курсор не принадлежит этому списку заказов',
|
||||
instance: '/api/v1/orders?cursor=broken',
|
||||
errors: [{ path: 'query.cursor', code: 'invalid_cursor' }],
|
||||
},
|
||||
};
|
||||
|
||||
const brokenTerminalPage = {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: {
|
||||
items: [],
|
||||
page: { limit: 2 },
|
||||
},
|
||||
};
|
||||
|
||||
assertOrdersPage(finalPage);
|
||||
assertProblem(invalidCursor, 400, 'invalid_cursor');
|
||||
expectFixtureFailure(
|
||||
() => assertOrdersPage(brokenTerminalPage),
|
||||
'page.nextCursor должен присутствовать',
|
||||
);
|
||||
|
||||
return {
|
||||
fixture: 'rest-api-contract-v1',
|
||||
transport: 'local response objects only; no server request was made',
|
||||
cases: [
|
||||
{ name: '200 final page with explicit null cursor', result: 'pass' },
|
||||
{ name: '400 invalid cursor with problem details', result: 'pass' },
|
||||
{ name: '200 page without nextCursor is rejected', result: 'pass' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const fieldArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2019-06-field-rest-api',
|
||||
title: 'Полевой разбор: контрактный тест REST API без подмены его production-проверкой',
|
||||
categories: ['HTTP', 'Тестирование', 'Диагностика'],
|
||||
cover: '/assets/editorial/2019/rest-api-contract-fixture-2019.svg',
|
||||
excerpt: 'В API «всё работает», пока UI не получает 200 без nextCursor или 400 в чужом формате. Собираем три воспроизводимых response-фикстуры, отделяем их от запроса к стенду и готовим маршрут интеграционной проверки.',
|
||||
readingMinutes: 13,
|
||||
},
|
||||
[
|
||||
paragraph('Симптом на интеграции прост: frontend получает ответ, JSON успешно распарсился, но следующий экран уже не знает, что делать. В одном релизе последняя страница приходит без <code>nextCursor</code>, в другом backend отдаёт HTML от proxy вместо problem document, в третьем optional <code>customer</code> становится <code>null</code>. Цена — не только падение компонента. Клиент может считать данные окончательными, показать неверный текст ошибки или повторять запрос, который никогда не станет успешным.'),
|
||||
paragraph('Ниже — полевой сценарий для маленького контракта <code>GET /api/v1/orders</code>. Он строится вокруг двух вещей: воспроизводимого HTTP-запроса, который следует выполнить на разрешённом тестовом URL, и локальной fixture, которую можно прогнать без сети. Важно не перепутать их. Фикстура доказывает, что наши правила отличают допустимое тело от недопустимого. Она не доказывает, что сервер, gateway, авторизация и production уже ведут себя так же.'),
|
||||
heading('Собираем симптомы в проверяемые случаи'),
|
||||
paragraph('Сначала вырезаем из инцидента общие слова. «Пагинация сломалась» превращается в утверждение: у <code>200 application/json</code> обязан быть объект <code>page</code>, а у него — ключ <code>nextCursor</code>, равный строке или null. «Ошибка непонятна» превращается в другое утверждение: у <code>400</code> ожидается <code>application/problem+json</code>, поле <code>status</code> совпадает с HTTP-кодом, а локальный <code>errors[0].code</code> даёт UI стабильный повод для действия.'),
|
||||
paragraph('Такая декомпозиция позволяет тестировать не весь сервис, а границу, которая уже известна из сбоя. Для выборки заказов хватит трёх cases: корректная последняя страница с <code>nextCursor: null</code>; корректный problem document для плохого cursor; намеренно испорченная страница, где cursor пропал. Третий case особенно полезен: если проверка его принимает, тест на деле проверяет лишь наличие JSON и не защищает договор.'),
|
||||
dataTable(
|
||||
'Минимальная матрица контрактной fixture',
|
||||
['Case', 'Статус и Content-Type', 'Ключевое ожидание', 'Ожидаемый итог'],
|
||||
[
|
||||
['Последняя страница', '<code>200 / application/json</code>', 'page.nextCursor присутствует и равен null', 'Принять ответ'],
|
||||
['Недопустимый cursor', '<code>400 / application/problem+json</code>', 'status совпадает; errors[0].code равен invalid_cursor', 'Принять управляемую ошибку'],
|
||||
['Регрессия пагинации', '<code>200 / application/json</code>', 'Ключ nextCursor отсутствует', 'Отклонить ответ с понятной причиной'],
|
||||
['Случай customer', '<code>200 / application/json</code>', 'customer отсутствует либо является объектом, но не null', 'Принять или отклонить строго по схеме'],
|
||||
],
|
||||
),
|
||||
heading('Фикстура должна проверять статус до тела'),
|
||||
paragraph('Порядок проверок важен. Нельзя сначала читать <code>body.items</code>, а затем мимоходом заметить, что статус был 400. Так код начинает парсить ошибку как список и рождает вторичную ошибку вроде «map is not a function». В fixture сначала сравниваются статус и <code>content-type</code>, потом только форма соответствующего тела. Для success нужен 200 и JSON. Для known validation error нужен 400 и problem+json. Неизвестный статус оставляем нераспознанным, чтобы интерфейс показал общий сбой и команда увидела новый случай.'),
|
||||
codeBlock([
|
||||
'function assertOrdersPage(response) {',
|
||||
' assert(response.status === 200, "ожидался HTTP 200");',
|
||||
' assert(mediaType(response.headers["content-type"]) === "application/json", "ожидался JSON");',
|
||||
' assert(Array.isArray(response.body.items), "items должен быть массивом");',
|
||||
' assert(response.body.page, "page обязателен");',
|
||||
' assert(Object.prototype.hasOwnProperty.call(response.body.page, "nextCursor"),',
|
||||
' "page.nextCursor должен присутствовать");',
|
||||
' assert(response.body.page.nextCursor === null ||',
|
||||
' typeof response.body.page.nextCursor === "string",',
|
||||
' "nextCursor должен быть строкой или null");',
|
||||
'}',
|
||||
]),
|
||||
paragraph('Этот фрагмент намеренно не делает сетевой запрос. <code>response</code> — обычный объект с status, headers и body. В полном пакете запускается та же идея: проверка принимает локальную финальную страницу, принимает 400 problem detail и убеждается, что сама отвергает 200 без <code>nextCursor</code>. Если такой negative case вдруг проходит, мы знаем, что защита ослабла до подключения API. Это корректный результат unit-level fixture, а не отчёт об endpoint.'),
|
||||
heading('Различаем optional, null и неизвестное поле'),
|
||||
paragraph('В реальной выдаче часто спорят о customer: пользователю без привязанного профиля объект не нужен, но UI может захотеть написать «клиент не указан». Это не повод разрешить все представления сразу. В выбранном договоре <code>customer</code> необязателен. Если ключ отсутствует, экран выбирает запасной текст. Если ключ присутствует, он обязан быть объектом с id и name. Значение <code>null</code> считается нарушением, потому что добавляет третью ветку без продукта и без причины.'),
|
||||
paragraph('Это правило не универсально. Другая команда может сделать customer обязательным и nullable, если null имеет отдельный бизнес-смысл. Тогда schema и fixture должны принять null, а интерфейс — назвать его. Плохой вариант один: backend меняет отсутствие на null «потому что так сериализатор отдал», а frontend должен догадаться. Contract test ценен тем, что такое изменение становится красным до того, как попадёт в карточку.'),
|
||||
codeBlock([
|
||||
'function assertCustomer(order) {',
|
||||
' var hasCustomer = Object.prototype.hasOwnProperty.call(order, "customer");',
|
||||
' if (!hasCustomer) return;',
|
||||
'',
|
||||
' assert(order.customer && typeof order.customer === "object",',
|
||||
' "customer при наличии должен быть объектом, не null");',
|
||||
' assert(typeof order.customer.id === "string", "customer.id обязателен");',
|
||||
' assert(typeof order.customer.name === "string", "customer.name обязателен");',
|
||||
'}',
|
||||
]),
|
||||
paragraph('Проверка не обязана быть сложной библиотекой schema validation. В 2019 маленькая функция на assert часто полезнее, когда она живёт рядом с тремя fixtures и легко читается разработчиком обоих слоёв. Позже её можно заменить валидатором на основе OpenAPI, но только после сравнения поддержки версии Schema Object. Цель текущего теста скромнее: удержать реально важные условия — статус, media type, required-поля и смысл optional-поля.'),
|
||||
figure(
|
||||
'/assets/editorial/2019/rest-api-contract-fixture-2019.svg',
|
||||
'Вертикальная схема контрактной проверки: локальные response fixtures проходят сначала через проверку HTTP-статуса и Content-Type, затем через схему success или problem; отдельная красная ветка показывает 200 без nextCursor, который должен быть отвергнут. Справа отмечен отдельный последующий запрос к тестовому стенду.',
|
||||
'Фикстура проверяет договор на заранее заданных данных. Реальный HTTP-запрос — следующий независимый этап, поэтому диаграмма не выдаёт локальный тест за production-проверку.',
|
||||
),
|
||||
heading('Добавляем воспроизводимый запрос, но не выдумываем его результат'),
|
||||
paragraph('После fixture берём разрешённый тестовый host и выполняем один запрос к первой странице. Команда ниже сохраняет заголовки и тело отдельно. Это важно: status и <code>Content-Type</code> видны в headers, а body можно показать в ревью без шума curl. В примере нет токена, cookies и адреса production. Подставлять их в статью, коммит или CI-лог нельзя; для закрытого API команда должна использовать безопасный тестовый способ аутентификации и скрытие секретов.'),
|
||||
codeBlock([
|
||||
'# Выполнять только на разрешённом тестовом URL и с безопасной авторизацией.',
|
||||
'curl -sS -D /tmp/orders.headers -o /tmp/orders.json \\',
|
||||
' -H "Accept: application/json" \\',
|
||||
' "https://api.example.test/api/v1/orders?limit=2"',
|
||||
'',
|
||||
'grep -Ei "^(HTTP/|content-type:)" /tmp/orders.headers',
|
||||
'node -e "const fs=require(\\"fs\\"); const body=JSON.parse(fs.readFileSync(\\"/tmp/orders.json\\")); console.log(body.page)"',
|
||||
]),
|
||||
paragraph('Этот запрос надо читать по шагам. Сначала сверяем фактический HTTP-код. Затем <code>Content-Type</code>; заголовок <code>application/json; charset=utf-8</code> может содержать параметры, поэтому production-парсер должен сравнивать media type корректно, а не полную строку, если сервер его допускает. Потом смотрим наличие items, page и nextCursor. Если ответ — 400, мы не запускаем проверку success, а сравниваем problem document с отдельной веткой. Результат фиксируем как запись факта, не как «API работает».'),
|
||||
heading('Связываем fixture с OpenAPI-описанием'),
|
||||
paragraph('У теста не должно быть второго тайного контракта. Перед запуском сверяем его условия с OpenAPI: <code>200</code> указывает на OrdersPage, <code>400</code> — на Problem, required содержит items и page, а nullable у nextCursor разрешает только null помимо string. Если fixture и YAML расходятся, сначала решаем, какой из них описывает продукт, и исправляем один источник. Нельзя чинить тест под случайный текущий ответ сервера и оставить спецификацию прежней: это вернёт спор в следующем релизе.'),
|
||||
dataTable(
|
||||
'Порядок диагностики при расхождении теста и сервера',
|
||||
['Наблюдение', 'На что указывает', 'Проверка', 'Действие'],
|
||||
[
|
||||
['Fixture падает на локальном положительном case', 'Ошибка в тесте или собственном примере', 'Сверить fixture с зафиксированным schema', 'Исправить тест/пример до сетевого запуска'],
|
||||
['Fixture принимает отрицательный case', 'Контракт не защищён от известной регрессии', 'Добавить конкретное assertion', 'Не продолжать с зелёным, но пустым тестом'],
|
||||
['Тестовый сервер отдаёт другой status', 'Нарушен Responses contract или выбран иной сценарий', 'Сохранить headers и запрос без секретов', 'Согласовать изменение или исправить endpoint'],
|
||||
['Status верный, тело другое', 'Сериализация/schema не совпали', 'Сравнить required, nullable и Content-Type', 'Исправить schema или mapper и повторить запрос'],
|
||||
],
|
||||
),
|
||||
heading('Маршрут от локального случая к интеграционной проверке'),
|
||||
orderedList([
|
||||
'Возьмите один пользовательский сбой и выразите его в одном проверяемом условии ответа.',
|
||||
'Добавьте успешную fixture, управляемую ошибку и отрицательный case, который обязан завершиться с ошибкой проверки.',
|
||||
'Сначала проверяйте HTTP-статус и media type, затем schema соответствующего тела.',
|
||||
'Зафиксируйте одно правило optional-поля и добавьте его в fixture и OpenAPI одновременно.',
|
||||
'Выполните идентичный запрос на разрешённом тестовом сервере, сохранив headers и body без секретов.',
|
||||
'Если результат расходится, не подгоняйте UI: сначала укажите, какая строчка контракта изменилась, и согласуйте переход.',
|
||||
]),
|
||||
heading('Что этот сценарий не обещает'),
|
||||
paragraph('Фикстура не измеряет latency, не проверяет права, не запускает gateway и не подтверждает, что cursor защищён от перебора. Она также не заменяет end-to-end сценарий, где UI действительно нажимает «ещё». Это сознательная граница: один быстрый локальный тест должен ловить разрыв контракта раньше, а серверный и браузерный уровни подтверждают другие свойства. Объявить fixture production-тестом означало бы скрыть эти пробелы, а не уменьшить риск.'),
|
||||
paragraph('Зато сценарий даёт команде чёткий предметный артефакт. Когда следующий change удалит <code>page.nextCursor</code>, поставит другой Content-Type или заменит optional object на null, можно показать конкретный case и конкретный пункт спецификации. Для автора 2019 года это уже не «проверим руками после релиза», а аккуратный мост от фронтенд-обработки ответа к договору backend и HTTP.'),
|
||||
heading('Короткий вывод'),
|
||||
bulletList([
|
||||
'Contract fixture должна принимать ожидаемые cases и обязательно отвергать известный плохой ответ.',
|
||||
'Проверка начинается со status и Content-Type; одинаковый JSON не делает 200 и 400 взаимозаменяемыми.',
|
||||
'Отсутствующее optional-поле и null — разные данные, если команда не зафиксировала обратное.',
|
||||
'Локальные response objects полезны до сети, но тестовый запрос к серверу остаётся отдельным и честно названным этапом.',
|
||||
]),
|
||||
],
|
||||
[rfcHttp, rfcStatus, rfcProblem, rfcLink, openApi, openApiOperation, openApiResponse, openApiSchema],
|
||||
);
|
||||
|
||||
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
|
||||
|
||||
if (process.argv.includes('--print-revisions')) {
|
||||
process.stdout.write(JSON.stringify(revisions));
|
||||
} else if (process.argv.includes('--run-fixture')) {
|
||||
process.stdout.write(JSON.stringify(runContractFixture(), null, 2) + '\n');
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function paragraph(text) {
|
||||
return '<p>' + text + '</p>';
|
||||
}
|
||||
|
||||
function heading(text) {
|
||||
return '<h2>' + text + '</h2>';
|
||||
}
|
||||
|
||||
function codeBlock(lines) {
|
||||
return '<pre><code>' + escapeHtml(lines.join('\n')) + '</code></pre>';
|
||||
}
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function dataTable(headers, rows) {
|
||||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||||
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function visibleText(html) {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function proseText(html) {
|
||||
return visibleText(
|
||||
html
|
||||
.replace(/<pre><code>[\s\S]*?<\/code><\/pre>/g, '')
|
||||
.replace(/<figure>[\s\S]*?<\/figure>/g, '')
|
||||
.replace(/<div class="table-scroll">[\s\S]*?<\/div>/g, ''),
|
||||
);
|
||||
}
|
||||
|
||||
function createRevision(meta, bodyParts, sources) {
|
||||
const bodyHtml = bodyParts.join('\n');
|
||||
const proseLength = proseText(bodyHtml).length;
|
||||
|
||||
if (proseLength < 5000 || proseLength > 15000) {
|
||||
throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength);
|
||||
}
|
||||
|
||||
if (sources.length < 2) {
|
||||
throw new Error(meta.slug + ': at least two primary or official sources are required');
|
||||
}
|
||||
|
||||
return {
|
||||
...meta,
|
||||
contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'),
|
||||
proseLength,
|
||||
};
|
||||
}
|
||||
|
||||
const webVitals = {
|
||||
title: 'web.dev: Web Vitals',
|
||||
url: 'https://web.dev/articles/vitals?hl=en',
|
||||
note: 'современная рамка пользовательских метрик; в переиздании она помогает не смешивать скорость отображения с одним сетевым числом',
|
||||
};
|
||||
|
||||
const lcpGuide = {
|
||||
title: 'web.dev: Optimize Largest Contentful Paint',
|
||||
url: 'https://web.dev/articles/optimize-lcp?hl=en',
|
||||
note: 'разделяет TTFB, задержку старта критического ресурса, его загрузку и задержку отрисовки; это позднейшая терминология для проверки гипотезы, а не выданная за отчёт 2019 года',
|
||||
};
|
||||
|
||||
const navigationTiming = {
|
||||
title: 'MDN: Navigation Timing',
|
||||
url: 'https://developer.mozilla.org/en-US/docs/Web/API/Performance_API/Navigation_timing',
|
||||
note: 'объект navigation entry и границы загрузки документа, DOM и обработчиков события загрузки',
|
||||
};
|
||||
|
||||
const resourceTiming = {
|
||||
title: 'MDN: PerformanceResourceTiming',
|
||||
url: 'https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming',
|
||||
note: 'состав времён и размеров отдельных ресурсов, включая transferSize, encodedBodySize и ограничения кросс-доменных записей',
|
||||
};
|
||||
|
||||
const performanceData = {
|
||||
title: 'MDN: Performance data',
|
||||
url: 'https://developer.mozilla.org/en-US/docs/Web/API/Performance_API/Performance_data',
|
||||
note: 'типы записей Performance API и смысл developer marks и measures',
|
||||
};
|
||||
|
||||
const chromePerformance = {
|
||||
title: 'Chrome DevTools: Performance features reference',
|
||||
url: 'https://developer.chrome.com/docs/devtools/performance/reference',
|
||||
note: 'как trace показывает работу loading, scripting, rendering и painting, а также custom marks',
|
||||
};
|
||||
|
||||
const chromeNetwork = {
|
||||
title: 'Chrome DevTools: Inspect network activity',
|
||||
url: 'https://developer.chrome.com/docs/devtools/network/',
|
||||
note: 'разделение запросов по типам, фильтры и проверка водопада без догадки по одному общему времени загрузки',
|
||||
};
|
||||
|
||||
function summarizeControlledProfile() {
|
||||
const fixture = [
|
||||
{ owner: 'network', label: 'document response', milliseconds: 180, bytes: 18000 },
|
||||
{ owner: 'javascript', label: 'parse and execute app.js', milliseconds: 165, bytes: 96000 },
|
||||
{ owner: 'css', label: 'fetch and build CSSOM', milliseconds: 90, bytes: 24000 },
|
||||
{ owner: 'image', label: 'fetch, decode and paint hero', milliseconds: 45, bytes: 72000 },
|
||||
];
|
||||
const owners = fixture.reduce((result, item) => {
|
||||
result[item.owner] = {
|
||||
milliseconds: item.milliseconds,
|
||||
bytes: item.bytes,
|
||||
label: item.label,
|
||||
};
|
||||
return result;
|
||||
}, {});
|
||||
const totalBytes = fixture.reduce((sum, item) => sum + item.bytes, 0);
|
||||
const totalStandaloneMilliseconds = fixture.reduce((sum, item) => sum + item.milliseconds, 0);
|
||||
|
||||
if (Object.keys(owners).length !== 4 || totalBytes !== 210000 || totalStandaloneMilliseconds !== 480) {
|
||||
throw new Error('controlled profile fixture no longer describes four independent owners');
|
||||
}
|
||||
|
||||
return {
|
||||
fixture: 'Детерминированный Node fixture: это проверка классификации, не browser trace и не production-замер.',
|
||||
owners,
|
||||
totalBytes,
|
||||
totalStandaloneMilliseconds,
|
||||
conclusion: 'В fixture четыре независимых владельца времени; складывать их в один waterfall нельзя, потому что часть работы может перекрываться.',
|
||||
};
|
||||
}
|
||||
|
||||
const practiceArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2019-08-practice-frontend-performance',
|
||||
title: 'Производительность первой загрузки: как собрать профиль вместо слова «тяжёлая»',
|
||||
categories: ['JavaScript', 'Производительность', 'Практика'],
|
||||
cover: '/assets/editorial/2019/frontend-loading-profile-2019.svg',
|
||||
excerpt: 'Страница кажется тяжёлой, но сетевой запрос может быть быстрым. Собираем один воспроизводимый профиль и отдельно проверяем сеть, JavaScript, CSS и изображения.',
|
||||
readingMinutes: 13,
|
||||
},
|
||||
[
|
||||
paragraph('Проблема начинается с фразы «страница тяжёлая». В ней нет владельца задержки: сервер мог ответить быстро, но браузер ждёт таблицу стилей; картинка уже пришла, но её не дают нарисовать длинные скрипты; файл JavaScript маленький в gzip, зато его разбор занимает главный поток. Пока все эти случаи называют одним числом «load», команда сжимает не тот ресурс и получает тот же пустой первый экран. Цена ошибки — ещё один релиз без понятного результата и пользователи, которые уходят до полезного содержимого.'),
|
||||
paragraph('Для первой загрузки я не начинаю с набора оптимизаций. Сначала выбираю один сценарий и делаю профиль, в котором четыре владельца времени видны отдельно: документ и сеть, JavaScript на главном потоке, CSS до готового стиля, изображение до декодирования и отрисовки. Это не обещание, что четыре полосы складываются в одну честную сумму. Их работа может пересекаться. Цель профиля проще: назвать следующий проверяемый вопрос, а не угадать виновника по размеру бандла.'),
|
||||
heading('Что именно считаем медленной первой загрузкой'),
|
||||
paragraph('У экрана есть несколько разных моментов: браузер получил начало HTML, увидел структуру, получил стили, нарисовал первый полезный контент и смог обработать действие. Между ними нет одной универсальной границы. <code>DOMContentLoaded</code> говорит о завершении разбора документа и defer-скриптов, а не о том, что главный блок уже нарисован. Событие <code>load</code> может ждать второстепенные картинки, которые не помогают пользователю начать работу. Поэтому запись «load за две секунды» не отвечает, почему кнопка или заголовок появились поздно.'),
|
||||
paragraph('В августе 2019 для расследования достаточно открыть DevTools и увидеть водопад, main thread и скриншоты загрузки. При переиздании можно соотнести результат с более поздним словарём LCP: он описывает момент, когда крупное содержание в viewport отрисовано. Но не стоит подменять этим словарём старую проверку и тем более писать вымышленное значение метрики. Если конкретный trace не записан, в заметке остаётся гипотеза и маршрут её проверки, а не число с точностью до миллисекунды.'),
|
||||
dataTable(
|
||||
['Наблюдение', 'Возможный владелец', 'Чего оно не доказывает', 'Первый запрос к данным'],
|
||||
[
|
||||
['HTML быстро пришёл, экран пустой', 'CSS, JavaScript или скрытый критический ресурс', 'Что origin медленный', 'Сверить responseStart документа с первым screenshot и полосой main thread'],
|
||||
['Водопад длинный', 'Один критический запрос, очередь приоритетов или несколько независимых ресурсов', 'Что самый большой файл всегда виноват', 'Найти ресурс, без которого не появляется полезный блок'],
|
||||
['app.js небольшой после сжатия', 'Parse, compile и execute JavaScript', 'Что код дёшев на слабом устройстве', 'Записать trace и посмотреть scripting на main thread'],
|
||||
['Hero уже скачан', 'Decode, style, layout или занятый main thread', 'Что изображение стало видимым', 'Связать URL ресурса со screenshot и событием paint'],
|
||||
],
|
||||
),
|
||||
paragraph('Профиль не требует сразу добавлять RUM или менять CDN. В первом проходе достаточно одной локальной страницы, одного пути и одной версии сборки. Он полезен именно потому, что ограничен: позже другой инженер может повторить условия и увидеть, какая полоса изменилась. Если смешать мобильный эмулятор, тёплый кэш, авторизованную сессию и три разных URL, сравнение превратится в набор впечатлений.'),
|
||||
heading('Фиксируем условия до нажатия Reload'),
|
||||
paragraph('Сначала записываю URL, действие пользователя и что считается полезным экраном. Не «страница открылась», а, например, «заголовок товара, цена и кнопка заказа видимы без прокрутки». Затем фиксирую, очищается ли кэш, есть ли Service Worker, какой viewport и какое ограничение CPU или сети выбрано. Эти параметры не делают лабораторный запуск похожим на каждого реального пользователя, но делают его повторяемым для сравнения двух веток.'),
|
||||
paragraph('Нельзя делать вывод о production только по одной локальной записи. Лабораторный профиль отвечает на вопрос «какой путь браузер прошёл в этих условиях». Полевая телеметрия отвечает на другой вопрос: «какой распределённый опыт получили пользователи». Для исправления конкретного регресса сначала нужен владелец из профиля; для приоритета работы нужна отдельная выборка. Смешивать эти доказательства — значит выдать диагностический опыт за статистику.'),
|
||||
codeBlock([
|
||||
'function collectLoadingEntries() {',
|
||||
' const navigation = performance.getEntriesByType("navigation")[0];',
|
||||
' const resources = performance.getEntriesByType("resource").map((entry) => ({',
|
||||
' name: new URL(entry.name).pathname,',
|
||||
' type: entry.initiatorType,',
|
||||
' duration: Math.round(entry.duration),',
|
||||
' transferSize: entry.transferSize,',
|
||||
' encodedBodySize: entry.encodedBodySize,',
|
||||
' }));',
|
||||
'',
|
||||
' return {',
|
||||
' navigation: navigation && {',
|
||||
' responseStart: Math.round(navigation.responseStart),',
|
||||
' domInteractive: Math.round(navigation.domInteractive),',
|
||||
' domContentLoadedEnd: Math.round(navigation.domContentLoadedEventEnd),',
|
||||
' },',
|
||||
' resources,',
|
||||
' };',
|
||||
'}',
|
||||
'',
|
||||
'console.table(collectLoadingEntries().resources);',
|
||||
]),
|
||||
paragraph('Этот код не измеряет CSSOM или время выполнения скрипта: он берёт только записи navigation и resource. Это намеренное ограничение. Из него можно увидеть тип ресурса, длительность и размеры там, где браузер имеет право раскрыть их. Для ресурсов с другого origin подробные поля могут быть нулевыми без <code>Timing-Allow-Origin</code>. Нулевое DNS или connect время также не доказывает, что сети не было: соединение могло быть переиспользовано или значение ограничено политикой доступа.'),
|
||||
paragraph('Сохраняйте результат с версией сборки и условиями, но не отправляйте в общий лог полный URL с пользовательскими параметрами. Для локальной диагностики обычно хватает пути, типа инициатора и округлённых времён. Если нужен отчёт для команды, приложите screenshot с моментом появления полезного блока и короткое пояснение: какая гипотеза проверялась, что действительно измерено и что пока неизвестно.'),
|
||||
heading('Разводим сеть, JavaScript, CSS и изображение'),
|
||||
paragraph('У документа и ресурса есть свой водопад: redirect, DNS, connect, запрос и ответ. Это сетевой слой, но даже его нельзя сократить до transferSize. Два одинаковых файла получают разную задержку из-за origin, очереди, приоритета, повторного соединения или кэша. Если критическая картинка обнаруживается только после выполнения скрипта, её поздний старт выглядит сетевой проблемой, хотя сначала надо проверить путь обнаружения в HTML и CSS.'),
|
||||
paragraph('JavaScript проверяю не размером файла, а временем на main thread после его прихода. В trace ищу длинные фрагменты scripting и связываю их с конкретным ресурсом или функцией через source map, если она доступна. CSS проверяю отдельно: таблица стилей может блокировать расчёт стиля и первый рендер, а большой DOM может удлинить style и layout. У изображения два шага: загрузка байтов и декодирование с отрисовкой. Сжатие файла полезно только если оно попало в доказанный критический участок.'),
|
||||
figure('/assets/editorial/2019/frontend-loading-profile-2019.svg', 'Профиль первой загрузки с четырьмя самостоятельными дорожками: документ и сеть, JavaScript на main thread, CSS до готового стиля и изображение до decode и paint', 'Одна фраза «тяжёлая страница» раскладывается на четыре владельца времени. Дорожки могут пересекаться, поэтому их нельзя бездумно суммировать.'),
|
||||
heading('Контролируемый fixture проверяет классификацию, а не скорость сайта'),
|
||||
paragraph('Чтобы не спорить о том, как отчёт группирует данные, в этом модуле есть детерминированный fixture. В нём четыре записи: response документа, работа JavaScript, построение CSSOM и decode hero-изображения. Скрипт складывает байты и длительности по владельцу и проверяет, что в результате действительно четыре независимые группы. Fixture не открывает браузер, не делает HTTP-запрос и не измеряет этот сайт. Его доказательство узкое: классификатор не потерял CSS внутри JavaScript и не выдал картинку за сеть.'),
|
||||
codeBlock([
|
||||
'// Из каталога web/:',
|
||||
'// node scripts/upgrade-2019-08.mjs --run-fixture',
|
||||
'',
|
||||
'{',
|
||||
' "owners": {',
|
||||
' "network": { "milliseconds": 180, "bytes": 18000 },',
|
||||
' "javascript": { "milliseconds": 165, "bytes": 96000 },',
|
||||
' "css": { "milliseconds": 90, "bytes": 24000 },',
|
||||
' "image": { "milliseconds": 45, "bytes": 72000 }',
|
||||
' },',
|
||||
' "totalBytes": 210000',
|
||||
'}',
|
||||
]),
|
||||
paragraph('Числа fixture не являются бюджетом и не являются результатом trace. Они выбраны так, чтобы тест ловил ошибку группировки. Например, если код отнесёт decode картинки к JavaScript, у результата исчезнет владелец <code>image</code>, и fixture упадёт. Для реальной страницы после этого всё равно нужен отдельный Reload в DevTools: только он покажет, перекрывались ли операции, какой URL был критическим и где браузер действительно потратил время.'),
|
||||
heading('Собираем короткий отчёт, пригодный для следующего запуска'),
|
||||
paragraph('Полезный отчёт помещается в несколько строк. Первая строка — условия: путь, кэш, viewport, throttle, хэш сборки. Вторая — наблюдение: «HTML получил ответ до первого screenshot, а полезный блок появился после scripting». Третья — конкретный владелец и ссылка на дорожку: «main thread: модуль checkout.js, участок parse плюс execute». Четвёртая — одна гипотеза изменения и критерий проверки. Если отчёт не называет владельца, он не помогает выбрать работу.'),
|
||||
paragraph('Не добавляйте в него слово «ускорили», пока не повторили профиль при тех же условиях. Например, перенос второстепенного виджета за событие пользователя может уменьшить scripting до полезного блока, но одновременно увеличить network idle. Это хороший обмен, если экран стал полезен раньше; это плохой аргумент, если измерен только размер одного чанка. Важно заранее зафиксировать, какой момент загрузки должен сдвинуться и какой вторичный эффект допустим.'),
|
||||
dataTable(
|
||||
['Фрагмент отчёта', 'Плохая запись', 'Проверяемая запись'],
|
||||
[
|
||||
['Симптом', 'Долго грузится', 'Карточка появляется после длинного scripting, хотя ответ HTML уже получен'],
|
||||
['Причина', 'Много JavaScript', 'В trace участок main thread привязан к модулю фильтров; это гипотеза до source-map проверки'],
|
||||
['Действие', 'Оптимизировать бандл', 'Не загружать модуль подсказок до первого взаимодействия и оставить проверку fallback'],
|
||||
['Критерий', 'Стало лучше', 'Повторить тот же Reload и сравнить момент полезного блока и длительность участка scripting'],
|
||||
],
|
||||
),
|
||||
heading('Меняем один критический путь за раз'),
|
||||
paragraph('Когда владелец назван, действие становится обычной инженерной работой. Для сети это может быть устранение лишнего redirect, раннее обнаружение ресурса или перенос критического файла на подходящий origin. Для JavaScript — разделение entry, удаление неиспользуемой ветки, откладывание виджета или уменьшение синхронной инициализации. Для CSS — критичный минимум, порядок подключения и уменьшение селекторов или DOM там, где trace показал style и layout. Для изображения — правильный размер, формат, ранний URL и отказ от lazy loading именно у первого значимого изображения.'),
|
||||
paragraph('Ни одна из этих мер не универсальна. <code>preload</code> помогает только ресурсу, который действительно нужен первому экрану; лишние preload конкурируют за сеть. Code splitting помогает, если код перестал быть частью начального пути; если модуль сразу нужен для рендера, дополнительный запрос может ухудшить ситуацию. Оптимизация картинки не поможет, если она уже скачана и ждёт занятый main thread. Поэтому после каждого изменения возвращаемся к тому же профилю, а не переносим удачную технику на все ресурсы подряд.'),
|
||||
orderedList([
|
||||
'Сформулировать полезный первый экран и зафиксировать URL, кэш, viewport, throttle и хэш сборки.',
|
||||
'Записать один Reload в DevTools с Network, Performance и screenshot, не называя результат production-метрикой.',
|
||||
'Разметить в отчёте документ, критический ресурс, участки scripting, style или layout и момент появления полезного блока.',
|
||||
'Выбрать одного владельца и одно изменение, которое должно сдвинуть конкретную полосу, а не весь мир сразу.',
|
||||
'Повторить тот же сценарий, сравнить только заявленный критерий и записать побочный эффект.',
|
||||
'Лишь после устойчивого лабораторного результата решать, нужна ли полевая метрика или выпускная проверка на реальных устройствах.',
|
||||
]),
|
||||
heading('Итог: профиль превращает жалобу в маршрут'),
|
||||
paragraph('Первая загрузка не становится понятной от одного Lighthouse-числа, веса JavaScript или события <code>load</code>. Нужна короткая карта: что браузер получил, что обнаружил поздно, что заняло главный поток и что не успело появиться на экране. Такая карта не требует большой платформы наблюдаемости, но запрещает прятать разные причины под слово «тяжёлая».'),
|
||||
paragraph('В этом упражнении нет заявленного trace конкретного сайта и нет обещания универсального порога. Есть воспроизводимый fixture для классификации и маршрут для настоящего профиля в браузере. Следующая статья разберёт, почему документ, CSS, JavaScript и изображение образуют зависимую критическую цепочку даже тогда, когда отдельные запросы выглядят быстрыми.'),
|
||||
],
|
||||
[navigationTiming, resourceTiming, performanceData, chromeNetwork, chromePerformance, webVitals, lcpGuide],
|
||||
);
|
||||
|
||||
const mechanismArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2019-08-mechanism-frontend-performance',
|
||||
title: 'Под капотом первой загрузки: где теряется время между HTML и полезным экраном',
|
||||
categories: ['JavaScript', 'Производительность', 'Браузер'],
|
||||
cover: '/assets/editorial/2019/frontend-critical-path-2019.svg',
|
||||
excerpt: 'Быстрый ответ origin не равен быстрому экрану. Разбираем зависимую цепочку HTML, CSS, JavaScript и изображения и проверяем, кому принадлежит задержка.',
|
||||
readingMinutes: 14,
|
||||
},
|
||||
[
|
||||
paragraph('Симптом выглядит противоречиво: backend показывает короткое время ответа, Network не содержит гигабайтных файлов, а пользователь всё равно ждёт пустой или нерабочий первый экран. Ошибка расследования в том, что серверный ответ принимают за завершение загрузки. Браузер после первого байта ещё должен разобрать HTML, обнаружить зависимости, получить стили, выполнить синхронный код, построить дерево рендера, декодировать нужные изображения и выделить время на paint. Быстрый origin закрывает только один участок этой цепочки.'),
|
||||
paragraph('Здесь не нужен мифический «браузер тормозит». Нужна модель зависимостей. Одни ресурсы можно качать параллельно, но некоторые работы ждут предыдущей границы: нельзя применить внешний stylesheet до его прихода; JavaScript без <code>defer</code> может остановить разбор HTML; картинка, добавленная только после выполнения приложения, не будет обнаружена preload scanner из начального документа. Критический путь — не список всех файлов, а цепочка того, без чего выбранный полезный экран не может появиться.'),
|
||||
heading('Документ задаёт не только разметку, но и момент обнаружения'),
|
||||
paragraph('HTML приходит потоково. Пока браузер читает начальный документ, он может обнаружить <code>link</code>, <code>script</code>, <code>img</code> и начать работу с ними раньше, чем весь ответ будет получен. Поэтому важен не только размер HTML, но и место, где расположен критический URL. Если hero-изображение или основной stylesheet скрыт за JavaScript-конфигурацией, браузер узнает о нём только после новой работы; лишняя задержка возникает до реальной загрузки байтов.'),
|
||||
paragraph('Это не аргумент за то, чтобы сделать весь HTML огромным. Начальный ответ должен содержать то, что позволяет браузеру увидеть и запросить первый экран: семантический каркас, нужный CSS и прямой адрес критического изображения или шрифта, если он действительно нужен. Второстепенные карточки, рекламные виджеты и модальные окна могут быть отложены. Решение принимают по роли на первом экране, а не по тому, какой компонент проще перенести в шаблон.'),
|
||||
dataTable(
|
||||
['Граница', 'Что открывает работу', 'Типичная ошибка', 'Как проверить'],
|
||||
[
|
||||
['Ответ HTML', 'Парсер и preload scanner видят URL из начальной разметки', 'Критический URL появляется только после boot приложения', 'Посмотреть документ и момент старта ресурса на waterfall'],
|
||||
['CSS', 'Стиль становится доступен для расчёта и рендера', 'Считать stylesheet обычной второстепенной картинкой', 'Сопоставить окончание CSS с моментом первого полезного paint'],
|
||||
['JavaScript', 'Parse, compile, execute и создание DOM', 'Смотреть только gzip-размер чанка', 'Выделить scripting и функцию в main-thread trace'],
|
||||
['Изображение', 'Запрос, байты, decode и paint', 'После responseEnd считать hero видимым', 'Связать URL с decode, screenshot и LCP-кандидатом, если метрика доступна'],
|
||||
],
|
||||
),
|
||||
paragraph('Navigation Timing описывает путь самого документа, а Resource Timing — путь отдельных ресурсов. Эти записи полезны, но не содержат полный причинный граф. Например, высокий <code>responseEnd</code> изображения говорит, когда закончилась передача, но не говорит, что оно было критическим или что его разрешили отрисовать. Поэтому запись из API нужно всегда читать рядом с DOM, сетевым водопадом и trace главного потока.'),
|
||||
heading('CSS — часть визуальной готовности, а не украшение после HTML'),
|
||||
paragraph('Для первого экрана CSS определяет, какие элементы видны, какие шрифты и размеры участвуют в layout и может ли браузер собрать корректное дерево рендера. Внешний stylesheet обычно имеет приоритетную роль: пока нет необходимых правил, браузер старается не показывать нестабильный или неверно стилизованный результат. Если приложение выводит разметку, но затем прячет её классом до окончания инициализации, пользователю всё равно: HTML существует, а полезного экрана нет.'),
|
||||
paragraph('Проверка начинается с конкретного stylesheet, а не с общего правила «инлайнить critical CSS». В trace смотрим, был ли CSS завершён до первого screenshot и не идут ли затем длинные style или layout. В Network смотрим, когда браузер обнаружил файл, насколько он конкурирует с другими ранними запросами и нет ли import-цепочки, которая откладывает правила. В DOM смотрим, не создаёт ли JavaScript огромное дерево или не меняет ли классы в несколько проходов. Каждая из этих причин требует другого изменения.'),
|
||||
codeBlock([
|
||||
'<link rel="stylesheet" href="/assets/app.css">',
|
||||
'<link rel="preload" as="image" href="/assets/hero-960.webp" type="image/webp">',
|
||||
'',
|
||||
'<main class="product-page">',
|
||||
' <h1>Название товара</h1>',
|
||||
' <img',
|
||||
' src="/assets/hero-960.webp"',
|
||||
' width="960"',
|
||||
' height="640"',
|
||||
' alt="Товар на нейтральном фоне"',
|
||||
' >',
|
||||
'</main>',
|
||||
]),
|
||||
paragraph('Этот фрагмент не является рецептом для всех страниц. Он показывает проверяемую идею: критический URL виден в начальном HTML, а размер изображения известен разметке. Preload оправдан только после доказательства, что именно этот ресурс нужен выбранному первому экрану. Добавить его ко всем картинкам — значит забрать пропускную способность у стиля, документа или другого важного ресурса. <code>loading="lazy"</code> у hero также нельзя ставить по привычке: оно намеренно откладывает старт загрузки.'),
|
||||
heading('JavaScript создаёт два разных вида задержки'),
|
||||
paragraph('Первый вид — сетевой и поисковый: browser должен обнаружить, запросить и получить скрипт. Второй — вычислительный: после прихода байтов браузер разбирает и выполняет код на главном потоке. Эти этапы имеют разный диагноз. Убрать десять килобайт из чанка полезно, если они были на критическом пути передачи. Но если задержку создаёт синхронная инициализация большого списка, форматирование данных или повторный layout, тот же файл может прийти быстро и всё равно задержать paint.'),
|
||||
paragraph('Особенно опасна инициализация, которая выглядит маленькой в diff: импорт добавляет polyfill, компонент при старте строит сотни строк таблицы, сторонний код измеряет каждый DOM-узел, аналитика синхронно проходит по странице. В Network это может быть один обычный JS-запрос. В trace будет длинная работа на main thread, иногда с несколькими зелёными и фиолетовыми участками style/layout после неё. Пока функция не названа, правило «сделаем code split» остаётся предположением.'),
|
||||
dataTable(
|
||||
['Наблюдение в trace', 'Рабочая гипотеза', 'Необязательный вывод', 'Проверяемое действие'],
|
||||
[
|
||||
['Длинный scripting сразу после app.js', 'Критический код выполняет лишнюю работу до первого экрана', 'Что весь app.js надо вынести в отдельный чанк', 'Найти функцию, отложить второстепенный путь и повторить тот же профиль'],
|
||||
['Несколько style/layout после одного обработчика', 'Код чередует чтение геометрии и запись классов', 'Что CSS-файл слишком большой', 'Сгруппировать измерения и изменения DOM, проверить число layout-проходов'],
|
||||
['Пустой экран до завершения JS', 'Разметка или критический ресурс создаётся только приложением', 'Что сервер обязан немедленно перейти на новый стек', 'Вывести минимальный каркас и критический URL раньше либо доказать иной путь'],
|
||||
['JS пришёл поздно', 'Ресурс поздно обнаружен или конкурирует в сети', 'Что выполнение кода дорогое', 'Сравнить startTime скрипта с HTML и приоритетом на waterfall'],
|
||||
],
|
||||
),
|
||||
heading('Изображение имеет жизнь после responseEnd'),
|
||||
paragraph('У изображения есть размер на диске, фактические пиксели, место в layout, момент декодирования и момент paint. Сетевой водопад честно покажет transfer и responseEnd, но пользователь увидит файл позже, если браузер занят скриптом, ждёт нужный стиль или декодирует слишком большое изображение. У hero также важен выбор варианта: нет смысла передавать desktop-оригинал на маленький экран, если разметка знает реальный размер контейнера.'),
|
||||
paragraph('Не следует объявлять каждую картинку LCP-кандидатом. Сначала на выбранном первом экране определяем, какой визуальный элемент действительно самый крупный и полезный. В современных инструментах это можно сопоставить с LCP, но статья не превращает этот термин в фальшивый замер 2019 года. Если доступен только screenshot, пишем честнее: «проверяем появление hero-изображения» и сохраняем условия. Если есть trace и metric marker, прикладываем его к конкретному URL.'),
|
||||
figure('/assets/editorial/2019/frontend-critical-path-2019.svg', 'Критическая цепочка первой загрузки: HTML открывает обнаружение CSS, JavaScript и hero-изображения; стили и свободный main thread нужны до полезного paint', 'Запросы могут идти параллельно, но полезный экран ждёт зависимые границы: обнаружение, нужный ресурс, доступный main thread и paint.'),
|
||||
heading('Четыре временных слоя нельзя заменить одной суммой'),
|
||||
paragraph('Полезно держать четыре вопроса. Первый: когда браузер получил первый байт HTML? Второй: когда начались и закончились критические запросы? Третий: чем был занят main thread между приходом ресурсов и первым полезным paint? Четвёртый: какой элемент на экране ещё ждал decode, стиль или layout? Даже если все числа сохранены в миллисекундах, они не образуют последовательность без перекрытий. Сложение длительностей может показать 900 ms там, где реальное окно загрузки 500 ms, и направить усилия в неверный участок.'),
|
||||
paragraph('Позднейшая разбивка LCP на TTFB, resource load delay, resource load duration и element render delay удобна как проверка полноты вопросов. Она не отменяет различий браузеров, не доказывает причину сама по себе и не позволяет пересчитать пользовательский опыт по одному тёплому запуску. Её ценность здесь практическая: если после сокращения байтов изображение не появилось раньше, проверяем render delay, а не повторяем ту же оптимизацию сильнее.'),
|
||||
codeBlock([
|
||||
'performance.mark("catalog: render-start");',
|
||||
'renderCatalog(shellData);',
|
||||
'performance.mark("catalog: render-end");',
|
||||
'performance.measure("catalog: initial-render",',
|
||||
' "catalog: render-start",',
|
||||
' "catalog: render-end");',
|
||||
'',
|
||||
'const measures = performance.getEntriesByType("measure");',
|
||||
'console.table(measures.map(({ name, duration }) => ({',
|
||||
' name,',
|
||||
' duration: Math.round(duration),',
|
||||
'})));',
|
||||
]),
|
||||
paragraph('User Timing не заменяет trace, но даёт приложению именованную границу: где начался и закончился его собственный render. Эту метку стоит ставить вокруг конкретной операции, а не вокруг всей загрузки. Иначе она будет включать сеть, таймеры и чужие скрипты, а название <code>initial-render</code> перестанет соответствовать измеряемому участку. На production такие marks требуют отдельного решения о сборе данных и приватности; в локальном профиле они помогают читать дорожку.'),
|
||||
heading('Выбираем действие по разрыву в цепочке'),
|
||||
paragraph('Если критическое изображение начинается заметно позже документа, сначала ищем его URL: оно в начальном HTML, в CSS или создаётся кодом? Если CSS завершён поздно, проверяем import-цепочку, объём и конкуренцию, а не переносим весь stylesheet inline. Если до первого полезного screenshot занята основная нить, идём в функцию, DOM или сторонний код. Если ресурс уже пришёл, а элемент не нарисован, проверяем доступность main thread, скрывающий класс, размер контейнера и decode.'),
|
||||
paragraph('Действие должно иметь обратимое доказательство. Например, временно убрать второстепенный виджет из начального пути и повторить профиль. Если scripting ушёл, а полезный блок появился раньше, гипотеза получила опору; затем решение оформляют аккуратно с fallback и проверкой функциональности. Если ничего не изменилось, не держим feature-ветку ради надежды. Возвращаемся к предыдущей границе и смотрим, какой ресурс или работа всё ещё ждёт.'),
|
||||
orderedList([
|
||||
'Определить полезный первый экран и назвать один визуальный элемент или интеракцию, которая должна быть готова.',
|
||||
'Проследить его назад: нужен ли ему HTML, stylesheet, скрипт, изображение, шрифт или данные.',
|
||||
'На waterfall проверить момент обнаружения и завершения каждого критического запроса.',
|
||||
'На main thread найти блоки scripting, style, layout, paint между готовностью ресурса и экраном.',
|
||||
'Сделать одно минимальное изменение на ранней разорванной границе и повторить условия профиля.',
|
||||
'Зафиксировать результат как лабораторное наблюдение; полевая метрика и выпускной бюджет остаются следующей отдельной работой.',
|
||||
]),
|
||||
heading('Итог: критический путь — это зависимость, а не рейтинг файлов'),
|
||||
paragraph('Сервер, сеть, JavaScript, CSS и картинка не соревнуются за один титул виновника. Они образуют путь, на котором ранняя задержка может спрятать более позднюю, а быстрый файл может ждать занятый main thread. Когда этот путь нарисован, команда перестаёт спорить о «самом тяжёлом» ресурсе и начинает проверять, какая граница действительно удерживает полезный экран.'),
|
||||
paragraph('Практический результат механизма — небольшой словарь для trace: обнаружен поздно, ждёт CSS, занял main thread, ждёт decode, нарисован позже. В следующем разборе этот словарь применяется к учебной карточке: ответ HTML быстрый, но экран остаётся пустым. Сценарий будет явно помечен как controlled fixture, чтобы не выдать иллюстрацию за измерение чужого продукта.'),
|
||||
],
|
||||
[navigationTiming, resourceTiming, performanceData, chromeNetwork, chromePerformance, webVitals, lcpGuide],
|
||||
);
|
||||
|
||||
const fieldArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2019-08-field-frontend-performance',
|
||||
title: 'Разбор первой загрузки: быстрый HTML, пустой экран и неверный фикс',
|
||||
categories: ['JavaScript', 'Производительность', 'Разбор'],
|
||||
cover: '/assets/editorial/2019/frontend-performance-diagnosis-2019.svg',
|
||||
excerpt: 'Учебный профиль показывает быстрый ответ HTML и поздний полезный экран. Разбираем, как отличить поздний hero, блокирующий JavaScript и CSS без выдуманного production-замера.',
|
||||
readingMinutes: 14,
|
||||
},
|
||||
[
|
||||
paragraph('Симптом: карточка товара открывается, серверный лог показывает короткий ответ HTML, но пользователь несколько секунд видит фон и каркас без товара. Первое поспешное решение — сжать hero-изображение. Оно может не изменить экран вообще, если изображение уже скачано и ждёт выполнения стартового JavaScript. Обратная ошибка тоже частая: вынести код в другой чанк, хотя картинка вообще не была обнаружена до запуска приложения. Цена такого поиска — серия случайных правок, которые нельзя объяснить следующему разработчику.'),
|
||||
paragraph('Ниже учебный разбор, а не trace реального сайта. Для контролируемого профиля мы задаём четыре независимые полосы: документ и сеть — 180 ms, JavaScript — 165 ms, CSS — 90 ms, hero после загрузки — 45 ms. Эти числа существуют только в fixture модуля и проверяют, что отчёт не смешивает владельцев. Они не складываются в «настоящие 480 ms», не описывают устройство пользователя и не дают права писать о production-результате. На их основе можно честно отрепетировать порядок расследования.'),
|
||||
heading('Фиксируем учебный сценарий и границу полезности'),
|
||||
paragraph('Полезным экраном в этом случае считаем три вещи: название товара, цену и hero-изображение рядом с кнопкой заказа. Spinner не считается результатом: он говорит только, что код начал работу. Это определение важно, потому что команда иначе может улучшить момент появления skeleton и объявить победу, хотя покупатель всё ещё не знает, что покупает. До любых изменений фиксируем тот же URL, тот же вариант страницы, состояние кэша и viewport.'),
|
||||
paragraph('В controlled fixture документ уже получил ответ, CSS имеет отдельный этап, JavaScript — отдельную работу, изображение — отдельные байты и decode. Так мы заранее не объявляем один слой главным. Если после настоящего Reload окажется, что hero вообще не на первом экране или содержимое текстовое, сценарий меняется: диагностика всегда начинается с фактического визуального критерия, а не с названия файла <code>hero.webp</code>.'),
|
||||
dataTable(
|
||||
['Полоса fixture', 'Данные fixture', 'Что можно утверждать', 'Что утверждать нельзя'],
|
||||
[
|
||||
['Документ и сеть', '180 ms, 18 000 bytes', 'Классификатор выделил ответ документа отдельным владельцем', 'Что origin конкретного сайта отвечает за 180 ms'],
|
||||
['JavaScript', '165 ms, 96 000 bytes', 'Отдельно учтён parse и execute app.js в учебном профиле', 'Что любой bundle такого размера блокирует ровно 165 ms'],
|
||||
['CSS', '90 ms, 24 000 bytes', 'CSS не потерян среди сетевых или JS-данных', 'Что stylesheet в реальном браузере всегда блокирует весь этот интервал'],
|
||||
['Hero', '45 ms, 72 000 bytes', 'Загрузка и decode изображения имеют свой владелец', 'Что responseEnd равен моменту видимости изображения'],
|
||||
],
|
||||
),
|
||||
paragraph('Такая таблица полезна именно своей скромностью. Она не говорит, какая полоса длиннее на устройстве пользователя, и не добавляет несуществующий waterfall. Она даёт контракт для инструмента и для автора статьи: в дальнейших фразах сеть означает сетевые записи, JavaScript — работу main thread, CSS — готовность стилей, изображение — путь до paint. Если фактический trace позже покажет перекрытие, модель не сломается: полосы всё равно остаются разными владельцами.'),
|
||||
heading('Первый вопрос: какой ресурс открывает полезный экран'),
|
||||
paragraph('В настоящем проекте я бы начал не с главного чанка, а с DOM и screenshot. Есть ли title и price в исходном HTML? Есть ли у hero прямой <code>src</code> или URL появляется в состоянии приложения? Не скрывает ли контейнер класс <code>is-loading</code> до завершения bootstrap? Эти вопросы часто дают результат быстрее, чем сортировка Network по размеру: если полезный блок создаётся только после <code>renderProduct()</code>, его картинка физически не могла стартовать раньше выполнения этого кода.'),
|
||||
paragraph('После этого проверяем waterfall в двух направлениях. От документа вперёд: когда открылись CSS, app.js и hero. От полезного элемента назад: какой URL, стиль и код нужны именно ему. Если hero начинает запрос после <code>app.js</code>, фиксируем не «медленную сеть», а позднее обнаружение. Если hero стартует рано, но screenshot меняется поздно, сеть перестаёт быть первой гипотезой: смотрим main thread, decode и скрывающую логику.'),
|
||||
codeBlock([
|
||||
'const hero = document.querySelector("[data-product-hero]");',
|
||||
'const css = document.querySelector("link[href*=app.css]");',
|
||||
'',
|
||||
'console.table({',
|
||||
' heroSrc: hero && hero.currentSrc,',
|
||||
' heroComplete: hero && hero.complete,',
|
||||
' heroNaturalWidth: hero && hero.naturalWidth,',
|
||||
' stylesheetLoaded: css && css.sheet !== null,',
|
||||
' productHidden: document.querySelector(".product.is-loading") !== null,',
|
||||
'});',
|
||||
]),
|
||||
paragraph('Этот фрагмент — локальная проверка состояния после загрузки, не автоматический benchmark. <code>img.complete</code> не доказывает, что изображение уже показано пользователю, а <code>link.sheet</code> не отвечает на вопрос о стоимости layout. Зато он быстро отделяет ситуацию «URL отсутствует или изображение не готово» от ситуации «ресурс уже доступен, ищем работу рендера». Если запускать его поздно вручную, фиксируйте это в заметке: console-проверка после события не воспроизводит точный момент первого paint.'),
|
||||
heading('Вторая проверка: не держит ли экран стартовый JavaScript'),
|
||||
paragraph('Представим, что waterfall показывает ранний hero и завершённый CSS, но полезный screenshot всё равно появляется после блока scripting. Тогда цель — не «разбить всё на чанки», а найти работу, которая происходит до <code>renderProduct</code>. Это может быть инициализация фильтров, формирование рекомендаций, синхронный разбор большой конфигурации или сторонний виджет. Любая из этих функций имеет другой безопасный момент запуска и другой риск для поведения страницы.'),
|
||||
paragraph('В Chrome DevTools выбираем участок main thread между окончанием критического запроса и появлением нужного screenshot. Затем смотрим Bottom-up или Call Tree, если source map позволяет увидеть исходные функции. Без source map не подставляем название модуля из фантазии: пишем путь скомпилированного ресурса и оставляем задачу на сопоставление. Отсутствие точного имени — не повод вернуться к догадке по весу бандла.'),
|
||||
dataTable(
|
||||
['Факт после Reload', 'Следующая гипотеза', 'Минимальный эксперимент', 'Критерий'],
|
||||
[
|
||||
['Hero и CSS стартовали рано, перед экраном длинный scripting', 'Bootstrap выполняет некритичную работу', 'Временно убрать второстепенный виджет из initial path', 'Сдвигается момент полезного screenshot и сокращается участок scripting'],
|
||||
['Hero стартовал после app.js', 'URL создаётся приложением', 'Показать URL в HTML или проверить preload только для hero', 'На waterfall запрос hero начинается раньше при тех же условиях'],
|
||||
['Hero завершился, но экран меняется после style/layout', 'DOM или классы создают поздний render', 'Сгруппировать записи DOM и убрать лишний ранний layout', 'Уменьшается work между ресурсом и paint'],
|
||||
['CSS заканчивается поздно', 'Стиль найден поздно или конкурирует за сеть', 'Проверить порядок link и import-цепочку', 'CSS приходит до нужного визуального этапа без новых ошибок стиля'],
|
||||
],
|
||||
),
|
||||
paragraph('Важно оставить эксперимент узким и обратимым. Не удаляйте сразу половину приложения. Отключите один реально второстепенный блок под локальным флагом или в отдельной ветке и повторите сценарий. Если экрана это не коснулось, фикс возвращают и не рекламируют «оптимизацию». Если сдвиг есть, следующий шаг — безопасно изменить загрузочный контракт: отложить модуль, оставить заглушку, проверить ошибку загрузки и убедиться, что пользовательская функция не исчезла навсегда.'),
|
||||
heading('Третья проверка: CSS и изображение не завершаются в один момент'),
|
||||
paragraph('Даже в аккуратном водопаде нельзя считать <code>responseEnd</code> финишем визуальной работы. Браузер должен применить стиль, рассчитать геометрию, при необходимости декодировать изображение и отрисовать кадр. Если JavaScript несколько раз читает размеры и тут же меняет классы, он может создавать повторные layout между готовым hero и paint. В этом случае перекодировка картинки даст небольшой сетевой выигрыш, но проблема полезного экрана останется в main thread.'),
|
||||
paragraph('С другой стороны, картинка может быть действительно слишком поздней: URL находится в background-image внешнего CSS, viewport получает неподходящий большой вариант или первый элемент помечен lazy loading. Проверка должна назвать один из этих фактов. У URL в CSS нет автоматического права на preload: сначала убедитесь, что это тот элемент, который нужен без прокрутки. Когда это доказано, раннее объявление ресурса или изменение разметки становятся осмысленным действием, а не массовой настройкой.'),
|
||||
figure('/assets/editorial/2019/frontend-performance-diagnosis-2019.svg', 'Дерево диагностики первого экрана: от полезного screenshot к четырём ветвям — позднее обнаружение в сети, JavaScript на main thread, CSS и layout, изображение с decode и paint', 'Диагностика начинается от наблюдаемого полезного экрана. Каждая ветвь задаёт свой минимальный эксперимент и не выдаёт учебный fixture за production-трассу.'),
|
||||
heading('Проверяем изменение тем же маршрутом, а не одним размером файла'),
|
||||
paragraph('Допустим, trace подтвердил, что блок рекомендаций синхронно строится до карточки. После переноса его за первую отрисовку нельзя останавливаться на уменьшении initial chunk. Повторяем Reload в тех же условиях и смотрим четыре вещи: появился ли полезный screenshot раньше, исчезла ли конкретная работа scripting, не стартовал ли hero позже из-за нового порядка, не сломалась ли карточка без рекомендаций. Только такой набор позволяет отличить улучшение критического пути от перемещения задержки в соседнюю полосу.'),
|
||||
paragraph('Если изменение касается CSS, дополнительно проверяем отсутствие скачка layout и доступность контента без внешнего файла. Если касается изображения — его фактические размеры, корректный alt и fallback. Если касается загрузки модуля — состояние ошибки и медленную сеть. Производительность первой загрузки не освобождает от корректности: пустой, но быстрый экран не считается результатом. В 2019 это особенно важно для постепенных улучшений поверх существующего интерфейса, а не для демонстрации красивого измерения.'),
|
||||
codeBlock([
|
||||
'performance.mark("product: shell-visible");',
|
||||
'showProductShell();',
|
||||
'',
|
||||
'loadRecommendationsLater().catch(() => {',
|
||||
' // Карточка уже полезна; ошибка второстепенного блока не скрывает цену и заказ.',
|
||||
' showRecommendationFallback();',
|
||||
'});',
|
||||
'',
|
||||
'performance.mark("product: recommendations-scheduled");',
|
||||
'const navigation = performance.getEntriesByType("navigation")[0];',
|
||||
'const shellVisible = performance.getEntriesByName("product: shell-visible")[0];',
|
||||
'console.log("shell after navigation", Math.round(',
|
||||
' shellVisible.startTime - navigation.startTime,',
|
||||
'));',
|
||||
]),
|
||||
paragraph('Название <code>initial-shell</code> здесь важно: измеряется момент, когда показали каркас продукта, а не вся страница и не подтверждённая пользовательская метрика. Для trace можно использовать <code>performance.mark</code> как ориентир рядом с Network и main thread. Но значение mark не становится доказательством, что контент полезен, пока это не подтверждено заранее определённым DOM и screenshot-критерием. Так команда не подменяет результат измерением удобной, но пустой стадии.'),
|
||||
heading('Маршрут выпуска без ложного production-отчёта'),
|
||||
paragraph('Перед выпуском инженер может написать честный итог: «В лабораторном Reload при таких-то условиях полезный блок появился раньше после переноса второстепенного модуля; отдельно проверены Network, main thread и fallback». Он не должен писать «все пользователи получили минус 300 ms», если полевая выборка не собиралась. Для поля нужны отдельные согласованные метрики, сегменты устройств и правила приватности. Лабораторный результат остаётся основанием для merge конкретной правки, а не заменой аналитики.'),
|
||||
paragraph('Если профиль не дал однозначной причины, это тоже нормальный итог. Сохраняем trace, условия и исключённые гипотезы: hero стартует рано, CSS готов до первого screenshot, но source map отсутствует для долгого scripting. Следующая задача тогда конкретна — восстановить карту исходников или изолировать функцию, а не повторять сжатие изображений. Неопределённость уменьшается фактами, а не более ярким заголовком оптимизации.'),
|
||||
orderedList([
|
||||
'Назвать полезный первый экран и записать его DOM-признаки до открытия DevTools.',
|
||||
'Снять один повторяемый Reload с фиксированными условиями, сохранив Network, screenshot и main-thread trace.',
|
||||
'Проверить, когда стартовали HTML, CSS, app.js и нужное изображение, не используя размер файла как приговор.',
|
||||
'Найти раннюю разорванную границу: позднее обнаружение, long scripting, style/layout или decode и paint.',
|
||||
'Сделать один обратимый эксперимент и проверить конкретный ожидаемый сдвиг, а также fallback и визуальную корректность.',
|
||||
'Сформулировать лабораторный вывод с его пределами; production-числа добавлять только после отдельного сбора полевых данных.',
|
||||
]),
|
||||
heading('Итог: быстрый ответ — это только начало расследования'),
|
||||
paragraph('Учебный fixture показывает важный навык: даже когда каждая полоса имеет число, не надо строить из них фальшивую общую скорость. Первая загрузка становится полезной после зависимой работы HTML, сети, CSS, JavaScript, изображения и paint. У каждой части есть собственный инструмент проверки и собственный способ сломаться.'),
|
||||
paragraph('Поэтому хороший разбор начинается с видимого критерия, идёт назад по зависимостям и заканчивается одним воспроизводимым изменением. В нём есть точные ограничения: fixture не является browser trace, trace не является полевой статистикой, а маленький бандл не равен быстрому экрану. Такая дисциплина делает следующую оптимизацию короче, потому что она отвечает на конкретный вопрос, а не на жалобу «страница тяжёлая».'),
|
||||
],
|
||||
[navigationTiming, resourceTiming, performanceData, chromeNetwork, chromePerformance, webVitals, lcpGuide],
|
||||
);
|
||||
|
||||
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
|
||||
.map(({ proseLength, ...revision }) => revision);
|
||||
|
||||
const isDirectRun = process.argv[1]
|
||||
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isDirectRun) {
|
||||
if (process.argv.includes('--print-revisions')) {
|
||||
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||||
} else if (process.argv.includes('--run-fixture')) {
|
||||
process.stdout.write(JSON.stringify(summarizeControlledProfile(), null, 2) + '\n');
|
||||
} else {
|
||||
process.stderr.write('Usage: node web/scripts/upgrade-2019-08.mjs --print-revisions | --run-fixture\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user