你構建的程式碼為什麼這麼大

雲音樂技術團隊發表於2022-12-23
本文作者:文西

前言

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

例如增加使用者記憶體使用消耗,記憶體的增加又會更頻繁的觸發 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

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中進行編譯

  2. 並不是所有 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

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

sideEffects

為什麼依賴包的 package.json 需要宣告 sideEffects?

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

透過 css-loader 引入 css 檔案是很典型的例子

import "./button.css";

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

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

{
  "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→D@2.0.0,Root→B→D@3.0.0類似的依賴關係時,node_module 結構如下

node_modules
  -- C <-- depends on D@2.0.0
  -- D@2.0.0
  -- B <-- depends on D@3.0.0
    -- node_modules
      -- D@3.0.0

可以看到在 node_modules 下巢狀安裝了 2 個版本的依賴 D,即D@2.0.0D@3.0.0。這可能導致在構建的產物中也同樣存在兩份相同依賴不同版本的程式碼,除了會影響程式碼體積,還可能導致程式碼執行異常

解決方式是升級 B 的依賴D@2.0.0→D@3.0.0,此時重新安裝後node_modules的巢狀結構會恢復扁平

node_modules
  -- C <-- depends on D@3.0.0
  -- D@3.0.0
  -- B <-- depends on D@3.0.0

我們可以使用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 資料,然後由效能分析等平臺將重複依賴的問題郵件抄送給相關開發者進行最佳化

總結

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

參考資料

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

相關文章