И снова здравствуй. И добро пожаловать в третью часть моего ультимативного гайда. Приготовься — скоро ты станешь настоящим профи.

Содержание серии

Как сделать бесконечно ленивым: Ультимативный гайд.

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

  • Как мы должны загружать вендор файлы с точки зрения правильной организации ленивой загрузки.

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

  • Что такое кэшируемость и как сделать наше приложение максимально кэшируемым.

  • А также как правильно настраивать группы кэша в Webpack и не испортить кэшируемость.


Как разделять вендор файлы

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

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

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

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

А теперь вернемся к вендорам. Представьте, что мы используем несколько NPM пакетов в нашем проекте и импортируем из них разные методы в разных ленивых чанках.

// Title (loaded initially)
import { chunk, difference, intersection, sortedUniq, takeWhile } from 'lodash-es';
import { addDays, addHours, addYears, addMonths } from 'date-fns';

// Chapter 1 (loaded lazily)
import { countBy, partition, sample, sampleSize, orderBy } from 'lodash-es';
import { differenceInBusinessDays, differenceInCalendarDays, differenceInCalendarQuarters, differenceInHours } from 'date-fns';

// Chapter 2 (loaded lazily)
import { debounce, memoize, throttle, once, curry } from 'lodash-es';
import { isToday, isTomorrow, isAfter, isDate } from 'date-fns';

Как браузер должен скачивать код этих пакетов/методов?

Частые ошибки конфигурации

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

"Единый файл проще сохранять и читать из кэша, чем несколько."

Но на самом деле делать так - плохой выбор. По нескольким причинам:

  1. Разница в скорости загрузки одного и нескольких файлов из кэша принебрежительно мала.

  2. Если у пользователя нет кэша, он обязан загружать все вендоры во время начальной загрузки. Причем, т.к. ни один из вендоров не загружается лениво, возникают дополнительные задержки во время начальной загрузки. А оно, повторюсь, является крайне важным с точки зрения UX.

  3. Кроме того, поскольку все вендоры включены в один файл, каждый раз, когда мы импортируем нового сущность из любого NPM пакета, хэш-код в имени вендор файла меняется. Что приводит к потере кэша. Что приводит к проблеме № 2.

Давайте обсудим 3-й пункт более подробно. Почему кэш вообще может "теряться"?

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

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

  1. меняются имена файлов нашего приложения;

  2. пользователи теряют свой кэш;

  3. и браузер сможет загрузить необходимые изменения.

И для этого существуют различные способы, но идеальный способ - включить хэш-код, основанный на содержимом файла, в его название. И самый простой способ как это сделать в Webpack - это добавить шаблонный плейсхолдер [contenthash] в output.filename и output.chunkName.

module.exports = {
  // ...
  output: {
    // ...
    chunkFilename: '[name].[contenthash].js',
    filename: '[name].[contenthash].js',
  },
};

В output помимо [contenthash], вы также можете использовать [hash], [chunkhash] или [fullhash]. Вы также можете вручную создавать уникальные строки, например, использовать дату и время сборки. Или вы также можете использовать hash конфигурацию из HtmlWebpackPlugin. Но ни один из этих вариантов, помимо использования [contenthash], не является идеальным с точки зрения поддержания высокой кэшируемости. Но мы рассмотрим это чуть позже.

Но на данный момент вы должны понимать, что использование [contenthash] считается наилучшей практикой при разработке приложения. Когда мы используем этот плейсхолдер, как только содержимое любого сгенерированного файла изменяется, его имя также меняется, что приводит к потере кэша.

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

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

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

Правильная конфигурация

Итак, как мы можем сделать так, чтобы наши вендоры загружались по-настоящему лениво? Нам нужно разделить их на лениво загружаемые части:

  • Если какие-либо экспортируемые сущности (не пакеты, а функции, классы и т.п. из пакетов) необходимо загружать изначально, то только они должны быть загружены изначально.

  • Но остальные сущности следует загружать только тогда, когда они действительно используются. Если мы рассмотрим пример из самого начала, то методы countBy и differenceInBusinessDays следует загружать только при открытии страницы Chapter 1.

Идеальная сборка для примера из начала статьи должна выглядеть так:

У нас есть начальный чанк для вендров, а также есть асинхронные чанки вендоров. Chapter 1 нужно будет загрузить только свой собственный чанк с кодом вендоров, в то время как Chapter 2 нужно будет загрузить свой. Как и в ленивой загрузке и должно быть.

Я хочу еще раз подметить, что в примере выше создание чанков никак не зависит от NPM пакетов. Содержимое date-fns и lodash-es разбилось на 3 части: они загружается частично во время изначальной загрузки и частично при загрузке каждой из ленивых страниц.

В Webpack такого разделения вендоров добиться легко. Необходимо лишь использовать optimization.splitChunks.chunks:

module.exports = {
  // ...
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
}

? Настройте свой Webpack таким образом, чтобы он разбивал вендор пакеты и загружал их лениво.


Повышай кэшируемость

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

Используй Webpack правильно

Давайте вернемся к тому, как правильно конфигурировать имена файлов в сборке. Как я уже сказал, большинство существующих подходов, отличных от использования [contenthash], не идеальны для поддержания достойной стратегии кэширования.

  • Использование шаблона [fullhash] или настройки hash из HtmlWebpackPlugin приводит к удалению кэша всех файлов каждый раз, когда мы вносим малейшие изменения.

  • Использование шаблонов [hash] и [chunkhash] приведет к созданию немного более стабильных файлов. Но они все равно не будут такими стабильными, как при использовании [contenthash].

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

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

module.exports = {
  // ...
  output: {
    // Bad examples
    filename: `[name].${Date.now()}.js`,
    filename: '[name].[fullhash].js',
    filename: '[name].[hash].js',
    filename: '[name].[chunkhash].js',
    // Good example
    filename: '[name].[contenthash].js',
  },
  plugins: [
    new HtmlWebpackPlugin({
      // Bad example
      hash: true,
    })
  ],
};

? Используйте только [contenthash] плейсхолдер, чтобы сделать ваше приложение максимально кэшируемым.

Очисти граф зависимостей

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

Давайте рассмотрим следующий пример. Представим, что из-за ужасного графика зависимостей исходных файлов Webpack создал запутанный график зависимостей выполнения. Как на картинке ниже.

Напомню, как читать такой график. Черными линиями обозначены "жесткие" зависимости. Каждый раз, когда браузеру необходимо скачать какой либо файл, все его "жестко" зависимые файлы тоже будет необходимо скачать. Т.е. в нашем плохом примере, при загрузке Page 1, нам необходимо скачать чанки 1-5. А чтобы скачать чанки 1-5, нам необходимо скачать чанки 6-10.

Допустим, мы изменили одну строку кода, добавив или убрав импорт, которая должна повлиять на содержимое [id].[hash].chunk6.js. Мы бы хотели, чтобы кэш был потерян только для этого файла. Однако на самом деле чанки 1-5, а также страницы 1-3, а также main.js, - все эти файлы тоже обновят свое содержимое. И, следовательно, с учетом приведенного выше графика выполнения, одно изменение в одну строку может привести к потере кэша для большинства файлов в проекте.

Webpack не способен изменить имя только одного файла. Упомянутые мной зависимости, и "жесткие", и "мягкие", хранятся внутри самих файлов. Page 1 хранит ссылки на все чанки 1-5 внутри себя. И если меняется имя одного из них, page1.[hash].js тоже обязан обновить свое содержимое, чтобы быть готовым скачать новый файл.

Чтобы понять, какие файлы будут затронуты при внесении определенных изменений, мы также можем проанализировать график зависимостей выполнения визуально "в обратном порядке". Чанки 1-5 зависят от чанка 6. Страницы 1-3 зависят от чанков 1-5. А main всегда зависит от страниц 1-3. А следовательно, все эти файлы теряют свой кэш.

Теперь давайте представим, что мы исправили график.

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

Что произойдет, если мы внесем точно такое изменение с новым импортом?

Если мы проанализируем график зависимостей в обратном порядке, то увидим, что теперь хэш будет обновлен только у chunk1, chunk5, page1 и main. Таким образом, только 5 файлов потеряют свой кэш.

Теперь небольшая задача для вас: какие файлы будут изменены, если chunk8 будет обновлен?

Ответ

Файлы main, page 2, page 3, chunk 2, chunk 3 и chunks 8 потеряют свой кэш.

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

Осторожно: вы потеряли кэш initial файлов

