在淘寶優化了一個大型項目,分享一些乾貨(代碼實例,圖文結合)

項目分享一千字,讀完須要5分鐘,實例部分三千字,讀完須要15分鐘,動手嘗試實例須要1小時,若有錯誤請指正。
複製代碼

在淘寶的優化第一彈

場景

本項目是淘系用戶增加團隊的一個大中臺系統,單頁應用,涵蓋不少業務功能,運用了不少懶加載頁面組件來提高性能,首屏時間 1s 左右,體驗良好。然而大項目文件不少,致使構建和發佈時間很長,內存佔用較大。個人任務是儘量優化與此相關的問題。前端

思路

  • 首先不難發現問題並不在用戶體驗上,而在於開發體驗,打包時間太長下降了開發效率。
  • 觀察項目,項目運用了不少懶加載組件來提高單頁性能,可是這些組件組件同時引用了不少重複的模塊,致使體積膨脹。
  • 接下來定位重複模塊具體有哪些,影響大不大,因此須要觀察打包後的 chunk 包含哪些模塊,重複程度如何。咱們能夠將 webpack 編譯器的輸出信息做一些設定,從而展現 chunk 代碼塊的具體信息:
const compiler = webpack(webpackConfig); //經過webpack配置信息獲得編譯器,而後配置其輸出信息
compiler.hooks.done.tap('done', (stats) => {
  console.log(
    stats.toString({
      colors: true,
      chunks: true,//這裏設爲true
      assets: true,
      children: false,
      modules: false,
    })
  );
}
複製代碼

固然,簡單的項目也能夠在打包命令後面加個參數:webpack --display-chunks,效果和上面至關。node

  • 在 chunk 信息中尋找在多個 chunk 中重複的模塊,將他們的路徑記錄,好比:
[./src/components/xxx.jsx] 4.52 KiB {55} {60} {66} {73} {87} {96} {113} {119} {127} {129} {133}
[./node_modules/base/yyy.js] 205 bytes {50} {54} {64} {70} {73} {74} {75} {80} {82} {83} {87} {92} {97} {104} {109} {111} {112} {113} {115} {117} {120} {127} {128} {129} {130} {132} {138} {150} {151}
[./node_modules/base/zzz.js] 205 bytes {50} {54} {64} {70} {73} {74} {75} {80} {82} {83} {87} {92} {97} {104} {109} {111} {112} {113} {115} {117} {120} {127} {128} {129} {130} {132} {138} {150} {151}
···
複製代碼

每一個大括號內都是一個 chunk 的 id,這三個模塊被重複打包到了衆多 chunk 中。webpack

  • 制定代碼分割策略,着重配置 optimization.splitChunks,提取重複模塊,要兼顧首屏性能,首頁須要的包不能太大,若是打得太大須要拆分。(項目代碼分割策略不便貼出,只能用下面的實例來代替了)git

  • 最後驗證打包效果,不斷調整策略直至最優。github

成果

摘自個人淘寶前端團隊週報:web

項目的打包編譯優化,取得有效成果,目前項目整體積比原來減小了 6.4M(原來體積 42.2M,如今 35.8M),編譯時間縮短 60% ,發佈時間縮短 20%,文件數減小 30 個,打包時再也不出現內存溢出問題。正則表達式

使用的技術只有一個:webpack 的 SplitChunksPlugin。SplitChunksPlugin 出了兩年,社區也積累了很多資料,我仍是以爲須要補充下面的實例教程,有兩個緣由:緩存

  • 中文社區對 SplitChunksPlugin 的某些屬性講解並不到位,官網教程翻譯到中文有些地方很差理解。
  • 我能找到的 demo 都很基礎,通常僅僅演示某個屬性的用法,我須要一個漸進的能把各類配置統一在一塊兒考慮的實例,這樣才能映射到實際項目。

如下代碼圖文預警,webpack 知識體系完整的老司機能夠直接略過,小白建議仔細閱讀。bash

打包優化中心思想

專心作事前,首先要找準大方向,纔不會在複雜項目中迷路。前端優化無外乎作兩件事:網絡

  • 優化用戶體驗
    • 減小首屏加載時間
    • 提高各項交互的流暢度,如表單驗證和頁面切換
  • 優化開發體驗
    • 減小構建耗時
    • 自動化完成一些重複工做,解放生產力,腳手架是表明性產物

而 webpack 提供了模塊化項目中最主要的優化手段:

  • 提取公共代碼
  • 按需加載(懶加載)

因此,咱們就是要經過 Webpack 的兩大優化手段,去完成上面前端優化的兩件事。當咱們面對龐大的項目摸不着頭腦,不妨跳出來看看。

SplitChunksPlugin 私房菜

SplitChunksPlugin 引入緩存組(cacheGroups)對模塊(module)進行分組,每一個緩存組根據規則將匹配到的模塊分配到代碼塊(chunk)中,每一個緩存組的打包結果能夠是單一 chunk,也能夠是多個 chunk。

webpack 作了一些通用性優化,咱們手動配置 SplitChunksPlugin 進行優化前,須要先理解 webpack 默認作了哪些優化,是怎麼作的,以後才能根據本身的須要進行調整。既然造了 SplitChunksPlugin,本身確定得用上,webpack 的默認優化就是經過 SplitChunksPlugin 配置實現的,以下:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      //在cacheGroups外層的屬性設定適用於全部緩存組,不過每一個緩存組內部能夠重設這些屬性
      chunks: "async", //將什麼類型的代碼塊用於分割,三選一: "initial":入口代碼塊 | "all":所有 | "async":按需加載的代碼塊
      minSize: 30000, //大小超過30kb的模塊纔會被提取
      maxSize: 0, //只是提示,能夠被違反,會盡可能將chunk分的比maxSize小,當設爲0表明能分則分,分不了不會強制
      minChunks: 1, //某個模塊至少被多少代碼塊引用,纔會被提取成新的chunk
      maxAsyncRequests: 5, //分割後,按需加載的代碼塊最多容許的並行請求數,在webpack5裏默認值變爲6
      maxInitialRequests: 3, //分割後,入口代碼塊最多容許的並行請求數,在webpack5裏默認值變爲4
      automaticNameDelimiter: "~", //代碼塊命名分割符
      name: true, //每一個緩存組打包獲得的代碼塊的名稱
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/, //匹配node_modules中的模塊
          priority: -10, //優先級,當模塊同時命中多個緩存組的規則時,分配到優先級高的緩存組
        },
        default: {
          minChunks: 2, //覆蓋外層的全局屬性
          priority: -20,
          reuseExistingChunk: true, //是否複用已經從原代碼塊中分割出來的模塊
        },
      },
    },
  },
};
複製代碼

其中五個屬性是控制代碼分割規則的關鍵,我再額外提一提:

  • minSize(默認 30000):使得比這個值大的模塊纔會被提取。
  • minChunks(默認 1):用於界定至少重複多少次的模塊纔會被提取。
  • maxInitialRequests(默認 3):一個代碼塊最終就會對應一個請求數,因此該屬性決定入口最多分紅的代碼塊數量,過小的值會使你不管怎麼分割,都沒法讓入口的代碼塊變小。
  • maxAsyncRequests(默認 5):同上,決定每次按需加載時,代碼塊的最大數量。
  • test:經過正則表達式精準匹配要提取的模塊,能夠根據項目結構制定各類規則,是手動優化的關鍵。

這些規則一旦制定,只有所有知足的模塊纔會被提取,因此須要根據項目狀況合理配置才能達到滿意的優化結果。

寶藏屬性 Name

name(默認爲 true),用來決定緩存組打包獲得的 chunk 名稱,容易被輕視但做用很大。奇特的是它有兩種類型取值,boolean 和 string:

  • 值爲 true 的時候,webpack 會基於代碼塊和緩存組的 key 自動選擇一個名稱,這樣一個緩存組會打包出多個 chunk。
  • 值爲 false 時,適合生產模式使用,webpack 會避免對 chunk 進行沒必要要的命名,以減少打包體積,除了入口 chunk 外,其餘 chunk 的名稱都由 id 決定,因此最終看到的打包結果是一排數字命名的 js,這也是爲啥咱們看線上網頁請求的資源,總會摻雜一些 0.js,1.js 之類的文件(固然,使資源名爲數字 id 的方式不止這一種,懶加載也能輕鬆辦到,且看下文)。
  • 值爲 string 時,緩存組最終會打包成一個 chunk,名稱就是該 string。此外,當兩個緩存組 name 同樣,最終會打包在一個 chunk 中。你甚至能夠把它設爲一個入口的名稱,從而將這個入口會移除。

一個實例玩轉各類場景

該上手擼代碼了。webpack 默認優化策略對普通規模的項目已然足夠,然而大廠的項目幾經轉手一般錯綜複雜,這時就須要手動優化了。下面咱們經過一個有必定結構複雜度的實例來玩轉 SplitChunksPlugin,當前版本的 webpack 是 4.43.0,項目結構以下:

|--node_modules/
|   |--vendor1.js
|   |--vendor2.js
|--pageA.js
|--pageB.js
|--pageC.js
|--utility1.js
|--utility2.js
|--utility3.js
|--webpack.config.js
複製代碼

vendor1.js:

export default () => {
  console.log("vendor1");
};
複製代碼

vendor2.js:

export default () => {
  console.log("vendor2");
};
複製代碼

pageA.js:

import vendor1 from "vendor1";
import utility1 from "./utility1";
import utility2 from "./utility2";

export default () => {
  console.log("pageA");
};
複製代碼

pageB.js:

import vendor2 from "vendor2";
import utility2 from "./utility2";
import utility3 from "./utility3";

export default () => {
  console.log("pageB");
};
複製代碼

pageC.js:

import utility2 from "./utility2";
import utility3 from "./utility3";

export default () => {
  console.log("pageC");
};
複製代碼

utility1.js:

import utility2 from "./utility2";

export default () => {
  console.log("utility1");
};
複製代碼

utility2.js:

export default () => {
  console.log("utility2");
};
複製代碼

utility3.js:

export default () => {
  console.log("utility3");
};
複製代碼

每一個文件內容並很少,關鍵在於它們的引用關係。webpack.config.js 配置以下:

var path = require("path");

module.exports = {
  mode: "development",
  // mode: "production",
  entry: {
    pageA: "./pageA",
    pageB: "./pageB",
    pageC: "./pageC",
  },
  optimization: {
    chunkIds: "named", // 指定打包過程當中的chunkId,設爲named會生成可讀性好的chunkId,便於debug
    splitChunks: {
      minSize: 0, // 默認30000(30kb),可是demo中的文件都很小,minSize設爲0,讓每一個文件都知足大小條件
      cacheGroups: {
        commons: {
          chunks: "initial",
          minChunks: 2,
          maxInitialRequests: 3, // 默認爲3
        },
        vendor: {
          test: /node_modules/,
          chunks: "initial",
          name: "vendor",
        },
      },
    },
  },
  output: {
    path: path.join(__dirname, "dist"),
    filename: "[name].js",
  },
};
複製代碼

控制檯運行:webpack,打包結果:

初始運行結果
初始運行結果
能夠看到,splitChunks 全局設置了 minSize=0,全部模塊都符合這個條件。緩存組 vendor 經過 test 正則匹配了 node_modules 的內容,打包到一個代碼塊 vendor.js 中;utility2.js 在 pageA,pageB,pageC 中都被引用,符合 commons 緩存組 minChunks=2 的規則,因此單獨打包到 commons~pageA~pageB~pageC.js 中。然而 utility3.js 也在 pageB.js,pageC.js 中被引用,符合 commons 的條件,卻依然分散在了 pageB 和 pageC 兩個 chunk 中,怎麼回事?咱們觀察輸出信息發現 pageB 入口須要加載 commons~pageA~pageB~pageC.js vendor.js pageB.js 這三個包,而咱們 commons 的規則裏 maxInitialRequests 爲 3,入口分包數量達到了上限,極可能是上限過小致使沒法繼續分包,因此咱們修改 commons 的規則,將 maxInitialRequests 增長到 5:

commons: {
  chunks: "initial",
  minChunks: 2,
  maxInitialRequests: 5, // 默認爲3時,沒法知足咱們的分包數量
},
複製代碼

再次打包,結果爲:

maxInitialRequests=5
maxInitialRequests=5
此次 utility3.js 被單獨打包爲 commons~pageB~pageC,同時 pageB 入口變爲了四個包:commons~pageA~pageB~pageC.js,vendor.js commons~pageB~pageC.js,pageB.js。因此,當咱們發現怎麼修改規則某些模塊就是提取不出,能夠看看是否是打包數到達了上限,去檢查 maxInitialRequests 和 maxAsyncRequests 這兩個屬性,maxAsyncRequests 和 maxInitialRequests 同樣,只不過決定的是按需加載的分包上限。

咱們繼續研究,不由產生疑問:爲何緩存組 commons 產出 commons~pageA~pageB~pageC.js 和 commons~pageB~pageC.js,而緩存組 vendor 產出 vendor.js?包名格式相差巨大。這就是 name 屬性在起做用,咱們註釋掉 vendor 中的 name:

vendor: {
  test: /node_modules/,
  chunks: "initial",
  // name: "vendor",
}
複製代碼

打包結果以下:

vendor不加name
vendor不加name
發現本來的 vendor.js 分裂成了 vendor~pageA.js 和 vendor~pageB.js,回想上一節 name 的特性,正是由於咱們沒有制定 name 的具體內容,默認爲 true,因此 webpack 會基於代碼塊和緩存組的 key 自動選擇一個名稱,這樣一個緩存組會打包出多個 chunk。而後咱們再體驗下 name 爲 false 的感覺:

splitChunks: {
  minSize: 0,// 默認30000(30kb),可是demo中的文件都很小,minSize設爲0,讓每一個文件都知足大小條件
  name:false,
  cacheGroups: {
    commons: {
      chunks: "initial",
      minChunks: 2,
      maxInitialRequests: 5, // 默認爲3時,沒法知足咱們的分包數量
    },
    vendor: {
      test: /node_modules/,
      chunks: "initial",
      name: "vendor",
    }
  }
}
複製代碼

打包結果以下:

name爲false
name爲false
緩存組 commons 產出的兩個 chunk 變成了 0.js 和 1.js,體積也減小了差很少 20 字節,也算一種優化方式了。

加點料,按需加載

上面的實例都是針對入口文件的優化,如今混入按需加載代碼,看看會給咱們的優化帶來什麼新體驗。項目中加入兩個懶加載文件,async1.js 和 async2.js:

|--node_modules/
|   |--vendor1.js
|   |--vendor2.js
|--pageA.js
|--pageB.js
|--pageC.js
|--utility1.js
|--utility2.js
|--utility3.js
|--async1.js
|--async2.js
|--webpack.config.js
複製代碼

async1.js 代碼:

import utility1 from "./utility1";

export default () => {
  console.log("async1");
};
複製代碼

async2.js 代碼:

import utility1 from "./utility1";

export default () => {
  console.log("async1");
};
複製代碼

pageA.js 更新爲:

import vendor1 from "vendor1";
import utility1 from "./utility1";
import utility2 from "./utility2";

export default () => {
  //懶加載
  import("./async1");
  import("./async2");
  console.log("pageA");
};
複製代碼

webpack.config.js 配置爲:

var path = require("path");

module.exports = {
  mode: "development",
  // mode: "production",
  entry: {
    pageA: "./pageA",
    pageB: "./pageB",
    pageC: "./pageC",
  },
  optimization: {
    chunkIds: "named", // 指定打包過程當中的chunkId,設爲named會生成可讀性好的chunkId,便於debug
    splitChunks: {
      minSize: 0, // 默認30000(30kb),可是demo中的文件都很小,minSize設爲0,讓每一個文件都知足大小條件
      // name:false,
      cacheGroups: {
        commons: {
          chunks: "all", //加入按需加載後,設爲all將全部模塊包括在優化範圍內
          // name: "commons",
          minChunks: 2,
          maxInitialRequests: 5, // 默認爲3,沒法知足咱們的分包數量
        },
        vendor: {
          test: /node_modules/,
          chunks: "initial",
          name: "vendor",
        },
      },
    },
  },
  output: {
    path: path.join(__dirname, "dist"),
    filename: "[name].js",
  },
};
複製代碼

其餘代碼不變。如今項目文件又增長了,直接 webpack 打包輸出的信息可能不太夠用,咱們執行:webpack --display-chunks,具體獲知每一個 chunk 包含哪些包含哪些模塊,屬於哪一個緩存組,這樣就能夠根據 chunk 的具體信息,判斷是否有重複模塊沒提取乾淨,是否有一些模塊明沒有命中咱們想要的規則,若是有,表明還有繼續優化的空間。打包結果以下:

按需加載1
按需加載1
那些命中緩存組的 chunk 都被標註了 split chunk 信息,入口 chunk 被標註了[entry],而兩個按需加載的文件被打包成 0.js 和 1.js,並不屬於任何緩存組或入口。

觀察結果咱們發現,utility1.js 同時被 pageA.js,async1.js,async2.js 三個模塊引用,照理應該命中 commons 緩存組的規則,從而被單獨提取成一個 chunk,然而結果是它依然打包在 pageA.js 中。這是由於 async1.js,async2.js 都是 pageA.js 的懶加載模塊,而 pageA.js 同步引用了 utility1.js,因此在加載 async1.js,async2.js 時 utility1.js 已經有了,直接拿來用便可,因此就不必提出一個新的 chunk,白白增長一個請求。

那什麼狀況下 utility1.js 纔會被單獨提出來?咱們調整代碼,將按需加載代碼從 pageA.js 移到 pageB.js:

pageA.js:

import vendor1 from "vendor1";
import utility1 from "./utility1";
import utility2 from "./utility2";

export default () => {
  //懶加載
  // import('./async1');
  // import('./async2');
  console.log("pageA");
};
複製代碼

pageB.js:

import vendor2 from "vendor2";
import utility2 from "./utility2";
import utility3 from "./utility3";

export default () => {
  //懶加載
  import("./async1");
  import("./async2");
  console.log("pageB");
};
複製代碼

執行 webpack --display-chunks,結果以下:

按需加載2
按需加載2
發現多了一個 chunk,utility1.js 被單獨提取到了 0.js 中,且屬於 commons 緩存組。將按需加載代碼從 pageA.js 移到 pageB.js 後,由於 pageB 和 pageA 並行,沒有依賴關係,因此 async1.js 和 async2.js 須要單獨加載 utility1.js 模塊,又由於 commons 緩存組 chunks=all,因此 async1.js,async2.js 和 pageA.js 的公共模塊 utility1.js 會被單獨提取。

最後咱們想把數字 id 名稱變成有意義的名稱,可使用 webpack 的 magic comments,把 pageB.js 改成:

import vendor2 from "vendor2";
import utility2 from "./utility2";
import utility3 from "./utility3";

export default () => {
  //懶加載
  import(/* webpackChunkName: "async1" */ "./async1");
  import(/* webpackChunkName: "async2" */ "./async2");
  console.log("pageB");
};
複製代碼

普通打包便可,結果爲:

按需加載chunk命名
按需加載chunk命名
這樣全部按需加載的 chunk 都有了名字,且單獨提取的 utility1.js 也命中了默認命名格式,有了本身的名字。

劃重點

這個實例運用了 webpack 兩樣主要優化手段,主要聚焦於如何讓項目打包處在咱們的掌控之中,不至於出現沒法理解的打包狀況,最終獲得想要的打包結果。但願讀完本文,你們面對再複雜的項目都能有優化入手點。

固然,優化自己是一件拆東補西的事,好比提取出一個公共 chunk,打包產出的文件就會多一個,也必然會增長一個網絡請求。當項目很龐大,每一個公共模塊單獨提取成一個 chunk 會致使打包速度出奇的慢,影響開發體驗,因此一般會取折衷方案,將重複的較大模塊單獨提取,而將一些重複的小模塊打包到一個 chunk,以減小包數量,同時不能讓這個包太大,不然會影響頁面加載時間。

在淘寶研究了一段時間打包的事兒,把個人心得分享給你們:優化就是在有限的時間空間和算力下,去除低效的重複(提出公共大模塊),進行合理的冗餘(小文件容許重複),並利用一些用戶無感知的區間(預加載),達到時間和空間綜合考量上的最優。

下一期,一塊兒走進 SplitChunksPlugin 源碼,條分縷析 webpack 的代碼分割原理。 沒多少人吧,趁機立個flag:點贊超50,一週內直接SplitChunksPlugin源碼撕出來。

--- 分割線 ---

謝謝你們捧場,本身立的flag跪着也要拔了,下一期在整了哈(指新建文件夾[dog])

源碼

個人代碼分割實例

參考資料

webpack 官網

官方 demo

相關文章
相關標籤/搜索