Webpack 代碼分離

2023-05-17 15:57 更新

代碼分離是 webpack 中最引人注目的特性之一。此特性能夠把代碼分離到不同的 bundle 中,然后可以按需加載或并行加載這些文件。代碼分離可以用于獲取更小的 bundle,以及控制資源加載優(yōu)先級(jí),如果使用合理,會(huì)極大影響加載時(shí)間。

常用的代碼分離方法有三種:

  • 入口起點(diǎn):使用 entry 配置手動(dòng)地分離代碼。
  • 防止重復(fù):使用 Entry dependencies 或者 SplitChunksPlugin 去重和分離 chunk。
  • 動(dòng)態(tài)導(dǎo)入:通過模塊的內(nèi)聯(lián)函數(shù)調(diào)用來分離代碼。

入口起點(diǎn)(entry point)

這是迄今為止最簡(jiǎn)單直觀的分離代碼的方式。不過,這種方式手動(dòng)配置較多,并有一些隱患,我們將會(huì)解決這些問題。先來看看如何從 main bundle 中分離 another module(另一個(gè)模塊):

project

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'),
   },
 };

這將生成如下構(gòu)建結(jié)果:

...
[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

正如前面提到的,這種方式存在一些隱患:

  • 如果入口 chunk 之間包含一些重復(fù)的模塊,那些重復(fù)模塊都會(huì)被引入到各個(gè) bundle 中。
  • 這種方法不夠靈活,并且不能動(dòng)態(tài)地將核心應(yīng)用程序邏輯中的代碼拆分出來。

以上兩點(diǎn)中,第一點(diǎn)對(duì)我們的示例來說無疑是個(gè)問題,因?yàn)橹拔覀冊(cè)?nbsp;?./src/index.js? 中也引入過 lodash,這樣就在兩個(gè) bundle 中造成重復(fù)引用。在下一章節(jié)會(huì)移除重復(fù)的模塊。

防止重復(fù)(prevent duplication)

入口依賴

配置 dependOn option 選項(xiàng),這樣可以在多個(gè) chunk 之間共享模塊:

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'),
   },
 };

如果我們要在一個(gè) HTML 頁面上使用多個(gè)入口時(shí),還需設(shè)置 optimization.runtimeChunk: 'single',否則還會(huì)遇到這里所述的麻煩。

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',
+  },
 };

構(gòu)建結(jié)果如下:

...
[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.js,index.bundle.js 和 another.bundle.js 之外,還生成了一個(gè) runtime.bundle.js 文件。

盡管可以在 webpack 中允許每個(gè)頁面使用多入口,應(yīng)盡可能避免使用多入口的入口:entry: { page: ['./analytics', './app'] }。如此,在使用 async 腳本標(biāo)簽時(shí),會(huì)有更好的優(yōu)化以及一致的執(zhí)行順序。

SplitChunksPlugin

SplitChunksPlugin 插件可以將公共的依賴模塊提取到已有的入口 chunk 中,或者提取到一個(gè)新生成的 chunk。讓我們使用這個(gè)插件,將之前的示例中重復(fù)的 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 配置選項(xiàng)之后,現(xiàn)在應(yīng)該可以看出,index.bundle.js 和 another.bundle.js 中已經(jīng)移除了重復(fù)的依賴模塊。需要注意的是,插件將 lodash 分離到單獨(dú)的 chunk,并且將其從 main bundle 中移除,減輕了大小。執(zhí)行 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

以下是由社區(qū)提供,一些對(duì)于代碼分離很有幫助的 plugin 和 loader:

  • mini-css-extract-plugin: 用于將 CSS 從主應(yīng)用程序中分離。

動(dòng)態(tài)導(dǎo)入(dynamic import)

當(dāng)涉及到動(dòng)態(tài)代碼拆分時(shí),webpack 提供了兩個(gè)類似的技術(shù)。第一種,也是推薦選擇的方式是,使用符合 ECMAScript 提案 的 import() 語法 來實(shí)現(xiàn)動(dòng)態(tài)導(dǎo)入。第二種,則是 webpack 的遺留功能,使用 webpack 特定的 require.ensure。讓我們先嘗試使用第一種……

在我們開始之前,先從上述示例的配置中移除掉多余的 entry 和 optimization.splitChunks,因?yàn)榻酉聛淼难菔局胁⒉恍枰鼈儯?/p>

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',
-    },
-  },
 };

我們將更新我們的項(xiàng)目,移除現(xiàn)在未使用的文件:

project

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

現(xiàn)在,我們不再使用 statically import(靜態(tài)導(dǎo)入) lodash,而是通過 dynamic import(動(dòng)態(tài)導(dǎo)入) 來分離出一個(gè) chunk:

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,是因?yàn)?webpack 4 在導(dǎo)入 CommonJS 模塊時(shí),將不再解析為 module.exports 的值,而是為 CommonJS 模塊創(chuàng)建一個(gè) artificial namespace 對(duì)象,更多有關(guān)背后原因的信息,請(qǐng)閱讀 webpack 4: import() and CommonJs。

讓我們執(zhí)行 webpack,查看 lodash 是否會(huì)分離到一個(gè)單獨(dú)的 bundle:

...
[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() 會(huì)返回一個(gè) promise,因此它可以和 async 函數(shù)一起使用。下面是如何通過 async 函數(shù)簡(jiǎn)化代碼:

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);
 });

預(yù)獲取/預(yù)加載模塊(prefetch/preload module)

Webpack v4.6.0+ 增加了對(duì)預(yù)獲取和預(yù)加載的支持。

