【譯】Google - 使用 webpack 進行 web 效能優化(二):利用好持久化快取

閱文前端團隊發表於2018-09-14

優化應用體積之後,下一個提升應用載入時間的策略就是快取。將資源快取在客戶端中,可以避免之後每次都重新下載。

bundle 的版本控制和快取頭的使用

使用快取的通用方法:

  1. 告訴瀏覽器需要快取一個檔案很長時間(比如,一年)

    # Server header
    Cache-Control: max-age=31536000
    複製程式碼

    ⭐️ 注意:如果你不熟悉 Cache-Control 的原理,請參閱 Jake Archibald 的文章: 關於快取的最佳實踐

  2. 當檔案改變時,檔案會被重新命名,這樣就迫使瀏覽器重新下載:

    <!-- 修改前 -->
    <script src="./index-v15.js"></script>
    
    <!-- 修改後 -->
    <script src="./index-v16.js"></script>
    
    複製程式碼

這個方法可以告訴瀏覽器去下載 JS 檔案,並將它快取,之後使用的都是它的快取副本。瀏覽器只會在檔名發生改變(或者一年之後快取失效)時才會請求網路。

使用 webpack,同樣可以做到,但使用的不是版本號,而是指定檔案的雜湊值。使用 [chunkhash] 可以將雜湊值寫入檔名中:

// webpack.config.js
module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.<strong>[chunkhash]</strong>.js',
        // → bundle.8e0d62a03.js
  },
};
複製程式碼

⭐️ 注意: 即使 bundle 不變,webpack 也可能生成不同的雜湊值 – 例如,你重新命名了一個檔案或者在不同的作業系統下編譯了 bundle。 當然,這其實是一個 bug,目前還沒有明確的解決方案,具體可參閱 GitHub 上的討論

如果你需要將檔名傳送給客戶端,可以使用 HtmlWebpackPlugin 或者 WebpackManifestPlugin

HtmlWebpackPlugin 是一個簡單但擴充套件性不強的外掛。在編譯期間,它會生成一個 HTML 檔案,檔案包含了所有已經被編譯的資源。如果你的服務端邏輯不是很複雜,那麼它應該能滿足你:

<!-- index.html -->
<!doctype html>
<!-- ... -->
<script src="bundle.8e0d62a03.js"></script>
複製程式碼

WebpackManifestPlugin 是一個擴充套件性更佳的外掛,它可以幫助你解決服務端邏輯比較複雜的那部分。在打包時,它會生成一個 JSON 檔案,裡面包含了原檔名和帶雜湊檔名的對映。在服務端,通過這個 JSON 就能方便的找到我們真正要執行的檔案:

// manifest.json
{
  "bundle.js": "bundle.8e0d62a03.js"
}
複製程式碼

擴充套件閱讀

將依賴和 runtime 提取到單獨的檔案中

依賴

應用的依賴通常比實際應用內的程式碼變更頻率低。如果將它們移到單獨的檔案中,瀏覽器就可以獨立快取它們 – 這樣每次應用中的程式碼變更也不會去重新下載它們。

關鍵術語:在 webpack 術語中,把帶有應用程式碼的獨立檔案稱之為 chunk。我們在下面的文章中會使用到這個名稱。

要將依賴項提取到獨立的 chunk 中,需要執行下面三個步驟:

  1. 將輸出檔名替換為[name].[chunkname].js

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

當 webpack 編譯應用時,它會將[name] 作為 chunk 的名稱。如果我們沒有新增 [name] 的部分,我們將不得不通過雜湊值來區分 chunk - 這樣就變得非常困難!

  1. entry 的值改為物件:

    // webpack.config.js
    module.exports = {
      // Before
      entry: './index.js',
      // After
      entry: {
        main: './index.js',
      },
    };
    複製程式碼

    在上面這段程式碼中,“main” 是 chunk 的名稱。這個名稱會在第一步時被 [name] 所替代。

    到目前為止,如果你構建應用,這個 chunk 還是包含了整個應用的程式碼 - 就像我們沒有做過上述這些步驟一樣。但接下來很快就將產生變化。

  2. 在 webpack 4 中,可以將 optimization.splitChunks.chunks: 'all' 選項新增到 webpack 的配置中:

    // webpack.config.js (for webpack 4)
    module.exports = {
      optimization: {
        splitChunks: {
          chunks: 'all',
        }
      },
    };
    複製程式碼

    這個選項可以開啟智慧程式碼拆分。使用了這個功能,webpack 將會提取大於 30KB(壓縮和 gzip 之前)的第三方庫程式碼。它同時也可以提取公共程式碼 - 如果你的構建結果會生成多個 bundle 時這將非常有用。(例如:假如你通過路由來拆分應用)。

    在 webpack 3 中新增 CommonsChunkPlugin 外掛:

    // webpack.config.js (for webpack 3)
    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
          // chunk 的名稱將會包含依賴
          // 這個名稱會在第一步時被 [name] 所替代
          name: 'vendor',
    
          // 這個函式決定哪個模組會被打入 chunk
          minChunks: module => module.context &&
            module.context.includes('node_modules'),
        }),
      ],
    };
    複製程式碼

    這個外掛會將路徑包含 node_modules 的所有模組移到一個名為 vendor.[chunkhash].js 的獨立檔案中。

完成這些更改後,每次打包都將從原來的生成一個檔案變為生成兩個檔案:main.[chunkhash].jsvendor.[chunkhash].js (vendors~main.[chunkhash].js 只有在 webpack 4 才有)。在 webpack 4 中,如果依賴項很小,則可能不會生成 vendor bundle - 這點做的不錯:

$ 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 runtime 程式碼

遺憾的是,僅僅提取第三方庫程式碼還是不夠的。如果你想嘗試在應用程式碼中修改一些東西:

// index.js
…
…

// 例如,增加這句:
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 打包時,除了模組程式碼之外,webpack 的 bundle 中還包含了 runtime - 一小段可以管理模組執行的程式碼。當你將程式碼拆分成多個檔案時,這小部分程式碼在 chunk id 和匹配的檔案之間會生成一個對映:

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

Webpack 將 runtime 包含在了最新生成的 chunk 中,這個 chunk 就是我們程式碼中的 vendor。每次 chunk 有任何變更,這一小部分程式碼也會隨之更改,同時也會導致整個 vendor chunk 發生改變。

為了解決這個問題,我們可以將 runtime 移動到一個獨立的檔案中。在 webpack 4 中,可以通過開啟 optimization.runtimeChunk 選項來實現:

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    runtimeChunk: true,
  },
};
複製程式碼

在 webpack 3 中,可以通過 CommonsChunkPlugin 建立一個額外的空 chunk:

// webpack.config.js (for webpack 3)
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',

      minChunks: module => module.context &&
        module.context.includes('node_modules'),
    }),

    // 這個外掛必須在 vendor 生成之後執行(因為 webpack 把執行時打進了最新的 chunk)
    new webpack.optimize.CommonsChunkPlugin({
      name: 'runtime',

      // minChunks: Infinity 表示任何應用模組都不能打進這個 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 的 runtime 可以節省額外的 HTTP 請求

為了達到更好的體驗,我們可以嘗試把 webpack 的 runtime 內聯到 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>
複製程式碼

Runtime 的程式碼不多,內聯到 HTML 中可以幫助我們節省 HTTP 請求(在 HTTP/1 中尤為重要;在 HTTP/2 中雖然沒那麼重要,但仍然能起到一定作用)。

下面就來看看要如何做。

如果你使用 HtmlWebpackPlugin 來生成 HTML

如果你使用 HtmlWebpackPlugin 來生成 HTML 檔案,那麼你一定需要 InlineSourcePlugin

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const InlineSourcePlugin = require('html-webpack-inline-source-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      // Inline all files which names start with “runtime~” and end with “.js”.
      // That’s the default naming of runtime chunks
      inlineSource: 'runtime~.+\\.js',
    }),
    // This plugin enables the “inlineSource” option
    new InlineSourcePlugin(),
  ],
};
複製程式碼

如果你通過自定義服務端邏輯來生成 HTML

在 webpack 4 中:

  1. 新增 WebpackManifestPlugin 外掛可以獲取生成的 runtume chunk 的名稱:

    // 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. 可以用一個便利的方式內聯 runtime chunk 的內容。例如,使用 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(`
        …
        &lt;script>${runtimeContent}&lt;/script>
        …
      `);
    });
    複製程式碼

在 webpack 3 中:

  1. 通過指定 filename ,可以使 runtime 的名稱不發生改變 :

    // webpack.config.js (for webpack 3)
    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
          name: 'runtime',
          minChunks: Infinity,
          filename: 'runtime.js',
            // → Now the runtime file will be called
            // “runtime.js”, not “runtime.79f17c27b335abc7aaf4.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(`
        …
        &lt;script>${runtimeContent}&lt;/script>
        …
      `);
    });
    複製程式碼

程式碼懶載入

通常,一個網頁會有自身的側重點:

  • 假如你在 YouTube 上載入一個視訊頁面,你更關心的肯定是視訊而不是評論。所以,這裡視訊就比評論重要。
  • 又比如你在一個新聞網站看一篇文章,你更關心的肯定是文章的文字而不是廣告。所以,這裡文字就比廣告重要。

上面的這些情況,都可以通過優先下載最重要的部分,稍後懶載入剩餘部分,從而來提升頁面首次載入的效能。在 webpack 中,使用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'),就會把這個模組放到單獨的 chunk 中:

$ 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() 函式時才會去下載。

這樣可以讓 入口 bundle 變得更小,從而減少首次載入時間。不僅如此,它還可以優化快取 - 如果你修改了入口 chunk 的程式碼,註釋 chunk 不會受到影響。

⭐️ 注意: 如果你使用 Babel 編譯程式碼,會因為 Babel 無法識別 import() 而出現語法錯誤。為了避免這個錯誤,你可以新增 syntax-dynamic-import 外掛。

擴充套件閱讀

將程式碼拆分為路由和頁面

如果你的應用有多個路由或頁面,但是程式碼中只有一個單獨的 JS 檔案(一個單獨的入口 chunk),這樣似乎會讓你的每次請求都附加了額外的流量。例如,當使用者訪問你網站的首頁:

【譯】Google - 使用 webpack 進行 web 效能優化(二):利用好持久化快取

他們並不需要載入其它頁面上用於渲染文章的程式碼 - 但他們卻載入了。此外,如果這個使用者經常只是訪問首頁,但你更改了其它頁面的文章程式碼,webpack 將會重新編譯,使整個 bundle 失效 - 這樣將導致使用者重新下載整個應用的程式碼。

如果我們將程式碼拆分到頁面中(或者單頁面應用的路由裡),使用者就會只下載真正用到的那部分程式碼。此外,瀏覽器也會更好地快取應用程式碼:當你改變首頁的程式碼時,webpack 只會讓相匹配的 chunk 失效。

單頁面應用

要通過路由來拆分單頁應用,可以使用 import()(參加上文程式碼懶載入部分)。如果你使用的是一個框架,目前也有現成的解決方案:

傳統多頁應用

要通過頁面來拆分傳統應用,可以使用 webpack 的 entry points。假設你的應用中有三類頁面:主頁、文章頁和使用者賬戶頁,- 那麼就應該有三個入口:

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

對於每個入口檔案,webpack 將構建一個單獨的依賴樹並生成一個 bundle,這個 bundle 裡只有包含這個入口所使用到的模組:

$ 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
複製程式碼

所以,如果只有 article 頁面使用到了 Lodash,那麼 home 和 profile bundle 就不會包含它 - 使用者也不會在訪問首頁的時候下載到這個庫。

但是,單獨的依賴樹有它們的缺點。如果兩個入口都使用到了 Lodash,同時你沒有將依賴項移到 vendor bundle 中,則兩個入口都將包含 Lodash 的副本。為了解決這個問題,在 webpack 4 中,可以在你的 webpack 配置中加入optimization.splitChunks.chunks: 'all'選項:

// webpack.config.js (適用於webpack 4)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
    }
  },
};
複製程式碼

這個選項可以開啟智慧程式碼拆分。有了這個選項,webpack 將自動查詢到公共程式碼,並且提取到單獨的檔案中。

在 webpack 3 中,可以使用 CommonsChunkPlugin 外掛,它會將公共的依賴項移動到一個新的指定檔案中:

// webpack.config.js (適用於 webpack 3)
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      // chunk 的名稱將會包含公共依賴
      name: 'common',

      // minChunks表示要將一個模組打入公共檔案時必須包含的 `minChunks` chunks 數量
      // (注意,外掛會分析所有 chunks 和 entries)
      minChunks: 2,    // 2 is the default value
    }),
  ],
};
複製程式碼

你可以嘗試調整 minChunks 的值來找到最優的方案。通常情況下,你希望它是一個較小的值,但隨著 chunk 數量的增加它會隨之增大。例如,有 3 個 chunk 時,minChunks 的值可能是 2 ,但是有 30 個 chunk 時,它的值可能是 8 - 因為如果你把它設定成 2,就會有很多模組要被打包進同一個公共檔案中,這樣檔案就會變得臃腫。

擴充套件閱讀

確保模組的 id 更加穩定

構建程式碼時,webpack 會為每個模組分配一個 ID。隨後,這些 ID 將在 bundle 裡的 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 由 4 變成了 5

   [5] ./comments.js 58 kB {0} [built]
複製程式碼

         ↓ ads.js 的 ID 由 5 變成了 6

   [6] ./ads.js 74 kB {1} [built]
       + 1 hidden module
複製程式碼

這將使包含或依賴於這些被更改 ID 的模組的所有 chunk 都無效 - 即使它們實際程式碼沒有更改。在我們的案例中,ID 為 0 的 chunk ( comments.js 的 chunk) 和 main chunk (其它應用程式碼的 chunk )都將失效 - 但其實只有 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(),
  ],
};
複製程式碼

擴充套件閱讀

總結

  • 快取 bundle 並通過更改 bundle 名稱來進行版本控制
  • 將 bundle 拆分成 app(應用) 程式碼、vendor(第三方庫) 程式碼和 runtime
  • 內聯 runtime 可以節省 HTTP 請求
  • 使用 import 懶載入非關鍵程式碼
  • 按路由或頁面拆分程式碼,從而避免載入不必要的檔案

更多分享,請關注YFE:

【譯】Google - 使用 webpack 進行 web 效能優化(二):利用好持久化快取

上一篇:譯】Google - 使用 webpack 進行 web 效能優化(一):減小前端資源大小

下一篇:譯】Google - 使用 webpack 進行 web 效能優化(三):監控和分析應用

相關文章