Кешування наперед, або Сповідь адепта WorkBox
Усім привіт, я Армен Айвазян, Front-End Developer в міжнародній ІТ-компанії АМО. У цій статті розповім про кешування наперед та як в цьому може допомогти WorkBox.
У статті я опишу реальний кейс реалізації на сайті AmoMama. Я навмисне опускаю багато технічного контексту, який можна легко нагуглити (наприклад: що ми юзаємо Next.js, або глибокі копання в конкретних інструментах і термінах). В цій статті я хочу описати саме концептуально ідею, яку можна буде застосувати на будь-якому проєкті, щоб дати вам один з можливих варіантів вирішення проблеми.
Якщо хтось захоче технічних деталей або уточнень, я завжди відкритий до дискусій, звертайтесь, з радістю поспілкуємося.
Ідея
Додам трохи контексту: AmoMama — це міжнародна мультимедійна платформа, частина міжнародної IT-компанії AMO, яка створює контент у ніші розважального сторітелінгу. Аудиторія сайту оцінюється в понад 40 мільйонів людей щомісяця.
Як і будь-яка історія, ця наша теж почалась з шаленої ідеї: «Чи можна зробити перехід між сторінками непомітним для користувача?». Хотілося реалізувати чудовий UX, щоб юзер клікнув на статтю і одразу побачив її зміст, а не йшов заварювати собі чай (умовно), поки перша картинка та заголовок завантажаться. Жартома, у нас в команді, ця фіча отримала назву — «безшовний перехід» або, науково, — seamless experience. Звучало потужно, просто, одним словом — багатообіцяюче.
Тому кому цікаво, чим це все закінчилося — «ласкаво прошу під кат!»
Перші кроки
Початкове бачення реалізації було таке: «а спробуймо кешувати дані наперед і віддавати їх користувачу, як тільки вони йому знадобляться, і щоб все це на стороні клієнта, для супер швидкості». Але що ж таке от це «кешувати дані наперед»? Почнемо з того, що таке вебкешування в простому, класичному його вигляді:
Якщо простими словами: замість того, щоб наш клієнт кожен раз ходив за одним і тим самим файлом (наприклад, картинкою милого котика) на сервер, в процесі/ після першого звернення за файлом ми зберігаємо дані у клієнтському браузері. І коли наступний раз наш юзер захоче отримати файл (порцію милування), він не буде чекати довгої відповіді з сервера, а буквально одразу отримає його зі свого браузеру (вебкешу). А кешувати дані наперед, значить все те саме, тільки файл повинен опинитися в кеші клієнта ще до того, як клієнт захоче отримати цей файл (так, звучить магічно, але ж хто не любить магічні пригоди?).
Нашим першим кандидатом був стильний-молодіжний Service Worker. Він працює в окремому потоці, може перехоплювати запити, в перспективі за його допомогою можна реалізувати офлайн-режим — ну, не інструмент, а мрія під нашу задачу. План мінімум:
- юзер клікає на пост;
- fetch-запит йде на API;
- Service Worker перехоплює його та замість довгого походу на API повертає кешовані дані.
Уважний читач вже міг помітити відсутність однієї дуже важливої ланки у всіх цих мріях. Але про це згодом, а хто здогадався, пишіть в коментарях, перед подальшим читанням. Лише прошу зробити знижку на те, що це була одна з перших моїх задач на проєкті, за що я дотепер дуже вдячний всім причетним до її створення і за те, що направили її мені. Це дуже круто отримувати такий цікавий виклик на старті. Бо ти відчуваєш себе важливою частиною дуже крутої команди, в якої є нестандартні задачі, які вона готова передати, в тому числі і тобі.
Ліричний відступ. У одного з наших конкурентів сторінки відкривались моментально і в DevTools, у вкладці Application, можна було побачити активний Service Worker. В цей момент ми остаточно переконались, що Service Worker — це правильний вибір.
Але ж яке було наше здивування, коли ми переглянули код цього воркера і виглядав він ось так:
function serviceWorker(cacheName, cacheUrls) { self.addEventListener('fetch', function (event) {}); } serviceWorker('sw', ['/']);
Все вірно: цей код не робить рівним рахунком нічого! Але нас вже було не зупинити і ми почали реалізацію нашої першої версії «безшовного переходу».
Перший млинець нанівець
Але перед історією першої реалізації, включу на трошки режим роз’яснювальної бригади: класична суть кешування Service Worker’ом (Cache first, falling back to network) в тому, що:
- юзер робить запит за даними;
- воркер перевіряє, чи є ці дані в кеші;
- якщо немає: ходить «в інтернет» та віддає їх користувачеві, а сам кешує їх на клієнті для наступного разу;
- якщо є: бере їх з кешу і повертає;
- юзер отримує дані.
І саме тут криється наша перша, основна проблема, яку уважний читач міг прослідкувати раніше, а саме:
1. Як нам взагалі посилати запити, щоб кешувати їх, до того як користувач вирішить почитати статтю? Бо в таких паблішерів, як ми, важливий саме перший захід, адже юзери дуже рідко переходять на ті самі статті ще раз, зазвичай користувач читає статтю і більше до неї не повертається. Тому нам не підходила така модель, нам потрібно було зробити так, щоб юзер отримав закешований запит з першого кліку. Що ж робити? Правильно! Нестандартні задачі потребують нестандартних рішень!
Приймається дивне, але таке логічне, рішення — робити запит за клієнта, поки він про це ще не знає.
Стає зрозумілий ключовий момент всієї цієї історії — не Service Worker’ом єдиним. Адже наш SW не вміє в DOM. Йому буквально про нього нічого невідомо (адже, як ми знаємо, він живе у своєму, окремому скоупі). Він не може дивитися на лінки нашої сторінки і «відправляти запити з коду». Та тут на арену виходить наш hook (який на цей момент ще просто функція pushSWLink), він «парсить» сторінку, на якій знаходиться юзер, і у фоні відправляє запити на API, які, своєю чергою, вже перехоплює наш Service Worker та кешує. А юзеру залишаються, так би мовити, «вершки» і він отримує дані одразу з кешу.
Реалізація нашого воркера до болю проста, щось на кшталт такого:
self.addEventListener('install', (event) => { event.waitUntil( caches .open(PRECACHE) .then((cache) => cache.addAll(PRECACHE_URLS)) .then(self.skipWaiting()), ); }); self.addEventListener('fetch', (event) => { if (event.request.url.startsWith(self.location.origin)) { event.respondWith( caches.match(event.request).then((cachedResponse) => { if (cachedResponse) { return cachedResponse; } return fetch(event.request).then((response) => { return response; }); }), ); } });
- При ініціалізації (install): прекешуємо якісь файли (одна із фішок Service Worker, яка дає можливість кешувати файли одразу після завантаження сторінки).
- Навішуємо слухач на запити (fetch): при отриманні такого запиту, дивимось, чи є в кеші ці дані:
- якщо немає — йдемо на API,
- якщо є — повертаємо їх з кешу.
Виходить стандартна схема роботи Service Worker’а із запитами і їх кешуванням.
І реалізація записування в кеш теж вийшла простенька (hook):
export function pushSWLink(link: string, id: string): void { caches.open(`posts-data`).then((cache) => { cache.addAll([ `/_next/data/${id}${link}.json?slug=${link.replace(/^\//, '')}`, ]); }); caches.open(`posts`).then((cache) => { cache.addAll([`${link}/`]); }); }
- Отримуємо лінку на запит (link).
- Кешуємо спочатку самі дані з API (перший cache.addAll).
- Кешуємо всю сторінку (другий cache.addAll) (це повʼязано з технологіями, які ми використовуємо, а саме Next.js, тому нам потрібно кешувати і дані, і всю сторінку).
Дивовижно, правда? Ось і ми так подумали. Ех, якби ж, адже це — тільки початок нашої історії.
2. Ми «задудосили» нашу API. В середньому, на сторінці по 17 лінків на інші статті, а отже не складно підрахувати, яку кількість запитів ми отримуємо, навіть якщо лише 1000 користувачів одночасно зайдуть на сторінку і наш hook почне з кожного клієнта відправляти по 17 майже одночасних запитів.
І тут наш hook отримує +1 до апгрейдів, і тепер він має скіл рекурсивних запитів (поки не завершиться попередній, до наступного не переходимо).
const applyCache = ( data: Array<{ name: string; path: string }>, counter = 0, ): void => { const { name, path } = data[counter]; const isNotLastItem = counter < data.length - 1; caches.open(name).then((cache) => { cache.match(path).then((response) => { if (!response) { cache.add(path).then(() => { if (isNotLastItem) applyCache(data, (counter += 1)); }); } else if (isNotLastItem) applyCache(data, (counter += 1)); }); }); };
Я знаю, це може звучати банально, але не згадати про це не міг, бо про цей кейс завжди варто памʼятати в таких випадках.
3. Ми закешували build скрипти. В якийсь момент ми помітили, що сторінки починають крашитись та видають дивні помилки. Як виявилось, коли кешуєш сторінку, не варто забувати, що ця сторінка містить посилання на js-build-срипти фреймворка.
І після пари деплоїв ми отримали таку картину: у юзерів, які закешували сторінку, у якої було посилання на старий build-скрипт не могли його дістати, адже після деплою файл змінився, а старий «помер». Тому ми були вимушені відмовитися від кешування сторінок і обмежитися лише кешуванням даних.
4. В «інтернетах» виявилось дуже мало інформації. На той момент на цю тему інформації в інтернеті майже не було. Це дуже ускладнювало задачу і нам доводилось набивати свої синці і складати цілісну картину з обрізків інформації.
5. А якщо юзер все ж таки вирішить повернутись на той самий пост, а він вже 10 разів оновився? Цю проблему ми просто записали, але вирішити на той момент так і не змогли.
І, нарешті, наш криптоніт.
6. Несумісність з іншими SW. У нас паралельним життям жив push notifications service (далі просто пушер, для зручності), який працював за допомогою Service Worker’а. І найбільшим відкриттям того часу для нас стало те, що на дикому заході може бути лише один шериф (на сайті — лише ОДИН SW).
А саме цікаве, будьте готові: вони не будуть викидати вам помилки, або переставати працювати через несумісність — ні. Вони будуть жити своїм життям та перехоплювати запити один одного, ніяк про це не пінгуючи. І тільки одному творцю Service Worker’ів відомо, як вони у себе там розрулюють, хто і що першим робить.
Це і «виробниче пекло», спричинене попередніми пунктами, стало тим криптонітом, який вбив нашу супергеройську ідею. Адже пушер був дуже бізнес-важливим, а кешування наперед — всього лише мрією. Тому фіча була відкладена «до нових віників», але, спойлер, як і будь-які відкладені хороші ідеї, ця мала своє повернення.
Розгерметизація старої ідеї та WorkBox
Через деякий час в поле наших радарів потрапляє такий собі товариш WorkBox.
Ідеальний кандидат. Співбесіда, офер, онбордінг... Ой, не туди. Але можливості інструмента вражали, ви тільки подивіться на весь цей список фіч. Як я і обіцяв, зараз ми не будемо зупинятись на його можливостях та вдаватися в деталі, а лише виділимо ключові моменти, які вирішують наші основні проблеми з минулої глави, а саме:
- Можливість обʼєднувати багато Service Worker’ів під одним затишним дахом WorkBox (вирішувало проблему з пушером).
- Також дає можливість майже з коробки реалізувати пушер в перспективі, відмовившись від нашого кастомного рішення.
- Зручна, проста і зрозуміла інвалідація кешу (вирішила проблему з оновленням постів).
- Зручні інструменти роботи з кешом та прекешом з коробки.
- Дуже багато фіч на перспективу. (офлайн-режим, гугл аналітика і тому подібні).
Ви тільки подивіться, як просто цей монстр з мільйоном фіч конфігурується (якщо відкинути інтеграцію з Next.js):
module.exports = { globDirectory: 'public/', globPatterns: ['**/*.{ico,svg}'], swDest: 'public/sw.js', swSrc: 'workbox/sw.js', };
І все! Все інше — це вже ті деталі, в які я обіцяв не заглиблюватись. Позначу лише для тих, хто захоче колись скористатися цим гайдом, що існує два шляхи його використання:
- generateSW;
- injectManifest.
І це реально два РІЗНИХ підходи, тому спочатку виберіть, який з них підходить вам, а тільки потім починайте з цим роботу, бо якщо не в такій послідовності робити, то буде дуже боляче, — і цей висновок я зробив на досвіді.
Тепер наш скрипт, який відповідав за кешування/ запити виглядає приблизно ось так:
self.addEventListener('fetch', (e) => { const { request } = e; const { destination, method, mode, url } = request; if (destination !== '' && method !== 'GET' && mode !== 'cors') return; if (!url.includes('.json?slug=')) return; e.respondWith( caches.open('posts').then(async (cache) => { return cache.match(request).then((cachedResponse) => { const fetchedResponse = fetch(request).then((networkResponse) => { cache.put(request, networkResponse.clone()); return networkResponse; }); return cachedResponse || fetchedResponse; }); }), ); });
Ідейна частина майже не змінилась, він став просто універсальнішим, обріс перевірками. Але нагадаю, що все це вже разом з пушером і більше вони не конфліктують.
А замість того, щоб лежати десь чистими js файлами в public папках, все це було в одному місці (оскільки WorkBox дозволяє писати скрипти окремими модулями, прямо на TS, і потім сам їх конвертує, мініфікує та «викидає» вже готовий один скрипт в public папку).
import { precacheAndRoute } from 'workbox-precaching'; import './workers/cache'; import './workers/pusher'; declare const self: ServiceWorkerGlobalScope; self.addEventListener('install', () => { self.skipWaiting(); }); // eslint-disable-next-line no-underscore-dangle precacheAndRoute(self.__WB_MANIFEST);
Тим часом наш «супер-hook», нікуди не зник, тільки трошки покращився (ми, наприклад, додали перевірку на offset, щоб тільки коли користувач доскролить до потрібного лінка, він закешував його), але, дотепер, він виконує ключову роль у всій цій пʼєсі.
Інформації по WorkBox виявилось ще менше. Майже все доводилось випробовувати методом спроб та помилок.
І перша із них — спроба застосувати на великому, живому та самодостатньому проєкті «чистий WorkBox», без надбудов. Поясню: WB має готові «пакети» для простої та зручної інтеграції, які і розраховані на такі кейси, коли тобі потрібно без зайвих рухів в конфігах next’а та webpack’а реалізувати у себе WorkBox. Але ми ж програмісти — круті перці, і кому потрібні ті надбудови, юзаємо чистий.
Щось таки навіть вийшло. Спочатку. Але з часом стало зрозуміло, що цього монстра неможливо масштабувати або оновлювати без втрати купи часу. Адже будь-які зміни в налаштуваннях WB тягнули за собою зміни в глобальних конфігах проєкту.
І ми вирішили спробувати готові пакети для інтеграції, які пропонував WorkBox. І, знаєте, це було дуже чудове рішення. Конфігурація зайняла мінімум часу, будь-які зміни в неї проходили майже моментально без переживань задіти основні конфіги проєкту. І десь в цей момент ми зрозуміли: це воно, воно працює так, як ми очікували.
Ніби на підтвердження, що ми на правильному шляху, тоді вийшла конференція від Google «Page Experience Seminar for Publishers 2022 from Google Partner Hub»? в якій ми брали участь як паблішер, партнер Google. Там один з топіків був саме про WB, але ми розуміли, що пішли набагато глибше в його використанні, ніж той базовий приклад, який показували на презентації можливостей WorkBox.
Результати
І ось наш супер-hook + WorkBox нарешті побачили повноцінний продакшн.
1. Швидкість віддачі даних користувачеві до:
Після:
Швидше в 26 разів (на 50ms). Для юзера той самий, довгоочікуваний, «безшовний перехід».
2. Розмір бандла:
3. Пушер прекрасно уживається з нашим кешем.
4. Весь кеш інвалідується і зберігається на клієнті на необхідний нам час.
І для тих, хто все ж хоче трошки технічних деталей та більше практичності від цієї статті, ось як ці результати виглядали під капотом:
hook
// функція для рекурсивного відправлення запитів, щоб не дудосити API async function fetchRequest(index: number, postsURL: string[]): Promise<void> { if (index + 1 > postsURL.length) return; // тут просто формуємо правильну урлу для запитів за даними const buildId = process.env.CONFIG_BUILD_ID; const url = postsURL[index]; const slug = url.replace(/^\//, ''); const json = `/_next/data/${buildId}/${slug}.json?slug=${slug}`; // сама логіка рекурсії if (!(await caches?.match(json))) { caches.open('posts').then(async (cache) => { try { await cache.add(json); } catch (e) { console.log('Failed Cache add', e); } return fetchRequest(index + 1, postsURL); }); } else fetchRequest(index + 1, postsURL); } export function useSWCache( postsURL: string[], // урли на сторінки ): (element?: HTMLElement) => void { // Та сама первірка на offset, щоб робити запити в потрібний момент, а не всі одразу const { observe, inView } = useInView({ unobserveOnEnter: true, rootMargin: `200px`, }); useEffect(() => { if (inView) fetchRequest(0, postsURL); }, [inView]); return observe; }
WorkBox
// Ось так просто додаємо наші SW import './workers/pusher'; declare const self: ServiceWorkerGlobalScope; // Автоматичне оновлення версії основного SW який хендлить всі процеси skipWaiting(); clientsClaim(); // Для тих хто вибрав шлях гнучкості - injectManifest const WB_MANIFEST = self.__WB_MANIFEST as { url: string; revision: string }[]; precacheAndRoute([]); // Додає activate прослуховувач подій, який очищатиме несумісні попередні кешування, створені старішими версіями Workbox cleanupOutdatedCaches(); // Основна логіка оброблювання fetch запитів const postsCacheStrategy = new CacheFirst({ cacheName: 'posts', plugins: [ new ExpirationPlugin({ // інвалідація кешу maxAgeSeconds: 60 * 10, }), ], }); registerRoute(/\.json\?slug=/, postsCacheStrategy, 'GET'); // Потрібно, щоб всі інші запити, крім цільових, завжди йшли на API setDefaultHandler(new NetworkOnly());
Конфігурація WB за допомогою next-pwa (якщо ваш стек Next.js, у іншому випадку, дивіться список):
const withPWA = require('next-pwa')({ // папка де буде зберігатися основний SW dest: 'public', // файл реалізації WB swSrc: 'src/workbox/init.ts', dynamicStartUrl: false, });
Висновок
Як ми можемо побачити, не варто розглядати WorkBox як рішення всіх ваших проблем, ви завжди можете додати свої модифікації та отримати бажаний результат в комбінації. Але якщо вам потрібно обʼєднати декілька сервіс-воркерів або отримати незабутній UX за допомогою кеша, цей друг створений для вас.
Сподіваюсь, ця стаття хоч трошки вам допоможе не набити так багато синців, як це зробили ми. Оскільки у звʼязку зі «специфічністю» задачі ми не використали потенціал WB навіть на пару відсотків, у нашої команди багато планів на нього: додати офлайн-режим, перенести гугл аналітику у WorkBox, переписати пушер як пакет боксу — і це тільки поверхня айсберга.
А цією статтею я лише хотів сказати: неординарні ідеї, в правильному середовищі, можуть і переростають у чудовий UX.
Також, якщо вам цікаво дізнатися більше про перформанс і те, які експерименти ми ставимо на наших проєктах, у нас ще є два цікавих матеріали на DOU: «Як покращити показники Google Core Web Vitals на прикладі мультимедійної платформи» та «Як з якісним моніторингом досягти показників перформансу сайту на рівні 95+».
Дякую всім за увагу!
23 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів