代碼體積減少80%!Taro H5轉換與優化升級

前言

作爲一個多端開發框架,Taro 從項目發起時就已經支持編譯到 H5 端。隨着 Taro 多端能力的不斷成熟,我們對 Taro H5 端應用的要求也不斷提升。我們已經不再滿足於“能跑”,更希望 Taro 能跑得快。

我們經常收到用戶反饋:爲什麼使用 Taro 腳手架創建的空項目,打包後代碼體積卻有 400KB+;也有用戶在 Issue 中提到,Taro 的部分 Api 佔用空間巨大,事實上功能卻並不完美,等等。作爲一個開源項目,我們非常重視社區開發者們的意見。所以在最新版本中,我們對 Taro H5 端的性能表現進行了優化。

作爲運行時的基礎,每一個 Taro 的 H5 端應用都需要引入 @tarojs/components 和 @tarojs/taro-h5 等基礎依賴包。在編譯、打包之後,這些依賴包大約會在首屏佔用 400KB 以上的空間。如果開發者還使用了 UI 庫,例如 Taro-UI,基礎體積還會更大,這嚴重限制了 Taro H5 端應用的性能優化空間。

事實上,我們在 H5 端應用中並不會使用到全部的 Taro 組件和 Api。將這些依賴包全部打包進應用是沒有必要,也是不合理的。進行死碼刪除(Dead code elimination),進一步縮減代碼體積,就是我們的優化方向之一。

效果

在介紹具體細節之前,我們先看一看優化的效果,因爲這可能會讓你更有興趣瞭解後面的內容。用一句話來說,效果非常顯著。

我們建立了一個空項目,在項目配置中加入了webpack-bundle-analyzer插件以查看編譯分析。下圖是優化前的打包文件分析結果:

而在優化後,對比非常明顯:

優化前生成的代碼總大小爲 455KB,而在優化後僅剩約 96KB,僅是原來的 1/5 左右。

你需要做什麼?

很簡單,作爲使用者,你不需要做任何代碼上的改動,只需要將 Taro 更新到最新版本即可。但在看不見的地方,Taro 卻默默地做了許多工作。下面會從原理出發,詳細介紹 Taro 的工作。

原理

死碼刪除(Dead code elimination)是一種代碼優化技術,可以刪除對應用程序執行結果沒有影響的代碼。Web Fundamentals 的一篇文章有提到,treeshaking 是由 Rollup 提出的一種死碼刪除的形式。

Tree shaking is a form of dead code elimination. The term was popularized by Rollup, but the concept of dead code elimination has existed for some time.

– Reduce JavaScript Payloads with Tree Shaking, Jeremy Wagner

通過在構建時進行靜態分析,編譯工具可以分析出我們代碼中真正的依賴關係。treeshaking 把我們的代碼想象成一棵樹,代碼的每個依賴項看作樹上的節點。將未使用過的依賴項從構建結果中移除,這就是 treeshaking 的基本思想。

那麼,假設我們手頭有一段代碼,我們要怎樣辨別其中可以刪除的部分呢?答案是,通過分析副作用:

// utils.js
module.exports.add = function (a, b) { return a + b };
module.exports.minus = function (a, b) { return a - b };
// index.js;
var utils = require('./utils.js');

utils.add(1, 2);

副作用這個名詞對於瞭解函數式編程的同學肯定不陌生。修改外部狀態,或者是產生輸出等等,都是副作用;而存在副作用的代碼,是不能被直接移除的。類似上面這個代碼示意,add2 模塊就是存在副作用的。

站在巨人的肩膀上

除了 Rollup 之外,支持 treeshaking 的工具/插件還有很多,比如 babel-plugin-transform-dead-code-elimination、uglify、terser等。 webpack 在 v2 之後就內置了對 treeshaking 的支持,並在 webpack@4 中對 treeshaking 功能進行了擴展。

