Files

8 lines
19 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 195,
"slug": "editorial-2022-08-practice-media-performance",
"title": "Медиа в первом экране: разделите разметку, геометрию и измерение",
"excerpt": "Поздний hero и скачок карточки часто начинаются с одной ошибки: команда смешивает описание ресурса, резервирование места и браузерное наблюдение. Разбираем контракт media slot, отрицательный путь и проверяемый критерий готовности.",
"contentHtml": "<p>У большого изображения в первом экране обычно видны три симптома: hero появляется поздно, карточка прыгает после загрузки или браузер выбирает не тот ресурс. Цена ошибки выше одной медленной картинки. Пользователь видит пустое место или меняющийся контент. Команда меняет <code>loading</code>, размеры и приоритет одновременно, а затем не может понять, что сработало.</p>\n<p>Тезис простой: медиа-слот нужно проверять как несколько независимых контрактов. Разметка описывает ресурс. Геометрия резервирует место. Загрузка и декодирование относятся к пути платформы. LCP и layout shift появляются только в наблюдаемом браузерном сценарии. Если свести всё к флагу <code>loaded</code>, тест легко выдаст правильный ответ на неправильный вопрос.</p>\n<h2>Сначала отделите симптом от причины</h2>\n<p>Возьмём карточку товара. В данных есть изображение размером 2400×1600, а компонент показывает его в области 320×213. Ниже по странице лежат ещё двадцать таких карточек. Пользователь открывает страницу на телефоне. Сеть получает тяжёлый файл, контейнер сначала не имеет высоты, а после ответа соседний текст сдвигается.</p>\n<p>Эта ситуация содержит как минимум три разные задачи. Нужно выбрать источник, подходящий слоту. Нужно объявить ожидаемую геометрию. Нужно проверить, что произошло в реальной странице. Уменьшение файла не исправит отсутствие высоты. <code>width</code> и <code>height</code> не докажут, что картинка стала кандидатом LCP. Учебный unit-тест не покажет, что layout shift исчез.</p>\n<h2>Механизм media slot</h2>\n<p>Для одного изображения зафиксируйте четыре поля.</p>\n<ul><li><strong>Markup.</strong> Компонент знает <code>src</code>, альтернативный текст и, если нужно, набор responsive-источников.</li><li><strong>Geometry.</strong> Контракт знает положительные <code>width</code> и <code>height</code> или ratio. Это вход для layout, а не снимок фактического CSS-box.</li><li><strong>Intent.</strong> Приложение может назвать слот критичным для первого сценария или отложенным. Такая метка объясняет решение команды, но не назначает реальный приоритет scheduler.</li><li><strong>Observation.</strong> Отдельный прогон браузера сообщает, какой ресурс загрузился, когда он отрисовался и менялась ли геометрия. Без этого нельзя утверждать, что оптимизация улучшила LCP или CLS.</li></ul>\n<p>Порядок важен. Без разметки нельзя планировать ресурс. Без геометрии нельзя обещать стабильный контейнер. После загрузки ещё не следует автоматически делать вывод о готовых пикселях. А запись с числом, переданная в локальную модель, не становится измерением браузера.</p>\n<h2>Учебный пример: контракт, а не браузер</h2>\n<p>Следующая модель ограничена памятью процесса. Она не создаёт DOM, не открывает сеть, не вызывает <code>decode()</code> и не запускает <code>PerformanceObserver</code>. Её задача — проверить порядок переходов и не дать названию функции обещать больше, чем оно делает.</p>\n<pre><code>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 &lt; 1 || height &lt; 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');</code></pre>\n<p>В учебном фрагменте переход <code>planned → declared-loaded</code> появляется через <code>declareLoaded</code>. Сигнал <code>teaching-load-ok</code> искусственный: он не отправляет запрос и не подтверждает ответ сервера. В настоящем коде его заменяет событие или адаптер платформы, а <code>markDecode</code> должен вызываться только после этого перехода. Вызов до загрузки отклоняется, нулевая геометрия не резервирует место, а источник без <code>src</code> или <code>alt</code> не попадает в контракт.</p>\n<figure><img src=\"/assets/editorial/2022/media-performance-2022-slot-contract.svg\" alt=\"Схема media slot: разметка и геометрия отделены от намерения загрузки, учебных переходов load и decode и внешнего наблюдения\" loading=\"lazy\" /><figcaption>Контракт делает границы явными. Схема не является DOM-деревом, browser trace или измерением LCP и CLS.</figcaption></figure>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика проблем одного media slot</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Пустое место до hero</td><td>Разметка или источник появляются поздно</td><td>Проверить HTML, resource timing и порядок запросов</td><td>Исправить композицию и сопоставление source-to-slot</td></tr><tr><td>Карточка сдвигается после ответа</td><td>У контейнера нет устойчивой geometry</td><td>Проверить dimensions в данных и фактический DOM в выбранном viewport</td><td>Добавить ratio или положительные width/height; затем проверить страницу</td></tr><tr><td>Hero приходит после второстепенных файлов</td><td>Intent приняли за реальный priority</td><td>Сопоставить resource trace, cache state и конкурирующие запросы</td><td>Уточнить источник и проверить browser-сценарий, а не менять label вслепую</td></tr><tr><td>Тест сообщает «готово» слишком рано</td><td>Fixture смешивает load, decode и paint</td><td>Посмотреть, какой API и какой слой реально вызывается</td><td>Разделить локальные переходы и вынести метрику в отдельный прогон</td></tr><tr><td>После ошибки слот исчезает</td><td>Нет отрицательного пути для недоступного медиа</td><td>Отключить ресурс или вернуть ошибку загрузки</td><td>Сохранить geometry, alt и понятный fallback</td></tr></tbody></table></div>\n<h2>Почему <code>width</code> и <code>height</code> не закрывают весь вопрос</h2>\n<p>Атрибуты размеров дают браузеру исходные данные для соотношения сторон. Это полезная часть контракта. Но компонент может обрезать изображение через <code>object-fit</code>, менять рамку в grid или скрывать слот после действия пользователя. Соседний блок может загрузить шрифт и изменить высоту. Поэтому unit-проверка должна говорить <code>geometryReserved</code>, а не <code>noLayoutShift</code>.</p>\n<p>Для разных представлений нужны разные размеры. Hero, карточка и миниатюра могут использовать один оригинал, но не одну рамку. Если высота неизвестна, запишите правило fallback. Не переносите размеры исходного файла в UI автоматически. Иначе большой оригинал создаст ложное чувство точности, а маленький слот всё равно будет рассчитан неверно.</p>\n<h2>Почему intent не равен приоритету</h2>\n<p>Метка <code>critical-candidate</code> полезна в code review. Она заставляет ответить, почему ресурс нужен до первого действия пользователя. Но это не команда браузеру скачать файл первым. На порядок влияют разметка, другие ресурсы, кеш, соединение, браузер и версия движка. В отчёте разделяйте намерение и факт: «компонент пометил слот критичным» — одно утверждение; «в этом запуске ресурс получил такой путь и такую отрисовку» — другое.</p>\n<p>Для отложенного медиа отрицательный путь не исчезает. Отложенный слот всё ещё должен иметь alt, geometry, состояние ошибки и понятное поведение при возвращении в viewport. Если продукту нужен placeholder, его тоже нужно описать. Не прячьте отсутствие ресурса за бесконечным skeleton без условия завершения.</p>\n<h2>Порядок действий</h2>\n<ol><li><strong>Запишите один симптом.</strong> Укажите URL, slot, viewport и действие пользователя. Формулировка «страница медленная» слишком широка.</li><li><strong>Разложите контракт.</strong> Найдите markup, geometry, intent и источник observation. Отметьте отсутствующее поле.</li><li><strong>Проверьте отрицательный путь.</strong> Подайте неизвестный source, нулевую высоту, недоступный ответ и повторный вход в отложенный слот.</li><li><strong>Запустите локальную проверку.</strong> Убедитесь, что geometry отклоняет невалидные значения, decode не следует до declared load, а fixture не называет себя LCP.</li><li><strong>Проведите браузерный прогон.</strong> Зафиксируйте браузер, версию, viewport, cache state, сеть, URL и список конкурирующих ресурсов.</li><li><strong>Сделайте одну правку.</strong> Меняйте geometry, source mapping или композицию. Не меняйте все слои одновременно.</li><li><strong>Повторите тот же сценарий.</strong> Сравните только наблюдаемые факты. Если результат не подтверждает гипотезу, верните одну правку и обновите причину.</li><li><strong>Оставьте доказательство.</strong> Сохраните локальный результат, browser report и правило отката рядом с изменением.</li></ol>\n<h2>Ограничения</h2>\n<p>Эта схема не моделирует <code>srcset</code> selection, CSS background, video poster, CDN negotiation, preload, lazy loading, кеширование, ошибки декодирования, server rendering и accessibility tree. Она не обещает, что размеры устранили CLS, и не определяет, какой ресурс станет LCP.</p>\n<p>Текущий LCP остаётся свойством конкретного документа и запуска. Кандидат может измениться до пользовательского ввода, а результат зависит от содержимого viewport и условий загрузки. Поэтому учебное число <code>value: 1</code> или зелёный PASS не являются production-результатом. Без реального trace нельзя писать «LCP улучшился на N миллисекунд».</p>\n<p>Не всякая проблема требует немедленной смены источника. Если hero поздний из-за серверной композиции, новый формат файла не устранит причину. Если layout меняется из-за контейнера, атрибуты изображения не заменят исправление CSS или данных. Контракт помогает выбрать слой, но не принимает решение за продукт.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Изменение готово, если для одного media slot выполнены все условия: markup содержит ожидаемый ресурс и alt; geometry принимает только валидную рамку; intent объяснён и не выдан за browser priority; отрицательный путь сохраняет понятный fallback; локальная fixture проверяет только свой порядок; браузерный прогон содержит условия и наблюдаемый результат. В отчёте отдельно названы факт, интерпретация и оставшееся ограничение. Если хотя бы один слой подтверждён только словом «работает», задача не готова.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.w3.org/TR/2017/REC-html52-20171214/single-page.html#the-img-element\" target=\"_blank\" rel=\"noopener noreferrer\">W3C HTML 5.2: элемент img</a> — Recommendation от 14 декабря 2017 года о разметке изображения, его размерах и загрузке; это историческая спецификация для контекста статьи.</li><li><a href=\"https://www.w3.org/TR/2022/WD-largest-contentful-paint-20220524/\" target=\"_blank\" rel=\"noopener noreferrer\">W3C: Largest Contentful Paint, First Public Working Draft от 24 мая 2022 года</a> — описывает кандидатов LCP и ограничения API; это рабочий документ, а не измерение конкретной страницы.</li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/decode\" target=\"_blank\" rel=\"noopener noreferrer\">MDN: HTMLImageElement.decode()</a> — справочное описание асинхронного декодирования изображения; вызов API не заменяет наблюдение LCP и layout shift.</li></ul>"
}