Как веб-пакет помогает с кэшированием ресурсов
Следующее (после оптимизации размера приложения , которое сокращает время загрузки приложения) — это кеширование. Используйте его, чтобы хранить части приложения на клиенте и избегать их повторной загрузки каждый раз.
Используйте управление версиями пакетов и заголовки кэша.
Общий подход к кэшированию заключается в следующем:
сообщите браузеру кэшировать файл на очень долгое время (например, год):
# Server header Cache-Control: max-age=31536000
Если вы не знакомы с тем, что делает
Cache-Control
, прочтите отличную публикацию Джейка Арчибальда о лучших практиках кэширования .и переименуйте файл, когда он будет изменен, чтобы принудительно выполнить повторную загрузку:
<!-- Before the change --> <script src="./index-v15.js"></script> <!-- After the change --> <script src="./index-v16.js"></script>
Этот подход предписывает браузеру загрузить файл JS, кэшировать его и использовать кэшированную копию. Браузер попадет в сеть только в том случае, если имя файла изменится (или пройдет год).
С вебпаком вы делаете то же самое, но вместо номера версии указываете хеш файла. Чтобы включить хэш в имя файла, используйте [chunkhash]
:
// webpack.config.js
module.exports = {
entry: './index.js',
output: {
filename: 'bundle.[chunkhash].js' // → bundle.8e0d62a03.js
}
};
Если вам нужно имя файла для отправки его клиенту, используйте HtmlWebpackPlugin
или WebpackManifestPlugin
.
HtmlWebpackPlugin
— простой, но менее гибкий подход. Во время компиляции этот плагин генерирует HTML-файл, который включает в себя все скомпилированные ресурсы. Если логика вашего сервера не сложна, то вам этого должно быть достаточно:
<!-- index.html -->
<!DOCTYPE html>
<!-- ... -->
<script src="bundle.8e0d62a03.js"></script>
WebpackManifestPlugin
— более гибкий подход, который полезен, если у вас сложная серверная часть. Во время сборки он генерирует файл JSON с сопоставлением имен файлов без хеша и имен файлов с хешем. Используйте этот JSON на сервере, чтобы узнать, с каким файлом работать:
// manifest.json
{
"bundle.js": "bundle.8e0d62a03.js"
}
Дальнейшее чтение
- Джейк Арчибальд о лучших практиках кэширования
Извлечь зависимости и среду выполнения в отдельный файл.
Зависимости
Зависимости приложения имеют тенденцию меняться реже, чем фактический код приложения. Если вы переместите их в отдельный файл, браузер сможет кэшировать их отдельно — и не будет повторно загружать их каждый раз при изменении кода приложения.
Чтобы извлечь зависимости в отдельный чанк, выполните три шага:
Замените имя выходного файла на
[name].[chunkname].js
:// webpack.config.js module.exports = { output: { // Before filename: 'bundle.[chunkhash].js', // After filename: '[name].[chunkhash].js' } };
Когда веб-пакет собирает приложение, он заменяет
[name]
именем чанка. Если мы не добавим часть[name]
, нам придется различать чанки по их хешу – а это довольно сложно!Преобразуйте поле
entry
в объект:// webpack.config.js module.exports = { // Before entry: './index.js', // After entry: { main: './index.js' } };
В этом фрагменте «main» — это имя чанка. Это имя будет заменено на
[name]
из шага 1.К этому моменту, если вы создадите приложение, этот фрагмент будет включать в себя весь код приложения – точно так же, как мы не делали этих шагов. Но это изменится через секунду.
В веб-пакете 4 добавьте
optimization.splitChunks.chunks: 'all'
в конфигурацию вашего веб-пакета:// webpack.config.js (for webpack 4) module.exports = { optimization: { splitChunks: { chunks: 'all' } } };
Эта опция включает интеллектуальное разделение кода. С его помощью веб-пакет будет извлекать код поставщика, если его размер превышает 30 КБ (до минификации и gzip). Также будет извлечен общий код — это полезно, если ваша сборка создает несколько пакетов (например , если вы разделите свое приложение на маршруты ).
В веб-пакет 3 добавьте
CommonsChunkPlugin
:// webpack.config.js (for webpack 3) module.exports = { plugins: [ new webpack.optimize.CommonsChunkPlugin({ // A name of the chunk that will include the dependencies. // This name is substituted in place of [name] from step 1 name: 'vendor', // A function that determines which modules to include into this chunk minChunks: module => module.context && module.context.includes('node_modules'), }) ] };
Этот плагин берет все модули, пути которых включают
node_modules
, и перемещает их в отдельный файл сvendor.[chunkhash].js
.
После этих изменений каждая сборка будет генерировать два файла вместо одного: main.[chunkhash].js
vendor.[chunkhash].js
( vendors~main.[chunkhash].js
для webpack 4). В случае с веб-пакетом 4 пакет поставщика может не сгенерироваться, если зависимости небольшие — и это нормально:
$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
Asset Size Chunks Chunk Names
./main.00bab6fd3100008a42b0.js 82 kB 0 [emitted] main
./vendor.d9e134771799ecdf9483.js 47 kB 1 [emitted] vendor
Браузер будет кэшировать эти файлы отдельно и повторно загружать только изменяющийся код.
Код времени выполнения веб-пакета
К сожалению, извлечь только код поставщика недостаточно. Если вы попытаетесь изменить что-то в коде приложения:
// index.js
…
…
// E.g. add this:
console.log('Wat');
вы заметите, что хэш vendor
также изменится:
Asset Size Chunks Chunk Names
./vendor.d9e134771799ecdf9483.js 47 kB 1 [emitted] vendor
↓
Asset Size Chunks Chunk Names
./vendor.e6ea4504d61a1cc1c60b.js 47 kB 1 [emitted] vendor
Это происходит потому, что в комплекте веб-пакета, кроме кода модулей, есть среда выполнения — небольшой фрагмент кода, который управляет выполнением модуля. Когда вы разделяете код на несколько файлов, этот фрагмент кода начинает включать сопоставление между идентификаторами фрагментов и соответствующими файлами:
// vendor.e6ea4504d61a1cc1c60b.js
script.src = __webpack_require__.p + chunkId + "." + {
"0": "2f2269c7f0a55a5c1871"
}[chunkId] + ".js";
Webpack включает эту среду выполнения в последний сгенерированный чанк, который в нашем случае vendor
. И каждый раз, когда изменяется какой-либо фрагмент, этот фрагмент кода тоже меняется, вызывая изменение всего фрагмента vendor
.
Чтобы решить эту проблему, давайте переместим среду выполнения в отдельный файл. В веб-пакете 4 это достигается включением опции optimization.runtimeChunk
:
// webpack.config.js (for webpack 4)
module.exports = {
optimization: {
runtimeChunk: true
}
};
В веб-пакете 3 сделайте это, создав дополнительный пустой чанк с помощью CommonsChunkPlugin
:
// webpack.config.js (for webpack 3)
module.exports = {
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: module => module.context && module.context.includes('node_modules')
}),
// This plugin must come after the vendor one (because webpack
// includes runtime into the last chunk)
new webpack.optimize.CommonsChunkPlugin({
name: 'runtime',
// minChunks: Infinity means that no app modules
// will be included into this chunk
minChunks: Infinity
})
]
};
После этих изменений каждая сборка будет генерировать три файла:
$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
Asset Size Chunks Chunk Names
./main.00bab6fd3100008a42b0.js 82 kB 0 [emitted] main
./vendor.26886caf15818fa82dfa.js 46 kB 1 [emitted] vendor
./runtime.79f17c27b335abc7aaf4.js 1.45 kB 3 [emitted] runtime
Включите их в index.html
в обратном порядке — и все готово:
<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
<script src="./vendor.26886caf15818fa82dfa.js"></script>
<script src="./main.00bab6fd3100008a42b0.js"></script>
Дальнейшее чтение
- Руководство Webpack по долгосрочному кэшированию
- Документация Webpack о среде выполнения и манифесте веб-пакета
- «Как получить максимальную отдачу от плагина CommonsChunkPlugin»
- Как работают
optimization.splitChunks
иoptimization.runtimeChunk
Встроенная среда выполнения веб-пакета для сохранения дополнительного HTTP-запроса.
Чтобы сделать ситуацию еще лучше, попробуйте встроить среду выполнения веб-пакета в ответ HTML. То есть вместо этого:
<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
сделай это:
<!-- index.html -->
<script>
!function(e){function n(r){if(t[r])return t[r].exports;…}} ([]);
</script>
Время выполнения невелико, и его встраивание поможет вам сохранить HTTP-запрос (очень важно для HTTP/1; менее важно для HTTP/2, но все равно может иметь эффект).
Вот как это сделать.
Если вы генерируете HTML с помощью HtmlWebpackPlugin
Если вы используете HtmlWebpackPlugin для создания HTML-файла, InlineSourcePlugin — это все, что вам нужно:
const HtmlWebpackPlugin = require('html-webpack-plugin');
const InlineSourcePlugin = require('html-webpack-inline-source-plugin');
module.exports = {
plugins: [
new HtmlWebpackPlugin({
inlineSource: 'runtime~.+\\.js',
}),
new InlineSourcePlugin()
]
};
Если вы генерируете HTML с использованием пользовательской логики сервера
С веб-пакетом 4:
Добавьте
WebpackManifestPlugin
, чтобы узнать сгенерированное имя фрагмента времени выполнения:// webpack.config.js (for webpack 4) const ManifestPlugin = require('webpack-manifest-plugin'); module.exports = { plugins: [ new ManifestPlugin() ] };
Сборка с этим плагином создаст файл, который выглядит следующим образом:
// manifest.json { "runtime~main.js": "runtime~main.8e0d62a03.js" }
Встраивайте содержимое фрагмента времени выполнения удобным способом. Например, с Node.js и Express:
// server.js const fs = require('fs'); const manifest = require('./manifest.json'); const runtimeContent = fs.readFileSync(manifest['runtime~main.js'], 'utf-8'); app.get('/', (req, res) => { res.send(` … <script>${runtimeContent}</script> … `); });
Или с веб-пакетом 3:
Сделайте имя среды выполнения статическим, указав
filename
:module.exports = { plugins: [ new webpack.optimize.CommonsChunkPlugin({ name: 'runtime', minChunks: Infinity, filename: 'runtime.js' }) ] };
Встраивайте содержимое
runtime.js
удобным способом. Например, с Node.js и Express:// server.js const fs = require('fs'); const runtimeContent = fs.readFileSync('./runtime.js', 'utf-8'); app.get('/', (req, res) => { res.send(` … <script>${runtimeContent}</script> … `); });
Код отложенной загрузки, который вам сейчас не нужен
Иногда на странице есть более и менее важные части:
- Если вы загружаете видео-страницу на YouTube, вас больше волнует видео, чем комментарии. Здесь видео важнее комментариев.
- Если вы открываете статью на новостном сайте, вас больше волнует текст статьи, чем реклама. Здесь текст важнее рекламы.
В таких случаях улучшите начальную производительность загрузки, сначала загружая только самое важное, а затем откладывая загрузку остальных частей. Для этого используйте функцию import()
и разделение кода :
// videoPlayer.js
export function renderVideoPlayer() { … }
// comments.js
export function renderComments() { … }
// index.js
import {renderVideoPlayer} from './videoPlayer';
renderVideoPlayer();
// …Custom event listener
onShowCommentsClick(() => {
import('./comments').then((comments) => {
comments.renderComments();
});
});
import()
указывает, что вы хотите динамически загружать определенный модуль. Когда веб-пакет видит import('./module.js')
, он перемещает этот модуль в отдельный фрагмент:
$ webpack
Hash: 39b2a53cb4e73f0dc5b2
Version: webpack 3.8.1
Time: 4273ms
Asset Size Chunks Chunk Names
./0.8ecaf182f5c85b7a8199.js 22.5 kB 0 [emitted]
./main.f7e53d8e13e9a2745d6d.js 60 kB 1 [emitted] main
./vendor.4f14b6326a80f4752a98.js 46 kB 2 [emitted] vendor
./runtime.79f17c27b335abc7aaf4.js 1.45 kB 3 [emitted] runtime
и загружает его только тогда, когда выполнение достигает функции import()
.
Это уменьшит размер main
пакета, уменьшив время начальной загрузки. Более того, это улучшит кеширование — если вы измените код в основном фрагменте, это не повлияет на фрагмент комментариев.
Дальнейшее чтение
- Документация Webpack для функции
import()
- Предложение JavaScript по реализации синтаксиса
import()
Разделите код на маршруты и страницы
Если ваше приложение имеет несколько маршрутов или страниц, но есть только один JS-файл с кодом (один main
фрагмент), вполне вероятно, что вы обслуживаете дополнительные байты при каждом запросе. Например, когда пользователь посещает домашнюю страницу вашего сайта:
им не нужно загружать код для рендеринга статьи, находящейся на другой странице, но они его загрузят. Более того, если пользователь всегда посещает только домашнюю страницу, а вы вносите изменения в код статьи, вебпак аннулирует весь пакет — и пользователю придется заново загружать все приложение.
Если мы разделим приложение на страницы (или маршруты, если это одностраничное приложение), пользователь загрузит только соответствующий код. Кроме того, браузер лучше кэширует код приложения: если вы измените код домашней страницы, веб-пакет аннулирует только соответствующий фрагмент.
Для одностраничных приложений
Чтобы разделить одностраничные приложения по маршрутам, используйте import()
(см. раздел «Код отложенной загрузки, который вам сейчас не нужен» ). Если вы используете фреймворк, у него может быть существующее решение для этого:
- «Разделение кода» в документации
react-router
(для React) - «Маршруты отложенной загрузки» в документации
vue-router
(для Vue.js)
Для традиционных многостраничных приложений
Чтобы разделить традиционные приложения по страницам, используйте точки входа веб-пакета. Если в вашем приложении есть три типа страниц: домашняя страница, страница статьи и страница учетной записи пользователя, в нем должно быть три записи:
// webpack.config.js
module.exports = {
entry: {
home: './src/Home/index.js',
article: './src/Article/index.js',
profile: './src/Profile/index.js'
}
};
Для каждого файла записи веб-пакет построит отдельное дерево зависимостей и сгенерирует пакет, включающий только модули, используемые этой записью:
$ webpack
Hash: 318d7b8490a7382bf23b
Version: webpack 3.8.1
Time: 4273ms
Asset Size Chunks Chunk Names
./0.8ecaf182f5c85b7a8199.js 22.5 kB 0 [emitted]
./home.91b9ed27366fe7e33d6a.js 18 kB 1 [emitted] home
./article.87a128755b16ac3294fd.js 32 kB 2 [emitted] article
./profile.de945dc02685f6166781.js 24 kB 3 [emitted] profile
./vendor.4f14b6326a80f4752a98.js 46 kB 4 [emitted] vendor
./runtime.318d7b8490a7382bf23b.js 1.45 kB 5 [emitted] runtime
Таким образом, если Lodash используется только на странице статьи, пакеты home
и profile
не будут включать его — и пользователю не придется загружать эту библиотеку при посещении домашней страницы.
Однако отдельные деревья зависимостей имеют свои недостатки. Если две точки входа используют Lodash, и вы не переместили свои зависимости в пакет поставщика, обе точки входа будут включать копию Lodash. Чтобы решить эту проблему, в веб-пакете 4 добавьте параметр optimization.splitChunks.chunks: 'all'
в конфигурацию вашего веб-пакета:
// webpack.config.js (for webpack 4)
module.exports = {
optimization: {
splitChunks: {
chunks: 'all'
}
}
};
Эта опция включает интеллектуальное разделение кода. При использовании этой опции веб-пакет будет автоматически искать общий код и извлекать его в отдельные файлы.
Или в веб-пакете 3 используйте CommonsChunkPlugin
— он переместит общие зависимости в новый указанный файл:
module.exports = {
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'common',
minChunks: 2 // 2 is the default value
})
]
};
Не стесняйтесь экспериментировать со значением minChunks
, чтобы найти лучший вариант. Как правило, вы хотите, чтобы он был небольшим, но увеличивайте его, если количество фрагментов растет. Например, для 3 чанков minChunks
может быть 2, а для 30 чанков это может быть 8 — потому что, если вы оставите значение 2, в общий файл попадет слишком много модулей, что слишком сильно его раздует.
Дальнейшее чтение
- Документация Webpack о концепции точек входа
- Документация Webpack о CommonsChunkPlugin
- «Как получить максимальную отдачу от CommonsChunkPlugin»
- Как работают
optimization.splitChunks
иoptimization.runtimeChunk
Сделать идентификаторы модулей более стабильными
При создании кода веб-пакет присваивает каждому модулю идентификатор. Позже эти идентификаторы используются в require()
внутри пакета. Обычно вы видите идентификаторы в выводе сборки прямо перед путями к модулям:
$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
Asset Size Chunks Chunk Names
./0.8ecaf182f5c85b7a8199.js 22.5 kB 0 [emitted]
./main.4e50a16675574df6a9e9.js 60 kB 1 [emitted] main
./vendor.26886caf15818fa82dfa.js 46 kB 2 [emitted] vendor
./runtime.79f17c27b335abc7aaf4.js 1.45 kB 3 [emitted] runtime
↓ Здесь
[0] ./index.js 29 kB {1} [built]
[2] (webpack)/buildin/global.js 488 bytes {2} [built]
[3] (webpack)/buildin/module.js 495 bytes {2} [built]
[4] ./comments.js 58 kB {0} [built]
[5] ./ads.js 74 kB {1} [built]
+ 1 hidden module
По умолчанию идентификаторы рассчитываются с помощью счетчика (т. е. у первого модуля идентификатор 0, у второго — 1 и т. д.). Проблема в том, что когда вы добавляете новый модуль, он может появиться в середине списка модулей, изменяя идентификаторы всех следующих модулей:
$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
Asset Size Chunks Chunk Names
./0.5c82c0f337fcb22672b5.js 22 kB 0 [emitted]
./main.0c8b617dfc40c2827ae3.js 82 kB 1 [emitted] main
./vendor.26886caf15818fa82dfa.js 46 kB 2 [emitted] vendor
./runtime.79f17c27b335abc7aaf4.js 1.45 kB 3 [emitted] runtime
[0] ./index.js 29 kB {1} [built]
[2] (webpack)/buildin/global.js 488 bytes {2} [built]
[3] (webpack)/buildin/module.js 495 bytes {2} [built]
↓ Мы добавили новый модуль…
[4] ./webPlayer.js 24 kB {1} [built]
↓ И посмотрите, что оно сделало! comments.js
теперь имеет идентификатор 5 вместо 4
[5] ./comments.js 58 kB {0} [built]
↓ ads.js
теперь имеет идентификатор 6 вместо 5
[6] ./ads.js 74 kB {1} [built]
+ 1 hidden module
Это делает недействительными все фрагменты, которые включают модули с измененными идентификаторами или зависят от них, даже если их фактический код не изменился. В нашем случае чанк 0
(часть с comments.js
) и main
чанк (часть с другим кодом приложения) становятся недействительными, тогда как должен был быть признан только main
.
Чтобы решить эту проблему, измените способ расчета идентификаторов модулей с помощью HashedModuleIdsPlugin
. Он заменяет идентификаторы на основе счетчиков хэшами путей к модулям:
$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
Asset Size Chunks Chunk Names
./0.6168aaac8461862eab7a.js 22.5 kB 0 [emitted]
./main.a2e49a279552980e3b91.js 60 kB 1 [emitted] main
./vendor.ff9f7ea865884e6a84c8.js 46 kB 2 [emitted] vendor
./runtime.25f5d0204e4f77fa57a1.js 1.45 kB 3 [emitted] runtime
↓ Здесь
[3IRH] ./index.js 29 kB {1} [built]
[DuR2] (webpack)/buildin/global.js 488 bytes {2} [built]
[JkW7] (webpack)/buildin/module.js 495 bytes {2} [built]
[LbCc] ./webPlayer.js 24 kB {1} [built]
[lebJ] ./comments.js 58 kB {0} [built]
[02Tr] ./ads.js 74 kB {1} [built]
+ 1 hidden module
При таком подходе идентификатор модуля меняется только в том случае, если вы переименовываете или перемещаете этот модуль. Новые модули не повлияют на идентификаторы других модулей.
Чтобы включить плагин, добавьте его в раздел plugins
конфигурации:
// webpack.config.js
module.exports = {
plugins: [
new webpack.HashedModuleIdsPlugin()
]
};
Дальнейшее чтение
- Документация Webpack о HashedModuleIdsPlugin
Подведение итогов
- Кэшируйте пакет и различайте версии, изменяя имя пакета.
- Разделите пакет на код приложения, код поставщика и среду выполнения.
- Встраивание среды выполнения для сохранения HTTP-запроса
- Отложенная загрузка некритического кода с
import
- Разделите код по маршрутам/страницам, чтобы избежать загрузки ненужного материала.