revise August 2022 media performance articles
Build and deploy / deploy (push) Successful in 14s

This commit is contained in:
2026-07-31 13:55:03 +03:00
parent ec1fa93fd2
commit a7c2991b49
7 changed files with 410 additions and 1 deletions
+309
View File
@@ -0,0 +1,309 @@
function escapeHtml(value) {
return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}
function paragraph(value) { return '<p>' + value + '</p>'; }
function heading(value) { return '<h2>' + value + '</h2>'; }
function codeBlock(value) { return '<pre><code>' + escapeHtml(value) + '</code></pre>'; }
function orderedList(items) { return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>'; }
function figure(src, alt, caption) { return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>'; }
function table(caption, headings, rows) {
return '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>'
+ headings.map((item) => '<th scope="col">' + item + '</th>').join('')
+ '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('')
+ '</tbody></table></div>';
}
function sources(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 plainText(html) {
return html.replace(/<[^>]+>/g, ' ').replaceAll('&nbsp;', ' ').replaceAll('&quot;', '"').replaceAll('&#039;', "'")
.replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&').replace(/\s+/g, ' ').trim();
}
function bodyText(html) { return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, '')); }
function createRevision(meta, parts, sourceItems) {
const contentHtml = parts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sources(sourceItems);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': основной текст вне диапазона: ' + proseLength);
return { ...meta, contentHtml, proseLength };
}
const lcpDraft = {
title: 'Largest Contentful Paint, W3C First Public Working Draft от 24 мая 2022 года',
url: 'https://www.w3.org/TR/2022/WD-largest-contentful-paint-20220524/',
note: 'датированный первичный документ. Он определяет API наблюдения candidate largest contentful paint и отдельно перечисляет его ограничения; fixture ниже этот API не вызывает и LCP не измеряет.',
};
const html52 = {
title: 'HTML 5.2, W3C Recommendation от 14 декабря 2017 года',
url: 'https://www.w3.org/TR/2017/REC-html52-20171214/semantics-embedded-content.html#the-img-element',
note: 'неизменяемая нормативная версия, доступная в августе 2022 года. Она задаёт элемент img и его атрибуты; проектные labels, операции модели и выводы о метриках в ней не определены.',
};
const commonSources = [lcpDraft, html52];
/**
* Детерминированный учебный контракт для одного media slot.
* Нет DOM, img-элемента, CSS layout, сети, decode API, browser scheduler,
* PerformanceObserver, реального LCP/CLS, trace, таймера или product metric.
* markup, reserved geometry, load/decode labels и observation record — данные
* модели. priorityIntent — решение приложения, не утверждение о приоритете браузера.
*/
function createMediaContract() {
return {
boundary: Object.freeze({
kind: 'teaching-media-contract-v1', dom: 'not-created', network: 'not-used', browser: 'not-run',
performanceObserver: 'not-created', lcp: 'not-measured', cls: 'not-measured', layout: 'not-calculated',
}),
state: {
markup: null, geometry: null, request: { phase: 'not-planned', priorityIntent: null },
decode: { phase: 'not-requested' }, observation: { phase: 'not-recorded', record: null },
history: [],
},
};
}
function declareMarkup(model, descriptor) {
if (!descriptor || typeof descriptor.src !== 'string' || !descriptor.src.startsWith('/media/')) return Object.freeze({ kind: 'markup-rejected', changed: false });
model.state.markup = Object.freeze({ src: descriptor.src, alt: String(descriptor.alt || ''), responsiveSourceDeclared: Boolean(descriptor.responsiveSourceDeclared) });
model.state.history.push('markup-declared');
return Object.freeze({ kind: 'markup-declared', changed: true });
}
function reserveGeometry(model, width, height) {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) return Object.freeze({ kind: 'geometry-rejected', changed: false });
model.state.geometry = Object.freeze({ width, height, ratio: width + ':' + height, source: 'declared-model-data' });
model.state.history.push('geometry-reserved');
return Object.freeze({ kind: 'geometry-reserved', changed: true, ratio: model.state.geometry.ratio });
}
function planLoad(model, priorityIntent) {
if (!model.state.markup) return Object.freeze({ kind: 'load-blocked-no-markup', changed: false });
if (!['critical-candidate', 'deferred-candidate'].includes(priorityIntent)) return Object.freeze({ kind: 'load-rejected-priority-label', changed: false });
model.state.request = { phase: 'planned', priorityIntent, transport: 'not-performed' };
model.state.history.push('load-planned:' + priorityIntent);
return Object.freeze({ kind: 'load-planned', changed: true, priorityIntent, transport: 'not-performed' });
}
function markLoad(model, token) {
if (model.state.request.phase !== 'planned' || token !== 'teaching-load-ok') return Object.freeze({ kind: 'load-not-accepted', changed: false });
model.state.request = { ...model.state.request, phase: 'declared-loaded' };
model.state.history.push('load-declared');
return Object.freeze({ kind: 'load-declared', changed: true, observation: 'not-a-network-event' });
}
function markDecode(model, token) {
if (model.state.request.phase !== 'declared-loaded' || token !== 'teaching-decode-ok') return Object.freeze({ kind: 'decode-not-accepted', changed: false });
model.state.decode = { phase: 'declared-decoded', source: 'teaching-token-not-image-decode' };
model.state.history.push('decode-declared');
return Object.freeze({ kind: 'decode-declared', changed: true, observation: 'not-a-browser-decode-event' });
}
function recordExternalObservation(model, record) {
if (!record || typeof record.kind !== 'string' || typeof record.value !== 'number') return Object.freeze({ kind: 'observation-rejected', changed: false });
if (model.state.decode.phase !== 'declared-decoded') return Object.freeze({ kind: 'observation-blocked-before-decode', changed: false });
model.state.observation = { phase: 'recorded', record: Object.freeze({ kind: record.kind, value: record.value, source: 'external-observation-supplied-by-caller' }) };
model.state.history.push('external-observation-recorded');
return Object.freeze({ kind: 'external-observation-recorded', changed: true, metricClaim: 'none' });
}
function readiness(model) {
return Object.freeze({
markupDeclared: model.state.markup !== null,
geometryReserved: model.state.geometry !== null,
loadDeclared: model.state.request.phase === 'declared-loaded',
decodeDeclared: model.state.decode.phase === 'declared-decoded',
observationRecorded: model.state.observation.phase === 'recorded',
realMetricMeasured: false,
});
}
export function runMediaPerformanceFixture() {
const model = createMediaContract();
const premature = markDecode(model, 'teaching-decode-ok');
const absentMarkup = planLoad(model, 'critical-candidate');
const markup = declareMarkup(model, { src: '/media/hero-960.avif', alt: 'Портрет товара', responsiveSourceDeclared: true });
const badGeometry = reserveGeometry(model, 0, 540);
const geometryAfterRejectedInput = model.state.geometry;
const geometry = reserveGeometry(model, 960, 540);
const plan = planLoad(model, 'critical-candidate');
const wrongLoad = markLoad(model, 'network-ok');
const load = markLoad(model, 'teaching-load-ok');
const decode = markDecode(model, 'teaching-decode-ok');
const observation = recordExternalObservation(model, { kind: 'candidate-note', value: 1 });
const ready = readiness(model);
const deferred = createMediaContract();
declareMarkup(deferred, { src: '/media/gallery-480.webp', alt: 'Дополнительный ракурс', responsiveSourceDeclared: false });
reserveGeometry(deferred, 480, 480);
const deferredPlan = planLoad(deferred, 'deferred-candidate');
const deferredBeforeLoad = readiness(deferred);
const assertions = {
boundaryDoesNotClaimBrowser: model.boundary.browser === 'not-run' && model.boundary.lcp === 'not-measured' && model.boundary.cls === 'not-measured',
decodeCannotPrecedeDeclaredLoad: premature.kind === 'decode-not-accepted',
loadNeedsMarkup: absentMarkup.kind === 'load-blocked-no-markup',
markupIsDeclaredNotRendered: markup.kind === 'markup-declared' && model.state.markup.src === '/media/hero-960.avif',
invalidGeometryDoesNotReserveSpace: badGeometry.kind === 'geometry-rejected' && geometryAfterRejectedInput === null,
geometryIsSeparateFromMarkup: geometry.kind === 'geometry-reserved' && model.state.geometry.ratio === '960:540',
intentIsNotBrowserPriority: plan.priorityIntent === 'critical-candidate' && plan.transport === 'not-performed',
arbitraryLoadTokenIsRejected: wrongLoad.kind === 'load-not-accepted',
declaredLoadHasNoNetworkClaim: load.kind === 'load-declared' && load.observation === 'not-a-network-event',
declaredDecodeHasNoDecodeApiClaim: decode.kind === 'decode-declared' && model.state.decode.source === 'teaching-token-not-image-decode',
observationNeedsDeclaredDecode: observation.kind === 'external-observation-recorded' && observation.metricClaim === 'none',
readinessKeepsMetricBoundary: ready.markupDeclared && ready.geometryReserved && ready.loadDeclared && ready.decodeDeclared && ready.observationRecorded && !ready.realMetricMeasured,
deferredIntentIsExplicit: deferredPlan.priorityIntent === 'deferred-candidate',
deferredSlotIsNotPretendedReady: deferredBeforeLoad.geometryReserved && !deferredBeforeLoad.loadDeclared && !deferredBeforeLoad.realMetricMeasured,
};
return Object.freeze({ assertions, model, deferred });
}
const fixtureExample = [
'# Запускает только учебную in-memory модель Node.js.',
'node web/scripts/upgrade-2022-08.mjs --verify-fixture',
'',
'# PASS fixture: 14/14 assertions',
'# Нет DOM, CSS layout, сети, decode(), PerformanceObserver, LCP или CLS.',
].join('\n');
const practiceArticle = createRevision({
slug: 'editorial-2022-08-practice-media-performance',
title: 'Изображение в первом экране: сначала контракт разметки, геометрии и наблюдения',
categories: ['Frontend', 'Производительность'],
cover: '/assets/editorial/2022/media-performance-2022-slot-contract.svg',
excerpt: 'Практический маршрут для медиа-слота: не путать declared geometry, load и decode, а проверку браузерной метрики вынести в отдельный наблюдаемый прогон.',
readingMinutes: 12,
}, [
paragraph('У большого изображения в первом экране обычно три видимых симптома: контент появляется поздно, блок прыгает после ответа или нужный ресурс оказывается в очереди не там, где его ожидали. Цена не сводится к цифре в отчёте: пользователь сначала видит пустое место или неверный порядок страницы, а команда начинает менять атрибуты вслепую. В следующем релизе та же ошибка возвращается другой картинкой.'),
paragraph('Причина часто в смешении четырёх фактов. Разметка говорит, какой ресурс описан. Геометрия говорит, какое место компонент резервирует по своему контракту. Load и decode относятся к платформенному пути, а LCP и layout shift появляются только в наблюдении конкретного документа и браузера. Ниже — учебная модель, где эти слова являются отдельными fields. Она не создаёт <code>img</code>, не запускает сеть и не измеряет LCP или CLS.'),
heading('Сначала назвать, что именно сломано'),
table('Матрица симптомов для одного media slot', ['Симптом', 'Не делаем вывод', 'Первый проверяемый факт', 'Действие'], [
['Пустое место до изображения', '«это точно LCP»', 'есть ли разметка и declared geometry для slot', 'зафиксировать dimensions рядом с источником layout'],
['Карточка сдвинулась после медиа', '«width/height гарантируют нулевой CLS»', 'меняется ли место slot в выбранном DOM-сценарии', 'проверить правила контейнера и резервирование отдельно'],
['Hero приходит поздно', '«один attribute задаёт реальный network priority»', 'какое намерение команда присвоила resource и где оно выражено', 'сверить разметку, ресурс и browser observation'],
['Декодирование заметно пользователю', '«load равен готовому пикселю»', 'какой этап реально наблюдался и в каком браузере', 'собирать отдельный trace, не выводить его из unit fixture'],
]),
heading('Минимальный контракт до оптимизации'),
paragraph('Для одного slot достаточно записать четыре независимые вещи: <code>src</code> и alt как declared markup; <code>width</code>/<code>height</code> как declared geometry; намерение <code>critical-candidate</code> или <code>deferred-candidate</code>; и факт внешнего наблюдения. Последнее особенно важно. Даже когда ресурс важен для первого экрана, локальная модель не может узнать, стал ли он candidate LCP: у неё нет document, paint и ввода пользователя.'),
paragraph('Это не отказ от оптимизации. Это способ не сделать ложный rollback. Если slot не имеет геометрии, сначала безопасно добавить её в компонент и проверить выбранные варианты изображения. Если ожидание priority не совпало с trace, не надо сразу объявлять другой label «быстрее»: нужно сохранить условия прогона, браузер, viewport, cache state и список конкурирующих ресурсов. Тогда следующий шаг воспроизводим.'),
codeBlock(fixtureExample),
paragraph('Fixture принимает строку <code>teaching-load-ok</code> и затем <code>teaching-decode-ok</code>. Это нарочно не callback браузера. Она проверяет только порядок локального контракта: без markup нельзя plan load, decode не допускается до declared load, а observation записывается после declared decode. Результат PASS не говорит, что файл был загружен, декодирован, показан или стал LCP-кандидатом.'),
figure('/assets/editorial/2022/media-performance-2022-slot-contract.svg', 'Схема одного media slot: declared markup ведёт к отдельно declared geometry; затем application intent планирует load, учебный token отмечает load и decode, а внешний observation record хранится отдельной веткой. Внизу явно написано, что это не DOM, не CSS layout и не измерение LCP или CLS.', 'Контракт не заменяет browser trace: он делает явным, какие факты должен принести следующий слой проверки.'),
heading('Геометрия — часть компонента, а не последняя правка CSS'),
paragraph('Резервирование места полезно рассматривать как вход компонента. В модели <code>reserveGeometry(960, 540)</code> сохраняет ratio <code>960:540</code>; оно не вычисляет box, не знает <code>object-fit</code> и не видит CSS соседей. Поэтому такой unit test может защитить обязательность dimensions в данных, но не вправе утверждать, что layout shift исчез. Для этого нужен отдельный browser-сценарий с реальной разметкой.'),
paragraph('У одного источника может быть несколько отображений: hero, карточка, миниатюра. Не переносите размеры оригинала как универсальный ответ. Для каждого отображения команда должна назвать ожидаемую рамку, источник пропорции и поведение при неизвестной высоте. Если это невозможно сформулировать рядом с компонентом, optimisation пока не имеет стабильной границы, а не «нуждается в ещё одном CSS hack». '),
heading('Очередь ресурса: намерение не равно факту браузера'),
paragraph('В contract есть <code>priorityIntent</code>, но это только label решения: critical или deferred candidate. Он помогает reviewer спросить, почему этот ресурс должен конкурировать в ранней части загрузки. Он не обещает, что user agent назначит конкретный приоритет, начнёт скачивание первым или сохранит порядок при другом cache state. Такой запрет на сильный вывод дешевле последующего разбора «почему в лаборатории было иначе». '),
paragraph('Для non-critical media разумнее сначала убедиться, что оно действительно не требуется до действия пользователя или ниже первого сценария просмотра. Слово deferred не означает «можно потерять»: у slot остаются alt, geometry, error state и проверка возвращения на экран. Если продукту важен placeholder, он тоже становится отдельной разметкой и геометрией, а не тайным следствием неуспешной загрузки.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Выберите один slot и один видимый дефект: поздний hero, скачок карточки или ошибочное раннее медиа. Не соединяйте их общим словом «производительность».',
'<strong>Причина.</strong> Разложите текущий компонент на markup, geometry, resource intent, load/decode и observation. Отметьте, какое поле отсутствует или меняется не там.',
'<strong>Проверка контракта.</strong> Запустите fixture. PASS проверяет только порядок учебных labels и отсутствие metric claim.',
'<strong>Проверка реализации.</strong> В выбранном браузере соберите отдельный сценарий: URL, viewport, cache state, список конкурирующих ресурсов и фактический layout. Это уже другой артефакт.',
'<strong>Действие.</strong> Добавьте geometry в component API, назовите resource intent и оставьте источник данных для responsive variant.',
'<strong>Откат.</strong> Если новый responsive источник показывает неверный кадр или ломает доступность, верните прежнее соответствие resource-to-slot. Не маскируйте проблему искусственным priority label без записи наблюдения.',
]),
heading('Что проверяет LCP-документ, а что остаётся за ним'),
paragraph('FPWD LCP от 24 мая 2022 года определяет наблюдение кандидатов и прямо называет ограничения эвристики. Это полезная граница: LCP — не синоним «любой большой картинки» и не обещание, что unit-модель может рассчитать пользовательское восприятие. HTML 5.2 фиксирует норму для <code>img</code>, но тоже не описывает продуктовую очередь и не выбирает за команду важный ресурс.'),
paragraph('Следующий проверяемый шаг — приложить к PR два артефакта. Первый: unit test данных slot, где отсутствующая или нулевая geometry отклоняется. Второй: короткий browser report с условиями и тем, что было реально замечено. Не заменяйте второй первым. Так изменение остаётся маленьким, но команда не делает вид, что локальный object уже является профилем браузера.'),
heading('Ограничение'),
paragraph('Модель не охватывает <code>srcset</code> selection, CSS background, video poster, CDN negotiation, cache, network error, EXIF orientation, accessibility tree, server rendering и реальные callbacks. Она не утверждает, что width/height сами по себе устраняют CLS, а priority intent — что-либо о scheduler. Для каждой из этих границ потребуется конкретная реализация и самостоятельная проверка.'),
], commonSources);
const mechanismArticle = createRevision({
slug: 'editorial-2022-08-mechanism-media-performance',
title: 'Медиа без ложных метрик: где заканчивается component contract и начинается браузерное наблюдение',
categories: ['Frontend', 'Производительность'],
cover: '/assets/editorial/2022/media-performance-2022-state-boundary.svg',
excerpt: 'Разбираем порядок состояний media slot и границу утверждений: разметка, геометрия, declared load/decode и внешний observation record не являются измерением браузерной метрики.',
readingMinutes: 13,
}, [
paragraph('После добавления оптимизации медиа часто возникает странный спор: один разработчик показывает unit test, другой — trace, а оба называют это «готовым LCP fix». Проблема такой подмены в том, что решение нельзя ни подтвердить, ни откатить: не ясно, что именно изменилось — markup, размеры, очередь, декодирование или условия запуска. При следующем изменении команда сравнит несравнимые результаты.'),
paragraph('Причина в том, что у этих фактов разные владельцы. Компонент владеет описанием slot и может требовать geometry. Приложение может назначить намерение важности. Браузер выполняет загрузку, decode и layout; измерительный код наблюдает его результат при конкретных условиях. Учебный контракт ниже не притворяется браузером: он хранит declared transitions и принимает внешний record только как supplied data.'),
heading('Пять слоёв вместо одного флага loaded'),
table('Состояния, которые нельзя заменить одним boolean', ['Слой', 'Владелец', 'Наблюдаемый факт', 'Чего факт не доказывает'], [
['Markup', 'компонент', 'slot описан src и alt', 'ресурс запрошен или показан'],
['Geometry', 'component API и layout contract', 'есть положительные width и height', 'реальный box и отсутствие shift'],
['Load intent', 'приложение', 'slot помечен critical/deferred candidate', 'приоритет scheduler и время ответа'],
['Declared load/decode', 'учебная fixture', 'локальный порядок transitions', 'сетевой load или decode API'],
['External observation', 'отдельный browser прогон', 'в record есть значение и условия', 'причину изменения без анализа trace'],
]),
heading('Почему load и decode надо разделить даже в учебном коде'),
paragraph('Слово loaded удобно, но оно склеивает минимум два вопроса: ресурс уже доступен по пути загрузки и он готов для отображения в конкретном контексте. Настоящие платформенные алгоритмы сложнее этой заметки. Поэтому model не пытается их воспроизвести: <code>markLoad</code> принимает только teaching token, а <code>markDecode</code> требует ранее объявленный load. Их разделение полезно не для симуляции браузера, а чтобы review увидел неверный порядок переходов.'),
paragraph('В реальном коде граница будет другой: события, Promise или framework adapter. Нельзя заменить их вызовом fixture и сказать, что браузер прошёл decode. Но можно заранее договориться, что логика UI не покажет «готово» только потому, что создан descriptor. Это небольшой, проверяемый контракт: описание slot не равно завершённой работе платформы.'),
codeBlock(fixtureExample),
figure('/assets/editorial/2022/media-performance-2022-state-boundary.svg', 'Диаграмма состояний media slot: markup и geometry независимы; planned load с label critical/deferred не является транспортом; declared load ведёт к declared decode; лишь затем можно сохранить external observation record. Боковая область исключает DOM, scheduler, LCP и CLS из учебной модели.', 'Разделение переходов оставляет место для настоящего browser-слоя, не превращая fixture в его подделку.'),
heading('Геометрия: проверяем вход, не рисуем несуществующий layout'),
paragraph('Функция <code>reserveGeometry</code> допускает только положительные целые width и height. Это намеренно узкое правило. Оно помогает найти данные, где component API вообще не знает ожидаемый ratio. Но функция не вычисляет CSS box, не учитывает aspect-ratio, grid, container query и не знает, чем закончится рендер. Поэтому assertion называется <code>geometryReserved</code>, а не <code>noLayoutShift</code>.'),
paragraph('Такое именование снижает риск плохой аналитики. Если тест говорит «CLS fixed», его потом используют как аргумент против реального наблюдения. Если тест говорит «в contract есть declared geometry», он остаётся честным: дальше можно провести page-level проверку. В архитектуре это полезнее красивого флага, потому что граница ответственности указана прямо в имени.'),
heading('Наблюдение — вход другого контура'),
paragraph('В модели <code>recordExternalObservation</code> принимает record с kind и числом. Она не создаёт <code>PerformanceObserver</code> и не называет число LCP. Его source явно равен <code>external-observation-supplied-by-caller</code>. Это заставляет следующий слой указать происхождение: какой браузер, какой URL, какие условия кэша, какой viewport и какой trace или запись лежит рядом. Без этих условий число остаётся неподписанным сигналом.'),
paragraph('FPWD LCP полезен именно этим различием: API наблюдает кандидатов в документе, а не принимает обещание компонента. В документе есть input, viewport, paint и условия, которых нет в plain JavaScript object. Поэтому даже правильная component schema не равна измерению. Она лишь делает измерение проще интерпретировать: reviewer понимает, какой slot был объявлен critical и какая geometry ожидалась.'),
heading('Неверный priority: как искать, не придумывая scheduler'),
paragraph('Когда команда говорит «картинка грузится не с тем приоритетом», сначала нужно заменить оценку на вопрос. Какая разметка описывает ресурс? Почему продукт считает slot ранним или отложенным? Какие другие ресурсы конкурируют в конкретном прогоне? Что зафиксировано в trace? Три первых вопроса относятся к коду и contract, четвёртый — к браузерному наблюдению. Ни один label в object не является ответом на все четыре.'),
paragraph('Если наблюдение расходится с intent, rollback должен быть обратимым. Верните прежний mapping resource-to-slot или прежнюю стратегию выбора variant, сохраните report и сформулируйте новую гипотезу. Не меняйте одновременно source, CSS, CDN и priority hint: тогда следующий trace не позволит установить причину. Один осмысленный эксперимент важнее серии атрибутов, добавленных в надежде на быстрый эффект.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Зафиксируйте один конфликт: slot объявлен ранним, но в browser report ресурс виден иначе; либо geometry есть в данных, но страница меняет место.',
'<strong>Причина.</strong> Проверьте, не заменены ли markup, geometry, load и observation флагом loaded или одной «performance» метрикой.',
'<strong>Проверка модели.</strong> Убедитесь, что переход decode до load отклоняется, а readiness никогда не утверждает real metric measured.',
'<strong>Проверка браузера.</strong> Отдельно сохраните среду запуска и trace. Укажите, какой вывод сделан из наблюдения, а какой остаётся гипотезой.',
'<strong>Действие.</strong> Назовите owner каждого слоя: component API, page composition, platform adapter, performance observer/report.',
'<strong>Откат.</strong> Откатывайте ровно один mapping или attribute-намерение, затем повторяйте тот же контролируемый сценарий. Не откатывайте «до хорошей цифры» без условия сравнения.',
]),
heading('Что можно использовать в production после модели'),
paragraph('Из fixture в production переносится не token и не число record. Переносится дисциплина: source media, geometry и intent должны иметь явную точку владения; browser observation должен быть подписан условиями; assertion должен называться ровно по тому факту, который проверяет. Это помогает и без Performance API: code review сразу видит, где component API допускает неизвестную высоту или где deferred slot неожиданно участвует в первом сценарии.'),
paragraph('HTML 5.2 описывает <code>img</code>, но не создаёт за продукт policy для всех вариантов изображений. LCP FPWD 2022 года — Working Draft, а не обещание одинакового результата для каждого документа. Эти статусы важнее рекламного языка: архитектурная заметка должна оставить место для браузерной разницы, версий и измерения, а не выдавать учебную схему за finished optimisation.'),
heading('Ограничение и следующий шаг'),
paragraph('Контракт не покрывает lazy loading, preload, responsive selection, background-image, animated formats, decoding errors, cross-origin timing, caching и ограничения устройств. Он также не строит accessibility tree и не проверяет alt в screen reader. Добавлять любое из этих свойств стоит только как новый layer со своим input и отрицательными assertions.'),
paragraph('Следующий шаг — выбрать один page-level media slot и оформить короткий evidence packet: скрин разметки/данных, ожидаемая geometry, rationale intent и протокол browser observation. Если после этого нужна правка, сделайте её одной: например, исправьте geometry contract или выбор source для этого slot. Затем повторите именно описанный сценарий. Так у оптимизации будет причина, проверка и безопасный путь назад.'),
], commonSources);
const fieldArticle = createRevision({
slug: 'editorial-2022-08-field-media-performance',
title: 'Медиа-слот в разборе: собрать доказательства до смены размера, источника и приоритета',
categories: ['Frontend', 'Тестирование'],
cover: '/assets/editorial/2022/media-performance-2022-diagnosis-rollback.svg',
excerpt: 'Полевой маршрут для позднего hero, скачущей карточки и спорного priority: какие факты записать, как сделать одну обратимую правку и где заканчивается учебная fixture.',
readingMinutes: 13,
}, [
paragraph('Фраза «картинка тормозит страницу» слишком широка, чтобы её исправить. Проблема может скрываться в лишнем исходнике для маленького slot, отсутствии зарезервированного места, неверной композиции первого экрана или предположении о browser priority без trace. Цена общей формулировки — опасный релиз: одновременно меняются CDN variant, CSS и атрибуты, а после этого никто не может объяснить, что помогло или что сломало доступный кадр.'),
paragraph('Начать нужно не с benchmark и не с обещания конкретной метрики. Возьмите один URL страницы, один viewport, один media slot и одно наблюдение пользователя. Затем отделите проверяемые поля component contract от того, что действительно увидел браузер. Учебная fixture этой партии проверяет только порядок локальных labels. Она не открывает страницу, не получает response и не выдаёт число за LCP или CLS.'),
heading('Карточка расследования до первой правки'),
table('Что приложить к задаче по медиа', ['Поле', 'Пример записи', 'Почему нужно', 'Чего не утверждает'], [
['Slot', 'product-card/main-image', 'ограничивает область изменения', 'что все изображения страницы одинаковы'],
['Geometry contract', '960×540, источник в данных карточки', 'даёт ожидаемую рамку review', 'реальный CSS box и отсутствие shift'],
['Intent', 'critical-candidate или deferred-candidate', 'объясняет ожидание команды', 'расписание browser scheduler'],
['Observation conditions', 'browser, viewport, cache state, route', 'делает повторный прогон сопоставимым', 'универсальный эффект во всех средах'],
['Rollback', 'вернуть прежний source mapping', 'ограничивает риск правки', 'отмену уже увиденного пользователем состояния'],
]),
heading('Три похожих симптома, три разные проверки'),
paragraph('Поздний hero проверяется не тем же способом, что и скачок layout. Для hero сначала посмотрите, какой slot продукт считает главным, какой source ему сопоставлен и какие конкуренты были в конкретном browser report. Для скачка сначала проверяется geometry contract и фактический DOM/layout выбранного сценария. Для «неверного priority» сначала уточняется, что означает это слово: product intent или факт, замеченный в trace. Одно измерение не закрывает все три вопроса.'),
paragraph('Полезно заранее записать и отрицательный результат. Например: «в component data dimensions есть, но при открытии фильтра slot меняет контейнер; вывод о причине не сделан». Такая запись лучше красивого диагноза, потому что она не превращает гипотезу в релизное решение. Сначала ограничивается неисправность, потом выбирается один эксперимент. Это особенно важно для изображений: они одновременно касаются content, CSS, доставки и доступности.'),
codeBlock(fixtureExample),
paragraph('Команда может добавить этот fixture рядом с данными или reducer, чтобы не потерять простую границу: нельзя declared decode до declared load; геометрия принимается отдельно; record внешнего наблюдения не делает <code>realMetricMeasured</code> истинным. Но fixture не заменяет полевой разбор. Если она PASS, это значит только, что локальный contract не назвал свою собственную строку браузерной метрикой.'),
figure('/assets/editorial/2022/media-performance-2022-diagnosis-rollback.svg', 'Диагностическая схема: жалоба делится на late hero, layout movement и priority dispute; для каждого собираются отдельные contract facts и browser observation conditions. Затем выполняется одна обратимая правка; если evidence не подтверждает гипотезу, возвращается прежний resource-to-slot mapping. Рамка снизу исключает из схемы измерение реального LCP и CLS.', 'В разборе медиа откат нужен для сохранения объяснимости: одна гипотеза, одна правка, тот же сценарий проверки.'),
heading('Проверка геометрии без ложного обещания CLS'),
paragraph('Зарезервированная geometry — хороший кандидат на маленькую правку, потому что у неё есть конкретный owner. В data contract можно отклонить нулевую ширину или высоту, показать fallback и не допустить, чтобы component молча получил неизвестную рамку. Но это не завершённый вывод о layout shift. Реальная страница содержит шрифты, контейнеры, соседние блоки и разные ветви состояния. Проверить их можно только через фактическую разметку и условия запуска.'),
paragraph('Если geometry добавляется в API впервые, rollback должен быть простым: вернуть прежнюю обработку конкретного slot и не удалять evidence packet. Не используйте откат как способ скрыть дефект: если прежний путь снова даёт скачок, он остаётся известным состоянием задачи. Следующая итерация меняет другую, явно названную причину — например контейнер или источник ratio, — а не все слои сразу.'),
heading('Проверка источника и priority без волшебного атрибута'),
paragraph('Для правильного источника недостаточно назвать меньший файл «лучшим». Slot может требовать другой crop, плотность, формат или доступный fallback. Сопоставьте resource-to-slot в одном месте и проверьте, что alt относится к содержанию, а не к имени файла. Если источник меняется, зафиксируйте, какой визуальный и content result ожидается; иначе откат будет невозможен, потому что новый файл окажется «оптимизацией» без критерия пригодности.'),
paragraph('Priority intent полезен как вопрос к архитектуре страницы: почему этот ресурс critical, а следующий deferred? Но ответ о фактическом порядке лежит в browser observation, а не в строке config. Даже датированный LCP draft не превращает product label в measurement. Поэтому report должен отделять: «мы задали такое намерение» и «в таких условиях увидели такой сигнал». Между ними остаётся место для других ресурсов, браузерной версии и cache state.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Запишите одно воспроизводимое наблюдение и его условия. Не начинайте с общего «нужно улучшить скорость».',
'<strong>Причина.</strong> Составьте список возможных слоёв: source mapping, declared geometry, page composition, application intent или platform behaviour. Не выбирайте виновника по заголовку DevTools.',
'<strong>Проверка contract.</strong> Проверьте component data и fixture: есть ли markup, положительная geometry, явный intent и отсутствует ли ложный metric claim.',
'<strong>Проверка браузера.</strong> Зафиксируйте browser/version, route, viewport, cache state и trace. Назовите отдельно факт и интерпретацию.',
'<strong>Действие.</strong> Внесите одну обратимую правку: geometry, source mapping или page composition. Сохраните прежнее значение для rollback.',
'<strong>Повтор.</strong> Прогоните тот же сценарий. Если гипотеза не подтверждена, откатите одну правку и расширьте evidence, а не добавляйте следующую оптимизацию поверх неё.',
]),
heading('Пример границы для code review'),
paragraph('На review стоит задать несколько коротких вопросов. Где у slot живут dimensions? Откуда они берутся и что происходит, если их нет? Почему этот ресурс находится в early path? Каким наблюдением это проверялось и где лежат условия? Какой один mapping будет возвращён, если результат неверен? Эти вопросы не требуют сложной платформенной теории, но не дают превратить optional attribute в неподтверждённое performance обещание.'),
paragraph('Ответ «проверили локально» недостаточен, если не указан слой. Локально можно проверить object и component render; в браузере — конкретный page state; в аналитике — выбранный сбор событий. У каждого слоя своя ошибка и свой владелец. Эта статья сознательно не приводит выдуманный trace или изменение процентов: без реального артефакта такие цифры только мешают отличать рабочую гипотезу от удачного пересказа.'),
heading('Историческая граница августа 2022'),
paragraph('В источниках используется датированный W3C First Public Working Draft LCP от 24 мая 2022 года и HTML 5.2 Recommendation от 14 декабря 2017 года. Первый документ описывает API наблюдения и его ограничения; второй — platform-level разметку. Ни один из них не доказывает поведение конкретного CDN, браузера или продукта. Labels <code>critical-candidate</code>, <code>deferred-candidate</code> и teaching tokens принадлежат только этой модели.'),
heading('Ограничение и следующий шаг'),
paragraph('Пакет не покрывает video, audio, background images, preload, lazy loading, <code>srcset</code>, image decode errors, server components, CDN transformation, field telemetry и доступность реального UI. Он также не задаёт target LCP/CLS, потому что без продукта, аудитории и среды это были бы произвольные числа. Не добавляйте такую цифру в задачу, пока не определён метод и условия наблюдения.'),
paragraph('Следующий шаг — провести один разбор по таблице и сохранить короткий report рядом с изменением. Если slot действительно требует новой geometry или source, добавьте соответствующий unit test. Затем отдельно повторите browser-прогон. Если есть расхождение, это не повод переписать историю: обновите гипотезу, откатите узкую правку и оставьте trace как вход следующей итерации.'),
], commonSources);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle].map(({ proseLength, ...revision }) => revision);
function verifyFixture() {
const report = runMediaPerformanceFixture();
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([name]) => name);
if (failed.length) { console.error('FAIL fixture: ' + failed.join(', ')); process.exitCode = 1; return; }
console.log('PASS fixture: ' + Object.keys(report.assertions).length + '/' + Object.keys(report.assertions).length + ' assertions');
}
if (process.argv.includes('--verify-fixture')) verifyFixture();
if (process.argv.includes('--print-revisions')) console.log(JSON.stringify(revisions));