在聲明 import 時(shí),使用下面這些內(nèi)置指令,可以讓 webpack 輸出 "resource hint(資源提示)",來告知瀏覽器:

  • prefetch(預(yù)獲取):將來某些導(dǎo)航下可能需要的資源
  • preload(預(yù)加載):當(dāng)前導(dǎo)航下可能需要資源

下面這個(gè) prefetch 的簡(jiǎn)單示例中,有一個(gè) HomePage 組件,其內(nèi)部渲染一個(gè) LoginButton 組件,然后在點(diǎn)擊后按需加載 LoginModal 組件。

LoginButton.js

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

這會(huì)生成 <link rel="prefetch" href="login-modal-chunk.js"> 并追加到頁面頭部,指示著瀏覽器在閑置時(shí)間預(yù)取 login-modal-chunk.js 文件。

與 prefetch 指令相比,preload 指令有許多不同之處:

  • preload chunk 會(huì)在父 chunk 加載時(shí),以并行方式開始加載。prefetch chunk 會(huì)在父 chunk 加載結(jié)束后開始加載。
  • preload chunk 具有中等優(yōu)先級(jí),并立即下載。prefetch chunk 在瀏覽器閑置時(shí)下載。
  • preload chunk 會(huì)在父 chunk 中立即請(qǐng)求,用于當(dāng)下時(shí)刻。prefetch chunk 會(huì)用于未來的某個(gè)時(shí)刻。
  • 瀏覽器支持程度不同。

下面這個(gè)簡(jiǎn)單的 preload 示例中,有一個(gè) Component,依賴于一個(gè)較大的 library,所以應(yīng)該將其分離到一個(gè)獨(dú)立的 chunk 中。

我們假想這里的圖表組件 ChartComponent 組件需要依賴一個(gè)體積巨大的 ChartingLibrary 庫(kù)。它會(huì)在渲染時(shí)顯示一個(gè) LoadingIndicator(加載進(jìn)度條) 組件,然后立即按需導(dǎo)入 ChartingLibrary:

ChartComponent.js

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

在頁面中使用 ChartComponent 時(shí),在請(qǐng)求 ChartComponent.js 的同時(shí),還會(huì)通過 <link rel="preload"> 請(qǐng)求 charting-library-chunk。假定 page-chunk 體積比 charting-library-chunk 更小,也更快地被加載完成,頁面此時(shí)就會(huì)顯示 LoadingIndicator(加載進(jìn)度條) ,等到 charting-library-chunk 請(qǐng)求完成,LoadingIndicator 組件才消失。這將會(huì)使得加載時(shí)間能夠更短一點(diǎn),因?yàn)橹贿M(jìn)行單次往返,而不是兩次往返。尤其是在高延遲環(huán)境下。

有時(shí)你需要自己控制預(yù)加載。例如,任何動(dòng)態(tài)導(dǎo)入的預(yù)加載都可以通過異步腳本完成。這在流式服務(wù)器端渲染的情況下很有用。

const lazyComp = () =>
  import('DynamicComponent').catch((error) => {
    // 在發(fā)生錯(cuò)誤時(shí)做一些處理
    // 例如,我們可以在網(wǎng)絡(luò)錯(cuò)誤的情況下重試請(qǐng)求
  });

如果在 webpack 開始加載該腳本之前腳本加載失?。ㄈ绻撃_本不在頁面上,webpack 只是創(chuàng)建一個(gè) script 標(biāo)簽來加載其代碼),則該 catch 處理程序?qū)⒉粫?huì)啟動(dòng),直到 chunkLoadTimeout 未通過。此行為可能是意料之外的。但這是可以解釋的 - webpack 不能拋出任何錯(cuò)誤,因?yàn)?webpack 不知道那個(gè)腳本失敗了。Webpack 將在錯(cuò)誤發(fā)生后立即將 onerror 處理腳本添加到 script 中。

為了避免上述問題,你可以添加自己的 onerror 處理腳本,將會(huì)在錯(cuò)誤發(fā)生時(shí)移除該 script。

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

在這種情況下,錯(cuò)誤的 script 將被刪除。Webpack 將創(chuàng)建自己的 script,并且任何錯(cuò)誤都將被處理而沒有任何超時(shí)。

bundle 分析(bundle analysis)

一旦開始分離代碼,一件很有幫助的事情是,分析輸出結(jié)果來檢查模塊在何處結(jié)束。 官方分析工具 是一個(gè)不錯(cuò)的開始。還有一些其他社區(qū)支持的可選項(xiàng):

  • webpack-chart: webpack stats 可交互餅圖。
  • webpack-visualizer: 可視化并分析你的 bundle,檢查哪些模塊占用空間,哪些可能是重復(fù)使用的。
  • webpack-bundle-analyzer:一個(gè) plugin 和 CLI 工具,它將 bundle 內(nèi)容展示為一個(gè)便捷的、交互式、可縮放的樹狀圖形式。
  • webpack bundle optimize helper:這個(gè)工具會(huì)分析你的 bundle,并提供可操作的改進(jìn)措施,以減少 bundle 的大小。
  • bundle-stats:生成一個(gè) bundle 報(bào)告(bundle 大小、資源、模塊),并比較不同構(gòu)建之間的結(jié)果。

下一步

接下來,查看 延遲加載 來學(xué)習(xí)如何在實(shí)際一個(gè)真實(shí)應(yīng)用程序中使用 import() 的具體示例,以及查看 緩存 來學(xué)習(xí)如何有效地分離代碼。

Further Reading


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)