{ "index": 194, "slug": "editorial-2022-08-mechanism-media-performance", "title": "Медиа без ложных метрик: как отделить контракт компонента от наблюдения браузера", "excerpt": "Изображение может иметь правильный размер в данных и всё равно поздно попасть на экран. Разбираем слои media slot, границы LCP-наблюдения, учебный контракт и обратимую проверку.", "contentHtml": "

Изображение в карточке занимает большую часть сетевого трафика, хотя на экране выглядит маленьким. В другом случае в разметке hero-блока указаны width и height, но первый экран всё равно меняет геометрию. Команда видит слово loaded, добавляет preload или меняет fetchpriority и ждёт улучшения. Через неделю уже трудно установить, что именно изменило результат: источник, разметка, CSS, очередь загрузки или условия замера. Цена ошибки — лишний трафик, поздний главный контент и релиз, который сложнее откатить, чем проверить.

\n

Тезис статьи простой: контракт media slot и наблюдение браузера — разные факты. LCP (Largest Contentful Paint) — наблюдаемая метрика отрисовки крупнейшего видимого элемента до пользовательского ввода; она не хранится в объекте контракта. Компонент может объявить источник, альтернативный текст и ожидаемую геометрию. Страница может выразить намерение загрузить slot раньше или позже. Браузер сам решает, когда получить ресурс, декодировать его, разместить и нарисовать. Только отдельный прогон страницы показывает, что произошло в конкретной среде. Если склеить эти слои одним флагом, локальная проверка начнёт выдавать обещание о поведении браузера.

\n

Механизм: пять слоёв одного media slot

\n

Начните с одного media slot — именованной области для медиа, например product-card/main-image. У него есть пять независимых вопросов. Какой ресурс описывает разметка? Какую рамку обещают данные? Считает ли приложение slot важным для первого экрана? На каком этапе локальная модель разрешает переход? Что реально увидел браузер при заданных URL, viewport и состоянии кэша?

\n
Слои контракта и граница доказательства
СлойВладелецПроверяемый фактЧего он не доказывает
MarkupКомпонентЕсть src и alt для slotРесурс уже запрошен или нарисован
GeometryДанные и layout contractШирина и высота положительныРеальный CSS box и отсутствие сдвига
Load intentКомпозиция страницыSlot помечен как critical или deferred candidateФактический приоритет scheduler
Declared transitionУчебная модельЛокальный порядок load → decodeСетевой ответ и API декодирования изображения
External observationБраузерный прогонЕсть запись с условиями и значениемПричина результата без анализа страницы и trace
\n

Таблица нужна, чтобы assertion одного слоя не использовался как доказательство другого. Проверка dimensions > 0 защищает вход компонента, но не доказывает наличие такого же CSS box и отсутствие CLS (Cumulative Layout Shift, неожиданных сдвигов layout). Label critical-candidate фиксирует решение приложения, но не сообщает, какой ресурс браузер выбрал первым. Запись LCP принадлежит наблюдению страницы, а не объекту из unit-теста.

\n

Почему loaded недостаточно

\n

Слово loaded часто скрывает несколько разных фактов. Страница может объявить намерение загрузить ресурс раньше. Если браузер отправит запрос, он получит ответ; затем данные изображения должны стать доступными для декодирования и отрисовки. Эти этапы могут перекрываться и не равны одной записи наблюдателя. Поэтому load нельзя переименовать в «медиа уже нарисовано».

\n

В учебной модели полезно разделить их явно. Она не создаёт DOM, не отправляет запрос, не вызывает PerformanceObserver и не измеряет LCP или CLS. Она проверяет только порядок переходов. Это безопасный пример: он объясняет инвариант и не выдаёт себя за профиль браузера.

\n
const media = {\n  phase: 'planned',\n  intent: 'critical-candidate',\n  geometry: { width: 960, height: 540 },\n};\n\nfunction markLoad(state, token) {\n  if (state.phase !== 'planned' || token !== 'teaching-load-ok') {\n    return { ...state, event: 'load-rejected' };\n  }\n  return { ...state, phase: 'declared-loaded', event: 'load-declared' };\n}\n\nfunction markDecode(state, token) {\n  if (state.phase !== 'declared-loaded' || token !== 'teaching-decode-ok') {\n    return { ...state, event: 'decode-rejected' };\n  }\n  return { ...state, phase: 'declared-decoded', event: 'decode-declared' };\n}\n\nconst rejected = markDecode(media, 'teaching-decode-ok');\nconst loaded = markLoad(media, 'teaching-load-ok');\nconst decoded = markDecode(loaded, 'teaching-decode-ok');\n\nconsole.log({ rejected, decoded });
\n

Имена teaching-load-ok и teaching-decode-ok намеренно говорят об ограничении. Это токены локальной модели. Первый вызов в примере показывает отрицательный путь: декодирование до объявления загрузки отклоняется. В реальном коде токены заменят события, Promise или адаптер платформы. Нельзя вызвать эту функцию вместо загрузки картинки и затем утверждать, что браузер показал изображение.

\n

Та же граница относится к геометрии. Проверка width > 0 и height > 0 полезна, когда данные приходят из CMS или API. Она ловит пустые данные до рендера. Но она не видит aspect-ratio, grid, container query, смену шрифта и динамический контент. Поэтому честное имя проверки — geometryDeclared, а не noLayoutShift.

\n
\"Схема
Слои media slot нельзя свести к одному флагу. Схема показывает границу между данными компонента, намерением страницы и наблюдением браузера.
\n

Как наблюдать реальную страницу

\n

Когда нужен LCP, запускайте наблюдение в странице, а не в объекте контракта. Следующий код — пример регистрации наблюдателя. Он показывает, откуда берётся запись, но сам по себе не даёт результата для вашего URL. Для корректного вывода нужно сохранить маршрут, viewport, браузер, состояние кэша и момент завершения сценария.

\n
const entries = [];\nconst supportsLcp =\n  typeof PerformanceObserver !== 'undefined' &&\n  PerformanceObserver.supportedEntryTypes?.includes(\n    'largest-contentful-paint',\n  );\n\nconst observer = supportsLcp\n  ? new PerformanceObserver((list) => {\n      entries.push(...list.getEntries());\n    })\n  : null;\n\nobserver?.observe({\n  type: 'largest-contentful-paint',\n  buffered: true,\n});\n\n// После завершения сценария сохраняют последний кандидат.\nconst lastEntry = entries[entries.length - 1] ?? null;\nobserver?.disconnect();\nconsole.log({ supportsLcp, lastEntry });
\n

Набор записей отвечает на вопрос «какой кандидат и в какой момент был замечен». Пока страница загружается, API может добавить несколько кандидатов; последняя запись — текущий последний кандидат на момент чтения. Если записей нет, это отсутствие данных, а не LCP, равный нулю. Записи не отвечают на вопрос «почему кандидат пришёл поздно». Причину ищут по цепочке: время ответа, выбор варианта, момент обнаружения URL браузером, декодирование, блокирующие ресурсы и layout. Не называйте число LCP результатом оптимизации, пока не определены условия сравнения и не повторён тот же сценарий.

\n

Намерение страницы тоже нужно проверять отдельно. Если hero помечен critical, это полезная подсказка для архитектуры и code review. Но планировщик браузера учитывает документ, сеть, кэш, тип ресурса и конкурентов. Строка в конфигурации не заменяет trace. Если наблюдение расходится с intent, сначала зафиксируйте расхождение, затем меняйте один слой.

\n

Симптом → причина → проверка → действие

\n
Карта диагностики медиа и ложных performance-выводов
СимптомПричинаПроверкаДействие
Маленькая картинка скачивает большой файлSlot получил неподходящий source или variantСопоставить slot, URL, размер ответа и выбранный форматИсправить mapping и повторить тот же сценарий
Карточка сдвигает соседний контентГеометрия отсутствует или меняется после рендераПроверить dimensions в данных и фактический DOM/layoutЗадать устойчивую рамку; отдельно проверить layout shift
Hero объявлен critical, но поздно появляетсяIntent принят за факт приоритетаСохранить trace с route, viewport и cache stateПроверять resource discovery и конкурентов, а не только label
Unit-тест говорит «медиа готово»Локальный переход назван браузерным результатомПроверить, создаются ли DOM, сеть и observerПереименовать assertion и добавить отдельный page-level прогон
После правки нет сравнимого эффектаОдновременно изменены source, CSS и priorityСравнить diff и условия запускаОткатить лишние изменения и повторить одну гипотезу
\n

Порядок действий

\n
  1. Запишите симптом. Выберите один URL, один media slot и один наблюдаемый дефект: поздний hero, лишний объём или сдвиг layout.
  2. Отделите намерение от факта. Выпишите ожидаемые source, dimensions и intent. Рядом укажите, что действительно видно в браузерном прогоне.
  3. Проверьте контракт. Убедитесь, что markup содержит корректный src и alt, geometry положительна, а названия assertion не обещают LCP или CLS.
  4. Проверьте отрицательный путь. Удалите dimensions, подставьте старый ответ, выберите неверный variant или воспроизведите позднее появление. Система должна отказать явно, а не показать ложную готовность.
  5. Снимите наблюдение. Зафиксируйте браузер и его версию, маршрут, viewport, состояние кэша, сетевые условия и trace. Не смешивайте данные разных сценариев.
  6. Измените один слой. Выберите source mapping, geometry, композицию страницы или intent. Сохраните прежнее значение для отката.
  7. Повторите тот же прогон. Сравнивайте одинаковые условия. Если гипотеза не подтверждена, верните узкую правку и расширьте проверку.
  8. Оставьте критерий. Запишите, какой факт считается исправлением и какой отрицательный путь обязан оставаться безопасным.
\n

Ограничения

\n

Эта модель не описывает все способы доставки медиа. За её пределами остаются srcset и sizes, CSS background, poster видео, lazy loading, preload, CDN-переговоры, кэш, ошибки декодирования, cross-origin timing, EXIF orientation и доступность. Каждый новый слой требует собственного входа и собственной проверки.

\n

Положительная geometry не гарантирует отсутствие layout shift. Правильный source не гарантирует лучший LCP. Наблюдение LCP не объясняет всю воспринимаемую скорость и не заменяет полевые данные. Учебный код не доказывает, что конкретная страница стала быстрее. Без реального прогона нельзя сообщать проценты, секунды или изменение конверсии.

\n

Отрицательный путь тоже предметен. Если источник недоступен, нужен согласованный fallback. Если dimensions не пришли, компонент должен выбрать безопасную рамку или явно показать ошибку. Если браузер не поддерживает нужный тип наблюдения, отчёт должен отметить отсутствие данных. Не подменяйте неизвестность нулём и не называйте пропущенное наблюдение успешным.

\n

Проверяемый критерий готовности

\n

Изменение готово, если для выбранного slot сохранены контракт и отчёт браузерного прогона. Контракт отдельно подтверждает разметку, положительную geometry и намерение страницы. Отчёт содержит одинаковые условия запуска и показывает наблюдаемый результат. Неверный порядок переходов, отсутствующая геометрия и недоступный source не приводят к ложному состоянию «готово». Изменён один слой; его эффект можно сравнить, а прежнее сопоставление вернуть без переписывания остальных слоёв.

\n

Проверяемые источники

" }