天天看點

npm 如何才能有效減包?

作者:騰訊技術工程

作者:atomliu,騰訊PCG前端開發工程師

| 導語 我是如何把一個 npm 包的接入成本從 500kb 降低到幾乎為 0kb 和 關于 npm 減包你所需要知道的大部分資訊

前言

十月份的某一天,有同學找到我說, slide 接入統一頂部欄後,整個站點體積增大了将近 500kb?導緻合流被阻斷了,需要将體積增長控制在 30kb以下才能允許合入主幹。當時我就震驚了,要知道這可是 gzip 後的體積啊,不少網站整站都沒有500kb,統一頂部欄是什麼玩意竟有如此威力?

騰訊文檔各個品類的頂部工具欄在之前都是品類獨立維護的,經常會發現各品類之間存在着不少的 功能/文案 差異,最痛的點還是在于如果需要新增一個功能,需要五六個品類都各自重複開發一遍,效率非常低,而且傳遞标準很容易不一緻。是以有了統一頂部欄服務(@tencent/docs-titlebar-service)(下稱為 TB),使用一個 npm 包将頂部欄的大部分功能收歸起來統一開發管理,這樣隻需要一次開發,各品類隻需要更新就好了。

從這個 npm 包的功能可以知道,基本上沒有什麼新的功能,隻是将之前散落在品類中的功能收歸到 npm 中而已,到底為什麼會導緻如此巨大的體積增長呢?

罪魁禍首是什麼?

産物分析工具選擇

要回答這個問題,就需要來分析 npm 包産物的産物到底是由什麼東西組成,提起産物分析就繞不開 Webpack Bundle Analyzer 這個 webpack 插件。其原理是通過分析 webpack 建構過程産物 state.json 來分析産物組成,網上大多數文章也是介紹的用這個插件來分析産物組成,但是實踐過程中發現其分析結果并不準确,導緻走了不少彎路,而且其強綁定了 webpack 這個建構工具,如果是使用 rollup 或者其他建構的就無能為力了(當然 rollup 也有自己的體積分析插件rollup-plugin-visualize)。一番搜尋後發現了 source-map-explorer 這個工具,隻需要産出 sourcemap 就能夠分析産物組成,是以可以相容任何建構工具和建構方式,其分析結果也比提到的更準确詳細一些,當然最好還是綜合對比多個工具來進行分析.

npm 包的産物體積分析

在沒有經過任何優化的情況下,使用 webpack 建構 titlebar,然後使用 source-map-explorer 對其進行産物分析,得到了這樣一張觸目驚心的體積圖,gzip 前的體積達到了 5MB….

npm 如何才能有效減包?

其源碼體積甚至都沒有排上号顯示出來,大量的 node_modules 中的依賴被打包進了産物中,右邊的 PcHeaderBadge 也是其一個依賴項,隻是因為是本地包是以被沒有顯示在 node_modules 中。

如何解決依賴問題

external 就能夠解決問題嗎

如何解決這個問題呢?其實說起來也簡單,将所有的依賴 external 掉就好了,簡單來說就是不「打包」依賴,在産物中保留對依賴的引用語句,這樣産物就隻有源碼的代碼。是以在建構的時候,擷取其 package.json 檔案,将 dependencies 與 peerDependencies 中的依賴都寫入 external 配置中,産物最終就是這個樣子的:

npm 如何才能有效減包?

來到 mirrors 源上檢視一下體積,從 5MB 降低到了 64KB~

npm 如何才能有效減包?

那這樣問題就解決了嗎,其實遠沒有,因為 TB 從一開始就是這樣做的,但就是這樣一個看起來體積資料比較優秀的 npm 包,卻給整站帶來了将近 500kb 的體積增長,到底是為什麼呢?

我們不得不再次回過頭來,仔細檢視 ppt 提供的體積增長圖,其實資訊都藏在這個圖裡面

npm 如何才能有效減包?

可以發現新增項和增大項主要可以分成兩部分,一部分是 TB及其node_modules(~300kb),另一部分則是看起來沒有直接依賴關系的依賴(~200kb),通過進一步的檢視詳細的 ppt 的産物分析圖可以得知 TB 部分的體積增長全部都是來自于依賴部分(此處圖顯示有誤差),也就是 node_modules。等等上文不是把依賴都 「external」掉了嗎,為什麼還有這麼大的體積呢,要回答這個問題,我們得了解一下「external」到底是如何減少包大小的

