你構建的代碼為什麼這麼大

語言: CN / TW / HK

本文作者:文西

前言

代碼體積的控制對前端來説至關重要,儘管網絡條件逐漸變好,但是代碼體積的增加不僅僅只影響資源加載速度,還會直接或間接影響瀏覽器各類性能指標。

例如增加用户內存使用消耗,內存的增加又會更頻繁的觸發 V8 引擎的 GC 機制,進而影響頁面交互性能。

本文從一個典型的 Webpack+Babel 工程出發,找到構建產物體積變大的常見原因和對應的解決思路,減少項目代碼構建後的體積

Babel

babel 最常見的用途就是代碼降級,使構建後的代碼能夠被低版本瀏覽器兼容,按照功能可以劃分兩部分

  1. API 降級
  2. 語法降級

通過 Babel 構建後的代碼為了適配低版本瀏覽器通常會比源代碼大上幾倍,這裏面除了源代碼外還包含 API 墊片和語法輔助函數,分別對應上訴的 API 降級和語法降級,我們看下如何減少這部分的代碼體積

core-js

💡 按照目前最新版本的 babel@7,@babel/polyfill 已經廢棄,我們使用 core-js 完成 API 的語法降級

core-js 可以為瀏覽器中可能不兼容的 API 提供墊片,例如 Promise,Map

```js import "core-js/modules/es.promise.js";

// 使用降級 API const promise = Promise.resolve(); ```

在需要降級的 API 調用前 require 對應的 core-js 模塊,就可以以污染全局變量或者原型鏈的方式實現 API 降級

手動插入 core-js 即麻煩又不安全,所以我們可以使用@babel/preset-env幫助我們自動插入 core-js 模塊

@babel/preset-env根據項目中 browserlist 定義的用户環境,選擇性插入墊片代碼,減少墊片代碼體積

在配置@babel/preset-env 時,useBuiltIns 屬性非常重要,有兩個值"entry"|"usage",分別為全量降級和按需降級

entry 全量降級

entry 非常直接,首先我們需要手動在代碼的第一行import 'core-js',在執行編譯時,會按照 browserlist 中定義的環境,把可能需要降級的 API 一次性插入並替換到 core-js 聲明的位置

開發者不再需要手動插入墊片,但這有個問題,即沒有使用的 API 仍然會被打進 bundle 中,由於 ECMAScript 標準的不斷髮展,core-js 在 g-zip 壓縮後也有 50kb 左右的體積,顯然還是太大了

usage 按需降級

當選擇 usage 時,babel 會掃描所有需要編譯的 JS 代碼,根據實際使用到的 API 選擇性插入所需墊片

看起來是相比 entry 的更優解,但實際過於理想

  1. 通常基於編譯速度的考慮,node_modules 下的模塊不會參與 Babel 編譯,僅參與 Webpack 打包,如果此時恰巧某個依賴包裏沒有聲明所需的墊片,那麼就可能出現墊片缺失,最終導致線上環境 JS 運行異常。

實際上這種情況在混亂的 npm 生態中非常普遍,有不少 npm 包直接使用 tsc 打包,除非開發者手動介入,否則構建產物中就會缺少 API 墊片,遇到這種情況往往只能在線上發現異常後手動添加依賴到babel.include中進行編譯

  1. 並不是所有 JS 代碼都會參與編譯,例如通過一些平台動態下發的腳本,這些平台動態下發的代碼完全不經過編譯,如果使用了未經降級的 api 也可能會出現 JS 運行異常。

可以看到 entry,usage 都是存在問題的,所以也就有了平台化的方案,polyfill.io

如果使用最新的現代化瀏覽器訪問該服務,那麼返回的 JS 內容則是空的,反之它會響應瀏覽器所需的降級 API,既控制了包體積,也能確保未經編譯的 JS 獲得降級 API。

Untitled

出於安全考慮,我們需要自部署服務,目前 polyfill.io 的 node.js 代碼是完全開源的,支持自部署,但是實際落地還需要考慮緩存和異常兜底

@babel/runtime

core-js 是為了解決 API 降級問題存在的,但是我們還有語法降級需要解決,例如 class,async

默認情況下 babel 為了實現 class 功能會生成一些內聯輔助函數,例如下圖的 createClass。這會產生一個問題,就是當多個模塊都使用 class 語法時則會生成多個相同的輔助函數,輔助函數不能複用

Untitled

我們可以通過註冊 babel 插件@babel/plugin-transform-runtime,將硬編碼輔助函數的方式改為從@babel/runtime引入輔助函數,實現不同模塊間輔助函數的複用

Untitled

從下圖可以看到 createClass 函數從硬編碼改為require("@babel/runtime/helpers/createClass"),代碼大幅縮小

Untitled

但是@babel/plugin-transform-runtime的方案也不是毫無問題,和 api 降級一樣,同樣面臨各種依賴包構建不標準帶來的困擾

最大的問題就是沒有辦法保證依賴包的產物一定使用了@babel/plugin-transform-runtime進行構建,語法降級使用了內聯的輔助函數,又或者使用了老版本的babel-runtime·,導致項目最終的構建產物對輔助函數進行了多次打包

以相對常見的依賴包構建工具 father-build 和 tsc 為例,他們都沒有將語法輔助函數通過@babel/runtime依賴包進行提取,而是都以硬編碼的形式存在每個 JS 模塊當中。

