Як покращити показники Google Core Web Vitals на прикладі мультимедійної платформи

Усім привіт, я Богдан Кладковий, Delivery Manager в компанії АМО. У цій статті розповім про свій досвід роботи з Google Core Web Vitals на прикладі сайту news.amomama.com, і поділюсь оптимальними практиками щодо покращення Google Core Web Vitals.

Я працюю не один, а в команді, тож і до цього тексту про наш проєкт долучились мої колеги-співавтори: Анна Єфімова, Front-end engineer; Вадим Олійник, Tech Lead Front-end; Олександр Марданов, Front-end engineer.

Щоб зрозуміти, чи релевантним буде наш досвід, опишу діяльність сайту Amomama. Це міжнародна мультимедійна платформа, що є частиною міжнародної IT-компанії AMO, і створює контент у ніші розважального сторітелінгу. Понад 40 мільйонів людей читають сайти щомісяця. Контент на сайті в основному є текстовим, з блоками тексту.

Чому і як ми прийшли до Google Core Web Vitals

Якщо вирішили оптимізувати сайт і бути конкурентоспроможними в очах Google, то з чого ж варто почати? Думаю, головне тут, — визначити пріоритети бізнесу:

  1. Швидкість і безперебійність роботи вашого сайту потрібна лише для певної цільової дії, наприклад, для покупки товару або здобуття нового клієнта.
  2. Відповідати всім ключовим метрикам Google, щоб сайт був зручним у використанні. Тобто бути ресурсом, який повною мірою зосереджується на інтересах відвідувачів сайту.

Перше, на чому треба робити фокус, — потреби бізнесу. Ми, наприклад, обрали для себе другий варіант, щоб повністю відповідати Core Web Vitals і задовольняти наших юзерів.

Друге — це фокус на цінність, яку може принести перформанс сайту. Чим зручніше юзеру перебувати на нашому сайті, тим більша ймовірність, що він повернеться.

Третє: коли потреби й цінність визначені, можна переходити до наступного етапу — аудиту поточного стану сайту і розроблення та імплементації набору рішень для ліквідації проблем, що виникли протягом аудиту. Далі йде створення передумов для виходу нових метрик чи зміни алгоритму розрахунку наявних.

Відповідність вимогам Core Web Vitalas є досить суттєвою конкурентною перевагою, адже лише невеликий відсоток світових сайтів перебуває в «зеленій» зоні Google.

Останній пункт, проте не менш важливий, — постійний і пильний моніторинг показників, що дозволяє вдосконалювати метрики перформансу й робити сайт швидким і зручним для користувача.

Ми працювали над досягненням таких результатів доволі довго. Спочатку намагалися оптимізувати стару версію сайту. На той момент наш продукт був клієнт-серверним монолітом — CMS, написаний на PHP. HTML теж рендерився шаблонами бекенда. CSS і JavaScript збиралися Webpack-ом і зберігалися на CDN. І ми сподівалися «доїхати» на ньому в «зелену» зону.

Проте, зрозумівши неефективність цієї ідеї, створили новий сайт. Щоб перевести всі мовні версії без ризику для роботи на нову архітектуру, знадобилося близько пів року. Для фронту потрібна була оптимізована під роботу з DOM-деревом технологія.

Оскільки SEO для медіаресурсів є критично важливим, а надійних рішень для SPA (Single Page Application) досі немає, ми вирішили використати наступний стек — React + Next.js. Стилі також вирішили писати через css-in-js і заюзали бібліотеку styled-components для швидшої і зручнішої розробки. Тому вже на новій архітектурі почали оптимізацію під наші потреби.

Важливо відзначити, що кожну ідею з оптимізації чи покращення перформанс-метрик потрібно тестувати, проводити заміри і вирішувати, залишати її чи ні. З досвіду скажу, що ми відревертили купу ідей, які в теорії мали б покращити наші показники, але, на жаль, цього не сталося.

Робота з Core Web Vitals у додатку Next.js

А тепер хочу поділитися досвідом роботи з Core Web Vitals у додатку Next.js. Зокрема розкажу, які неочевидні речі імплементували в себе на проєкті, з якими проблемами зіштовхнулися та які є варіанти їх розв’язання.

Насправді порад може бути дуже багато, але ми з колегами виділили, на нашу думку, найцікавіші ідеї. Для повного занурення у світ Core Web Vitals від Google можу порекомендувати цей ресурс.

Отже, крок за кроком описуватиму наш досвід. Поїхали!

1. Аналіз залежностей проєкту і як позбутися unused javascript

Щоб зменшити загальний розмір скриптів, важливо здійснити аналіз залежностей проєкту. Якщо ви використовуєте Webpack, для цього є чудовий плагін Webpack Bundle Analyzer. Після білда проєкту він покаже, з яких скриптів складається ваш бандл.

Так само важливо переглянути залежності, що підключаються, і проаналізувати їхню необхідність. Ми пройшлися за списком у package.json, шукаючи у проєкті місця використання кожної з них. Таким чином ми знайшли кілька залежностей, що вже не використовуються, і випадки, коли з бібліотеки, що підключається, використовувалася тільки невелика частина. Наприклад, з бібліотеки humps ми використовували лише одну функцію і лише в одному місці у проєкті. При цьому щоразу в бандл потрапляло зайвих 2 КБ усієї бібліотеки.

Ми написали тести на код, який використовує функцію з бібліотеки, перенесли код функції до нашого проєкту, переконалися, що тести, як і раніше, працюють, і видалили бібліотеку з залежностей. Тому не нехтуйте аналізом ваших залежностей у проєкті, це справді важливо.

prefetch={false}

Перевіряючи наш сайт через Google Pagespeed, ми помітили, що в unused js постійно показується статична сторінка «About us», хоча ми перевіряємо пост, і вона ніде крім меню не фігурує. Як виявилося, Next.js може завантажувати у фоні сторінки, на які користувач має змогу перейти за посиланням. У документації було сказано, що ця поведінка зберігається за замовчуванням і спрацьовує при ховері на компонент Link з ’next/link’. Але на мобільних пристроях вона спрацьовувала, коли посилання перебувало у viewport. Зокрема, посилання на сторінку «About us» знаходилось у хедері. Додавши до тега Link атрибут prefetch={false}, ми позбулися цієї проблеми.

Схожа ситуація з’явилася пізніше і на постах, коли ми додали «хлібні крихти», і вони були під хедером. Як це вирішити, ми вже знали. Проблема спостерігалася в Next.js v.10 (major version), зараз, можливо, її вже пофіксили.

2. Функція getServerSideProps має бути в директорії pages

Згідно з документацією Next.js, модулі, які ви імпортуєте в getServerSideProps, не потрапляють у клієнтський бандл.

Але в нашому випадку це не працювало, тому що файли в директорії pages реекспортували цю функцію з іншої директорії:

export { DynamicPage as default, getServerSideProps } from '@src/containers/DynamicPage';

Будьте обережні з подібним реекспортом, адже Next.js розраховує, що getServerSideProps буде саме у файлі в директорії pages.

Це стосується й об’єкта `config`, який теж має перебувати саме у файлі в директорії pages.

3. Lazyload for embeds and images

Оскільки наш сайт — це платформа для споживання контенту, то у наших статтях багато тексту, картинок, а також ембедів (вбудованого медіаконтенту із соціальних мереж) — Twitter, Facebook, Giphy, Imgur, Instagram, TikTok, YouTube. Ми використовували npm-пакети для зображення кожного окремого ембеда.

У першій версії сторінка посту важила кілька мегабайтів. Звичайно ж, ми вирішили це виправити. Статті дуже довгі, і частину ембедів найімовірніше ніхто не побачить, бо не доскролить до них. То навіщо одразу вантажити їх на сторінці? Адже це крім npm-пакета ще й сам фрейм ембеда, і всі ресурси з того сайту, що вбудовується.

Ми використовували бібліотеку react-lazyload. У парі з next/dynamic отримуємо відмінну комбінацію — dynamic відокремлює код ембеда в окремий чанк, а lazyload дозволяє вантажити цей чанк при підході viewport-а до місця ембеда. На практиці це виглядає приблизно так:

import dynamic from 'next/dynamic';
import LazyLoad from 'react-lazyload';

const TikTokEmbed = dynamic(() =>
 import('./components/TikTokEmbed').then((mod) => mod.TikTokEmbed),
) as typeof TikTokEmbedType;

