利用長期快取功能

Webpack 如何協助快取素材資源

最佳化應用程式大小後,下一個可改善應用程式載入時間的做法就是快取。您可以使用此功能將應用程式的部分內容保留在用戶端,避免每次都重新下載。

使用套件版本控制和快取標頭

快取的常見做法如下:

  1. 請瀏覽器將檔案快取一段很長的時間 (例如一年):

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

    如果您不熟悉 Cache-Control 的功能,請參閱 Jake Archibald 的最佳快取做法文章

  2. 並在檔案名稱變更時重新命名,強制重新下載:

    <!-- Before the change -->
    <script src="./index-v15.js"></script>
    
    <!-- After the change -->
    <script src="./index-v16.js"></script>
    

這種做法會指示瀏覽器下載 JS 檔案、將檔案快取,然後使用快取的副本。只有在檔案名稱變更 (或一年後) 時,瀏覽器才會連線至網路。

使用 webpack 時,您可以執行相同的操作,但指定檔案雜湊碼,而非版本號碼。如要將雜湊字串加入檔案名稱,請使用 [chunkhash]

// webpack.config.js
module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.[chunkhash].js' // → bundle.8e0d62a03.js
  }
};

如果您需要傳送檔案名稱給用戶端,請使用 HtmlWebpackPluginWebpackManifestPlugin

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

    當 webpack 建構應用程式時,會將 [name] 替換為區塊名稱。如果不加入 [name] 部分,我們就必須根據雜湊來區分區塊,這非常困難!

  2. entry 欄位轉換為物件:

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

    在這段程式碼中,「main」是區塊的名稱。這個名稱會取代步驟 1 中的 [name]

    到目前為止,如果您建構應用程式,這個區塊將包含整個應用程式程式碼,就像我們尚未完成這些步驟一樣。但這會在幾秒鐘後改變。

  3. 在 webpack 4 中,將 optimization.splitChunks.chunks: 'all' 選項新增至 webpack 設定:

    // webpack.config.js (for webpack 4)
    module.exports = {
      optimization: {
        splitChunks: {
          chunks: 'all'
        }
      }
    };
    

    這個選項可啟用智慧程式碼分割功能。有了這個選項,如果供應商程式碼超過 30 kB (在精簡和 gzip 之前),webpack 就會擷取該程式碼。它也會擷取常用程式碼,如果您的建構作業產生多個套件 (例如將應用程式分割為路徑),這項功能就很實用。

    在 webpack 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].jsvendor.[chunkhash].js (webpack 4 為 vendors~main.[chunkhash].js)。如果是 webpack 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

瀏覽器會個別快取這些檔案,只重新下載變更的程式碼。

Webpack 執行階段程式碼

很抱歉,光是擷取供應商代碼是不夠的。如果您嘗試變更應用程式程式碼中的某些內容:

// 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

這是因為 webpack 套件除了模組的程式碼外,還有執行階段,也就是用於管理模組執行作業的一小段程式碼。將程式碼拆分為多個檔案後,這段程式碼就會開始在區塊 ID 和對應檔案之間建立對應關係:

// vendor.e6ea4504d61a1cc1c60b.js
script.src = __webpack_require__.p + chunkId + "." + {
    "0": "2f2269c7f0a55a5c1871"
}[chunkId] + ".js";

Webpack 會將這個執行階段納入上次產生的區塊,在本例中為 vendor。每次任何區塊變更時,這段程式碼也會變更,導致整個 vendor 區塊變更。

為解決這個問題,我們將執行階段移至個別檔案。在 webpack 4 中,您可以透過啟用 optimization.runtimeChunk 選項來達成這項目標:

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    runtimeChunk: true
  }
};

在 webpack 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 執行階段,以節省額外的 HTTP 要求

為進一步改善情況,請嘗試將 webpack 執行階段內嵌至 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 中則較不重要,但仍可能發揮作用)。

做法如下。

如果您使用 HtmlWebpackPlugin 產生 HTML

如果您使用 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

