{ "index": 195, "slug": "editorial-2022-08-practice-media-performance", "title": "Медиа в первом экране: разделите разметку, геометрию и измерение", "excerpt": "Поздний hero и скачок карточки часто начинаются с одной ошибки: команда смешивает описание ресурса, резервирование места и браузерное наблюдение. Разбираем контракт media slot, отрицательный путь и проверяемый критерий готовности.", "contentHtml": "

У большого изображения в первом экране обычно видны три симптома: hero появляется поздно, карточка прыгает после загрузки или браузер выбирает не тот ресурс. Цена ошибки выше одной медленной картинки. Пользователь видит пустое место или меняющийся контент. Команда меняет loading, размеры и приоритет одновременно, а затем не может понять, что сработало.

\n

Тезис простой: медиа-слот нужно проверять как несколько независимых контрактов. Разметка описывает ресурс. Геометрия резервирует место. Загрузка и декодирование относятся к пути платформы. LCP и layout shift появляются только в наблюдаемом браузерном сценарии. Если свести всё к флагу loaded, тест легко выдаст правильный ответ на неправильный вопрос.

\n

Сначала отделите симптом от причины

\n

Возьмём карточку товара. В данных есть изображение размером 2400×1600, а компонент показывает его в области 320×213. Ниже по странице лежат ещё двадцать таких карточек. Пользователь открывает страницу на телефоне. Сеть получает тяжёлый файл, контейнер сначала не имеет высоты, а после ответа соседний текст сдвигается.

\n

Эта ситуация содержит как минимум три разные задачи. Нужно выбрать источник, подходящий слоту. Нужно объявить ожидаемую геометрию. Нужно проверить, что произошло в реальной странице. Уменьшение файла не исправит отсутствие высоты. width и height не докажут, что картинка стала кандидатом LCP. Учебный unit-тест не покажет, что layout shift исчез.

\n

Механизм media slot

\n

Для одного изображения зафиксируйте четыре поля.

\n\n

Порядок важен. Без разметки нельзя планировать ресурс. Без геометрии нельзя обещать стабильный контейнер. После загрузки ещё не следует автоматически делать вывод о готовых пикселях. А запись с числом, переданная в локальную модель, не становится измерением браузера.

\n

Учебный пример: контракт, а не браузер

\n

Следующая модель ограничена памятью процесса. Она не создаёт DOM, не открывает сеть, не вызывает decode() и не запускает PerformanceObserver. Её задача — проверить порядок переходов и не дать названию функции обещать больше, чем оно делает.

\n
const model = {\n  markup: null,\n  geometry: null,\n  request: { phase: 'not-planned', intent: null },\n  decode: 'not-requested',\n};\n\nfunction declareMarkup(state, descriptor) {\n  if (!descriptor || typeof descriptor.src !== 'string'\n      || typeof descriptor.alt !== 'string'\n      || !descriptor.src.startsWith('/media/')) {\n    return { ...state, event: 'markup-rejected' };\n  }\n  return { ...state, markup: descriptor, event: 'markup-declared' };\n}\n\nfunction reserveGeometry(state, width, height) {\n  if (!Number.isInteger(width) || !Number.isInteger(height)\n      || width < 1 || height < 1) {\n    return { ...state, event: 'geometry-rejected' };\n  }\n  return { ...state, geometry: { width, height }, event: 'geometry-reserved' };\n}\n\nfunction planLoad(state, intent) {\n  if (!state.markup) return { ...state, event: 'load-blocked-no-markup' };\n  if (!['critical-candidate', 'deferred-candidate'].includes(intent)) {\n    return { ...state, event: 'intent-rejected' };\n  }\n  return { ...state, request: { phase: 'planned', intent }, event: 'load-planned' };\n}\n\nfunction declareLoaded(state, signal) {\n  if (state.request.phase !== 'planned' || signal !== 'teaching-load-ok') {\n    return { ...state, event: 'load-rejected' };\n  }\n  return {\n    ...state,\n    request: { ...state.request, phase: 'declared-loaded' },\n    event: 'load-declared',\n  };\n}\n\nfunction markDecode(state) {\n  if (state.request.phase !== 'declared-loaded') {\n    return { ...state, event: 'decode-rejected-before-load' };\n  }\n  return { ...state, decode: 'declared-decoded', event: 'decode-declared' };\n}\n\nconst planned = planLoad(\n  reserveGeometry(\n    declareMarkup(model, { src: '/media/card-320.webp', alt: 'Карточка товара' }),\n    320,\n    213,\n  ),\n  'deferred-candidate',\n);\nconst loaded = declareLoaded(planned, 'teaching-load-ok');\nconst decoded = markDecode(loaded);\nconst premature = markDecode(planned);\n\nconsole.assert(decoded.event === 'decode-declared');\nconsole.assert(premature.event === 'decode-rejected-before-load');\nconsole.assert(reserveGeometry(model, 0, 213).event === 'geometry-rejected');
\n

В учебном фрагменте переход planned → declared-loaded появляется через declareLoaded. Сигнал teaching-load-ok искусственный: он не отправляет запрос и не подтверждает ответ сервера. В настоящем коде его заменяет событие или адаптер платформы, а markDecode должен вызываться только после этого перехода. Вызов до загрузки отклоняется, нулевая геометрия не резервирует место, а источник без src или alt не попадает в контракт.

\n
\"Схема
Контракт делает границы явными. Схема не является DOM-деревом, browser trace или измерением LCP и CLS.
\n

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

\n
Диагностика проблем одного media slot
СимптомПричинаПроверкаДействие
Пустое место до heroРазметка или источник появляются поздноПроверить HTML, resource timing и порядок запросовИсправить композицию и сопоставление source-to-slot
Карточка сдвигается после ответаУ контейнера нет устойчивой geometryПроверить dimensions в данных и фактический DOM в выбранном viewportДобавить ratio или положительные width/height; затем проверить страницу
Hero приходит после второстепенных файловIntent приняли за реальный priorityСопоставить resource trace, cache state и конкурирующие запросыУточнить источник и проверить browser-сценарий, а не менять label вслепую
Тест сообщает «готово» слишком раноFixture смешивает load, decode и paintПосмотреть, какой API и какой слой реально вызываетсяРазделить локальные переходы и вынести метрику в отдельный прогон
После ошибки слот исчезаетНет отрицательного пути для недоступного медиаОтключить ресурс или вернуть ошибку загрузкиСохранить geometry, alt и понятный fallback
\n

Почему width и height не закрывают весь вопрос

\n

Атрибуты размеров дают браузеру исходные данные для соотношения сторон. Это полезная часть контракта. Но компонент может обрезать изображение через object-fit, менять рамку в grid или скрывать слот после действия пользователя. Соседний блок может загрузить шрифт и изменить высоту. Поэтому unit-проверка должна говорить geometryReserved, а не noLayoutShift.

\n

Для разных представлений нужны разные размеры. Hero, карточка и миниатюра могут использовать один оригинал, но не одну рамку. Если высота неизвестна, запишите правило fallback. Не переносите размеры исходного файла в UI автоматически. Иначе большой оригинал создаст ложное чувство точности, а маленький слот всё равно будет рассчитан неверно.

\n

Почему intent не равен приоритету

\n

Метка critical-candidate полезна в code review. Она заставляет ответить, почему ресурс нужен до первого действия пользователя. Но это не команда браузеру скачать файл первым. На порядок влияют разметка, другие ресурсы, кеш, соединение, браузер и версия движка. В отчёте разделяйте намерение и факт: «компонент пометил слот критичным» — одно утверждение; «в этом запуске ресурс получил такой путь и такую отрисовку» — другое.

\n

Для отложенного медиа отрицательный путь не исчезает. Отложенный слот всё ещё должен иметь alt, geometry, состояние ошибки и понятное поведение при возвращении в viewport. Если продукту нужен placeholder, его тоже нужно описать. Не прячьте отсутствие ресурса за бесконечным skeleton без условия завершения.

\n

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

\n
  1. Запишите один симптом. Укажите URL, slot, viewport и действие пользователя. Формулировка «страница медленная» слишком широка.
  2. Разложите контракт. Найдите markup, geometry, intent и источник observation. Отметьте отсутствующее поле.
  3. Проверьте отрицательный путь. Подайте неизвестный source, нулевую высоту, недоступный ответ и повторный вход в отложенный слот.
  4. Запустите локальную проверку. Убедитесь, что geometry отклоняет невалидные значения, decode не следует до declared load, а fixture не называет себя LCP.
  5. Проведите браузерный прогон. Зафиксируйте браузер, версию, viewport, cache state, сеть, URL и список конкурирующих ресурсов.
  6. Сделайте одну правку. Меняйте geometry, source mapping или композицию. Не меняйте все слои одновременно.
  7. Повторите тот же сценарий. Сравните только наблюдаемые факты. Если результат не подтверждает гипотезу, верните одну правку и обновите причину.
  8. Оставьте доказательство. Сохраните локальный результат, browser report и правило отката рядом с изменением.
\n

Ограничения

\n

Эта схема не моделирует srcset selection, CSS background, video poster, CDN negotiation, preload, lazy loading, кеширование, ошибки декодирования, server rendering и accessibility tree. Она не обещает, что размеры устранили CLS, и не определяет, какой ресурс станет LCP.

\n

Текущий LCP остаётся свойством конкретного документа и запуска. Кандидат может измениться до пользовательского ввода, а результат зависит от содержимого viewport и условий загрузки. Поэтому учебное число value: 1 или зелёный PASS не являются production-результатом. Без реального trace нельзя писать «LCP улучшился на N миллисекунд».

\n

Не всякая проблема требует немедленной смены источника. Если hero поздний из-за серверной композиции, новый формат файла не устранит причину. Если layout меняется из-за контейнера, атрибуты изображения не заменят исправление CSS или данных. Контракт помогает выбрать слой, но не принимает решение за продукт.

\n

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

\n

Изменение готово, если для одного media slot выполнены все условия: markup содержит ожидаемый ресурс и alt; geometry принимает только валидную рамку; intent объяснён и не выдан за browser priority; отрицательный путь сохраняет понятный fallback; локальная fixture проверяет только свой порядок; браузерный прогон содержит условия и наблюдаемый результат. В отчёте отдельно названы факт, интерпретация и оставшееся ограничение. Если хотя бы один слой подтверждён только словом «работает», задача не готова.

\n

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

" }