{ "index": 195, "slug": "editorial-2022-08-practice-media-performance", "title": "Медиа в первом экране: разделите разметку, геометрию и измерение", "excerpt": "Поздний hero и скачок карточки часто начинаются с одной ошибки: команда смешивает описание ресурса, резервирование места и браузерное наблюдение. Разбираем контракт media slot, отрицательный путь и проверяемый критерий готовности.", "contentHtml": "
У большого изображения в первом экране обычно видны три симптома: hero появляется поздно, карточка прыгает после загрузки или браузер выбирает не тот ресурс. Цена ошибки выше одной медленной картинки. Пользователь видит пустое место или меняющийся контент. Команда меняет loading, размеры и приоритет одновременно, а затем не может понять, что сработало.
Тезис простой: медиа-слот нужно проверять как несколько независимых контрактов. Разметка описывает ресурс. Геометрия резервирует место. Загрузка и декодирование относятся к пути платформы. LCP и layout shift появляются только в наблюдаемом браузерном сценарии. Если свести всё к флагу loaded, тест легко выдаст правильный ответ на неправильный вопрос.
Возьмём карточку товара. В данных есть изображение размером 2400×1600, а компонент показывает его в области 320×213. Ниже по странице лежат ещё двадцать таких карточек. Пользователь открывает страницу на телефоне. Сеть получает тяжёлый файл, контейнер сначала не имеет высоты, а после ответа соседний текст сдвигается.
\nЭта ситуация содержит как минимум три разные задачи. Нужно выбрать источник, подходящий слоту. Нужно объявить ожидаемую геометрию. Нужно проверить, что произошло в реальной странице. Уменьшение файла не исправит отсутствие высоты. width и height не докажут, что картинка стала кандидатом LCP. Учебный unit-тест не покажет, что layout shift исчез.
Для одного изображения зафиксируйте четыре поля.
\nsrc, альтернативный текст и, если нужно, набор responsive-источников.width и height или ratio. Это вход для layout, а не снимок фактического CSS-box.Порядок важен. Без разметки нельзя планировать ресурс. Без геометрии нельзя обещать стабильный контейнер. После загрузки ещё не следует автоматически делать вывод о готовых пикселях. А запись с числом, переданная в локальную модель, не становится измерением браузера.
\nСледующая модель ограничена памятью процесса. Она не создаёт DOM, не открывает сеть, не вызывает decode() и не запускает PerformanceObserver. Её задача — проверить порядок переходов и не дать названию функции обещать больше, чем оно делает.
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 не попадает в контракт.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Пустое место до 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 |
width и height не закрывают весь вопросАтрибуты размеров дают браузеру исходные данные для соотношения сторон. Это полезная часть контракта. Но компонент может обрезать изображение через object-fit, менять рамку в grid или скрывать слот после действия пользователя. Соседний блок может загрузить шрифт и изменить высоту. Поэтому unit-проверка должна говорить geometryReserved, а не noLayoutShift.
Для разных представлений нужны разные размеры. Hero, карточка и миниатюра могут использовать один оригинал, но не одну рамку. Если высота неизвестна, запишите правило fallback. Не переносите размеры исходного файла в UI автоматически. Иначе большой оригинал создаст ложное чувство точности, а маленький слот всё равно будет рассчитан неверно.
\nМетка critical-candidate полезна в code review. Она заставляет ответить, почему ресурс нужен до первого действия пользователя. Но это не команда браузеру скачать файл первым. На порядок влияют разметка, другие ресурсы, кеш, соединение, браузер и версия движка. В отчёте разделяйте намерение и факт: «компонент пометил слот критичным» — одно утверждение; «в этом запуске ресурс получил такой путь и такую отрисовку» — другое.
Для отложенного медиа отрицательный путь не исчезает. Отложенный слот всё ещё должен иметь alt, geometry, состояние ошибки и понятное поведение при возвращении в viewport. Если продукту нужен placeholder, его тоже нужно описать. Не прячьте отсутствие ресурса за бесконечным skeleton без условия завершения.
\nЭта схема не моделирует srcset selection, CSS background, video poster, CDN negotiation, preload, lazy loading, кеширование, ошибки декодирования, server rendering и accessibility tree. Она не обещает, что размеры устранили CLS, и не определяет, какой ресурс станет LCP.
Текущий LCP остаётся свойством конкретного документа и запуска. Кандидат может измениться до пользовательского ввода, а результат зависит от содержимого viewport и условий загрузки. Поэтому учебное число value: 1 или зелёный PASS не являются production-результатом. Без реального trace нельзя писать «LCP улучшился на N миллисекунд».
Не всякая проблема требует немедленной смены источника. Если hero поздний из-за серверной композиции, новый формат файла не устранит причину. Если layout меняется из-за контейнера, атрибуты изображения не заменят исправление CSS или данных. Контракт помогает выбрать слой, но не принимает решение за продукт.
\nИзменение готово, если для одного media slot выполнены все условия: markup содержит ожидаемый ресурс и alt; geometry принимает только валидную рамку; intent объяснён и не выдан за browser priority; отрицательный путь сохраняет понятный fallback; локальная fixture проверяет только свой порядок; браузерный прогон содержит условия и наблюдаемый результат. В отчёте отдельно названы факт, интерпретация и оставшееся ограничение. Если хотя бы один слой подтверждён только словом «работает», задача не готова.
\n