程式碼分割

程式碼拆分是 webpack 最引人注目的功能之一。此功能讓你可以將程式碼拆分為各種套件,然後可以依需求或平行載入。它可用於產生較小的套件並控制資源載入優先順序,如果使用得當,可能會對載入時間產生重大影響。

有三個可用的程式碼拆分一般方法

  • 輸入點:使用 entry 設定手動拆分程式碼。
  • 防止重複:使用 輸入相依性SplitChunksPlugin 來刪除重複項並拆分區塊。
  • 動態匯入:透過模組內的內嵌函式呼叫來拆分程式碼。

輸入點

這是目前為止最簡單、最直觀的分割程式碼方式。不過,它比較手動,而且有一些我們會探討的陷阱。讓我們看看我們如何從主程式碼分割另一個模組

專案

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
  |- index.js
+ |- another-module.js
|- /node_modules

another-module.js

import _ from 'lodash';

console.log(_.join(['Another', 'module', 'loaded!'], ' '));

webpack.config.js

 const path = require('path');

 module.exports = {
-  entry: './src/index.js',
+  mode: 'development',
+  entry: {
+    index: './src/index.js',
+    another: './src/another-module.js',
+  },
   output: {
-    filename: 'main.js',
+    filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
 };

這將產生以下建置結果

...
[webpack-cli] Compilation finished
asset index.bundle.js 553 KiB [emitted] (name: index)
asset another.bundle.js 553 KiB [emitted] (name: another)
runtime modules 2.49 KiB 12 modules
cacheable modules 530 KiB
  ./src/index.js 257 bytes [built] [code generated]
  ./src/another-module.js 84 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 245 ms

如前所述,這種做法有一些陷阱

  • 如果進入區塊之間有任何重複的模組,它們將包含在兩個程式碼中。
  • 它沒有那麼靈活,而且不能用於使用核心應用程式邏輯動態分割程式碼。

這兩點中的第一點對我們的範例來說肯定是一個問題,因為 lodash 也導入到 ./src/index.js 中,因此會在兩個程式碼中重複。讓我們在下一個區段中移除這個重複。

防止重複

進入相依性

dependOn 選項允許在區塊之間共用模組

webpack.config.js

 const path = require('path');

 module.exports = {
   mode: 'development',
   entry: {
-    index: './src/index.js',
-    another: './src/another-module.js',
+    index: {
+      import: './src/index.js',
+      dependOn: 'shared',
+    },
+    another: {
+      import: './src/another-module.js',
+      dependOn: 'shared',
+    },
+    shared: 'lodash',
   },
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
 };

如果我們要在一個 HTML 頁面上使用多個進入點,也需要 optimization.runtimeChunk: 'single',否則我們可能會遇到這裡所描述的問題。

webpack.config.js

 const path = require('path');

 module.exports = {
   mode: 'development',
   entry: {
     index: {
       import: './src/index.js',
       dependOn: 'shared',
     },
     another: {
       import: './src/another-module.js',
       dependOn: 'shared',
     },
     shared: 'lodash',
   },
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
+  optimization: {
+    runtimeChunk: 'single',
+  },
 };

以下是建置結果

...
[webpack-cli] Compilation finished
asset shared.bundle.js 549 KiB [compared for emit] (name: shared)
asset runtime.bundle.js 7.79 KiB [compared for emit] (name: runtime)
asset index.bundle.js 1.77 KiB [compared for emit] (name: index)
asset another.bundle.js 1.65 KiB [compared for emit] (name: another)
Entrypoint index 1.77 KiB = index.bundle.js
Entrypoint another 1.65 KiB = another.bundle.js
Entrypoint shared 557 KiB = runtime.bundle.js 7.79 KiB shared.bundle.js 549 KiB
runtime modules 3.76 KiB 7 modules
cacheable modules 530 KiB
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
  ./src/another-module.js 84 bytes [built] [code generated]
  ./src/index.js 257 bytes [built] [code generated]
webpack 5.4.0 compiled successfully in 249 ms

如你所見,除了 shared.bundle.jsindex.bundle.jsanother.bundle.js 之外,還產生了另一個 runtime.bundle.js 檔案。

儘管 webpack 允許每個頁面使用多個進入點,但如果可能的話,應避免使用多個進入點,而改用具有多個匯入項目的進入點:entry: { page: ['./analytics', './app'] }。在使用 async 腳本標籤時,這樣可以獲得更好的最佳化和一致的執行順序。

SplitChunksPlugin

SplitChunksPlugin 允許我們將共用相依項抽取到現有的進入點區塊或全新的區塊。讓我們用它來消除前一個範例中的 lodash 相依項

webpack.config.js

  const path = require('path');

  module.exports = {
    mode: 'development',
    entry: {
      index: './src/index.js',
      another: './src/another-module.js',
    },
    output: {
      filename: '[name].bundle.js',
      path: path.resolve(__dirname, 'dist'),
    },
+   optimization: {
+     splitChunks: {
+       chunks: 'all',
+     },
+   },
  };

在設定 optimization.splitChunks 組態選項後,我們現在應該會看到重複的相依項從 index.bundle.jsanother.bundle.js 中移除。此外掛程式應該會注意到我們已將 lodash 分離到一個單獨的區塊,並從我們的 main bundle 中移除無用負載。讓我們執行 npm run build 來看看是否成功

...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 8.92 KiB [compared for emit] (name: index)
asset another.bundle.js 8.8 KiB [compared for emit] (name: another)
Entrypoint index 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB index.bundle.js 8.92 KiB
Entrypoint another 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB another.bundle.js 8.8 KiB
runtime modules 7.64 KiB 14 modules
cacheable modules 530 KiB
  ./src/index.js 257 bytes [built] [code generated]
  ./src/another-module.js 84 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 241 ms

以下是社群提供的其他一些有用的外掛程式和載入器,用於分割程式碼

動態匯入

在動態程式碼分割方面,webpack 支援兩種類似的技術。第一種也是推薦的方法是使用 import() 語法,它符合 ECMAScript 動態匯入提案。第二種是 webpack 獨有的舊方法,使用 require.ensure。我們先來試試第一種方法...

在開始之前,讓我們從上述範例中的組態中移除額外的 entryoptimization.splitChunks,因為在接下來的示範中不需要它們

webpack.config.js

 const path = require('path');

 module.exports = {
   mode: 'development',
   entry: {
     index: './src/index.js',
-    another: './src/another-module.js',
   },
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
-  optimization: {
-    splitChunks: {
-      chunks: 'all',
-    },
-  },
 };

我們也會更新專案以移除現在未使用的檔案

專案

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
  |- index.js
- |- another-module.js
|- /node_modules

現在,我們不會靜態匯入 lodash,而是使用動態匯入來分隔一個區塊

src/index.js

-import _ from 'lodash';
-
-function component() {
+function getComponent() {
-  const element = document.createElement('div');

-  // Lodash, now imported by this script
-  element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+  return import('lodash')
+    .then(({ default: _ }) => {
+      const element = document.createElement('div');
+
+      element.innerHTML = _.join(['Hello', 'webpack'], ' ');

-  return element;
+      return element;
+    })
+    .catch((error) => 'An error occurred while loading the component');
 }

-document.body.appendChild(component());
+getComponent().then((component) => {
+  document.body.appendChild(component);
+});

我們需要 default 的原因是,從 webpack 4 開始,在匯入 CommonJS 模組時,匯入不再會解析為 module.exports 的值,而是會為 CommonJS 模組建立一個人工命名空間物件。如需有關此原因的更多資訊,請閱讀 webpack 4: import() and CommonJs

讓我們執行 webpack,看看 lodash 是否已分隔到一個獨立的套件

...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 13.5 KiB [compared for emit] (name: index)
runtime modules 7.37 KiB 11 modules
cacheable modules 530 KiB
  ./src/index.js 434 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 268 ms

由於 import() 會傳回一個 Promise,因此它可以用於 async 函式。以下是簡化程式碼的方法

src/index.js

-function getComponent() {
+async function getComponent() {
+  const element = document.createElement('div');
+  const { default: _ } = await import('lodash');

-  return import('lodash')
-    .then(({ default: _ }) => {
-      const element = document.createElement('div');
+  element.innerHTML = _.join(['Hello', 'webpack'], ' ');

-      element.innerHTML = _.join(['Hello', 'webpack'], ' ');
-
-      return element;
-    })
-    .catch((error) => 'An error occurred while loading the component');
+  return element;
 }

 getComponent().then((component) => {
   document.body.appendChild(component);
 });

預先擷取/預先載入模組

Webpack 4.6.0+ 新增支援預先擷取和預先載入。

在宣告匯入時使用這些內嵌指令,可讓 webpack 輸出「資源提示」,告訴瀏覽器以下資訊:

  • 預先擷取:資源可能在未來某個導覽時需要
  • 預先載入:資源也會在目前的導覽期間需要

一個範例是有一個 HomePage 元件,它會呈現一個 LoginButton 元件,然後在被按一下後,依需求載入一個 LoginModal 元件。

LoginButton.js

//...
import(/* webpackPrefetch: true */ './path/to/LoginModal.js');

這會導致 <link rel="prefetch" href="login-modal-chunk.js"> 附加在頁面標頭中,這會指示瀏覽器在閒置時間預先擷取 login-modal-chunk.js 檔案。

與預先擷取相比,預先載入指令有許多差異

  • 預先載入的區塊會與父區塊平行載入。預先擷取的區塊會在父區塊載入完畢後才開始載入。
  • 預先載入的區塊具有中等優先權,且會立即下載。預先擷取的區塊會在瀏覽器閒置時下載。
  • 預先載入的區塊應由父區塊立即要求。預先擷取的區塊可以在未來的任何時間使用。
  • 瀏覽器支援不同。

一個範例是有一個 Component,它總是依賴一個應在個別區塊中的大型函式庫。

讓我們想像一個元件 ChartComponent,它需要一個龐大的 ChartingLibrary。它在呈現時會顯示一個 LoadingIndicator,並立即依需求匯入 ChartingLibrary

ChartComponent.js

//...
import(/* webpackPreload: true */ 'ChartingLibrary');

當要求一個使用 ChartComponent 的頁面時,也會透過 <link rel="preload"> 要求 charting-library-chunk。假設 page-chunk 較小且完成得較快,頁面會顯示一個 LoadingIndicator,直到已經要求的 charting-library-chunk 完成。由於它只需要一個回合行程,而不是兩個,因此這會稍微縮短載入時間。特別是在延遲較高的環境中。

有時您需要自行控制預載。例如,任何動態匯入的預載都可以透過非同步指令碼完成。這在串流伺服器端渲染的情況下會很有用。

const lazyComp = () =>
  import('DynamicComponent').catch((error) => {
    // Do something with the error.
    // For example, we can retry the request in case of any net error
  });

如果指令碼載入在 webpack 自行載入該指令碼之前失敗(如果該指令碼不在頁面上,webpack 會建立一個指令碼標籤來載入其程式碼),則該捕捉處理常式不會啟動,直到 chunkLoadTimeout 未傳遞。這種行為可能會出乎意料。但這是可以解釋的——webpack 無法擲出任何錯誤,因為 webpack 不知道該指令碼已失敗。錯誤發生後,webpack 會立即將 onerror 處理常式新增到指令碼。

若要防止此類問題,您可以新增自己的 onerror 處理常式,在發生任何錯誤時移除指令碼

<script
  src="https://example.com/dist/dynamicComponent.js"
  async
  onerror="this.remove()"
></script>

在這種情況下,會移除有錯誤的指令碼。webpack 將建立自己的指令碼,並且會在沒有任何逾時的情況下處理任何錯誤。

套件分析

一旦您開始分割程式碼,分析輸出以查看模組在哪裡結束會很有用。官方分析工具 是個不錯的起點。還有一些其他社群支援的選項

  • webpack-chart:webpack 統計資料的互動式圓餅圖。
  • webpack-visualizer:視覺化並分析您的套件,以查看哪些模組佔用空間,哪些可能是重複的。
  • webpack-bundle-analyzer:一個外掛程式和 CLI 工具,可以將套件內容表示為一個方便的互動式可縮放樹狀圖。
  • webpack bundle optimize helper:此工具會分析你的套件並提供可行的建議,說明如何改善以減少套件大小。
  • bundle-stats:產生套件報告(套件大小、資產、模組),並比較不同建置之間的結果。
  • webpack-stats-viewer:一個為 webpack 統計資料建置的外掛程式。顯示有關 webpack 套件詳細資訊的更多資訊。

後續步驟

參閱 延遲載入 以取得更具體的範例,瞭解如何在實際應用程式中使用 import(),以及 快取 以瞭解如何更有效地分割程式碼。

33 貢獻者

pksjcepastelskysimon04jonwheelerjohnstewshinxitomtaschelevy9527rahulcschrisVillanuevarafdeshaunwallaceskipjackjakearchibaldTheDutchCoderrouzbeh84shaodahongsudarsangpkcoltonefreitasnEugeneHlushkoTiendo1011byzykAnayaDesignwizardofhogwartsmaximilianschmelzersmelukovchenxsanAdarahatesgoralsnitin315artem-malko