Обложка статьи «Кэширование кода для JavaScript-разработчиков на примере Chrome»

Кэширование кода для JavaScript-разработчиков на примере Chrome

Перевод статьи «Code caching for JavaScript developers»

Кэширование кода (также называемое кэшированием байт-кода) является важным инструментом оптимизации. Оно уменьшает время запуска часто посещаемых сайтов за счёт кэширования результатов парсинга и компиляции. Большинство популярных браузеров реализует кэширование в некоторой форме, и Chrome не исключение. О том, как Chrome и V8 кэшируют скомпилированный код, уже много всего написано и сказано.

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

Обзор кэширования кода

Кэш оперативной памяти

У Chrome есть два уровня кэширования скомпилированного в V8 кода (классических скриптов и скриптов модулей): быстрый и «лучший из возможного» кэш в оперативной памяти, обеспечиваемый средствами V8 (кэш Isolate), а также полный сериализованный кэш на диске.

Кэш Isolate работает со скриптами, скомпилированными в том же V8 Isolate (т. е. тот же процесс, грубо говоря «одни и те же страницы сайта при навигации по одной и той же вкладке»). Это «лучшее из возможного» в том смысле, что кэш оперативной памяти, насколько это возможно, быстр и минимален: он использует уже имеющиеся данные за счёт потенциально более низкой частоты обращений и отсутствия кэширования между процессами.

  • Когда V8 компилирует скрипт, скомпилированный байт-код сохраняется в хеш-таблице (в куче V8), ключ которого определяется исходным кодом сценария.
  • Когда Chrome просит V8 скомпилировать другой скрипт, V8 сначала проверяет, соответствует ли исходный код этого скрипта чему-либо в хеш-таблице. Если соответствует, просто возвращается существующий байт-код.

Этот кэш быстрый и эффективный, но на практике у него лишь 80% частоты попаданий.

Кэш на диске

Кэш кода на диске управляется Chrome (в частности, с помощью Blink) и заполняет пробел, который кэш Isolate не может заполнить: совместное использование кэшей кода между процессами и между несколькими сеансами Chrome. Он использует преимущества существующего кэша HTTP-ресурсов, который управляет кэшированием и очисткой данных с истёкшим сроком действия, полученных по сети.

  1. Когда JS-файл запрашивается впервые (т. е. выполняется «холодный» запуск), Chrome загружает его и даёт V8 для компиляции. Он также сохраняет файл в кэше браузера на диске.
  2. Когда JS-файл запрашивается во второй раз (т. е. выполняется «тёплый» запуск), Chrome берёт файл из кэша браузера и снова передаёт его в V8 для компиляции. Однако на этот раз скомпилированный код сериализуется и прикрепляется к кэшированному файлу скрипта в качестве метаданных.
  3. В третий раз (т. е. «горячий» запуск) Chrome извлекает как файл, так и метаданные файла из кэша и передаёт их в V8. Тот в свою очередь десериализует метаданные и может пропустить компиляцию.

В итоге:

cache

Кэширование кода делится на холодные, тёплые и горячие запуски, используя кэш в оперативной памяти при тёплых запусках и дисковый кэш при горячих запусках.

Основываясь на этом описании, можно оптимизировать использование кэшей кода на вашем сайте.

Совет 1: не делайте ничего

Лучшее, что JS-разработчик может сделать для оптимизации кэширования кода, — ничего не делать. Но ничего не делать можно по-разному: пассивно и активно.

Кэширование кода в конце концов является частью реализации браузера. По сути, это увеличение производительности за счёт дополнительных расходов памяти, реализация и эвристика которых могут постоянно меняться. Мы, как разработчики V8, должны делать всё возможное, чтобы эти эвристики работали для каждого. Если чрезмерно оптимизировать кэширование кода, можно очень разочароваться уже после нескольких релизов, когда эти детали изменятся. Кроме того, другие механизмы JavaScript могут иметь различные эвристики для своей реализации кэширования кода. Так что во многих отношениях лучший совет для получения кэшированного кода похож на совет по написанию JS: пишите чистый идиоматический код и сделайте всё возможное, чтобы оптимизировать его кэширование.