為什麼 external 可以減包

關于 external 如何降低 npm 包的體積大小上文已經提到過,很容易了解(不将依賴直接打包進産物,而是利用 import / require 這樣的語句保持對依賴的引用),那麼 npm 包最終在 ppt 中會是如何進行打包的呢?那些 require 語句是如何找到依賴的。

當 ppt 下載下傳 TB 這個 npm 包的時候,會同時下載下傳其`dependencies 到子node_modules中,而 peerDependencies 中的依賴則會被下載下傳到和TB同級的node_modules中,devDependencies 中的依賴則會忽略,比如當 package.json 中是如下的依賴關系時:

"`dependencies`": {
        "a": "1.0.0",
        "b": "1.0.0"
    },
    "devDependencies": {},
    "`peerDependencies`": {
        "c": "1.0.0"
    },           

當下載下傳到 ppt 的時候,會形成如下的目錄結構:

--src
--node_modules(1)
----c
----TB
------dist
------node_modules(2)
--------a
--------b           

TB dist 中的引入語句在 ppt 建構的時候,會首先去同級的`node_modules(2)中尋找依賴,如果找不到的話就會向上尋找找到 node_modules(1)中,那這不是 external 了個寂寞嗎,一樣的是會下載下傳依賴和進行打包,别急,上面提到的目錄結構是隻有 TB 一個包存在的情況,如果有多個 npm 存在,npm 有一個概念叫做 npm dedupe —— 當有「符合版本範圍」的依賴多次出現時,會被提升到依賴樹的上層,實作共享依賴

a
+-- b <-- depends on [email protected]
|   `-- [email protected]
`-- d <-- depends on c@~1.0.9
    `-- [email protected]           

在這種依賴關系下,npm dedupe 會将依賴樹簡化成這樣,因為 [email protected] 符合 b 和 d 對其的版本需求。

a
+-- b
+-- d
`-- [email protected]           

這樣依賴,依賴樹中就隻存在一份 c了,進而降低「重複打包」,沒錯 external 并不能神奇的把依賴去除掉,而是通過保留引用語句,然後通過 npm dedupe機制來降低重複打包的次數。如果沒有 external,在 npm 建構的時候,依賴就已經被打包進産物中了,那就沒有絲毫優化空間了。

external 沒有徹底

了解了 external 減包的原理,回過頭來看 TB 到底是有什麼問題,跑了一下 source-map-explorer 很直覺的可以看到還有一些依賴被打包了

npm 如何才能有效減包?

排查發現主要是以下幾個原因導緻的

  • 引用了 common 部分的代碼,而這部分代碼的依賴沒有并寫到TB的 package.json 中,導緻 external 不了
  • 有部分使用到的依賴,并沒有寫在 package.json 中,而是直接寫在了根目錄中的 package.json 中
  • 因為 external 是從 package.json 中擷取的,是以字段中的隻是@tencent/dui,而實際使用中用到的是 import Modal from '@tencent/dui/lib/components/Modal'這種寫法,導緻沒有比對上,external 沒有生效

common 目錄的直接引用問題

第一個問題其實是由于曆史原因,之前這個倉庫所有的包都是共用一份依賴,在上半年改造成了 pnpm workspace 的倉庫,每個子包具有了自己的 package.json,有自己的依賴,但是 common 這個目錄因為工作量比較大的原因并沒有進行改造,是以還存在着大量的包「直接引用 common 源碼」這種不符合 workspace 規範的行為,common 目錄中的依賴都還是直接寫在根目錄的,導緻沒有 external 。這裡選擇直接将 common 目錄也改造成一個 npm 包,将依賴寫入子包中,這樣在 commom 這個包建構的時候依賴就會被 external 掉,其他 npm 包通過 npm 包的方式引用 common 中的代碼,這樣 common 包也就被 external 掉了,這樣徹底解決了 common 目錄中的依賴打包問題,而且降低了 common 目錄的重複編譯次數,提高了建構速度。

使用了不在子包 package.json 中的依賴

