決戰效能之巔 - Taro H5 轉換與優化升級

凹凸實驗室發表於2019-03-05

前言

作為一個多端開發框架,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外掛以檢視編譯分析。下圖是優化前的打包檔案分析結果:

before

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

after

優化前生成的程式碼總大小為 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 的基本思想。

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

// add.js
export default function adda, b{ return a + b; }

// add2.js
console.log('這是一個log')
export default function add2a, b{ return a + b; }

// index.js
import add from './add.js' // 沒有副作用,可以刪除
import add2 from './add2.js' // 有副作用,不能直接刪除

// EOF
複製程式碼

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

站在巨人的肩膀上

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

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

  1. 如果是 npm 模組,需要package.json中存在sideEffects欄位,並且準確配置了存在副作用的原始碼。
  2. 必須使用 ES6 模組語法。由於諸如babel-preset-env之類的 babel 預配置包預設會對程式碼的模組機制進行改寫,還需要將modules設定為false,將模組解析的工作直接交給 webpack。
  3. 需要工作在 webpack 的production模式下。

webpack 的 treeshaking 工作主要分為兩步。第一步是在模組級別移除未使用且無副作用的模組,這一步由 webpack 的內建外掛完成;第二步是在檔案級別移除未使用的程式碼,這一步由程式碼壓縮工具 Terser 完成的。

移除未使用的模組

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

webpack 文件 中有提到,這一舉動正是為了讓 webpack 正確地識別到沒有副作用的程式碼模組。

在 webpack 中,模組依賴分析是由內建外掛 SideEffectsFlagPlugin 進行的。

image-20190225220418363

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

ModuleConcatenationPlugin 中,帶著sideEffectFree標記的模組將不會被打包:

image-20190222111301698

來到這裡,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 模組。

image-20190225154632128

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

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

image-20190225160508956

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

image-20190225162018328

同時,我們也在sideEffects屬性中對樣式檔案做了標記,幫助 webpack 對樣式程式碼的副作用進行識別,在專案編譯的程式碼中保留樣式程式碼。

Api 的 ES 模組化

同樣,以前在釋出 @tarojs/taro-h5 之前,Taro 也需要執行命令 yarn build,使用 Rollup 對原始碼進行打包,輸出為dist/index.js檔案:

image-20190225162654885

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

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

image-20190225165957461

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

image-20190225172609369

那麼問題來了。在 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 列表:

image-20190225210238592

下面是編譯前後的程式碼對比。可以看到,在編譯後setStoragegetStorage的呼叫都被替換為 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

相關文章