Помимо пассивного бездействия, вы также должны стараться активно «ничего не делать». Любая форма кэширования по своей природе зависит от того, что ничего не меняется, поэтому бездействие — лучший способ сохранить кэшированные данные. Активно ничего не делать можно разными способами.

Не меняйте код

Всякий раз, когда вы отправляете новый код, он ещё не кэширован. Когда браузер делает HTTP-запрос для URL-адреса сценария, он может включать дату последней выборки этого URL-адреса. Если сервер знает, что файл не изменился, он может отправить ответ «304 Not Modified», который сохраняет кэш кода «горячим». В противном случае ответ «200 OK» обновляет кэшированный ресурс и очищает кэш кода, возвращая его обратно в «холодный» режим.

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

Не меняйте URL’ы

Кэш кода связан с URL-адресом скрипта, так как это облегчает поиск, ведь нет необходимости читать фактическое содержимое скрипта. Это означает, что изменение URL-адреса (включая любые параметры запроса) создаёт новую запись в кэше ресурсов, а вместе с ним и новую запись «холодного» кэша.

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

Не меняйте поведение выполнения

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

Эта оптимизация работает лучше всего, когда каждый запуск скрипта выполняет один и тот же код или хотя бы одинаковые функции. Это может быть проблемой, если у вас есть, например A/B-тесты, которые зависят от выбора времени выполнения:

if (Math.random() > 0.5) {
  A();
} else {
  B();
}

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

Совет 2: сделайте что-нибудь

Конечно, совет не делать ничего (пассивно или активно) не очень удовлетворит вас. Помимо того что вы ничего не делаете, учитывая текущую эвристику и реализацию, есть несколько вещей, которые вы можете сделать. Эвристика может измениться, сам этот совет может измениться и нет никакой альтернативы для профилирования.

Отделите библиотеки от кода

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

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

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

Объедините библиотеки с кодом

Кэширование кода выполняется после выполнения каждого скрипта. Это означает, что кэш будет включать в себя именно те функции в этом скрипте, которые были скомпилированы по завершении его выполнения. Это ведёт к нескольким важным последствиям для библиотечного кода:

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

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

Одним из решений этой проблемы является объединение библиотек в единый сценарий, таким образом кэширование кода «видит», какие части библиотеки используются. Это полная противоположность совету выше, универсального решения нет. Конечно, не рекомендуется объединять все ваши JS-скрипты в один большой бандл. В целом разделение его на несколько более мелких скриптов будет полезнее (например, множественные сетевые запросы, потоковая компиляция, интерактивность страниц и т. д.).

Используйте преимущество эвристики IIFE

Только те функции, которые скомпилированы к моменту, когда завершится выполнение скрипта, учитываются в кэше, поэтому существует много видов функций, которые не будут кэшироваться, несмотря на выполнение в более поздний момент. Обработчики событий (даже onload()), цепочки промисов, неиспользуемые библиотечные функции и всё, что лениво компилируется без вызова к моменту, когда </script> виден — всё это остаётся ленивым и не кэшируется.

Один из способов сделать эти функции кэшированными — заставить их компилироваться. Распространённым способом принудительной компиляции является использование эвристики IIFE. IIFE (immediately-invoked function expressions) — это шаблон, в котором функция вызывается сразу после создания:

(function foo() {
  // …
})();