Taro H5 端在構建過程中,使用 webpack 作爲構建的核心。在 webpack 中使用 treeshaking 功能有幾個需要注意的地方:

  • 如果是npm模塊,需要package.json中存在sideEffects字段,並且準確配置了存在副作用的源代碼。

  • 必須使用 ES6 模塊語法。由於諸如babel-preset-env之類的 babel 預配置包默認會對代碼的模塊機制進行改寫,還需要將modules設置爲false,將模塊解析的工作直接交給 webpack。

  • 需要工作在 webpack 的production模式下。

webpack 的 treeshaking 工作主要分爲兩步。第一步是在模塊級別移除未使用且無副作用的模塊,這一步由 webpack 的內置插件完成;第二步是在文件級別移除未使用的代碼,這一步由代碼壓縮工具 Terser 完成的。

移除未使用的模塊

前面我們提到,需要在package.json中配置sideEffects字段。

webpack 文檔中有提到,這一舉動正是爲了讓 webpack 正確地識別到沒有副作用的代碼模塊。

在 webpack 中,模塊依賴分析是由內置插件 SideEffectsFlagPlugin 進行的。

經過 SideEffectsFlagPlugin處理後,沒有使用過並且沒有副作用的模塊都會被打上sideEffectFree標記。

在ModuleConcatenationPlugin 中,帶着sideEffectFree標記的模塊將不會被打包:

來到這裏,webpack 完成了在模塊級別對未使用模塊的排除。接下來,依靠 Terser,webpack 可以在文件級別,對未使用、無副作用的代碼進行移除。

移除未使用的代碼

在 CommonJS 規範中,我們通過require函數來引入模塊,通過module.exports進行導出。這意味着我們可以在代碼中的任何地方用任何方式引入和導出模塊:可以是在某個需要等待用戶輸入的回調函數中,或者是在符合某個條件才進行引入等等。

所以在使用 ES6 的模塊系統之前,對 Javascript 做編譯時的依賴關係分析是近乎不可能的(並不是完全不可能。prepack 通過實現一個 JS 解釋器,甚至可以在編譯時提前進行靜態計算)。

// utils.js
module.exports.add = function (a, b) { return a + b };
module.exports.minus = function (a, b) { return a - b };

// index.js;
var utils = require('./utils.js');

utils.add(1, 2);

像上面這段代碼,雖然我們最終只使用了add函數,但minus函數也會在最終的打包代碼中出現,因爲在編譯時無法快速得知是否使用了minus函數。

在 ES6 的模塊系統中,我們使用import/export語法來進行模塊的引入和導出。與 CommonJS 規範不同的是,這套新的模塊系統存在一些限制:import/export行爲只能在代碼的頂層、默認使用嚴格模式等等。這些限制使代碼模塊的導入與導出變得靜態化,模塊間的依賴關係在開發時已經確定,編譯器也更容易解析我們的代碼。

// utils.js
export function add (a, b) { return a + b };
export function minus (a, b) { return a - b };

// index.js;
import { add } from './utils.js';
add(1, 2);

在使用 ES6 模塊系統改造後,我們可以清楚地看到,minus函數確實沒有被使用過,所以可以安全地將其從最終打包代碼中移除。

當然,具體的分析過程非常複雜。變量提升、object 取值操作、for(var i in list) 語句、自執行函數、函數傳參(onClick(function a () {…}))等等,都有可能導致意料之外的情況,從而導致 treeshaking 失效。如果想了解 Terser 的具體處理過程,百度/Google 會是最好的老師。

Taro 做了什麼

Taro 需要對依賴包做一些修改。

組件的 ES 模塊化

在進行組件庫的 ES 模塊化改造之前,如果要發佈 @tarojs/components 包,Taro 會執行命令 yarn build,使用 webpack 對源代碼進行打包,輸出爲dist/index.js文件。由於 webpack 並不支持輸出爲 ES 模塊,所以這是個 UMD 模塊。

這個文件佔據了 462KB 的空間,並且由於模塊規範等問題,無法進行 treeshaking。所以就算開發者在 Taro 的項目中只引入了兩個組件,最終的打包結果也會包含所有的內置組件。