這類由社區維護的 npm 包我們不好處理,但是可以通過收斂公司內部構建工具的方式,統一處理公司內部維護的依賴包,使它們構建的產物符合應用打包的需求,我們在文章結尾處再説

Tree-shaking

tree-shaking 是減少構建產物體積最有效的方式,以常用 lodash 為例,g-zip 後的體積 24kb,但是項目中使用到的函數並不多,如果能夠為它啟用 tree-shaking,代碼體積能控制在 1kb 以內

如何為依賴代碼啟用 tree-shaking?

  1. package.json 聲明 module 字段,地址指向 ESM 規範的構建產物
  2. package.json 聲明sideEffects:false,告訴 Webpack 整個依賴包沒有存在副作用,或者指明存在副作用模塊的地址

ESM

ESM 相比 commonjs 具備靜態分析能力, 這是 tree-shaking 的前置依賴條件,所以我們需要 babel 構建我們的源代碼時保留 import 語法,不要編譯成 commonjs

jsx { "presets": [ [ "@babel/preset-env", { "modules": false // 保留ESM語法 } ] ] }

sideEffects

為什麼依賴包的 package.json 需要聲明 sideEffects?

這裏需要引申出自函數式編程中的純函數副作用函數概念,如果我們的代碼沒有存在任何副作用,tree-shaking 確實可以不需要類似 sideEffects 的副作用聲明,但實際上副作用普遍存在我們的代碼中,如果只依據函數是否被引用過作為 DCE(Dead Code Elimination) 的條件,很容易影響程序運行的正確性

通過 css-loader 引入 css 文件是很典型的例子

jsx import "./button.css";

對於 webpack 來説 button.css 同樣是一個模塊,這裏沒有引用任何的具名函數,但是引入 css 模塊是會為我們帶來一個副作用,它會為 html 插入一個 style 標籤。如果 webpack 認為他是沒有副作用的,那麼在 minify 階段 webpack 會刪除這行代碼,最終導致樣式錯亂

為了告訴 webpack 這個 css 文件是存在副作用的,不能刪除,sideEffects 就可以怎麼寫

json { "sideEffects": ["*.css", "*.less"] }

公司內部維護的依賴相比開源社區,很容易忽略sideEffects的聲明,如果存在公司內部的依賴構建工具,可以將sideEffects添加到相關的模板代碼中,默認為依賴包開啟 tree-shaking

回到社區現狀我們再來看 tree-shaking,lodash 推出了支持 tree-shaking 的lodash-es,antd@4 也不再需要安裝babel-plugin-import插件,可以通過 tree-shaking 的方式原生支持代碼按需加載,從而大幅縮小構建體積

Duplicate dependencies 重複依賴

依賴重複打包是前端開發中的常見問題,容易出現在公司內部長期無人維護的依賴包中

當我們的項目中存在 Root→C→[email protected],Root→B→[email protected]類似的依賴關係時,node_module 結構如下

```jsx node_modules -- C <-- depends on [email protected] -- [email protected] -- B <-- depends on [email protected] -- node_modules -- [email protected]

```

可以看到在 node_modules 下嵌套安裝了 2 個版本的依賴 D,即[email protected][email protected]。這可能導致在構建的產物中也同樣存在兩份相同依賴不同版本的代碼,除了會影響代碼體積,還可能導致代碼運行異常

解決方式是升級 B 的依賴[email protected][email protected],此時重新安裝後node_modules的嵌套結構會恢復扁平

```jsx node_modules -- C <-- depends on [email protected] -- [email protected] -- B <-- depends on [email protected]

```

我們可以使用find-duplicate-dependencieswebpack-bundle-analyzer這些工具輔助我們排查依賴重複打包的問題

最佳實踐

回顧文章我們對一個典型前端應用可能影響 Bundle 體積的因素進行了分析,同時提出對應的解決方案。在文章的結尾我們可以更進一步通過工程化和平台化的手段,以相對一勞永逸的方式解決上訴問題

如下圖,@company/app-builder負責構建應用,@company/module-builder負責構建依賴包,然後通過使用封裝的 babel 配置@company/babel-base,統一處理 JS 編譯

Untitled

babel-base關閉 core-js 的 api 降級,由 app-builder 開啟平台 polyfill.io 方案,同時babel-base開啟@babel/plugin-transform-runtime,為應用和依賴包啟用語法輔助函數抽離

module-builder關閉 ESM 語法的轉換,為app-builder做 tree-shaking 時提供必要前置條件

通過這種方式,我們就可以實現在構建過程中減少代碼體積的最佳實踐

至於重複依賴的問題,由於必定需要開發者介入做版本選擇,所以我們可以考慮在部署平台構建時自動上報 Dependency graph 數據,然後由性能分析等平台將重複依賴的問題郵件抄送給相關開發者進行優化

總結

本文從構建工具的角度,闡述瞭如何減少構建產物的體積。可以看到僅僅處理應用的構建是不夠的,為了實現最佳效果,我們還需要介入公司內部依賴包的構建,使依賴包的構建產物符合應用構建的需求。只有具備全場景的構建能力才能最大程度降低代碼的構建體積。

參考資料

  • https://docs.npmjs.com/cli/v8/commands/npm-dedupe
  • https://babeljs.io/docs/en/babel-plugin-transform-runtime
  • https://babeljs.io/docs/en/babel-preset-env
  • https://babeljs.io/docs/en/babel-polyfill
  • https://webpack.js.org/guides/tree-shaking/#root
  • https://cdn.polyfill.io/v3/

本文發佈自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!