Так как IIFE вызываются немедленно, большинство движков JavaScript пытаются обнаружить их и немедленно скомпилировать, чтобы избежать затрат на ленивую компиляцию с последующей полной компиляцией. Существуют различные эвристики для раннего обнаружения IIFE (до анализа функции), наиболее распространённой из которых является символ «(» перед ключевым словом function.

Поскольку эта эвристика применяется рано, она запускает компиляцию, даже если функция на самом деле вызывается не сразу:

const foo = function() {
  // Лениво пропущено
};
const bar = (function() {
  // С нетерпением скомпилировано
});

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

Группируйте небольшие файлы вместе

В Chrome — минимальный размер для кэшей кода, сейчас это 1 КБ исходного кода. Сценарии меньше не кэшируются, так как затраты будут больше выгоды.

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

Избегайте встроенных скриптов

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

Простые сценарии не стоит встраивать в HTML, лучше выносить их в отдельные файлы.

Используйте кэши сервис-воркера

Сервис-воркер — это механизм, позволяющий вашему коду перехватывать сетевые запросы на ресурсы на вашей странице. Они позволяют вам создавать локальный кэш из некоторых ваших ресурсов и обслуживать ресурс из кэша всякий раз, когда их запрашивают. Это особенно полезно для страниц, которые продолжают работать в offline-режиме вроде PWA.

Ниже типичный пример сайта, использующего сервисный воркер. Регистрация воркера в основном файле сценария:

// main.mjs
navigator.serviceWorker.register('/sw.js');

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

// sw.js
self.addEventListener('install', (event) => {
  async function buildCache() {
    const cache = await caches.open(cacheName);
    return cache.addAll([
      '/main.css',
      '/main.mjs',
      '/offline.html',
    ]);
  }
  event.waitUntil(buildCache());
});

self.addEventListener('fetch', (event) => {
  async function cachedFetch(event) {
    const cache = await caches.open(cacheName);
    let response = await cache.match(event.request);
    if (response) return response;
    response = await fetch(event.request);
    cache.put(event.request, response.clone());
    return response;
  }
  event.respondWith(cachedFetch(event));
});

Эти кэши могут включать в себя кэшированные ресурсы JS. Но поскольку ожидается, что кэши воркеров будут преимущественно использоваться для PWA, для них используется немного другая эвристика по сравнению с обычным «автоматическим» кэшированием в Chrome. Во-первых, они сразу же создают кэш кода при каждом добавлении ресурса JS. Это означает, что кэш доступен уже при второй загрузке (а не только при третьей, как в обычном случае). Во-вторых, генерируется «полный» кэш для этих скриптов — функции больше не компилируются лениво. Всё компилируется и помещается в кэш. Преимущество заключается в быстрой и предсказуемой производительности, без каких-либо зависимостей порядка выполнения, хоть и за счёт увеличения использования памяти. Обратите внимание, что такая эвристика применяется только к кэшам сервисных воркеров, а не к другому использованию Cache API. В настоящее время Cache API вообще не выполняет кэширование кода, когда используется вне сервисных воркеров.

Трассировка

Ни один из советов выше не поможет ускорить работу вашего сайта. К сожалению, информация о кэшировании сейчас не предоставляется в DevTools, поэтому наиболее надёжный способ выяснить, какие из сценариев вашего сайта кэшируются, — использовать чуть более низкий уровень chrome://tracing.

chrome://tracing записывает инструментальные трассировки Chrome в течение некоторого периода времени с такой визуализацией :

tracing

Трассировка записывает поведение всего браузера, включая другие вкладки, окна и расширения, поэтому она лучше всего работает, когда выполняется в чистом профиле пользователя, с отключёнными расширениями и без открытия других вкладок браузера:

# Запустите новый сеанс браузера Chrome с чистым профилем пользователя и отключёнными расширениями
google-chrome --user-data-dir="$(mktemp -d)" --disable-extensions

При записи вы должны выбрать, какие категории трассировать. В большинстве случаев вы можете просто выбрать набор категорий «Web developer» (Веб-разработчик), но категории можно выбрать и вручную. Важная категория для кэширования кода — v8.

chrome tracing categories web developer

chrome tracing categories v8

После записи с категорией v8 найдите фрагменты v8.compile в трассировке (в качестве альтернативы вы можете ввести v8.compile в поле поиска интерфейса). Эти компоненты показывают компилируемый файл и некоторые метаданные о компиляции.

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

chrome tracing cold run

При тёплом запуске есть две записи v8.compile на сценарий: одна для фактической компиляции (как указано выше) и одна (после выполнения) для создания кэша. Вы можете узнать последнюю, так как она имеет поля метаданных cacheProduceOptions и producedCacheSize.

chrome tracing warm run

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

chrome tracing hot run

Для большинства разработчиков кэширование кода должно «просто работать». И работает это лучше всего (как и любой кэш), когда всё остаётся неизменным, и с использованием эвристики, которая может меняться между версиями. Тем не менее, кэширование кода имеет особенности, которые можно использовать, и ограничения, которых стоит избегать. Тщательный анализ с использованием chrome://tracing может помочь вам настроить и оптимизировать использование кэша вашим сайтом.

Не смешно? А здесь смешно: @ithumor