事實上,@tarojs/components 的源碼本身是使用 ESM 規範的:

所以只要讓 webpack 直接解析組件庫的源碼,我們立即就可以享受到 webpack 自帶 treeshaking 帶來的好處了。

同時,我們也在sideEffects屬性中對樣式文件做了標記,幫助 webpack 對樣式代碼的副作用進行識別,在項目編譯的代碼中保留樣式代碼。

Api 的 ES 模塊化

同樣,以前在發佈 @tarojs/taro-h5 之前,Taro 也需要執行命令 yarn build,使用 Rollup 對源代碼進行打包,輸出爲dist/index.js文件:

這個文件佔據了 262KB 的空間。同樣,只要是個 Taro 的 H5 端應用,生成的代碼都會全量引入這個文件。

我們對 @tarojs/taro-h5 進行模塊化改造的思路與 @tarojs/components 相同。我們希望 @tarojs/taro-h5 模塊本身遵守 ESM 模塊規範,那就只需要標記一下sideEffects,再修改一下模塊入口就好。

粗略一看,@tarojs/taro-h5 還挺 “ESM” 的,但這還不夠。我們還需要將這些 Api 以 namedExports 的形式導出,開發者使用import { XXX } from '@tarojs/taro-h5’導入 Api 即可。

那麼問題來了。在 Taro 項目中,我們一直使用的是 defaultImport,並不會使用 Api 的 namedExports 形式:

import Taro from '@tarojs/taro-h5'
Taro.navigateTo()
Taro.getSystemInfo()
// Taro.xxx ...

只要 Api 是通過對Taro變量取屬性獲取,Taro變量就需要具備所有的 Api,treeshaking 也就無從談起。

有沒有辦法把 defaultImport 修改爲 namedImports 呢?答案是肯定的。我們寫了一個 babel 插件 babel-plugin-transform-taroapi,將指定的 Api 調用替換爲 namedImports,未指定的變量則保留屬性取值的形式。具體源碼可以在這裏查看。

// const apis = new Set(['navigateTo', 'navigateBack', ...])
{
  babel: {
    preset: ['babel-preset-env'],
    plugins: [
      // ...,
      ['babel-plugin-transform-taroapi', {
        packageName: '@tarojs/taro-h5',
        apis
      }]
    ]
  }
}

這個插件接受一個對象作爲配置參數:packageName屬性指定需要進行替換的模塊名,apis接受一個 Set 對象,也就是所有 Api 的列表。

爲了避免後期手動維護 Api 列表的情況,我們給 @tarojs/taro-h5 模塊加了一個編譯任務,通過一個簡單的Rollup 插件,在執行yarn build命令時生成一份 Api 列表:

下面是編譯前後的代碼對比。可以看到,在編譯後setStorage、getStorage的調用都被替換爲 namedImports。

// 編譯前
import Taro from '@tarojs/taro-h5';
Taro.initPxTransform({});
Taro.setStorage()
Taro'getStorage'
// 編譯後
import Taro, { setStorage as _setStorage, getStorage as _getStorage } from '@tarojs/taro-h5';
Taro.initPxTransform({});
_setStorage();
_getStorage();

到這裏,雖然過程比較艱辛,但我們對 @tarojs/taro-h5 的模塊化改造終於完成了。

最後

截至目前,Taro 在 H5 端的完成度已經很高,但是並不完美。未來,在對已有問題進行修復的同時,我們還將繼續爲 Taro H5 端帶來更多新的特性,比如對社區中呼聲相當高的switchTab、頁面滾動監聽onPageScroll、下拉刷新onPullDownRefresh等 Api 的支持,更加統一的頁面切換動畫,以及更加穩定的多頁面模式等等。

Taro 發展到現在,離不開社區的支持。非常感謝在 github、微信羣中踊躍反饋的開發者們。如果你對Taro有什麼想法或建議,Taro 非常歡迎你來吐槽或觀光:

https://github.com/NervJS/taro

更多內容,請關注前端之巔。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章