同樣是由于曆史原因,之前的依賴都是寫在根目錄的,或者新寫代碼的同學會直接将依賴安裝在根目錄,這樣子代碼是能夠運作的,因為依賴是會一層一層的向上進查找,但是就會導緻 external 失效。于是寫了一個腳本通過 depcheck 這個 npm 包,批量掃描源碼中使用到的依賴,然後去根目錄下的 package.json 中查找版本,最終寫入到子包的 package.json 中

帶路徑的依賴包需要單獨的 external

第三個問題好解決,為有類似這種用法的依賴包,單獨寫上一條正則比對,比對上所有以其包名為開頭的的引入語句就好了。

// 從 package.json 中擷取依賴, 用于 rollup 的 external
const getExternal = (path: string) => {
    const packageJson = require(path);

    return [
        ...Object.keys(packageJson.`dependencies` || {}),
        ...Object.keys(packageJson.`peerDependencies` || {}),
        ...Object.keys(packageJson.devDependencies || {}),
        /@babel\/runtime/,
        /^\@tencent\/docs-design-resources/,
        /@tencent\/dui/
    ];
};           

這樣幾個方向的改造過後,所有依賴都被 external 了,為後續的優化打下了堅實的基礎。

不規範的依賴寫法

在經過上節的一些改造之後,在檢視 TB 的産物分析圖可以發現确實沒有相關依賴被打包進來了,但是在檢視 ppt 的産物分析圖會發現,還是有一些包最終被重複打包了,分析發現問題還是出在依賴的寫法上:

  • 存在一個依賴同時寫在 dependencies 與 peerDependencies中,在 npm 中,其會被視為 dependencies 下載下傳到 ts 的 node_moduls 中,失去了 peerDependencies 的意義,可能造成重複打包。而在 [email protected] 以上的版本才會被優先視為 peerDependencies
  • peerDependencies 寫了具體的版本,導緻在 npm 判斷已有的依賴不符合其版本需求,下載下傳了多份依賴,造成多版本的重複打包
  • dependencies 中使用到的依賴版本與其他 npm 包或者 ppt 自己使用到的依賴版本不一緻,導緻依賴沒有被 dedupe,造成多版本打包
  • dui 系列包一直沒有釋出正式版本(1.0.0),在 npm 的了解中,1.0.0 版本之前每一個 patch 版本都有可能是不相容的,是以對這種包是不會使用 npm dedupe 政策來複用的,是以有大量 dui 的重複打包

要解決這些問題,也隻能規範寫法

  • package.json 中 dependencies 的依賴版本号應該帶上 ^(沒有已知的相容問題下),讓其能夠接受的版本比較寬泛,提高 npm dedupe 的可能性
  • 依賴不要重複寫在 peerDependencies 和 dependencies 中,dependencies 加上 dedupe 機制能夠解決大部分重複打包的問題
  • 像 dui 這種包可以使用 "@tencent/dui": ">=0.108.0 <1.0.0" 這種版本寫法,強制 dedupe 生效
  • 如果你明确的知道需要寫 peerDependencies 依賴,也請不要寫具體的版本号,可以使用類似 1.x 這種寫法,最大程度的複用外部的依賴。除非你的包确實隻能在 "react": ">=16.14.0" 這個版本之上運作。不然在能夠自動下載下傳 peerDependencies的包管理器中就會被下載下傳多版本的依賴,導緻重複打包
npm7 以上的版本會自動安裝 peerDependencies,而 pnpm 在截止 7.14.0 的 latest 版本中都不會自動安裝,可以通過配置開啟

common js 格式導緻依賴 tree shaking 失效

ppt 增大的體積中,不少都不是 TB 的直接依賴,通過分析發現其實是其依賴 workbench 的子依賴,為什麼子依賴的體積會增多,通過經驗分析應該是由于 TB 産出的是 cjs 産物,而 trees haking 隻有在 esm 格式下才能生效,導緻全量引入了 workbench

