Інженерні історії · 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 продає жувальні вітаміни-добавки безпосередньо споживачам у США. Більшість покупок відбувається на двох поверхнях: сторінці деталей товару (PDP) і наборі рекламних цільових сторінок, які містять той самий 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. Рейтинг «добре» чи «погано» надто грубий, щоб побачити, як сторінка повільно погіршується, тож я перереалізував логнормальну криву оцінювання 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). Це задало шаблон для решти: кожен відкладений ресурс у парі з вбудованим запобіжником, який резервує його місце або фіксує його позицію.
Виправлення 2: мерехтіння масштабу на мобільному було через неправильно розміщений viewport meta
Окремо, перші завантаження на мобільному рендерилися віддаленими, на всю ширину, а потім різко переходили до правильного масштабу приблизно через 0.8с. Структурна причина, у <head> теми: тег <meta viewport> стояв після приблизно 50KB вбудованих скриптів застосунків (оптимізатор сторінки і A/B-інструмент, впроваджені перед ним). На повільному з'єднанні браузер ще не знаходив інструкції viewport, розкладав сторінку за замовчуванням шириною 980px (масштаб приблизно 0.42) і малював наближений кадр. Лише після розбору десятків кілобайтів вбудованого скрипту він доходив до viewport meta і перебудовував макет до ширини пристрою.
Виправлення це впорядкування: перемістити charset і viewport на самий верх <head>, перед тим як рендериться будь-який застосунок. Viewport meta перемістився приблизно з байта 54520 на байт 179. Браузер тепер знає правильну ширину до того, як щось намалює, тож немає наближеного кадру, з якого треба різко виходити.
Виправлення 3: стилі перед розміткою, інакше зірки і заголовки стрибають
Третя причина була проблемою угоди в більш ніж одному місці. Блок {% style %} секції раніше стояв після її розмітки, тож при повільному завантаженні браузер малює розмітку без стилів, а потім перестильовує, коли розбирається CSS. Звідси два симптоми. Експортовані з Figma зірки рейтингу це SVG з preserveAspectRatio="none" і width/height="100%"; їхнє єдине обмеження розміру жило в тому пізньому {% style %}, тож зірка на мить рендерилася завширшки близько 412px, більшу частину вікна перегляду, протягом приблизно 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-зображення двічі, повільніше, ніж якби взагалі не було попереднього завантаження. - Попередньо завантажити самостійно розміщений Inter Tight woff2. Сторінка товару оголошує
@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:
- Оснастка зірки, яка читає обмежувальний прямокутник SVG під час завантаження, тож спалах гігантської зірки фіксується як виміряна ширина, а не враження.
- Оснастка шрифту, яка читає мережевий тайминг для запиту woff2, підтверджуючи, що попереднє завантаження перемістило його раніше.
- Пара з метрик і сплеску, яка використовує шкалу масштабу CDP і парні знімки екрана, щоб зловити мерехтіння масштабу на мобільному як зміну масштабу в часі.
Коли анонімна квота PageSpeed закінчилася посеред проходу, я запускав lighthouse@12 локально. Дисципліна протягом усього: відтворити дефект за дроселювання, виміряти його, застосувати виправлення, виміряти знову, і лише тоді відправляти, кожна зміна ізольована, тож вона відкочується в один клік.
Результат
Розділено за тим, наскільки сильно я можу захистити кожен.
Виміряно (лабораторія і пісочниця). CLS шляху панелі впав з 0.75 до менш ніж 0.001. CLS десктопної галереї впав з 0.893 до 0.004. Мерехтіння масштабу на мобільному, спалах гігантської зірки і стрибок заголовка були усунені правильним упорядкуванням viewport meta і стилів секцій. Запит Inter Tight перемістився приблизно з 1965мс на 312мс. Це лабораторні репро та репро в пісочниці за дроселювання, позначені як такі, а не польові перцентилі.
Отримано з поля. Дефект, з якого все почалося, сплеск CLS приблизно 0.9, що зачіпав близько третини мобільних переглядів сторінок з CLS, походив з даних атрибуції RUM, а не з лабораторної здогадки. Це число, якому я довіряю найбільше, бо воно походить з реальних сесій.
Перевірено. Робочий конвеєр RUM тепер звітує LCP, CLS, INP, FCP і TTFB з поелементною атрибуцією та оцінками за кривою Lighthouse, на власних сторінках товарів, у приватну Sheet, якою володіє команда.
Показники конверсії та доходу магазину належать бренду і перебувають під NDA. І я не прив'язував би конкретне число доходу до одного виправлення CLS, навіть якби публікував його: чесна одиниця тут це інженерія.
Вплив на клієнта, чесно сформульований
Ці виправлення захищають довіру і швидкість прийняття рішення. Мобільний відвідувач з платної реклами тепер отримує сторінку, яка тримається нерухомо, малюється в правильному масштабі і показує товар чітко на екрані з високим DPR: без наближення і різкого повернення, без гігантської зірки, що спалахує під галереєю, без контенту, що смикається, поки усталюється висувна панель кошика. На поверхні, де вся презентація доходить за кілька секунд, це усунене тертя з рішення про покупку. Захист і сприяння в момент рішення, з комерційними показниками, залишеними бренду під NDA.
Уроки
- Синтетичні інструменти це діагностика, а не вирок про досвід. Корисні для пошуку ресурсів, що блокують рендеринг, і важкої роботи головного потоку, і не замінюють вимірювання відвідувачів, яких ви маєте. Використовуйте обидва, а коли вони не збігаються, перемагає поле.
- Атрибуція це те, що робить RUM виправданим. Число CLS це пожежна сигналізація;
largestShiftTargetце номер кімнати. Стовпець елемента перетворив полювання на набір виправлень. - Відкладайте байти, але ніколи не дозволяйте елементу перебудовуватися. Кожен асинхронний чи відкладений ресурс потребує вбудованого критичного запобіжника, який резервує його місце або фіксує його позицію. І стилі йдуть перед розміткою, завжди, інакше пізній 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 першими, перед скриптами застосунків) і вбудований критичний стиль закритого стану cart-drawer, який убив сплеск 0.9.snippets/conditional-assets.liquid: неблокуюче завантаження CSS cart-drawer, яке вбудований запобіжник робить безпечним.snippets/pdp-lcp-preload.liquid: попереднє завантаження LCP-зображення (з побайтовим збігом до<img>галереї) і попереднє завантаження Inter Tight woff2.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 вітрини, логіка товару, обмеження платформи, вимірювання та релізи в продакшн.
Почати розмову