У складному вебзастосунку з кількома компонентами, що потребують даних, є ймовірність здійснення зайвих викликів API. Уявіть собі просту сторінку онлайн-магазину: користувач переглядає сторінку, потім другу, а потім повертається до першої. Тоді застосунок, найімовірніше, повторить попередні виклики API, щоб по суті отримати ті ж дані, які вже має.
Звісно, що може допомогти кешування браузера. Але це також залежить від віддаленого сервера, який надає відповідні заголовки, а так відбувається не завжди.
Отже, ми хочемо зробити щось у нашому коді, аби доповнити кешування браузера (якщо воно є) і швидкодія була якнайкращою.
У попередній статті ми показали, як можна покращити ефективність коду за допомогою мемоїзації: створення нових версій ваших функцій, які використовують внутрішній кеш, щоб уникнути повторних обчислень. А чи можна зробити те саме з промісами та уникнути перероблювання викликів API?
У цій статті ми розповімо, як це зробити і як уникнути проблем (включно з серйозними). Плюс — буде ще кілька вдосконалень, які поліпшать роботу вашого вебзастосунку.
Шляхи, якими ми не підемо
Почнімо з розв'язань проблеми повторюваних викликів API… а потім відкинемо їх! Зверніть увагу, що ми розглянемо зміни лише клієнтської частини: сервер для кешування є очевидною ідеєю, але він може бути недоступним для вас з різних причин.
Перший (і найочевидніший) спосіб виправити проблему — це перевірити, чи доступні нам необхідні дані ще до виклику. Іншими словами, пишемо алгоритм роботи кешу! Потрібно буде його додати (можливо, за допомогою CacheStorage) та змінити усюди код так, щоб кеш перевірявся перед викликом… але — це погана ідея: забагато змін і шансів для нових помилок!
Можливо, ви зауважили, що у React, Vue або подібних фреймворках можна не додавати новий кеш, а скористатись глобальним сховищем на сервісному рівні. Він перевіряє сховище перед викликом кінцевої точки, щоб уникнути непотрібних викликів. Звичайно, ви збільшите сховище, щоб зберігати й раніше отримані значення.
Такий спосіб теж працює і не потребує багато змін коду, оскільки більшість дій виконуватиметься на сервісному рівні, а не по всьому коду. Але вам все одно доведеться змінювати свій код незвичними способами, тож загроза нових помилок лишається.
Насправді ми шукаємо спосіб, де змін в коді якомога менше і не потрібно додавати власноруч написаний код для перевірки наявних даних. Окрім того, ми хотіли б користуватись цим способ вибірково: потрібна гнучкість, щоб самим обирати, де потрібне наше вдосконалення, а де ні.
Нарешті, згадаємо про термін REST: ми хочемо застосувати вдосконалення до викликів GET, але не до викликів POST, PUT або DELETE; вони завжди повинні буди вихідними (навіть якщо були здійснені раніше). Наше виправлення повинно бути дуже конкретним!
Перша (невдала) спроба
Згадуючи роботу з попередньої статті про мемоїзацію, ми могли б подумати про застосування цієї ж техніки. Ми тоді написали таку функцію:
const memoize = (fn) => {
const cache = new Map();
return (...args) => {
const strX = JSON.stringify(args);
if (!cache.has(strX)) {
cache.set(strX, fn(...args));
}
return cache.get(strX);
};
};
Наша функція вищого порядку memoize(...)
приймає будь-яку функцію та повертає нову функцію, яка використовує кеш. За кожного виклику цієї нової функції, вона спочатку перевіряє наявність у кеші аргументів, за допомогою яких ви викликаєте функцію. Якщо вони там є, то замість обчислення вона повертає кешоване значення. Якщо аргументів немає в кеш-пам'яті, викликається оригінальна функція, її значення зберігається до кеш-пам'яті, а потім вона повертає значення тому, хто її викликав.
Перший раз, коли ви викликаєте функцію з певним набором аргументів, вона виконає свою роботу, а за повторного виклику поверне кешований результат першого виклику.
Повернімося до проблеми API. Загальною моделлю у застосунках є маршрутизація викликів API через функцію getData()
, яка встановлює загальні параметри (наприклад, токен у заголовках для автентифікації), а потім використовує якусь бібліотеку (Axios, SuperAgent або навіть звичайний fetch) для виклику, повернувши проміс. Отже, ця функція мала б такий вигляд:
const getData = (urlToCall, someOtherParameters) => {
// set up headers
// set up options
// set up other things
return doTheCall(urlToCall, headers, options, etc)
}
Як можна уникнути дубльованих викликів до API? Чому б не застосувати memoize(...)
до функції getData(...)
? Якщо ми це зробимо, то за кожного повторого виклику API, ми отримаємо початковий проміс без (повторного) виклику кінцевої точки API. Ми можемо створити функцію getCachedData(...)
, яка використовує кеш, схожу на цю:
const getCachedData = memoize(getData);
Щоб оптимізувати виклики, просто замініть getCachedData(...)
на getData(...)
де завгодно. Звичайно, можуть бути деякі API GET
, які ви не хочете оптимізувати в такий спосіб (можливо, до якоїсь кінцевої точки, яка завжди повертає щойно оновлені дані). Тоді нехай залишиться початковий виклик getData(...)
. Ми не використовуватимемо пам'ять для інших REST-викликів, наприклад POST
, PUT
або DELETE
; їх потрібно виконувати щоразу.
Чудове розв'язання… але з прихованою помилкою! Що ж ми не пропустили?
Повноцінне розв'язання
Попередній спосіб, здавалося б, добре працює; що не так? Проблема виникне, якщо не вдасться виконати виклик API. Виконати виклик ще раз? Відповідь проста: нічого не робити! Ми вже зберегли до пам'яті (відхилений) проміс, тому подальші виклики повернуть його значення з кешу. Насправді з нашим мемоїзованим промісом не буде жодного способу повторити виклик… що ж робити?
Що нам дійсно потрібно, то це навчити нашу мемоїзовану функцію запобігати можливим помилкам. Якщо виклик не виконано, проміс повинен вилучатися з кешу, щоб подальші виклики працювали з нуля і викликали саме API, а не кешоване значення. Ми можемо змінити це досить легко.
const memoize = (fn) => {
const cache = new Map();
return (...args) => {
const strX = JSON.stringify(args);
if (!cache.has(strX)) {
cache.set(
strX,
fn(...args).catch((err) => {
cache.delete(strX);
return err;
})
);
}
return cache.get(strX);
};
};
Ось так розв'язується проблема. Додаємо .catch(...)
до проміса, перш ніж додати його до cache. Після цієї зміни, якщо виклик API не виконується, відхилений проміс вилучається з кешу. Якщо ви повторите оригінальний виклик пізніше, API буде запитано знову, оскільки результати першого виклику не кешуються.
Патерн попередніх викликів (prefetching)
Що можна вдосконалити для користувачів вашого застосунку? Можливо, ваша вебсторінка не здатна бути швидшою, тоді можна створити ілюзію пришвидшення! З мемоїзацією промісів легше реалізувати патерн попередніх викликів: дані передаються до пам'яті заздалеідь, а коли вони застосунок насправді їх потребує, вони вже під рукою. Ви часто можете передбачити дії користувача в певний момент, а з попередньо отриманими даними, робота вашої сторінки здаватиметься надшвидкою.
Попереднє отримання даних може бути складним, але з мемоїзацією промісів це просто. Ви лишень повинні запустити виклик даних, які, ймовірно, будуть запитані, а потім ігнорувати результати! Головне, якщо у майбутньому буде другий виклик, а потрібні результати були запитані раніше, то мемоїзований проміс вже матиме їх і миттєво поверне.
Для реалізації попередніх викликів не потрібно нічого змінювати в коді; просто додайте відповідні виклики у місцях де вони можуть знадобитись. Наприклад, попередні запити можуть бути корисними для перегляду сторінок зі згорнутими меню або частинами вмісту (якщо користувачу надано можливість переглядати цю додаткову інформацію).
Одне застереження: намагайтеся не передати куті меду з кількістю попередніх запитів, оскільки браузери мають обмеження на вихідні з'єднання. Якщо виконуватиметься занадто багато запитів, то вони можуть заблокувати роботу застосунку на деякий час, доки всі попередні виклики не повернуть результат.
Патерн SWR
SWR (Stale While Revalidate) — другий патерн, який допоможе вашій сторінці здаватися швидшою. Він працює так: ви запитуєте якісь дані, а проміс вже кешовано і він повертає поточні дані. А одночасно запускається новий запит API для оновлення (можливо застарілих) даних з пам'яті для подальшого використання. Після перевірки застарілості кешованих даних ви можете погратись і визначати, повертати чи не повертати дані негайно. Та для першого разу спрацює й описана перед цим логіка.
Докладніше про постійний вміст — у статті RFC-5861.
Зауважте, що це не просте кешування: застосування мемоїзації передбачає одноразовий виклик API, а з SWR API виклики надсилаються завжди, навіть якщо викликач негайно отримує результати.
Чому варто не працювати з кешуванням браузера? Кеш браузера працює дуже добре для статичного вмісту, але якщо сервер не надає значень max-age та stale-while-revalidate у HTTP-заголовках, ви можете покладатися лише на себе. Пам'ятайте, що браузер використовує кеш, щоб швидко повертати дані, але не оновлює його постійно.
Звичайно ми можемо здійснювати запити, ігноруючи кеш (тобто взагалі не кешувати дані), проте це згубить всі наші намагання пришвидшити роботу інтерфейсу. SWR — це проміжне рішення, яке добре працює з мемоїзацією промісів у більшості випадків.
Що потрібно змінити для цього підходу?
const memoize = (fn) => {
const cache = new Map();
return (...args) => {
const strX = JSON.stringify(args);
const result = cache.get(strX); /* (1) */
cache.set( /* (2) */
strX,
fn(...args).catch((err) => {
cache.delete(strX);
return err;
})
);
return result !== undefined /* (3) */
? result
: cache.get(strX);
};
* };
Найперше (1) ми намагаємось отримати наявний проміс із кешу; на цьому кроці ми можемо отримати проміс, якщо запит успішний, або undefined у разі невдалого запиту. Далі (2) ми завжди перезаписуємо кеш з новим значенням проміса, як це описано у попередньому розділі. Нарешті (3), якщо ми отримали проміс, ми повертаємо його, а якщо ні — щойно додане ним до кешу значення. Важливий момент: щоб переконатися, що запит на сервер насправді надіслано, вам потрібно змусити браузер додати випадковий параметр (наприклад, xyzzy=${Date.now()})
в кінці рядка запиту. Для цього додайте ці заголовки до вашого запиту:
-
pragma: no-cache
-
cache-control: no-cache
Будь-який із цих методів змусить ваш браузер фактично повторити виклик і пропустити його кеш. «Подвійна атака» попередніх запитів і SWR може зробити ваш код значно адаптивнішим, а з мемоізацією ви отримаєте все це разом, блискуче!
Мемоїзація викликів API у фреймворках
У статті про мемоїзацію функцій ми побачили, що популярні фреймворки на кшталт React і Vue мають спрощену версію мемоїзації — вони просто кешують єдине значення. Розгляньмо, як вони працюють з мемоїзацією викликів API, з патерном попередніх викликів та патернами SWR.
У React є бібліотека SWR, яка надає цю функціональність, а у Vue її перенесли до SWVV. React Query є ще однією бібліотекою, яка дозволяє застосункам React (і React Native) користуватися згаданими можливостями.
Ці бібліотеки прості й легко встановлюються, водночас вони мають більше функціональності, ніж ми згадали у цій статті, тому радимо спробувати.
Підсумуємо
У цій статті ми оглянули мемоїзацію промісів для прискорення майбутніх пошуків даних API. Ми також поговорили про кілька додаткових методів для відчуття пришвидшення: про попередній виклик, який надає дані заздалегідь, а також про раніше кешовані дані, які можна використати принагідно.
Нарешті, ми обговорили деякі доступні бібліотеки для React та Vue, які простіші за описані у статті способи мемоїзації. Без особливих зусиль ви можете отримати кращий і адаптивніший застосунок — тож, здається, немає жодної причини тікати від оптимізації!
Ще немає коментарів