esm 是“EcmaScript module”的縮寫,cjs 是“CommonJS module”的縮寫,各個子產品之前的定義和詳細差別可以看 各子產品類型詳解,這裡不贅述了。簡而言之,早期各個端使用不同的模規範,而CJS 是node原有使用的規範。ESM 是為了統一各端子產品标準而推出的新标準,但是在各端的支援度不太一樣。現代浏覽器中基本可以無腦使用,不過為了相容老舊的浏覽器webpack 等建構工具一般都會将其轉化為 CJS 再供浏覽器使用,而 vite 在開發模式下就是原生輸出 ESM 檔案給浏覽器使用。最重要的是 tree shaking 隻有才 ESM 中才會生效。而node環境下的 ESM 支援 仍然有一些問題,是以為了通用性考慮,目前仍然建議大家庫産出 CJS 産物。 – – 來自「應該如何打包一個 ts 庫」

由于涉及到 less檔案的處理,是以選擇使用了 rollup 來打包 esm 産物,支援同時産出 esm 與 cjs 格式的産物(因為 bundless 的打包方式不太好處理 less檔案)。同時将 package.json 中 main 字段指向 cjs 産物,module 字段指向 esm 産物。如果是沒有樣式檔案的包,更加推薦使用 bundless 的建構工具(tsc,father),進行 file to file 的建構,保持目錄結構,同時在 package.json 中配置上 sideEffects,提供最佳的 tree shaking 效果。

再來看下産物分析結果,猜測果然正确,與workbench相關的依賴全部都去除掉了~

npm 如何才能有效減包?

處理一些巨石依賴

通過前面 external 的章節我們可以得知,external 并不是銀彈,并不是把依賴 external 之後就萬事大吉了,應該有一些還是會在最終建構中被打入産物中,是以一些非常典型的巨石依賴同樣也需要處理。

  • lodash 很容易被全量引入 ,可以切換成使用 lodash-es,esm 版本的lodash,可以完美的 tree-shaking 掉沒有使用到的函數,如果存量的代碼太多難以修改的話,也可以通過配置 babel-plugin-lodash 來實作「按需引用」,并不推薦使用 lodash.get 這種子包,原因 官方有提到,且在下個大版本中将會被删除
  • dui 系列包并沒有 esm 格式的産物,無法被 tree shaking,是以同樣推薦使用 babel-plugin-import 插件來實作「按需引用」
{
    plugins: [
        ['import', {
            libraryName: '@tencent/dui',
            libraryDirectory: 'lib/components',
            camel2DashComponentName: false,
        }, 'dui'],
        ['import', {
            libraryName: '@tencent/dui-mobile',
            libraryDirectory: 'lib/components',
            camel2DashComponentName: false,
        }, 'dui-mobile']
    ]
}           
  • moment 這個庫很大,且沒有提供很好的維護,導緻沒法減包,可以切換成 dayjs 來降低體積,其體積降低了98%以上,且api完全相容
npm 如何才能有效減包?

Dynamic import

