Статьи

Локально извлекаемые AJAX-запросы в кэш-памяти: обертывание Fetch API

Эта статья написана приглашенным автором Питером Бенгтссоном . Гостевые посты SitePoint нацелены на привлечение интересного контента от известных авторов и спикеров сообщества JavaScript.

В этой статье показано, как реализовать локальный кеш извлеченных запросов, чтобы при многократном выполнении он вместо этого считывал данные из хранилища сеансов. Преимущество этого в том, что вам не нужно иметь собственный код для каждого ресурса, который вы хотите кэшировать.

Следите, если вы хотите выглядеть действительно круто на следующей вечеринке JavaScript, где вы можете продемонстрировать различные навыки жонглирования обещаниями, современными API и локальным хранилищем.

Fetch API

На данный момент вы, надеюсь, знакомы с fetch . Это новый нативный API в браузерах, заменяющий старый XMLHttpRequest API.

Там, где он не был полностью реализован во всех браузерах, вы можете использовать GitHub для извлечения polyfill (и если вам нечего делать весь день, вот спецификация Fetch Standard ).

Наивная альтернатива

Предположим, вы точно знаете, какой ресурс вам нужно скачать, и хотите загрузить его только один раз. Вы можете использовать глобальную переменную в качестве кеша, примерно так

 let origin = null fetch('https://httpbin.org/get') .then(r => r.json()) .then(information => { origin = information.origin // your client's IP }) // need to delay to make sure the fetch has finished setTimeout(() => { console.log('Your origin is ' + origin) }, 3000) 

На CodePen

Это просто полагается на глобальную переменную для хранения кэшированных данных. Непосредственная проблема заключается в том, что кэшированные данные исчезают, если вы перезагрузите страницу или перейдете на новую страницу.

Давайте обновим наше первое наивное решение, прежде чем разберем его недостатки.

 fetch('https://httpbin.org/get') .then(r => r.json()) .then(info => { sessionStorage.setItem('information', JSON.stringify(info)) }) // need to delay to make sure the fetch has finished setTimeout(() => { let info = JSON.parse(sessionStorage.getItem('information')) console.log('Your origin is ' + info.origin) }, 3000) 

На CodePen

Первая и непосредственная проблема заключается в том, что fetch основана на обещании, то есть мы не можем точно знать, когда она закончилась, поэтому, чтобы быть уверенными, мы не должны полагаться на ее выполнение до тех пор, пока ее обещание не разрешится.

Вторая проблема заключается в том, что это решение очень специфично для конкретного URL-адреса и определенной части кэшированных данных (ключевая information в этом примере). Нам нужно общее решение, основанное на URL.

Первая реализация — упрощение

Давайте поместим обертку в fetch которая также возвращает обещание. Код, который вызывает его, вероятно, не заботится, пришел ли результат из сети или из локального кэша.

Итак, представьте, что вы делали это:

 fetch('https://httpbin.org/get') .then(r => r.json()) .then(issues => { console.log('Your origin is ' + info.origin) }) 

На CodePen

И теперь вы хотите обернуть это, чтобы повторные сетевые вызовы могли извлечь выгоду из локального кэша. Давайте просто назовем его cachedFetch , поэтому код выглядит так:

 cachedFetch('https://httpbin.org/get') .then(r => r.json()) .then(info => { console.log('Your origin is ' + info.origin) }) 

При первом запуске необходимо разрешить запрос по сети и сохранить результат в кеше. Второй раз его надо брать прямо из локального хранилища.

Давайте начнем с кода, который просто fetch функцию fetch :

 const cachedFetch = (url, options) => { return fetch(url, options) } 

На CodePen

Это работает, но бесполезно, конечно. Давайте начнем с сохранения выбранных данных.

 const cachedFetch = (url, options) => { // Use the URL as the cache key to sessionStorage let cacheKey = url return fetch(url, options).then(response => { // let's only store in cache if the content-type is // JSON or something non-binary let ct = response.headers.get('Content-Type') if (ct && (ct.match(/application\/json/i) || ct.match(/text\//i))) { // There is a .json() instead of .text() but // we're going to store it in sessionStorage as // string anyway. // If we don't clone the response, it will be // consumed by the time it's returned. This // way we're being un-intrusive. response.clone().text().then(content => { sessionStorage.setItem(cacheKey, content) }) } return response }) } 

На CodePen

Здесь много чего происходит.

Первое обещание, возвращаемое fetch фактически выполняется и выполняет запрос GET. Если есть проблемы с CORS (Cross-Origin Resource Sharing .text() , .json() .text() , .json() или .blob() не будут работать.

Наиболее интересной особенностью является то, что мы должны клонировать объект Response, возвращаемый первым обещанием. Если мы этого не сделаем, мы будем слишком много вводить, и когда конечный пользователь обещания попытается вызвать .json() (например), он получит эту ошибку:

 TypeError: Body has already been consumed. 

Другая вещь, на которую следует обратить внимание, — это осторожность в отношении типа ответа: мы храним ответ только в том случае, если код состояния равен 200 и тип содержимого — application/json или text/* . Это потому, что sessionStorage может хранить только текст.

Вот пример использования этого:

 cachedFetch('https://httpbin.org/get') .then(r => r.json()) .then(info => { console.log('Your origin is ' + info.origin) }) cachedFetch('https://httpbin.org/html') .then(r => r.text()) .then(document => { console.log('Document has ' + document.match(/<p>/).length + ' paragraphs') }) cachedFetch('https://httpbin.org/image/png') .then(r => r.blob()) .then(image => { console.log('Image is ' + image.size + ' bytes') }) 

До сих пор в этом решении важно то, что оно работает без помех для запросов JSON и HTML. И когда это изображение, оно не пытается сохранить его в sessionStorage .

Вторая реализация — фактически возврат кеша

Поэтому наша первая реализация просто заботится о хранении ответов на запросы. Но если вы вызываете cachedFetch во второй раз, он не будет пытаться извлечь что-либо из sessionStorage . Что нам нужно сделать, это вернуть, во-первых, обещание, а обещание должно разрешить объект Response .

Давайте начнем с очень простой реализации:

 const cachedFetch = (url, options) => { // Use the URL as the cache key to sessionStorage let cacheKey = url // START new cache HIT code let cached = sessionStorage.getItem(cacheKey) if (cached !== null) { // it was in sessionStorage! Yay! let response = new Response(new Blob([cached])) return Promise.resolve(response) } // END new cache HIT code return fetch(url, options).then(response => { // let's only store in cache if the content-type is // JSON or something non-binary if (response.status === 200) { let ct = response.headers.get('Content-Type') if (ct && (ct.match(/application\/json/i) || ct.match(/text\//i))) { // There is a .json() instead of .text() but // we're going to store it in sessionStorage as // string anyway. // If we don't clone the response, it will be // consumed by the time it's returned. This // way we're being un-intrusive. response.clone().text().then(content => { sessionStorage.setItem(cacheKey, content) }) } } return response }) } 

На CodePen

И это просто работает!

Чтобы увидеть его в действии, откройте CodePen для этого кода и, как только вы окажетесь там, откройте вкладку Сеть вашего браузера в инструментах разработчика. Нажмите кнопку «Выполнить» (верхний правый угол CodePen) пару раз, и вы увидите, что по сети постоянно запрашивается только изображение.

Одна из особенностей этого решения — отсутствие спагетти с обратным вызовом. Поскольку вызов sessionStorage.getItem является синхронным (он же блокирующим), нам не приходится иметь дело с «Было ли это в локальном хранилище?» Внутри обещания или обратного вызова. И только если там что-то было, мы возвращаем кешированный результат. Если нет, то оператор if просто продолжает обычный код.

Третье внедрение — как насчет Expiry Times?

До сих пор мы использовали sessionStorage который похож на localStorage за исключением того, что sessionStorage очищается при запуске новой вкладки . Это означает, что мы используем «естественный способ» не кешировать вещи слишком долго. Если бы мы вместо этого использовали localStorage и что-то кешировали, он просто застрял бы там «навсегда», даже если удаленный контент изменился. И это плохо.

Лучшее решение — дать пользователю контроль. (Пользователь в данном случае — веб-разработчик, использующий нашу функцию cachedFetch ). Как и в случае хранилища, такого как Memcached или Redis на стороне сервера, вы устанавливаете время жизни, определяя, как долго оно должно кэшироваться.

Например, в Python (с Flask)

 >>> from werkzeug.contrib.cache import MemcachedCache >>> cache = MemcachedCache(['127.0.0.1:11211']) >>> cache.set('key', 'value', 10) True >>> cache.get('key') 'value' >>> # waiting 10 seconds ... >>> cache.get('key') >>> 

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

Но прежде чем мы это сделаем, как это будет выглядеть? Как насчет чего-то вроде этого:

 // Use a default expiry time, like 5 minutes cachedFetch('https://httpbin.org/get') .then(r => r.json()) .then(info => { console.log('Your origin is ' + info.origin) }) // Instead of passing options to `fetch` we pass an integer which is seconds cachedFetch('https://httpbin.org/get', 2 * 60) // 2 min .then(r => r.json()) .then(info => { console.log('Your origin is ' + info.origin) }) // Combined with fetch's options object but called with a custom name let init = { mode: 'same-origin', seconds: 3 * 60 // 3 minutes } cachedFetch('https://httpbin.org/get', init) .then(r => r.json()) .then(info => { console.log('Your origin is ' + info.origin) }) 

Новая важная вещь, которую мы собираемся добавить, заключается в том, что каждый раз, когда мы сохраняем данные ответа, мы также записываем, когда мы сохраняли их. Но обратите внимание, что теперь мы можем также переключиться на более localStorage хранилище localStorage вместо sessionStorage . Наш пользовательский код истечения срока действия гарантирует, что мы не получим ужасно устаревшие попадания в кеш в остальном localStorage .

Итак, вот наше окончательное рабочее решение:

 const cachedFetch = (url, options) => { let expiry = 5 * 60 // 5 min default if (typeof options === 'number') { expiry = options options = undefined } else if (typeof options === 'object') { // I hope you didn't set it to 0 seconds expiry = options.seconds || expiry } // Use the URL as the cache key to sessionStorage let cacheKey = url let cached = localStorage.getItem(cacheKey) let whenCached = localStorage.getItem(cacheKey + ':ts') if (cached !== null && whenCached !== null) { // it was in sessionStorage! Yay! // Even though 'whenCached' is a string, this operation // works because the minus sign converts the // string to an integer and it will work. let age = (Date.now() - whenCached) / 1000 if (age < expiry) { let response = new Response(new Blob([cached])) return Promise.resolve(response) } else { // We need to clean up this old key localStorage.removeItem(cacheKey) localStorage.removeItem(cacheKey + ':ts') } } return fetch(url, options).then(response => { // let's only store in cache if the content-type is // JSON or something non-binary if (response.status === 200) { let ct = response.headers.get('Content-Type') if (ct && (ct.match(/application\/json/i) || ct.match(/text\//i))) { // There is a .json() instead of .text() but // we're going to store it in sessionStorage as // string anyway. // If we don't clone the response, it will be // consumed by the time it's returned. This // way we're being un-intrusive. response.clone().text().then(content => { localStorage.setItem(cacheKey, content) localStorage.setItem(cacheKey+':ts', Date.now()) }) } } return response }) } 

На CodePen

Будущее внедрение — лучше, любитель, круче

Мало того, что мы localStorage чрезмерного использования этих веб-API, лучше всего то, что localStorage работает в миллиард раз быстрее, чем полагаясь на сеть. См. Этот пост в блоге для сравнения localStorage и XHR : localForage и XHR . Он измеряет другие вещи, но в основном приходит к выводу, что localStorage действительно быстр и разогрев дискового кэша редки.

Так как же мы можем улучшить наше решение?

Работа с бинарными ответами

Наша реализация здесь не беспокоит кэширование нетекстовых вещей, таких как изображения, но нет никаких причин, по которым это невозможно. Нам нужно немного больше кода. В частности, мы, вероятно, хотим хранить больше информации о Blob . Каждый ответ — это в основном Blob. Для текста и JSON это просто массив строк. А type и size самом деле не имеют значения, потому что это то, что вы можете выяснить из самой строки. Для двоичного содержимого BLOB- объект должен быть преобразован в ArrayBuffer .

Для любопытных, чтобы увидеть расширение нашей реализации, которая поддерживает изображения, проверьте этот CodePen .

Использование хешированных ключей кеша

Еще одно потенциальное улучшение — обменять пространство на скорость, хэшируя каждый URL, который мы использовали как ключ, к чему-то гораздо меньшему. В приведенных выше примерах мы использовали лишь несколько очень маленьких и аккуратных URL-адресов (например, https://httpbin.org/get ), но если у вас действительно большие URL-адреса с большим количеством штук в строке запроса и у вас их много, это действительно может сложить.

Решением этой проблемы является использование этого аккуратного алгоритма, который, как известно, безопасен и быстр:

 const hashstr = s => { let hash = 0; if (s.length == 0) return hash; for (let i = 0; i < s.length; i++) { let char = s.charCodeAt(i); hash = ((hash<<5)-hash)+char; hash = hash & hash; // Convert to 32bit integer } return hash; } 

Если вам это нравится, проверьте этот CodePen . Если вы проверите хранилище в своей веб-консоли, вы увидите такие ключи, как 557027443 .

Вывод

Теперь у вас есть рабочее решение, которое вы можете использовать в своих веб-приложениях, где, возможно, вы используете веб-API и знаете, что ответы могут быть довольно хорошо кэшированы для ваших пользователей.

И последнее, что может быть естественным расширением этого прототипа, — это взять его за пределы статьи и в реальный, конкретный проект с тестами и README и опубликовать его на npm — но это в другой раз!