[譯] 保持 webpack 快速執行的訣竅:一本提高構建效能的現場指導手冊

ThinkerNoah發表於2018-01-30

保持 webpack 快速執行的訣竅:一本提高構建效能的現場指導手冊

webpack 是用於打包前端資源的絕佳工具。然而,當執行開始變慢時,開箱即用的生態和大量的第三方工具使得優化變得十分困難。雖然效能不佳是一種常態而不是特例。但也不是沒有辦法來優化,經過幾個小時的調研與試錯,我完成了這樣一份現場指南,可以讓我們在加快構建的道路上學到更多知識。

[譯] 保持 webpack 快速執行的訣竅:一本提高構建效能的現場指導手冊

昔日的構建工具:連線提花機的織機。

前言

2017 年是 Slack 前端團隊雄心勃勃的一年。經過幾年的快速迭代開發,我們有不少的技術債務和進行大規模現代化的巨集偉計劃。首先,我們計劃用 React 重寫我們的 UI 元件,並全面使用上現代 JavaScript 語法。然而在我們希望這一點能夠實現之前,我們需要一套構建系統來支援這一新的工具星雲。

到目前為止,我們只能依靠檔案的簡單連線,雖然這一體系已經讓我們走到了這一步,但顯然它不會讓我們再更進一步了。 我們需要一套真正的構建系統。所以,作為一個具有良好的社群支援、易用性和功能集的強大起點,我們選擇了 webpack。

我們的專案切換到 webpack 的過渡大部分是平穩的。很平穩,直到,它遇到了構建效能問題。我們的構建花了幾分鐘,而不是幾秒鐘:與我們曾經習慣的秒級連線相差甚遠。Slack 的 Web 團隊在任何一個工作日都可以部署 100 次,所以我們感覺到了構建時間的急劇增長。

構建效能一直是 webpack 使用者群的關注重點,儘管核心團隊在過去幾個月裡一直在努力改進,但你仍然可以採取很多方法來自行改進自己的構建。下面的這些技巧幫助我們將構建時間縮短了 10 倍,我們將它們分享出來,希望能幫助到大家。

開始前,先測量

在嘗試優化之前,最重要的是瞭解時間在哪裡被浪費掉了。webpack 沒有提供這些資訊,但這些必需的資訊還能通過其他的方法來得到。

Node.js 的 inspector

Node 自帶了一個可以用來分析構建的 inspector。如果你不熟悉效能分析,不需要灰心:Google 很努力地解釋了 實現的細節。對 webpack 構建階段的粗略理解在這裡將是非常有益的,儘管他們的文件 簡要介紹了這一點,但閱讀一些 核心 程式碼 是非常有益的。

請注意,如果您的構建內容足夠大(比如有數百個模組或是需時超過一分鐘),則可能需要將分析過程分解為多個部分,以防止開發人員工具崩潰。

長期記錄

分析幫助我們確定了我們構建前端的緩慢部分,但是它不適合隨著時間的推移觀察趨勢。我們希望每次構建都能夠報告精確的時序資料,以便我們可以看到在每個昂貴的步驟(轉譯,壓縮和本地化)中花費了多少時間,並確定我們的優化是否有效。

對於我們來說,大部分的工作不是由 webpack 本身完成的,而是由我們所依賴的各種載入器和外掛完成的。總的來說,這些依賴並沒有提供精確的時序資料,雖然我們希望看到 webpack 採用標準化的方式來向第三方報告這種資訊,但是與此同時我們發現我們必須手動進行一些額外的日誌記錄。

對於載入器來說,這意味著解除我們的依賴關係。雖然這不適合作為一個長期策略,但是在我們進行優化的時候,對於我們辨認出過程中緩慢的部分是非常有用的。另一方面,外掛更容易分析。

便宜的測量外掛

外掛將自己附加到與構建的不同階段相關的 事件 上。通過測量這些階段的持續時間,我們可以粗略的測量我們外掛的執行時間。

UglifyJSPlugin 是一個典型的測量外掛,這種技術是有效的,因為其大部分工作是在 optimize-chunk-assets 階段。下面是一個簡單的外掛例程:

let CrudeTimingPlugin = function() {};

CrudeTimingPlugin.prototype.apply = function(compiler) {
  compiler.plugin('compilation', (compilation) => {
    let startOptimizePhase;

    compilation.plugin('optimize-chunk-assets', (chunks, callback) => {
      // 使用粗略測量壓縮時間的方法。
      // UglifyJSPlugin 在這個編譯階段完成全部工作,
      // 所以我們計算整個階段的時間。
      startOptimizePhase = Date.now();

      // 對於非同步階段,不要忘記呼叫回撥函式
      callback();
    });

    compilation.plugin('after-optimize-chunk-assets', () => {
      const optimizePhaseDuration = Date.now() - startOptimizePhase;
        console.log(`optimize-chunk-asset phase duration: ${optimizePhaseDuration}`);
      });
    });
};

module.exports = CrudeTimingPlugin;
複製程式碼

上面的例子目的是粗略地測量 UglifyJSPlugin 的執行時間差。請注意瞭解外掛將在哪些階段執行,因為可能有重疊。

把它新增到你的外掛列表裡,在 UglifyJS 之前,就像這樣:

const CrudeTimingPlugin = require('./crude-timing-plugin');

module.exports = {
plugins: [
    new CrudeTimingPlugin(),
    new UglifyJSPlugin(),
  ]
};
複製程式碼

這些資訊的價值大大超過了獲取它的成本,一旦你明白了時間花在了哪裡,就能夠有效地減少花費的時間。

並行操作

webpack 的很多工作本身就是並行的。通過把工作擴充套件到儘可能多的處理器上來獲得巨大的效果,如果你有多餘的 CPU 核心可“燒”,現在是“燒掉它”的時候了。

幸運的是,有一堆以此為目的打造的軟體包:

  • parallel-webpack 將並行執行整個 webpack 構建。我們在 Slack 中使用它來為我們的五種程式語言生成對應的資源。
  • happypack 將會並行地執行載入器,就像 thread-loader 一樣,由 webpack 核心團隊編寫和維護。並可以與 babel-loader 和其他轉譯器搭配起來。
  • UglifyJS 外掛的使用者可以使用最近新增的 並行選項

注意,拉起新執行緒有一個不小的成本。建議只在消耗較大的操作中,基於你之前的分析,靈活地應用它們。

降低工作負載

當我們的 webpack 測量實現完成時,我們意識到在幾個地方做了不必要的工作。砍掉這些地方為我們節省了大量的時間:

簡化壓縮

壓縮是一個巨大的時間沉澱 —— 佔據我們三分之一到一半的構建時間。我們評估了不同的工具,從 Butternutbabel-minify,結果卻發現 UglifyJS 在並行配置下是最快的。

然而,對我們來說,關於要處理的效能問題相關的核心資訊 被埋在作者的長篇大論之下

同大家認為的不同,對於大多數 JavaScript 來說,空白的去除和符號的改變能夠壓縮程式碼的 95%,是主要程式碼壓縮的核心,而不是精心設計的程式碼轉換。人們可以簡單地禁用壓縮加速 Uglify 構建 3 至 4 倍。

我們試了一下,結果令人咋舌。就像承諾的那樣,壓縮速度是原來的 3 倍,而且我們生成的打包檔案大小几乎沒有增長。不過 React 使用者以這種方式禁用壓縮應該警惕一個警告:detection methodsreact-devtools 用來報告你正在使用 React 的開發版本。經過一些試錯,我們發現以下配置解決了這個問題:

new UglifyJsPlugin({
  uglifyOptions: {
    compress: {
      arrows: false,
      booleans: false,
      cascade: false,
      collapse_vars: false,
      comparisons: false,
      computed_props: false,
      hoist_funs: false,
      hoist_props: false,
      hoist_vars: false,
      if_return: false,
      inline: false,
      join_vars: false,
      keep_infinity: true,
      loops: false,
      negate_iife: false,
      properties: false,
      reduce_funcs: false,
      reduce_vars: false,
      sequences: false,
      side_effects: false,
      switches: false,
      top_retain: false,
      toplevel: false,
      typeofs: false,
      unused: false,

      // 除非宣告瞭正在使用生產版本的react-devtools,
      // 否則關閉所有型別的壓縮。
      conditionals: true,
      dead_code: true,
      evaluate: true,
    },
    mangle: true,
  },
}),
複製程式碼

注意:此配置適用於 UglifyJS webpack 外掛的 1.1.2 版本。

檢測變數根據版本而不同,React 16使用者可能單獨使用_compress:false_。

通常優先考慮最終傳送給使用者的位元組數,所以請注意在工程團隊和下載應用程式的使用者之間取得平衡。

程式碼重用

開發中需要找到並進入多個相同程式碼的包是很常見的事。當這種情況發生時,壓縮器的工作將不必要地增加。 我們把打包通過 webpack Bundle AnalyzerBundle Buddy 這兩部顯微鏡找到重複的項,並將其用 webpack 的 CommonsChunkPlugin 分成共享塊。

跳過部分解析

webpack 會在查詢依賴關係的同時,將每個 JavaScript 檔案解析為 語法樹。這個過程是很昂貴的,所以如果你確定一個檔案(或一組檔案)永遠不會使用 import,require 或者 define 語句,你可以告訴 webpack 在這個過程中排除它們。以這種方式跳過大型庫可以大幅提高效率。有關更多詳細資訊,請參見 noParse 選項。

排除

通過類似的方式,你可以從載入器 排除 檔案,許多外掛提供 類似的選項。這可以實在的提高工具的效能,例如也依靠語法樹來完成自身工作的轉譯器和壓縮器。在 Slack 中,我們只編譯我們確認使用了 ES6 特性的程式碼,並且忽略不直接提供給客戶的程式碼的壓縮。

DLL 外掛

DllPlugin 將允許你在後面的階段剝離預先構建好的包供 webpack 使用,非常適合像 Vendor 庫這樣的大型,較少移動的依賴項。雖然它傳統上是一個需要大量配置的外掛,但是 autodll-webpack-plugin 為更簡單的實現鋪平了道路,值得一看。

使用記錄來穩定模組 ID

webpack 為依賴關係樹中的每個模組分配一個 ID。隨著新模組的新增以及其他模組的移除,樹會發生變化,同時也會改變其中每個模組的 ID。這些 ID 被置入每個 webpack 發出的檔案中,而高階別的模組混合(譯者注:應指交叉依賴,npm 一直以來的的一大嚴重問題)可能導致不必要的重建。 通過使用 records 來防止這種情況,在構建之間穩定您的模組ID。

建立一個清單塊

在 Slack,每次釋出新版本時,我們都會使用雜湊檔名來快取破解。開啟瀏覽器開發人員工具的“網路”選項卡,您將看到“_application.d4920286de51402132dc.min.js”檔案的請求。這種技術對於快取控制來說是非常棒的,但是這也意味著 webpack 無法在不借助摘要的情況下將模組對映到相應的檔名。

摘要是模組 ID 到雜湊的簡單對映,當 非同步匯入模組時,webpack 將用它來解析檔名:

{
    0: "d4920286de51402132dc", /* ← 為應用打包而生成的雜湊值 */
    1: "29a3cf9344f1503c9f8f",
    2: "e22b11ab6e327c7da035",
    /* .. 等等等 ... */
}
複製程式碼

預設情況下,webpack 將在它新增到每個打包檔案頂部的樣板程式碼中包含這個摘要。然而這是有問題的,因為每次新增或刪除模組時摘要都必須更新 —— 這種情況我們每天都會發生。每當摘要發生變化時,我們不僅需要等待所有打包檔案的重建,而且還要破壞快取,迫使我們的客戶重新下載它們。

僅僅保持模組ID穩定是不夠的。我們需要將模組摘要完全提取到一個單獨的檔案中;在我們或是我們的客戶沒有花費重建和重新下載任何東西的成本的情況下,就能夠定期改變。所以我們用CommonsChunk外掛建立了一個 manifest檔案。這大大減少了重建的頻率,而且還讓我們只傳送了一個 webpack 的樣板程式碼的副本。

Source maps

源地圖(Source maps)是除錯時用到的關鍵工具,但是生成它們將花費一定時間,改動 webpack 的 開發工具選單選項 並選擇一個最合適自己的除錯風格。 cheap-source-map 方案在構建效能和可除錯性間取得了不錯的平衡。

快取

我們的部署節奏很快,這意味著當前的構建和之前的之間通常只有很小的差異。隨著在正確的地方被快取,我們可以加速大部分 webpack 本來會做的工作。

我們使用 cache-loader 來快取結果(babel-loader 的使用者通常會優先選擇使用它的 內建快取,UglifyJSPlugin 的 內建快取,以及加入了 HardSourceWebpackPlugin

有關 HardSourceWebpackPlugin 的一點筆記

webpack 所做的很多工作都在載入器/外掛執行之外,而且大部分工作都會遵循傳統避開快取。為了解決這個問題,我們引入了一個外掛 HardSourceWebpackPlugin,用於快取 webpack 內部模組處理的中間結果。

為此,我們必須仔細列舉可能需要快取的所有外部因素,並徹底地進行測試。在我們的例子中包括:轉移,CDN 資源路徑和依賴版本。這不是個輕鬆地差事,但結果是值得的 —— 啟動快取後,我們的熱構建快了 20 秒。

最後要注意的是,每當程式包依賴性發生變化時,請記住清除快取 - 可以使用 npm postinstall script 自動執行。一個陳舊、不相容的快取可能會以新的和有趣的方式對你的構建造成嚴重破壞。

保持版本最新

在 webpack 生態系統中,保持最新狀態是值得的。核心團隊近期已經做了很多工作來提高構建速度,如果你沒有使用最新版本的依賴項,你可能會錯過大量的效能提升。 當我們從 webpack 3.0 升級到 3.4 時,我們發現加速了幾十秒鐘,而我們完全沒有改變配置,並且這樣的改進還在繼續。

定期升級並跟上前面提到的如並行性等新功能的更新。在 Slack ,我們盡我們所能地留意 Github 上的釋出,webpack團隊部落格, babel團隊部落格以及其他有關他們工作的部落格。

不要忘記讓你的 Node 保持在最新的版本 — 軟體包不是唯一的改進途徑。

硬體上的投資

當一天結束的時候,你的構建必須在某個地方執行,並且要在某個東西上執行。 如果最終的構建是在史前級的裝置上進行的話,那麼對整體構建效能,即便進行了最優秀的優化,都會產生很大的影響。

當我們的任務剛開始進行時,我們的構建伺服器是 Amazon EC2 家族的成員,C3。 通過將例項型別更新到 C4 產品(處理器更快,更強大),隨著程式碼庫的增長,我們看到了構建時間和可用於擴充套件的並行能力相關選項的顯著改進。 使用者通常擔心的從例項支援的機器到 EBS 的過渡過程不需要感到絕望:webpack 積極地快取檔案操作,我們沒有發現遷移到 EBS 後效能存在降低現象。

如果它在您的能力(和預算)範圍內,那麼請評估更好的硬體和基準,以找到最佳的配置。

貢獻

像 webpack 這樣的基礎設施專案幾乎都出奇的窮; 無論是時間還是金錢,對您使用的工具做出貢獻將為您和社群中的其他人改善這一工具的生態系統。Slack 最近為 webpack 專案做了捐贈,以確保團隊能夠繼續工作,我們鼓勵其他人也這樣做。

貢獻也可以通過反饋的形式進行。作者往往熱衷於聽到他們的使用者提供的更多資訊,瞭解他們需要在哪裡花費最多的精力,而且 webpack 甚至鼓勵使用者 對核心團隊的優先事項投票。 如果你關心構建效能,或者你已經有了改進的想法,那就讓你的聲音被大家聽到吧。

後話

webpack 是一個夢幻般的,多功能工具,不需要花費天價。這些技術幫助我們將建造時間的中位數從 170 秒縮短到了 17 秒,儘管他們為我們的工程師們提高了部署經驗,但他們並不是一個已經十分完善的專案。如果您對如何進一步提高構建效能有任何想法,我們很樂意聽取您的意見。當然,如果你喜歡解決這些問題 來和我們一起工作吧!

非常感謝 Mark Christian, Mioi Hanaoka, Anuj Nair, Michael “Z” Goddard, Sean Larkin and, of course, Tobias Koppers 對這篇文章和 webpack 專案做出的貢獻。

擴充套件閱讀

感謝 Matt Haughey 的支援。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章