通過上文的一些手段,将絕大部分的外部依賴都去除掉了(這個圖并不是完全按照文章順序來優化的,所有還有一些 dui 的問題

npm 如何才能有效減包?

剩下的大頭主要是 @tencent/docs-scenario-components-certification `@tencent/docs-scenario-components-folder-selector 與 @tencent/docs-scenario-components-pc-header-badge 這幾個内部依賴包,是以對他們進行了上述的類似改造,優化了一波體積,但是還是有 100 多kb體積,因為這兩個包全局确實隻有一份,沒辦法 dedupe(去重)。

不過這幾個包之前也有用到,按道理來說不是新增的才對,後來詢問得知,ppt 的産物體積對比隻是分析了同步js,而這幾個依賴在以前是通過 Dynamic imports 導入的,是以沒有記錄在體積資料中。而 TB在之前接入過程中發現使用 Dynamic imports 的時候(webpack4 cjs),會導緻 ppt 建構報錯(wenpack4),一時沒有排查到原因,是以隻能同步的引用這幾個包。 知道原因了就很簡單,因為現在已經切換成了 rollup 建構的 esm 産物,理論上是完美支援 Dynamic imports 的,果然很順利的切換成了 Dynamic imports,可以看到基本上隻留下了一些零碎的依賴。

npm 如何才能有效減包?

css 檔案應該單獨打包成檔案嗎

我的結論是應該獨立成css檔案,讓外部單獨引用,理由如下:

  • 降低 js 大小,同時使得外部可以通過工具去除未使用的 css,壓縮效果也更好
  • 使得 app 可以通過 head style 提前加載 css,避免樣式跳動
  • 降低 js 運作的耗時

還有一些建構時容易出現的體積問題

  • ts 會使用一些「相容性文法」來把你的進階文法轉變成低級文法,預設是會直接寫入到代碼中,開啟 importHelpers 這個選項之後可以變成從 tslib 中導入,避免「相容性文法」被重複打包多次(檔案級),tslib 同樣可以被 dedupe 降低重複打包次數
  • 作為 npm 包,可以不需要将 corejs 打包進産物中,不過這一塊相當的複雜,本文也不詳細探讨了,作為參考,可以看看 antd 的建構配置,謹慎調整。
// 簡化後的相容性相關配置如下
{
  presets: [
    [
      '@babel/preset-env',
      {
        modules: false,
        loose: true,
        targets: ['last 2 versions', 'Firefox ESR', '> 1%', 'ie >= 11'],
      },
    ],
  ],
  plugins: [
    [
      '@babel/plugin-transform-runtime',
      {
        version: `^${require('@babel/runtime/package.json').version}`,
      },
    ],
  ],
};           

如何防止倉庫持續腐壞

在這次減包過程中,處理了非常多的問題,但是這個倉庫參與開發人數比較多,沒辦法向每一個開發同學來宣導這些注意事項,如果沒有辦法告訴大家要怎麼做,沒幾個星期就會被打回原形,是以需要配合工具來避免倉庫持續腐壞掉:(其中的 eslint 檢查都是增量檢查,僅避免問題增多)

  1. 通過 @nrwl/eslint-plugin-nx 這個 eslint 包來禁止包之間直接直接通過相對路徑進行引用
  2. 通過 eslint-plugin-import 這個包,禁止使用沒有在 package.json 中聲明的依賴
  3. pre-commit 階段使用 lint-stage 進行檢查
  4. 在倉庫的不規範用法收斂性之後,通過配置 pnpm,直接禁止子包使用根目錄的依賴,這時候可以直接把根目錄下的依賴相關字段删除,且添加檢查不允許新增
  5. 新增一系列規範性的檢查,在建構階段就可以進行提醒,在mr階段進行阻斷性檢查,阻止一些壞寫法被合入主幹

總結

經過上文我們可以發現,關于減包,我們應該站在品類的視角來看待。隻關注 npm 包的大小是不夠的,因為此時這個 js 的體積是不完整的(還保留着對依賴的引用關系),最終應該以品類中的「打包大小」來衡量才是更加真實的資料,不然就會造成TB這種看起來隻有幾十kb,最終導緻品類增大了幾百kb的“事故”。經過一系列的優化,最終将接入統一頂部欄對于 ppt 品類的體積影響降低到了幾乎為 0

npm 如何才能有效減包?

回顧一下減包要注意的一些地方

  • 産出 esm 産物,使品類可以 tree shaking,避免依賴被全量引入
    • 使用了樣式檔案
      • rollup 産出 esm && cjs 産物,因為 bundless 的打包方式不能很好的處理 less檔案
    • 純 ts 包
      • bundless 打包,同時配置上 sideEffects,提供最佳的 tree shaking 效果
  • external 所有依賴,是 dedupe 機制生效避免重複打包的必要條件
    • 注意依賴版本的寫法,沒有相容性問題存在的情況下,可以使用版本要求比較寬松的 ^ 寫法
    • 帶路徑使用的依賴需要單獨使用正則寫 external 語句
    • 不要混用 dep 和 peerdep,不要濫用 peerdep,合理配置的 dep 就可以實作減包,peerdep 隻會讓依賴安裝更加的複雜
    • 确定要用 peerdep 的時候,也不要寫死版本,請用1.x這種寫法
  • lodash / dui 這種用到的内容占總體積比較小的依賴,優先使用其 esm 格式的産物,利用好 tree shaking 去除沒有用到的代碼,沒有 esm 産物的配合 babel 插件實作按需引用
  • 改不了的頑固分子就換,典型的就是 moment->dayjs
  • css 檔案總是應該單獨打包,優點上文提到過了,這也是 vite 預設的打包行為
  • 開啟 tsconfig 的 importHelpers 選項,可以不要打包 corejs

全文終,感謝閱讀,有任何錯誤歡迎指出交流~