Инженерные истории · 03
Измерение реальных пользователей, а не только PageSpeed
- Core Web Vitals
- RUM
Как я собрал небольшой конвейер мониторинга реальных пользователей на странице товара Shopify, нашёл с его помощью всплеск сдвига макета, который лаборатория не видела, и устранил каждую причину, не жертвуя пиксельной точностью.
Core Web Vitals · web-vitals attribution · Google Apps Script · Critical CSS · Font loading · Shopify OS 2.0
Это разбор 03 из проекта витрины Lumway. Он полностью раскрывает тему производительности и измерений от начала до конца. Разбор 06 охватывает более широкую картину аналитики. Здесь тема узкая: страница товара, её Core Web Vitals и инструмент, который я собрал, чтобы доверять числам.
Контекст: телефон, платный трафик, решение за секунды
Lumway продаёт жевательные витамины напрямую потребителю в США. Большая часть покупок происходит на двух поверхностях: странице товара и наборе рекламных лендингов, которые несут тот же buy box. Трафик в основном платный и в основном мобильный. Посетитель приходит из рекламы, на телефоне среднего уровня, часто на медленном соединении, и за несколько секунд решает, выглядит ли это как настоящий товар, за который стоит платить. Страница, которая мерцает, прыгает или отрисовывает увеличенный кадр, прежде чем устояться, воспринимается как дешёвая в самый неподходящий момент. Поэтому производительность здесь никогда не была абстрактной оценкой, за которой гонятся. Это было первое впечатление и скорость принятия решения на поверхности, где делаются деньги.
Проблема: лаборатория и поле разошлись
Я начал с PageSpeed Insights и Lighthouse. Лабораторные числа для темы были в порядке: при быстрой закешированной тестовой загрузке страница товара отрисовывалась быстро и стояла неподвижно.
Реальные посетители рассказывали другую историю. Всплеск сдвига макета около 0,9 задевал примерно треть мобильных просмотров страниц с CLS, и на быстрых тестовых прогонах, которые я делал, он был практически невидим. Синтетический прогон прогревает кеш, работает на быстром соединении и часто отрисовывает до того, как вообще случится та самая последовательность, которая вызвала сдвиг. Сдвиг был реальным, частым в поле, а мой лабораторный инструментарий продолжал говорить мне, что страница здорова.
Формулировка важна, потому что это легко превратить в дешёвый тезис «PageSpeed врёт», и это было бы неправильно. PageSpeed и Lighthouse: хорошие диагностические инструменты. Они с настоящей строгостью выявляют ресурсы, блокирующие рендер, слишком большие изображения и стоимость главного потока. Чем они не являются, так это заменой измерения тех посетителей, которые у вас реально есть. Один синтетический профиль не может представить весь разброс устройств, соединений и состояний кеша, который приносит реальный трафик. Лаборатория: контролируемый замер. Поле: правда об опыте. Мне нужны были оба, а был только один.
Почему одной лаборатории здесь было недостаточно
Две детали сделали этот разрыв конкретным. Всплеск зависел от таймингов: он шёл от ресурса, который загружался поздно и перекомпоновывал страницу, поэтому при прогретой быстрой загрузке ресурс уже присутствовал, никакой перекомпоновки, никакого сдвига. Именно то условие, которое порождало плохой опыт, лаборатория воспроизводила с наименьшей вероятностью. А PageSpeed указывал мне на неправильную работу: его самым громким флагом были байты изображения в hero (об этом компромиссе позже), тогда как сдвиг макета, который реально вредил пользователям, не был главным заголовком. Если работать сверху вниз, отчёт заставил бы меня смягчать изображение товара и так и не тронуть настоящий дефект. Мне нужно было полевое измерение, ограниченное этой страницей, которым владею я.
Ограничения для инструмента
Я установил несколько жёстких ограничений, прежде чем что-либо писать.
- Никакой новой инфраструктуры. Никакого поставщика аналитики, никакого сервера, никакой базы данных. Ценность должна была быть в данных, а не в системе, которую надо обслуживать.
- Он никогда не должен ломать страницу. Код производительности, который ухудшает страницу, которую он измеряет, хуже, чем его отсутствие.
- Вне данных маркетинга. В магазине уже были GA4 и продуктовая аналитика, обе принадлежали маркетингу. Телеметрия производительности, попав туда, была бы шумом в их воронке и шумом, который мне пришлось бы потом отфильтровывать обратно.
- Никаких PII, строго ограничено. Измерять собственные страницы товара и больше ничего, и не нести ничего о человеке.
Инструмент: небольшой конвейер RUM, которым я владею
Я собрал всё это как один snippet Shopify, pdp-web-vitals.liquid, примерно 51 строка, обёрнутый в единственную асинхронную IIFE внутри try/catch, так что сбой в любом месте оставляет страницу нетронутой. Он делает четыре вещи.
Он измеряет реальные метрики. Он загружает attribution-сборку web-vitals v4 и подписывает все пять слушателей: LCP, CLS, INP, FCP, TTFB. Полевые значения из собственной сессии посетителя, а не лабораторная оценка.
Он оценивает каждую метрику так же, как Lighthouse. Рейтинг «good» или «poor» слишком грубый, чтобы увидеть, как страница медленно становится хуже, поэтому я переписал логнормальную кривую оценки Lighthouse (дополнительная функция ошибок erfc через приближение Абрамовица-Стигана) по тем же порогам, которые использует Lighthouse (LCP good/poor на 2500/4000 мс, CLS на 0,1/0,25). Каждая метрика приходит как оценка от 0 до 100, читаемая сама по себе и пригодная для отслеживания тренда во времени.
Он называет виновника. Attribution-сборка сообщает, какой элемент вызвал каждую метрику, это хранится как metric_target с ограничением в 160 символов: largestShiftTarget для CLS, element для LCP, interactionTarget для INP. Один этот столбец превратил упражнение из угадывания в починку. Число CLS говорит вам, что страница сдвинулась. largestShiftTarget говорит вам, что именно сдвинулось.
Он отправляет строку дёшево и безопасно. Транспорт: единственный fetch к эндпоинту Google Apps Script:
fetch(ENDPOINT, {
method: 'POST', mode: 'no-cors', keepalive: true,
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
body: body
});
Три решения здесь намеренные. text/plain держит запрос «простым», чтобы браузер пропустил CORS preflight, на который эндпоинт Apps Script всё равно не ответил бы корректно. mode: 'no-cors' делает его fire-and-forget: мне нужно только доставить строку, а не читать ответ. keepalive: true позволяет отправке пережить выгрузку страницы, так что метрика, финализированная в конце визита, всё равно отправляется. Клиентского сэмплирования нет. Объём вместо этого ограничен областью: snippet рендерится только на собственных страницах товара и только когда включён переключатель enable_rum. Apps Script добавляет каждую строку в приватный Google Sheet.
Почему приватный Sheet, а не GA4
GA4 уже был в магазине, так зачем поднимать Sheet.
- Владение. GA4 принадлежит маркетингу и для этой задачи шумный. Sheet принадлежит инженеру и тихий. Когда мне нужен p75 по каждой метрике и топ проблемных элементов, я читаю свою собственную таблицу, а не отчёт в чужом владении.
- Ноль инфраструктуры. Apps Script плюс Sheet: нечего запускать, оплачивать или поддерживать в рабочем состоянии.
- Нужные столбцы как полноценные данные. Оценка Lighthouse по каждой метрике и атрибуция элемента здесь обычные столбцы. В общем инструменте аналитики они были бы неудобными собственными измерениями, если бы вообще поместились.
- Никаких PII, вне воронки. Строки несут имя метрики, значение, оценку, рейтинг, целевой элемент, шаблон, путь, грубый флаг устройства и тип соединения. Ничего о человеке и ничего, что загрязняло бы воронку конверсии, по которой отчитывается бизнес.
Что он выявил
Когда стали приходить реальные строки, картина быстро прояснилась. На плохих просмотрах largestShiftTarget указывал на одно и то же место: главную секцию страницы товара, контейнер buy box. Что-то толкало весь основной контент вниз после первой отрисовки, а затем возвращало его назад. Один этот столбец перевёл меня от «страница иногда сдвигается на мобильных» к «найти, что смещает главную секцию во время загрузки», а это уже решаемо. Это разложилось на четыре отдельные причины, все они порождали одно и то же семейство прыжка при первой отрисовке, и каждую я выпустил как отдельный изолированный, откатываемый pull request.
Исправление 1: всплеск 0,9 был отрисовкой выдвижной корзины в потоке
Коренной причиной, когда атрибуция подсказала мне, куда смотреть, был собственный отложенный CSS выдвижной корзины темы. Чтобы срезать байты, блокирующие рендер, таблицы стилей корзины загружаются неблокирующе на этих шаблонах с media="print", который меняется на all при загрузке. Побочный эффект: собственный элемент <cart-drawer> идёт первым элементом <body>, и пока его CSS не пришёл, он без стилей, поэтому отрисовывается в обычном потоке, высотой примерно 560px, вверху страницы. Он толкал всё, что ниже, вниз. Когда отложенный CSS приходил и делал корзину position:fixed; visibility:hidden, страница резко возвращалась вверх. Этот рывок и был сдвигом, и он совпадал с тем, что RUM записал по главной секции.
Перед асинхронными ссылками на таблицы стилей я встраиваю крошечный критический стиль, который фиксирует закрытое состояние корзины:
cart-drawer{position:fixed;top:0;right:0;left:auto;width:100vw;height:100%;visibility:hidden;z-index:1000;}
cart-drawer.active{visibility:visible;}
Специфичность: вот и весь трюк. Правило закрытого состояния имеет вес 0-0-1, намеренно слабое, так что когда загружается настоящая таблица стилей, её открыватель .active (0-2-0) всё равно побеждает и корзина открывается нормально. Корзина теперь вне потока с первой отрисовки, поэтому она никогда не может сместить страницу, и в её открытии ничего не меняется. Проверено в песочнице: CLS на этом пути упал с 0,75 до значения ниже 0,001 (лабораторное измерение на воспроизведении, а не полевой перцентиль; полевой всплеск, который к этому подтолкнул, пришёл из RUM). Это задало паттерн для остального: каждый отложенный ресурс в паре с inline-защитой, которая резервирует его место или фиксирует его позицию.
Исправление 2: мобильное мерцание масштаба было неправильно размещённым мета-тегом viewport
Отдельно: первые мобильные загрузки отрисовывались уменьшенными, во всю ширину, а потом примерно через 0,8с резко переключались на правильный масштаб. Проблема структурная, в <head> темы: тег <meta viewport> стоял после примерно 50KB инлайновых скриптов приложений (оптимизатор страницы и A/B-инструмент, вставленные перед ним). На медленном соединении браузер ещё не находил инструкции viewport, раскладывал страницу с шириной по умолчанию 980px (масштаб примерно 0,42) и отрисовывал увеличенный кадр. Только после разбора десятков килобайт инлайнового скрипта он доходил до meta viewport и перекомпоновывался под ширину устройства.
Исправление в порядке: перенести charset и viewport в самый верх <head>, до того как отрендерится любое приложение. Meta viewport переместился примерно с байта 54520 на байт 179. Браузер теперь знает правильную ширину, прежде чем что-либо отрисовать, поэтому нет увеличенного кадра, из которого надо выскакивать.
Исправление 3: стили перед разметкой, иначе звёзды и заголовки прыгают
Третья причина была проблемой соглашения в нескольких местах. Блок {% style %} секции раньше стоял после её разметки, поэтому на медленной загрузке браузер отрисовывает разметку без стилей, а потом перестраивает стили, когда разбирается CSS. Отсюда пришли два симптома. Звёзды рейтинга, экспортированные из Figma, это SVG с preserveAspectRatio="none" и width/height="100%". Их единственное ограничение размера жило в том позднем {% style %}, поэтому звезда кратко отрисовывалась шириной около 412px, почти во весь viewport, примерно 150мс, прежде чем схлопнуться до 20px. А h1 сначала отрисовывался размером темы по умолчанию 40px без отступов, а потом переключался на секционные 32px с правильными отступами.
Исправление: правило, которое я сделал обязательным для каждой собственной секции в этом магазине. {% style %} идёт первым, до разметки. Тот же дефект и то же исправление в секции галереи снизили CLS десктопной галереи с 0,893 до 0,004 (измерение в песочнице).
Исправление 4: зарезервировать высоту, предзагрузить правильные байты, разместить шрифт у себя
Последняя группа про то, чтобы не давать вещам с известным размером приходить без зарезервированного места.
- Резервировать высоту слайда галереи из реального соотношения изображения. Каждый слайд задаёт
aspect-ratioиз серверногоpreview_image.aspect_ratioизображения, так что слот держит правильную высоту до того, как придут пиксели. Изображение на сцене остаётсяwidth:100%; height:autoбез принудительного соотношения, поэтому оно облегает загруженное фото без чёрных полос, пока слайд вокруг него резервирует место. - Предзагрузить LCP-изображение галереи, с точным совпадением байтов. Первое изображение галереи это LCP-элемент, поэтому
pdp-lcp-preload.liquidпредзагружает его в<head>сimagesrcsetиimagesizes, идентичными галерейному<img>(ширины 343/494/658/988/1316). Совпадение байтов важно: если предзагрузка и реальный<img>расходятся в кандидатах, браузер скачивает LCP-изображение дважды, что медленнее, чем вообще не предзагружать. - Предзагрузить самостоятельно размещённый woff2 Inter Tight. Страница товара объявляет
@font-faceдля Inter Tight из самостоятельно размещённого файла, который иначе обнаруживается только при разборе того CSS, с опозданием на секунды на мобильных. Предзагрузка его в<head>(сcrossorigin, который нужен для запросов шрифтов даже при одинаковом источнике) сдвинула запрос примерно с 1965мс до 312мс.
Компромисс: пиксельная точность важнее незначительных байтов
Два решения в этом проходе намеренно шли против того, чего хотела лаборатория.
Шрифты. Я загрузил Oswald и League Spartan неблокирующе с display=optional. Это убивает CLS от подмены шрифта: браузер либо получает шрифт вовремя, либо использует запасной и никогда не подменяет, так что эти два начертания никогда не могут вызвать позднюю перекомпоновку. Я намеренно не сделал то же самое с Inter Tight, основным начертанием бренда. display=optional показал бы системный запасной шрифт на первой некешированной загрузке, ломая пиксельно точное первое впечатление. Поэтому Inter Tight предзагружен и размещён самостоятельно, а не понижен: принять небольшую загрузку шрифта, чтобы сохранить задуманный шрифт на первой отрисовке.
Изображение hero. Самым громким флагом PageSpeed было то «-72.6 KiB» на hero, и я его не срезал. Уменьшение LCP-изображения ниже 2x DPR снимает флаг и заметно размывает товар на телефонах с DPR от 2,6 до 3, которыми пользуется большая часть платного мобильного трафика. На странице, где фото товара это и есть подача, размытый hero стоит дороже, чем несколько десятков сэкономленных килобайт. Пиксельная точность победила незначительные байты, при том что это оставляет жёлтый флаг в отчёте, и я принял бы это решение снова.
Честный охват: пугающий TBT это не тема
Хочу честно сказать о том, что я не исправил, потому что это важно для оценки работы. Десктопный Total Blocking Time выглядит тревожно, около 2920мс. Это почти целиком сторонний JavaScript приложений, а не тема: приложение персонализации на сайте примерно 510KB, менеджер тегов примерно 473KB, инструмент записи сессий примерно 147KB, продуктовая аналитика примерно 201KB и почтовое ПО примерно 98KB. Собственные числа темы уже здоровые: FCP около 0,6с, LCP примерно от 1,1 до 1,6с на десктопе, Speed Index около 1,5 до 1,7с (лабораторные измерения).
Снижение этого TBT означает удаление или отложенную загрузку приложений, а каждое заслуживает своего места по деловой причине (персонализация, управление тегами, воспроизведение сессий, аналитика, email жизненного цикла). Это деловое решение о затратах и выгоде, а не изменение темы, которое я могу сделать в одностороннем порядке. Поэтому я это измерил, атрибутировал, отчитался и оставил компромисс людям, которые им владеют. Заявлять, что я «исправил TBT», удалив чью-то аналитику, было бы нечестно и вне рамок задачи.
Валидация: изолированные стенды, а не ощущения
Каждое исправление проверялось в контролируемом воспроизведении, прежде чем выйти. Я собрал небольшие стенды на Puppeteer, которые управляют Chrome через DevTools Protocol при троттлинге slow-4G:
- Стенд для звёзд, который читает bounding box у SVG во время загрузки, так что вспышка гигантской звезды ловится как измеренная ширина, а не как впечатление.
- Стенд для шрифта, который читает сетевые тайминги запроса woff2, подтверждая, что предзагрузка сдвинула его раньше.
- Пара стендов (метрики и серия кадров), которая использует временную шкалу масштаба CDP и парные скриншоты, чтобы поймать мобильное мерцание масштаба как изменение масштаба во времени.
Когда анонимная квота PageSpeed закончилась посреди прохода, я запускал lighthouse@12 локально. Дисциплина на всём протяжении: воспроизвести дефект под троттлингом, измерить его, применить исправление, измерить снова, и только потом выпускать, каждое изменение изолировано, так что откатывается в один клик.
Итог
Разделено по тому, насколько твёрдо я могу защитить каждый пункт.
Измеренное (лаборатория и песочница). CLS на пути выдвижной корзины упал с 0,75 до значения ниже 0,001. CLS десктопной галереи упал с 0,893 до 0,004. Мобильное мерцание масштаба, вспышка гигантской звезды и прыжок заголовка были устранены правильным порядком meta viewport и секционных стилей. Запрос Inter Tight сдвинулся примерно с 1965мс до 312мс. Это воспроизведения в лаборатории и песочнице под троттлингом, помеченные как таковые, а не полевые перцентили.
Полученное из поля. Дефект, с которого всё началось, всплеск CLS около 0,9, затрагивавший примерно треть мобильных просмотров страниц с CLS, пришёл из данных атрибуции RUM, а не из лабораторной догадки. Это число, которому я доверяю больше всего, потому что оно пришло из реальных сессий.
Проверенное. Рабочий конвейер RUM теперь сообщает LCP, CLS, INP, FCP и TTFB с атрибуцией по каждому элементу и оценками по кривой Lighthouse, на собственных страницах товара, в приватный Sheet, которым владеет команда.
Показатели конверсии и выручки магазина принадлежат бренду и находятся под NDA. И я не стал бы привязывать конкретную цифру выручки к одному исправлению CLS, даже если бы её опубликовал: честная единица измерения здесь это инженерная работа.
Влияние на клиента, честно сформулированное
Эти исправления защищают доверие и скорость принятия решения. Мобильный посетитель из платной рекламы теперь получает страницу, которая стоит неподвижно, отрисовывается в правильном масштабе и показывает товар чётко на экране с высоким DPR: без зума и рывка, без гигантской звезды, вспыхивающей под галереей, без контента, который дёргается, пока выдвижная корзина встаёт на место. На поверхности, где вся подача укладывается в несколько секунд, это убранное трение из решения о покупке. Защита и поддержка в точке принятия решения, а коммерческие цифры остаются у бренда под NDA.
Уроки
- Синтетические инструменты это диагностика, а не вердикт об опыте. Полезны для поиска ресурсов, блокирующих рендер, и тяжёлой работы главного потока, но не замена измерению тех посетителей, которые у вас есть. Используйте оба, а когда они расходятся, побеждает поле.
- Атрибуция это то, что делает RUM окупаемым. Число CLS это пожарная сигнализация.
largestShiftTargetэто номер комнаты. Столбец с элементом превратил охоту в набор исправлений. - Откладывайте байты, но никогда не давайте элементу перекомпоноваться. Каждому асинхронному или отложенному ресурсу нужна inline-защита с критическим стилем, которая резервирует его место или фиксирует его позицию. И стили идут до разметки, всегда, иначе поздний CSS превращается в FOUC и CLS.
- Подбирайте правильный размер инструмента и отделяйте стоимость темы от стоимости сторонних средств. Система измерения была одним snippet на 51 строку, одним Apps Script и одним Sheet. Большая часть пугающего времени блокировки была не в моей власти исправить, и сказать это прямо часть работы.
Технические ссылки
Основные файлы, для тех, кто читает тему:
snippets/pdp-web-vitals.liquid: инструмент RUM (attribution-сборка web-vitals, оценка по кривой Lighthouse, атрибуция элемента, no-cors транспорт к Apps Script).layout/theme.liquid: порядок head (charset и viewport первыми, до скриптов приложений) и inline критический стиль закрытого состояния выдвижной корзины, который убил всплеск 0,9.snippets/conditional-assets.liquid: неблокирующая загрузка CSS выдвижной корзины, которую inline-защита делает безопасной.snippets/pdp-lcp-preload.liquid: предзагрузка LCP-изображения (с точным совпадением байтов с галерейным<img>) и предзагрузка woff2 Inter Tight.snippets/product-gallery-lum.liquid: высота слайда, зарезервированная из реального соотношения сторон изображения, и LCP<img>, которому должна соответствовать предзагрузка.snippets/oswald-font.liquid,snippets/league-spartan-font.liquid: неблокирующие вторичные шрифты сdisplay=optional.sections/lum-template-font.liquid: гейтenable_rum, который ограничивает snippet RUM собственными страницами товара.
RUM раскрывается в этом разборе. Разбор 06 ссылается на него как на один из четырёх изолированных конвейеров аналитики. Поведение выдвижной корзины под сторонним приложением освещается в разборе 02. Здесь она появляется только как источник сдвига макета.
Нужен инженер Shopify, который работает по всему коммерческому пути?
Я работаю там, где сходятся UX витрины, продуктовая логика, ограничения платформы, измерения и доставка в продакшн.
Начать разговор