export function Embed({ href = '' }: EmbedProps): JSX.Element {
 return (
   <Wrapper>
     <LazyLoad offset={150} once>
       <TikTokEmbed href={href} />
     </LazyLoad>
   </Wrapper>
 );
}

Цей підхід дозволив нам зменшити розмір сторінки в десятки разів.

До речі, як альтернатива використанню купи окремих бібліотек — можна замінити їх одним стороннім сервісом, зазвичай платним. Наприклад, для деяких ембедів ми використовуємо iframely.

У цілому next/dynamic дуже потужний інструмент, щоб зменшити загальну вагу бандла. З його допомогою ми також відділяли на сторінці компоненти, які багато важать, але не завжди використовуються.

Наприклад, блок із додатковими постами, як-от релейтеди та інші рекомендаційні блоки (вони заповнюються з нашої CMS для кожного посту). Або реклама одного з партнерів з великою кількістю коду, але не всі її пости використовують.

const AdvBuilder = dynamic(() => import('../AdvSlots/AdvBuilder').then((mod) => mod.AdvBuilder), ) as typeof AdvBuilderComponentType;

function Component() {
return (
    <Container>
      <SomeGeneralComponent />
      {anchor && <AdvBuilder slot={anchor} isAnchor />}
    </Container>
  )
}

Таким чином, увесь код реклами буде завантажений окремим js-файлом у разі його використання на сторінці.

Але у lazyload-підходу є один мінус — він сильно знижує показник CLS, Cumulative Layout Shift. Google дивиться, як змінюється положення елементів на сторінці, і знижує показники, якщо текст, наприклад, зміщується вбік/вгору/вниз під час завантаження чи скролу. А оскільки ембеди завантажуються на клієнті, а не на сервері, то потрібно і це якось вирішувати. У компонента LazyLoad є пропс placeholder, який теоретично має допомогти.

Спочатку ми намагалися поставити туди компонент зі skeleton. Це була заглушка з фіксованою висотою, як у ембеда. Щось подібне реалізовано в Instagram. Це трохи допомогло, але стрибки все одно відбувалися. Та й десятки анімацій на всій сторінці дали про себе знати.

Ми вирішили, що краще обрізати частину ембеда, задаючи ширину й висоту контейнера навколо LazyLoad, і вказавши йому overflow: auto;. Це допомогло нам зменшити кількість урлів з поганим CLS, які ми відстежували в Google Search Console.

Щоправда, мінуси тут теж є: ідеальну висоту дуже важко підібрати, і будуть випадки, коли картинка надто обрізається або після неї залишається багато порожнього місця. Та й можливість скролити ембед може заважати при мобільному скролі екрану. У планах є провести A/B-тест із overflow: hidden;.

Ще варто врахувати момент зі стрибками ембедів і картинок, коли відбувається hydration на клієнті. Адже сервер не знає розміру вікна браузера і те, яку висоту заздалегідь задати. Вирішити проблему нам допомагає пакет mobile-detect. Оскільки він працює і на клієнті, і на сервері, ми безпечно можемо приховувати певні мобільні або десктопні блоки на сторінці або задавати потрібні розміри елементам.

[Embed.Instagram]: {
  component: InstagramEmbed, 
  width: { 
    [DeviceType.desktop]: '450px', 
    [DeviceType.tablet]: '450px', 
    [DeviceType.mobile]: '330px', 
  }, 
  height: { 
    [DeviceType.desktop]: '770px', 
    [DeviceType.tablet]: '770px', 
    [DeviceType.mobile]: '620px', 
  }, 
},

Знати deviceType, звичайно, корисно, але тут є мінуси. Наприклад, ми не можемо використовувати getStaticProps на таких сторінках, це буде працювати тільки на SSR-сторінках. Нас це влаштовує, тому що контент додається, оновлюється, проводяться A/B-тести + реклама також залежить від пристрою.

Через певний час ми шукали, як ще оптимізувати lazyload-підхід. Знайшли корисну статтю про react-lazyload. Там обговорювали її вплив на продуктивність і порадили змінити на іншу — react-cool-inview. Вона працює через браузерівський intersection observer і могла б покращити перформанс.

Код став виглядати трохи інакше:

import dynamic from 'next/dynamic'; 
import { useInView } from 'react-cool-inview'; 

const TikTokEmbed = dynamic(() => import('./components/TikTokEmbed').then((mod) => mod.TikTokEmbed), ) as typeof TikTokEmbedType; 

export function Embed({ href = '' }: EmbedProps): JSX.Element { 
  const { observe, inView } = useInView({ 
    unobserveOnEnter: true, 
    rootMargin: `${OFFSET}px`, 
  }); 

  return ( 
    <div ref={observe}> 
      {inView && <TikTokEmbed href={href} />} 
    </div> 
  ); 
}

Взагалі історія зі стороннім медіаконтентом дуже цікава і завжди челенджова, тому ми й досі намагаємося знайти рішення, яке може показувати нашому користувачеві ембеди в повному розмірі для комфортного споживання контенту.

Крім того, ми відмовилися від js-бібліотек, щоб «залейзити» картинки, і використали нативний атрибут loading.

Таке рішення було зумовлене не лише бажанням розвантажити main thread (адже ця чудова технологія поки має неповну підтримку), але й необхідністю покращити SEO для картинок.

4. Critical CSS

Критичні стилі — це взагалі цікава тема, особливо коли у вашому аплікейшені css-in-js-підхід. Ми намагалися виділити критичний CSS (ті мінімальні стилі, які потрібні для відтворення першого екрана сторінки), вставляти його інлайном у <head>, а решту стилів підвантажувати асинхронно. За допомогою синхронного завантаження лише потрібних стилів це дозволяє зменшити LCP. А якщо правильно виділити критичні стилі та додати їх перед , то не виникне проблеми із CLS, тому що стилі першого екрана використовуються ще до рендеру сторінки.

Але в контексті нашого проєкту це не надало переваг LCP через те, що ми використовуємо styled components, як згадувалося раніше.

Згідно з документацією styled components і прикладом інтеграції з Next.js, рендер додатку проходить уже з усіма стилями:

const originalRenderPage = ctx.renderPage 
ctx.renderPage = () => originalRenderPage({ 
  enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />), 
});

Щоб зібрати лише критичні стилі та відрендерити додаток з ними, можна зробити щось на зразок:

ctx.renderPage({
 enhanceApp: (App) => (props) 
=> return sheet.collectStyles( 
  <App {...props} pageProps={{ ...props.pageProps, onlyCriticalStyles: true }} />, 
);})

При цьому у всьому додатку потрібно рендерити лише критичні блоки, якщо переданий проп `onlyCriticalStyles`.

У другому випадку ми не підміняємо `ctx.renderPage`, а викликаємо його з критичними стилями. Відповідно він викликається ще раз, коли Next.js буде рендерити сторінку. Тому відбудуться два рендери (один з критичними стилями, а другий — з усіма). Таким чином нам вдасться спочатку відрендерити додаток лише зі стилями для першого екрана й асинхронно довантажити всі стилі. Але через те, що ми двічі викликаємо рендер, це погіршує показник LCP і нівелює переваги critical CSS.

Тому одного дня ми вирішили переписати наші стилі на css-modules-підхід, використовуючи SCSS, що значно покращило показники, а додавання ще й критичних стилей за допомогою одного рядка коду в next.config.js взагалі нас приємно вразило:

experimental: { optimizeCss: true }

5. Next head без preload

Щоб покращити FCP і LCP, ми намагалися відкладати завантаження клієнтських Next.js-скриптів. Відповідно до цього Github-обговорення Next.js-скрипти додаються з `<link rel="preload">` і `<script async >`. Використання одночасно збільшує показник FCP.

Є два варіанти вирішення проблеми:
1. Використовувати `<script defer>`.
2. Не додавати `<link rel="preload">` в `<head>`.

Оскільки рішення з відмовою від `<link rel="preload">` простіше в реалізації, ми використовували його для відкладення скриптів Next.js.

import { Head } from 'next/document';
 
export class HeadWithoutPreload extends Head { 
  getPreloadDynamicChunks () { 
    return [] 
  } 
  getPreloadMainLinks () { 
    return [] 
  } 
}

6. React-lazy-hydration, next-boost

