Перейти до вмісту

Інженерні історії · 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; тут вона з'являється лише як джерело зсуву макета.

Назад до кейсуLumway

Потрібен інженер Shopify, який працює на всьому комерційному шляху?

Я працюю там, де сходяться UX вітрини, логіка товару, обмеження платформи, вимірювання та релізи в продакшн.

Почати розмову