Вы уже заметили, что во всех примерах выше, main.js всегда теряет свой кэш. Даже когда мы исправили наше дерево зависимостей. Это происходит потому, что главный entry JavaScript файл всегда будет зависеть от всех других сгенерированных файлов из сборки. Таким образом, этот файл всегда теряет свой кэш при каждом внесении изменений.

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

А происходит это из-за такого понятия как runtime chunk. Это кусочек кода, который хранит в себе всю информацию о том, какие файлы существуют в проекте. Если меняется какое-либо имя файла, runtime chunk меняет свое содержимое, чтобы включить в себя новое имя файла. Следовательно, если в нашем примере с [id].[hash].chunk6.js мы не будем менять граф зависимостей, то кэш потеряют всего 2 файла: chunk6 и main, т.к. по умолчанию main включает в себя runtime chunk.

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

ℹ️ Независимо от размера наших изменений, кэш entry javascript файла всегда будет удален. Это вторая по важности причина, по которой мы должны сделать наши исходные javascript файлы как можно более компактными.


Настрой cache groups

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

Возможно, вы заметили, что в нашем примере с "правильной конфигурацией" размер исходного вендор файла вышел довольно большим. При использовании описанного мной подхода он может стать еще больше, особенно когда мы используем несколько NPM библиотек: react, react-dom, zustand, zod, axios и т.д. Потому что, даже если некоторые библиотеки можно загружать лениво, довольно многие из них все равно необходимо загружать изначально. И большой размер изначального вендор файла может негативно сказаться на скорости загрузки приложения. Однако мы можем устранить эту проблему, настроив группы кэша.

С помощью настройки групп кэша мы можем указывать Webpack, как генерировать чанки JavaScript, в том числе какие файлы исходного кода и/или вендоров следует включать в определенные чанки. Настроив группы кэша, мы можем разбивать один файл на несколько или, наоборот, объединять их. А еще, если мы настроим их правильно, мы можем улучшить кэшируемость.

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

Однако, название "cache groups" подразумевает, что группы должны создаваться для управлением кэшем, а не только ускорения загрузки. И хорошей практикой будет считаться создание групп кэша только тогда, когда мы уверены, что создаваемая нами группа будет стабильной с течением времени, чтобы она теряла кэш как можно реже.

ℹ️ Чтобы сделать группы кэша стабильными, мы должны включать в создаваемые нами группы только "стабильных" вендоров.

Например, react, react-dom, axios и zod должны использоваться целиком в нашем приложении. Мы не можем загрузить только часть их исходного кода. И, следовательно, они могут быть включены в группу кэширования. Но такие пакеты как date-fns или lodash-es могут загружаться кусочно, а следовательно могут отличаться по своему содержанию в зависимости от того, какие экспортируемые объекты используются в нашем проекте, поэтому нам не следует создавать для них группы кэша.

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

Чтобы создать такие группы, мы должны использовать splitChunks.cacheGroups:

module.exports = {
  optimization: {
   splitChunks: {
     cacheGroups: {
      react: {
        filename: `react.[contenthash:8].js`,
        // it's better to create groups for initial vendors only,
        //  but don't use it in micro-fe apps
        chunks: 'initial',
        // Will include `react`, `react-dom`, and `react-router-dom`
        //  in a single chunk
        test: /react/,
      },
     },
   },
  },
};

И вуаля! Мы убили двух зайцев одним выстрелом:

  1. Наш изначально загружаемый вендор файл разбит на 2 файла, а от того загружается в 2 раза быстрее.

  2. Созданный через cache groups чанк будет хранится в кэше пока не истечет срок годности, т.к. react, react-dom и react-router-dom крайне редко будут меняться в приложении.

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


Заключение

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

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

  • ? Настройте свой Webpack таким образом, чтобы он разбивал вендор пакеты и загружал их лениво.

  • ? Используйте только [contenthash] плейсхолдер, чтобы сделать ваше приложение максимально кэшируемым.

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

  • ? Настройте группы кэша Webpack для стабильных vendor пакетов, чтобы дополнительно разбить файлы для повышения скорости загрузки и кэшируемости.

Мы на финишной прямой, еще чуть-чуть:
Как сделать сайт бесконечно ленивым. Часть 4: Преждевременная загрузка

Вот мои соц. сети: LinkedInTelegramGitHub. Свидимся! ✌️

Комментарии (0)