Під Next.js уже зроблено багато рішень, які мають оптимізувати його та робити у рази швидшим. Одне з таких рішень — react-lazy-hydration. Його суть полягає у відкладеній гідрації компонентів SSR за часом або подією (наприклад, при доскролі).

Ідея дуже цікава, і ми вирішили спробувати застосувати її у нас. Погравшись з різними варіантами, дійшли висновку, що нам ця бібліотека не підходить. Не тому, що погана, просто вона важить більше, ніж дає переваги від відкладеної гідрації. Адже наш основний контент — це текст і блоки з постами. Якби в пості були складніші блоки з каруселями або чимось важким, то, напевно, вигода була б більш відчутною.

Ще одна цікава бібліотека, яку ми розглядали, — це next-boost. Дати нам позитивний приріст вона не змогла, можливо, потрібно було витратити більше часу на її налаштування. Потенціал, безперечно, у ній є, але ми навряд чи ще повернемося до неї, тому що вже працюємо над переходом на React Server Components, про що, дуже сподіваюсь, зможемо розказати вже цього року.

7. Перехід на Preact

У певний момент наш загальний бандл став занадто важким, і ми вже не могли нічого з нього видалити. Знаючи, що крім React є ще Preact, ми вирішили спробувати. Саме налаштування нескладне, а встановлення Preact-пакета і додавання в Next.js конфіг alias-ів забрало лише кілька хвилин:

webpack: (config, { dev, isServer }) => {

   // Replace React with Preact only in client production build
   if (!dev && !isServer) {
     Object.assign(config.resolve.alias, {
       react: 'preact/compat',
       'react-dom/test-utils': 'preact/test-utils',
       'react-dom': 'preact/compat',
     });
   }
 
   return config;
 },

Це зменшило наш загальний бандл з 119 КБ до 84.9 КБ, що було дуже добре. Звісно, через застарілі пакети, які давно не оновлювалися, ми знайшли деякі баги на сайті після тестів. Проблема полягала в бібліотеці компонента Loader і плавного скролу. Успішно переписавши їх, ми залишилися на Preact.

8. Оновлення версії — Next.js

У себе на проєкті ми постійно стежимо за версіями всіх пакетів і за можливості оновлюємо їх. Оскільки ми використовували деякі legacy-пакети, оновлення Next.js деколи було проблематичним.

Отже, довгий час ми не могли оновитися далі версії 10.0.5 через одну бібліотеку для роботи зі спрайтами. Пізніше позбулися спрайтів і змогли оновити Next.js до 11-ої версії. АЛЕ! Після проведення замірів показників на сайтах ми помітили, що показники просіли на 15–20 пунктів за лабораторними даними, хоча Next.js обіцяв покращену оптимізацію. Ми, звичайно, хотіли перейти на нову версію, але швидкість сайту для нас була важливішою. Тому відкотилися до 10-ої версії й чекали, коли можна буде оновитися. На щастя, це сталося вже на 12-й версії.

Тож фіксуйте версії пакетів і не поспішайте оновлюватися. До речі, щодо їхньої фіксації в package.json: були випадки, коли патч-версія повністю ламала сайт, тому будьте уважними.

Зараз ситуація з нашими показниками наступна:

Mobile:


Desktop:

Висновок

Отже, ми успішно перейшли на нову архітектуру, понабивали гулі, і зараз її не просто підтримуємо, а розвиваємо і завжди готові до виходу нових метрик від Google.

Команда проводить регулярні технічні та продуктові ресерчі щодо покращення показників перформансу, тому планую й надалі ділитись з вами актуальними темами, зокрема: як вимірювати перформанс та імітувати польові дані в ріалтаймі, не чекаючи 28 днів від Google. А також, деякими лайфхаками від нас, наприклад: як Service Worker може допомогти покращити користувацький досвід і показники перформансу вашого аплікейшену, та як можна покращити показники нової метрики Interaction to Next Paint (INP).

Дякую за увагу!

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось11
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дякую. Цікаво. Хоча next.js і не використовуємо. Було б цікаво почитати як ви заміряєте показники після змін

Привіт! Дякуємо за фідбек. Наступний матеріал буде якраз про моніторинг :)

Ви вже випустили другу частину? Я мабуть пропустив

99% на такому проекті — краса! Дякую за героїчну сповідь, молодці.

Підписатись на коментарі