【譯】Google - 使用 webpack 進行 web 性能優化(二):利用好持久化緩存

優化應用體積以後,下一個提高應用加載時間的策略就是緩存。將資源緩存在客戶端中,能夠避免以後每次都從新下載。html

bundle 的版本控制和緩存頭的使用

使用緩存的通用方法:前端

  1. 告訴瀏覽器須要緩存一個文件很長時間(好比,一年)vue

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

    ⭐️ 注意:若是你不熟悉 Cache-Control 的原理,請參閱 Jake Archibald 的文章: 關於緩存的最佳實踐node

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

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

這個方法能夠告訴瀏覽器去下載 JS 文件,並將它緩存,以後使用的都是它的緩存副本。瀏覽器只會在文件名發生改變(或者一年以後緩存失效)時纔會請求網絡。webpack

使用 webpack,一樣能夠作到,但使用的不是版本號,而是指定文件的哈希值。使用 [chunkhash] 能夠將哈希值寫入文件名中:git

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

⭐️ 注意: 即便 bundle 不變,webpack 也可能生成不一樣的哈希值 – 例如,你重命名了一個文件或者在不一樣的操做系統下編譯了 bundle。 固然,這實際上是一個 bug,目前尚未明確的解決方案,具體可參閱 GitHub 上的討論github

若是你須要將文件名發送給客戶端,可使用 HtmlWebpackPlugin 或者 WebpackManifestPluginweb

HtmlWebpackPlugin 是一個簡單但擴展性不強的插件。在編譯期間,它會生成一個 HTML 文件,文件包含了全部已經被編譯的資源。若是你的服務端邏輯不是很複雜,那麼它應該能知足你:vue-router

<!-- 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),這樣彷佛會讓你的每次請求都附加了額外的流量。例如,當用戶訪問你網站的首頁:

他們並不須要加載其它頁面上用於渲染文章的代碼 - 但他們卻加載了。此外,若是這個用戶常常只是訪問首頁,但你更改了其它頁面的文章代碼,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 性能優化(三):監控和分析應用

相關文章
相關標籤/搜索