Как HTTP-кэширование помогает сделать сайт быстрее

Олег Кусов31.12.2021

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

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

HTTP-кэширование

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

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

Как работает кэширование?

Если пользователь никогда раньше не посещал сайт, все данные придется загрузить с сервера. Однако повторная загрузка страницы за счет кеширования статики произойдет в разы быстрее. Например, при полной загрузке одной из страниц Wikipedia браузер получит 742 килобайта. При повторной загрузке по Сети будет передано лишь 89 байт HTML-разметки.

Пример приложения

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

Ниже в коде простой http-сервер обрабатывает запросы на '/', '/dashboard' и '/dashboard-data'. Первые два запроса нужны для тестирования кэширования HTTP-данных, а вот с '/dashboard-data' мы протестируем json-формат.

import { createServer } from "http";
import fs from "fs";
import md5 from "md5";

const getPage = (name, data) => `
<!doctype html>
  <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport"
              content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        <title>${name}</title>
    </head>
    <body>
         <nav><ul><li><a href="/">Main</a></li><li><a href="/dashboard">Dashboard</a></li><li><a href="/contacts">Contacts</a></li></ul></nav>
        ${data}
    </body>
    <script>
        document.querySelector('.dashboard-button').addEventListener('click', () => {
            fetch('http://localhost:8000/orders')
.then(res => res.json()).then(data => {
                document.querySelector('.dashboard-data').innerHTML = JSON.parse(data.data).map(sausage => "<div class='sausage'>" + sausage.title + "</div>")
            })
        })
    </script>
  </html>
`;

const server = createServer((req, res) => {
 switch (req.url) {
  case "/": {
   const page = getPage(
    "main page",
    Array.from({ length: 500 }).map(
     () => "<div>some main data</div>"
    )
   );
   const etag = md5(page);
   if (etag === req.headers["if-none-match"]) {
    res.writeHead(304);
    return res.end();
   }
   res.writeHead(200);
   return res.end(page);
  }
  case "/dashboard": {
   const page = getPage(
    "dashboard page",
    '<div class="dashboard-data"></div><button class="dashboard-button">get dashboard data</button>'
   );
   const etag = md5(page);
   if (etag === req.headers["if-none-match"]) {
    res.writeHead(304);
    return res.end();
   }
   res.writeHead(200, {
    ETag: etag,
    "cache-control":
     "max-age=10, must-revalidate",
   });
   return res.end(page);
  }
  case "/contacts": {
   const page = getPage(
    "contacts page",
    Array.from({ length: 200 }).map(
     () =>
      "<div>contacts</div><div>it's contacts page</div>"
    )
   );
   const etag = md5(page);
   if (etag === req.headers["if-none-match"]) {
    res.writeHead(304);
    return res.end();
   }
   res.writeHead(200);
   return res.end(page);
  }
  case "/orders":
   try {
    fs.readFile(
     "./data.json",
     "utf8",
     function (err, orders) {
      if (err) {
       return console.log(err);
      }

      const etag = md5(orders);

      if (etag === req.headers["if-none-match"]) {
       res.writeHead(304);
       return res.end();
      }
      res.writeHead(200, {
       "Content-Type": "application/json",
       ETag: etag,
       "cache-control":
        "max-age=10, must-revalidate",
      });
      return res.end(
       JSON.stringify({
        data: orders,
       })
      );
     }
    );
   } catch (e) {
    console.log(e);
   }
   break;
  default:
   res.writeHead(404);
   res.end();
 }
});

server.listen(8000);
console.log("Server started");

Кэшироваться будет страница /dashboard с помощью токена в etag и заголовка cache-control: max-age=0, must-revalidate. max-age=0 говорит нам о том, что максимальное время жизни кэша - 0 секунд. must-revalidate означает, что по истечении max-age ревалидировать кэш. etag - это хэш, с помощью которого проверяется неизменность данных на сервере. Если с сервера приходит etag, браузер автоматически при каждом новом запросе будет отправлять заголовок if-none-match и сервер должен возвращать 304.

Валидация

Процесс проверки ресурса в кэше называется валидацией. Она происходит, когда пользователь нажимает на кнопку перезагрузки страницы или когда с сервера приходит ответ с заголовком cache-control: must-revalidate. Валидация происходит если в заголовках используется etag или max-age и его устаревшие альтернативы.

