Используйте долгосрочное кэширование

Как веб-пакет помогает с кэшированием ресурсов

Следующее (после оптимизации размера приложения , которое сокращает время загрузки приложения) — это кеширование. Используйте его, чтобы хранить части приложения на клиенте и избегать их повторной загрузки каждый раз.

Используйте управление версиями пакетов и заголовки кэша.

Общий подход к кэшированию заключается в следующем:

  1. сообщите браузеру кэшировать файл на очень долгое время (например, год):

    # Server header
    Cache-Control: max-age=31536000
    

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

  2. и переименуйте файл, когда он будет изменен, чтобы принудительно выполнить повторную загрузку:

    <!-- 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"
}

Дальнейшее чтение

Извлечь зависимости и среду выполнения в отдельный файл.

Зависимости

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

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

  1. Замените имя выходного файла на [name].[chunkname].js :

    // webpack.config.js
    module.exports = {
      output: {
        // Before
        filename: 'bundle.[chunkhash].js',
        // After
        filename: '[name].[chunkhash].js'
      }
    };
    

    Когда веб-пакет собирает приложение, он заменяет [name] именем чанка. Если мы не добавим часть [name] , нам придется различать чанки по их хешу – а это довольно сложно!

  2. Преобразуйте поле entry в объект:

    // webpack.config.js
    module.exports = {
      // Before
      entry: './index.js',
      // After
      entry: {
        main: './index.js'
      }
    };
    

    В этом фрагменте «main» — это имя чанка. Это имя будет заменено на [name] из шага 1.

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

  3. В веб-пакете 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>

Дальнейшее чтение

Встроенная среда выполнения веб-пакета для сохранения дополнительного 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:

  1. Добавьте 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"
    }
    
  2. Встраивайте содержимое фрагмента времени выполнения удобным способом. Например, с 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:

  1. Сделайте имя среды выполнения статическим, указав filename :

    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
          name: 'runtime',
          minChunks: Infinity,
          filename: 'runtime.js'
        })
      ]
    };
    
  2. Встраивайте содержимое 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 пакет, уменьшив время начальной загрузки. Более того, это улучшит кеширование — если вы измените код в основном фрагменте, это не повлияет на фрагмент комментариев.

Дальнейшее чтение

Разделите код на маршруты и страницы

Если ваше приложение имеет несколько маршрутов или страниц, но есть только один JS-файл с кодом (один main фрагмент), вполне вероятно, что вы обслуживаете дополнительные байты при каждом запросе. Например, когда пользователь посещает домашнюю страницу вашего сайта:

Домашняя страница WebFundamentals

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

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

Для одностраничных приложений

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

Для традиционных многостраничных приложений

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

// 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, в общий файл попадет слишком много модулей, что приведет к его слишком сильному раздуванию.

Дальнейшее чтение

Сделать идентификаторы модулей более стабильными

При создании кода веб-пакет присваивает каждому модулю идентификатор. Позже эти идентификаторы используются в 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()
  ]
};

Дальнейшее чтение

Подведение итогов

  • Кэшируйте пакет и различайте версии, изменяя имя пакета.
  • Разделите пакет на код приложения, код поставщика и среду выполнения.
  • Встраивание среды выполнения для сохранения HTTP-запроса
  • Отложенная загрузка некритического кода с import
  • Разделите код по маршрутам/страницам, чтобы избежать загрузки ненужного материала.