使用 webpack 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>
        …
      `);
    });
    

或使用 webpack 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() 可指定您要動態載入特定模組。當 webpack 看到 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 首頁

他們不需要載入程式碼來轉譯位於不同網頁上的文章,但他們會載入程式碼。此外,如果使用者一律只造訪首頁,而您變更了文章程式碼,webpack 就會使整個套件失效,使用者必須重新下載整個應用程式。

如果我們將應用程式分割為多個頁面 (如果是單頁應用程式,則分割為多個路徑),使用者只會下載相關程式碼。此外,瀏覽器會更妥善地快取應用程式程式碼:如果您變更首頁程式碼,Webpack 只會讓對應的區塊失效。

適用於單頁面應用程式

如要依據路徑分割單頁應用程式,請使用 import() (請參閱「您目前不需要的延遲載入程式碼」一節)。如果您使用的是架構,可能會有現成的解決方案:

  • react-router 文件中的「程式碼分割」 (適用於 React)
  • vue-router 說明文件中的「延遲載入路徑」 (適用於 Vue.js)

適用於傳統多頁應用程式

如要依頁面分割傳統應用程式,請使用 webpack 的進入點。如果您的應用程式有三種頁面:首頁、文章頁面和使用者帳戶頁面,則應有三個項目:

// webpack.config.js
module.exports = {
  entry: {
    home: './src/Home/index.js',
    article: './src/Article/index.js',
    profile: './src/Profile/index.js'
  }
};

對於每個進入點檔案,webpack 會建構獨立的依附元件樹狀結構,並產生組合,其中只包含該進入點使用的模組:

$ 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,homeprofile 套件就不會納入 Lodash,使用者也不必在造訪首頁時下載這個程式庫。

不過,分開的依附元件樹狀結構也有缺點。如果兩個進入點都使用 Lodash,且您尚未將依附元件移至供應商套件,則兩個進入點都會包含 Lodash 的副本。如要解決這個問題,請在 webpack 4 中,將 optimization.splitChunks.chunks: 'all' 選項新增至 webpack 設定:

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

這個選項可啟用智慧程式碼分割功能。使用這個選項後,webpack 會自動尋找通用程式碼,並將其擷取到個別檔案中。

或者,在 webpack 3 中,使用 CommonsChunkPlugin,即可將常見的依附元件移至新的指定檔案:

module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'common',
      minChunks: 2    // 2 is the default value
    })
  ]
};

您可以自由調整 minChunks 值,找出最適合的值。一般來說,您應該將其保持在較小值,但如果區塊數量增加,則應提高該值。舉例來說,如果有 3 個區塊,minChunks 可能為 2,但如果有 30 個區塊,minChunks 可能為 8,因為如果將其設為 2,太多模組會進入共用檔案,導致檔案過度膨脹。

延伸閱讀

讓模組 ID 更穩定

建構程式碼時,Webpack 會為每個模組指派 ID。之後,這些 ID 會用於套件中的 require()。您通常會在建構輸出內容中看到 ID,位於模組路徑前方:

$ 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

根據預設,系統會使用計數器計算 ID (也就是第一個模組的 ID 為 0,第二個模組的 ID 為 1,以此類推)。這樣做的問題是,當您新增新模組時,它可能會出現在模組清單中間,並變更所有後續模組的 ID:

$ 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 現在有 ID 5,而非 4

[5] ./comments.js 58 kB {0} [built]

ads.js 現在有 ID 6,而非 5

[6] ./ads.js 74 kB {1} [built]
       + 1 hidden module

這會使所有包含或依附 ID 已變更的模組無效,即使實際程式碼未變更也一樣。在本例中,0 區塊 (含有 comments.js 的區塊) 和 main 區塊 (含有其他應用程式程式碼的區塊) 都會失效,但只有 main 區塊應失效。

如要解決這個問題,請使用 HashedModuleIdsPlugin 變更模組 ID 的計算方式。它會將計數器 ID 替換為模組路徑的雜湊:

$ 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

採用這種方法時,只有在您重新命名或移動模組時,模組的 ID 才會變更。新模組不會影響其他模組的 ID。

如要啟用外掛程式,請將其新增至設定檔的 plugins 區段:

// webpack.config.js
module.exports = {
  plugins: [
    new webpack.HashedModuleIdsPlugin()
  ]
};

延伸閱讀

總結

  • 快取套件並透過變更套件名稱來區分版本
  • 將套件分割為應用程式程式碼、供應商程式碼和執行階段
  • 內嵌執行階段以儲存 HTTP 要求
  • 使用 import 延後載入非必要程式碼
  • 依路線/頁面分割程式碼,避免載入不必要的內容