Pull to refresh

Cache API — кэшируем данные на стороне клиента

Level of difficultyMedium
Reading time6 min
Views6.4K

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


Прежде чем мы будем говорить о самом API, немного поговорим про контекст. Когда мы говорим о кэшировании веб-приложений, перед нами несколько (зачастую независимых) путей:

  1. Image Cache

  2. Preload Cache

  3. Cache API

  4. HTTP Cache

Рассказ о каждом из них, ровно, как и сравнение, — отдельная статья. Здесь же я хочу рассказать именно о Cache API.

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

Но и тут путей организации кэширования на стороне клиента несколько:

  1. Local Storage

  2. IndexedDB

  3. Cache Storage

  4. И еще много других 😖

А вот тут поговорим о каждом из данных путей:

  • С Local Storage понятно - устоявшийся стандарт хранилища на стороне клиента, но, как и всегда, есть нюансы - максимальный размер 5 Мб, а еще он синхронен и блокирует основной поток.

  • IndexedDB - интересное решение. Стоит учитывать, что максимальный размер варьируется в зависимости от браузера, однако этот размер в любом случае больше, чем 5 Мб. Но все упирается в то, что есть следующий метод - Cache Storage, созданный специально для хранения кэша?

  • Cache Storage - хранилище, созданное специально для кэша. Так же как и в IndexedDB, максимальный размер варьируется в зависимости от браузера.  

Также, если вы хотите детальнее узнать о концепциях, лежащих в основе Web Storage - есть прекрасная статья.

Я упомянул о том, что размер Local Storage ограничен 5 Мб, а IndexedDB и Cache Storage - ограничен свободным пространством и различными реализациями в зависимости от браузера, но я не сказал о том, что мы можем узнать, сколько доступно свободного пространства для записи и сколько уже занято.

Для этого есть прекрасное API - Storage Manager. И еще одна новость - он имеет хорошую поддержку в большинстве современных браузеров.

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

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

Исходный код
if (navigator.storage && navigator.storage.estimate) {
  const quota = await navigator.storage.estimate();
  // quota.usage -> свободное пространство ( в байтах).
  // quota.quota -> доступное пространство (в байтах).
  const percentageUsed = (quota.usage / quota.quota) * 100;
  console.log(`Вы используете ${percentageUsed}% от доступного пространства.`);
  const remaining = quota.quota - quota.usage;
  console.log(`Вы можете записать еще ${remaining} байт.`);
}

Итак, подытожим:

Local Storage

IndexedDB

Cache Storage

Позволяет хранить большие объемы данных

Асинхронен

Удобен для хранения кэша

Разобрав каждый из методов, давай уже приступим к написанию своего примера. Представим, что нам необходимо реализовать веб-приложение с новостной лентой. Поскольку мы хотим снизить нагрузку на сервер - добавим кэширование. Сами же новости будем забирать с помощью JSONPlaceholder. А рабочий же пример будет в конце.


Для начала, давайте получим данные и добавим их на нашу страницу.

main.ts
main.ts
Исходный код
const postFeed = document.querySelector("#postFeed");

const createArticleElement = (title: string, body: string) => `
    <article class='article card'>
      <h2 class='title'>${title}</h2>
      <p class='text'>${body}</p>
    </article>
  `;

const addToFeed = (data: { title: string; body: string }[]) => {
  if (postFeed) {
    data.forEach(({ title, body }: { title: string; body: string }) => {
      postFeed.innerHTML += createArticleElement(title, body);
    });
  }
};

if (postFeed) {
  fetch("https://jsonplaceholder.typicode.com/posts").then(async (res) => {
      const data = await res.json();

      addToFeed(data);
    });
  }
}

А вот наш HTML

index.html
index.html
Исходный код
<main id="app">
    <div class='feed' id='postFeed'>
    </div>
</main>

В итоге, мы получаем такую страницу:

Страница с новостями
Страница с новостями

Если мы зайдем в DevTools и откроем вкладку Network, то заметим, что сейчас запрос на получение новостей происходит на каждое открытие страницы.

Как раз здесь в игру вступает CacheAPI:

main.ts
main.ts
Исходный код
(async () => {
  if (postFeed) {
    const url = "https://jsonplaceholder.typicode.com/posts";
    const cache = await caches.open("cache"); // Открываем кэш. Если такого не было до - создаестся новый.


    const cachedResponse = await cache.match(url);


    if (cachedResponse) {
      const data = await cachedResponse.json();
      addToFeed(data);


      return;
    }


    fetch(url).then(async (res) => {
      cache.put(url, res);


      const cloned = res.clone();
      addToFeed(await cloned.json());
    });
  }
})();

Что здесь добавилось?

Во-первых, мы открываем новый кэш. Если же кэша с таким именем нет - добавится новый.

Исходный код
const cache = await caches.open("cache");

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

Исходный код
const cachedResponse = await cache.match(url);


if (cachedResponse) {
  const data = await cachedResponse.json();
  addToFeed(data);


  return;
}

Если же кэша нет - запрашиваем с сервера данные и кладем их в кэш по этому запросу и, опять же, добавляем на страницу.

Исходный код
fetch(url).then(async (res): Promise<void> => {
    cache.put(url, res);

    const cloned = res.clone();
    addToFeed(await cloned.json());
});

Хорошо, с одной проблемой разобрались - теперь мы не будем делать лишние запросы на сервер и храним данные ответа на устройстве клиента. Но есть одна проблема: если мы добавим новую новость, запрос не уйдет на сервер, потому что достанется из кэша, и, следовательно, пользователь ее не увидит. Чтобы решить эту проблему - можно использовать несколько способов, однако, для простоты, я приведу один - в ответе будем возвращать заголовок Expires для удаления кэша, как время истекает.

На клиенте же будем проверять, истекло ли время. Если да - удаляем данные из кэша.

Исходный код
(async () => {
  if (postFeed) {
    const url = "https://jsonplaceholder.typicode.com/posts";
    const cache = await caches.open("cache");

    const cachedResponse = await cache.match(url);

    if (cachedResponse) {
      const expiresValue = cachedResponse.headers.get("expires");
      const dateValue = cachedResponse.headers.get("date");
      const maxAge = 7200000; // 2 часа
      const exp =
        expiresValue && expiresValue !== "-1"
          ? Date.parse(expiresValue)
          : Date.parse(dateValue ?? "") + maxAge;

      if (Date.now() < exp) {
        const data = await cachedResponse.json();
        addToFeed(data);
        return;
      }

      cache.delete(url);
    }

    fetch(url).then(async (res): Promise<void> => {
      const cloned = res.clone();

      const headers = new Headers([...cloned.headers.entries()]);
      headers.set("date", new Date().toUTCString());

      const { body, ...rest } = res;

      await cache.put(
        url,
        new Response(body, {
          ...rest,
          headers,
        }),
      );

      addToFeed(await cloned.json());
    });
  }
})();

Сначала мы получаем заголовок Expires и Date, они нам пригодятся для того, чтобы определять, просрочен ли кэш. Далее мы определяем дефолтное время жизни токена, если сервер не вернул заголовок Expires - 2 часа. Если время жизни токена еще не просрочено, то берем значение из кэша, если же просрочена - удаляем значение.

Исходный код
if (cachedResponse) {
      const expiresValue = cachedResponse.headers.get("expires");
      const dateValue = cachedResponse.headers.get("date");
      const maxAge = 7200000; // 2 часа
      const exp =
        expiresValue && expiresValue !== "-1"
          ? Date.parse(expiresValue)
          : Date.parse(dateValue ?? "") + maxAge;

      if (Date.now() < exp) {
        const data = await cachedResponse.json();
        addToFeed(data);
        return;
      }

      cache.delete(url);
    }

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

Исходный код
fetch(url).then(async (res): Promise<void> => {
  const cloned = res.clone();

  const headers = new Headers([...cloned.headers.entries()]);
  headers.set("date", new Date().toUTCString());

  const { body, ...rest } = res;

  await cache.put(
    url,
    new Response(body, {
      ...rest,
      headers,
    }),
  );

  addToFeed(await cloned.json());
});

А вот и рабочий пример.

Также советую почитать больше про Cache Storage API в официальной документации. Или же в упрощенной версии - MDN.


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

Tags:
Hubs:
Total votes 5: ↑5 and ↓0+5
Comments13

Articles