Cache-Control: max-age=<любое число>

Этот параметр используется, как правило, для кеширования статики. Рекомендуется использовать максимальное значение, не превышающее 1 год (31536000 секунд).

max-age является современным способом установки времени жизни ресурса, однако со времен HTTP 1.0 нам достались заголовки expires и last-modified. Если в заголовках нет cache-control: max-age, браузер будет проверять время жизни с помощью expires и last-modified.

Но как обновлять статику в браузере, ведь с cache-control: max-age=31536000 она будет целый год браться из кэша? Для этого используется стратегия версионирования, когда к названию файла дописывается версия файла или хэш. Обычно обновление версии происходит автоматически с помощью хэша, конфигурируемого сборщиком.

Что будет, если в примере выше для страницы /dashboard отправить следующее:

res.writeHead(200, {'cache-control': 'max-age=10' });

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

В примере выше вместе с etag мы отправляли cache-control: max-age=0, must-revalidate, однако когда мы просто отправляем etag, браузер всё равно будет отправлять заголовок if-none-match. Зачем же тогда нужны max-age и must-revalidate, если все работает без них?

Must-revalidate

Кэш бывает как свежим, так и устаревшим. При этом устаревший кэш не удаляется сразу. Браузер может использовать его в различных ситуациях, например, при потере соединения с сервером. Поэтому чтобы явно сказать браузеру: "Эй, браузер, я хочу ревалидировать кэш", необходимо использовать must-revalidate. А max-age используется совместно с must-revalidate, потому что браузеру нужно понимать, когда точно кэш становится устаревшим. Поэтому для большей надежности вместе с etag следует использовать cache-control: max-age=0, must-revalidate. Но есть еще cache-control: no-cache, который ну очень похож на cache-control: max-age=0, must-revalidate.

Cache-Control: no-cache

Этот параметр подходит для кэширования не статического контента (json, html). Необходимо, чтобы сервер присылал заголовок etag (обычный токен для проверки свежести данных). Браузер отправит запрос на сервер с заголовком If-none-match: <значение из etag>. Сервер возьмет etag из if-none-match и если этот токен совпадает с etag текущего ресурса на сервере (то есть данные на сервере не поменялись), сервер вернет статус 304 (Not Modified) и данные не будут отправлены клиенту, а будут получены из кэша.

Чувстуете, да? no-cache как-будто полностью повторяет max-age=0, must-revalidate. Так оно и есть. Они идентичны по своему поведению. Однако с точки зрения логики работы использование max-age=0 без параметра must-revalidate означает, что браузер "должен" ревалидировать ресурс, в случае с no-cache браузер "обязан" это сделать. А вот чтобы браузер был обязан ревалидировать max-age=0 следует использовать с ним must-revalidate. Получаем, что cache-control: max-age=0, must-revalidate по логике работы повторяет cache-control: no-cache.

Автоматическое кеширование

Помимо автоматического кеширования статического контента, браузер автоматически кеширует HTML, если пользователь нажмет на кнопку "Назад". Это удобная фича позволяет создать бесшовный опыт использования сайта. Однако иногда такое поведение нам не нужно, в этом случае можно использовать заголовок cache-control: no-store.

Cache-Control: no-store

Всегда получать данные с сервера и не хранить ничего в кэше.

Кэширование запросов с JSON-данными

Итак, мы примерно разобрались с тем, как работает кэш для статического контента и для HTML-разметки. Теперь давайте рассмотрим работу кэша для json. А, впрочем, что же тут рассматривать? Работать оно будет также, как и для HTML-разметки. Добавляем в заголовки etag и cache-control: max-age=0, must-revalidate или cache-control: no-cache и всё готово.

 case "/orders":
   try {
    fs.readFile(
     "./data.json",
     "utf8",
     function (err, orders) {
      if (err) {
       return console.log(err);
      }

      const etag = md5(orders);

      if (etag === req.headers["if-none-match"]) {
       res.writeHead(304);
       return res.end();
      }
      res.writeHead(200, {
       "Content-Type": "application/json",
       ETag: etag,
       "cache-control":
        "max-age=10, must-revalidate",
      });
      return res.end(
       JSON.stringify({
        data: orders,
       })
      );
     }
    );
   } catch (e) {
    console.log(e);
   }

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