【Webpack】507- 基於Tree-shaking的多平台Web代碼打包實踐

  • 2020 年 2 月 26 日
  • 筆記

在業務中,我們常常會遇到一個場景:同一套web業務代碼要在多平台下執行其對應的不同職能。這樣很容易出現兩個問題:代碼里「屍橫遍野」的環境判斷和分支,提高了代碼維護難度;執行環境下載了其他環境的功能代碼,造成了資源的浪費。只要我們合理使用Webpack的Tree-shaking功能,就可以很好地解決問題。

一、需求背景

不以解決實際問題為目標的技術實踐都是耍流氓 —— shijisun

需求

出現一套Web代碼在多個平台下執行需要實現不同功能的問題,功能包括但不限於:數據加載、展示樣式、用戶交互等。

例如,騰訊課堂H5課程詳情頁需要承載起H5AppPadApp小程序等多平台的頁面功能,以該頁面在H5App兩個環境下的對比為例:

對比 項

H5

App

數據加載

CGI數據

首屏從App加載數據,並行加載CGI數據,競速關係

功能組件

全量展示

不展示播放器、目錄、推薦課程等模塊,同時老師模塊展示樣式/用戶交互不一樣

版本判斷

不需要版本判斷

依賴App版本開啟分銷、砍價、拼團、打卡等功能

用戶反饋

功能完全由H5實現

切換班級、支付保障浮層展示、查看課詳圖片、跳轉打卡小程序等功能需要依賴Native原生組件

存在問題

1、代碼里「屍橫遍野」的環境判斷和分支,提高了代碼維護難度;

2、執行環境下載了其他環境的功能代碼,造成了資源的浪費;

問題到底有多嚴峻呢?請看下面實際生產環境代碼的截圖?

二、技術方案

靈感,是由於頑強的勞動而獲得的獎賞。—— 列賓

以其中的一個組件為例(如下代碼),只要是在移動端需要適配多平台,那類似這樣 isApp() 的運行時環境判斷代碼一定不會少見(無論你是通過App/小程序內嵌H5頁面、React-Native-Web三端同構、kbone同構小程序/H5等)。

export class SaleLabel extends React.Component</* … */> {    // ...    componentDidMount() {      if (isApp()){        // APP 需要判斷版本是否支持分銷,如果支持分銷才開始初始化流程      } else {        // H5 直接獲初始化分銷數據      }    }    // ...    handleClick = () => {      if (isApp()){        // APP 內直接喚起原生浮層,不需要額外判斷      } else {        // H5 執行檢查流程        // 1. 檢查登錄信息 2. 獲取分銷token 3. 展示分銷浮層      }      // ...    }    // ...  }

這樣的代碼一方面容易在多次迭代中慢慢淪為垃圾代碼(當然這個可以通過更合理的目錄和代碼重構解決);另一方面在不同的平台也加載了多餘的代碼邏輯,例如App相關的邏輯代碼在H5上完全不會執行,但是還是被加載了。

一套web代碼想要在多個平台實現不同功能,無論你使用 條件分支、還是 繼承派生 等方法,一個頁面一份代碼打天下的實踐已經無法滿足我們的需求了。細究這麼多種多平台同構的方案,其基本原理都是一份統一API的代碼,通過編譯打包引用不同的平台底層組件,最後打包成多份可執行程序的過程。

那麼純Web的場景是否可以有類似的實踐呢?

重新回頭看上文的 isApp 判斷邏輯,如果我們把運行時環境判斷提前到編譯時環境判斷,根據邏輯判斷的結果,通過 Tree-shaking 優化去除多餘的代碼,那麼就能得到指定運行平台的可執行代碼了!

export class SaleLabel extends React.Component</* … */> {    // ...    componentDidMount() {      if (true){ // 編譯時環境變量注入,利用Tree-shaking去除多餘代碼        // APP...      } else {        // H5...      }    }    // ...    handleClick = () => {      if (true){ // 編譯時環境變量注入,利用Tree-shaking去除多餘代碼        // APP...      } else {        // H5...      }      // ...    }    // ...  }

✨✨整體方案如下✨✨:

  • 1、使用環境變量注入的方法在打包階段將編譯時環境變量注入到執行代碼里,在通過自動化 Tree-shaking 將多餘的代碼自動去除;
  • 2、通過多輪編譯過程的形式對需要區分多執行環境的頁面執行多次打包,每次固定打出一個執行環境下的代碼;
  • 3、通過Nginx以及直出路由控制返回給用戶端的代碼;

三、實現落地

老夫寫代碼就是一把梭 —— 匿名程序員

3.1 環境注入

DefinePlugin允許創建一個在編譯時可以配置的全局常量。

通過 webpack.DefinePlugin 注入編譯時環境變量,後續我們的執行代碼里就可以引用這個環境變量進行當前平台的判斷了。

new webpack.DefinePlugin({      // RUNTIME_ENV_EXPECT: JSON.stringify('H5'),        // ...      RUNTIME_ENV_EXPECT: JSON.stringify('APP'),  });

當然如果你的項目用到了TypeScript,那你還需要全局聲明 TS 類型。

/**   * 運行時環境變量(由構建工具打包時注入)   */  declare const RUNTIME_ENV_EXPECT: string;

3.2 代碼重構(容器組件/功能組件)

需要對目前項目代碼進行代碼重構,重構範圍包括:

  • 分離容器組件和功能組件,通常容器組件以組合的形式實現,功能組件以繼承的方式實現;
  • 容器組件,以組合的形式實現,控制同層級組件的引用;
  • 功能組件,以繼承的方式實現,通常你需要一個基礎父組件和多個平台下的子組件;
  • 更新環境判斷邏輯,需要把運行時環境判斷修改為編譯時環境判斷,同時這也是一個梳理的過程,你可以了當前你的代碼需要支撐多少平台;

每一個組件的範式目錄樹:

...  Component(組件目錄,例如Container、Bottom等)      ├── app.tsx (app實現邏輯)      ├── app.tsx (H5實現邏輯或者抽象的統一邏輯)      ├── index.tsx (環境判斷 & 路由入口)      ├── ipad.tsx (ipad實現邏輯)      └── type.tsx (組件相關類型封裝)  ...  以上的目錄組成由你的組件所需要支撐的運行平台而定。

3.3 開啟 Tree-shaking 功能

本文章已 Webpack4 的角度進行闡述,其他版本或者構建工具可以進行參考。

3.3.1 更新構建配置

開啟Tree-shaking,具體查看 本文檔。

需要注意的是 Tree-shaking依賴 ES6模塊語法,如果你的項目使用的babel:

  • 設置 @babel/preset-env 相關配置;
  • 也不能引用類似 @babel/plugin-transform-modules-commonjs,這種會把模塊編譯成commonjs的插件;
// 以下的babel配置可以通過 babel-loader 動態設置或者複寫,基本原則不變  module.exports = {    presets: [      [        '@babel/preset-env',        {          modules: false, // 設置成false指定不轉換模塊類型          useBuiltIns: 'usage',          corejs: 2        }      ],      '@babel/preset-typescript',      '@babel/preset-react'    ],    plugins: [      // '@babel/plugin-transform-modules-commonjs', // 不能引用類似模塊,否則會破壞 Tree-shaking      '@babel/plugin-proposal-optional-chaining',      '@babel/plugin-transform-runtime',      '@babel/plugin-proposal-class-properties',      '@babel/proposal-object-rest-spread',      '@babel/plugin-syntax-dynamic-import'    ]  };

3.3.2 確認組件是否無副作用

聲明指定文件的副作用,可以通過 include 或者 exclude 指定文件範圍。

rules: [{      test: /.tsx?$/,      loader: ['ts-loader'],      include: [path.resolve(rootDir, 'src')],      sideEffects: false // 聲明無副作用  }]

注意:

  • 如果你是一個新的項目,可以通過聲明 package.jsonsideEffects 屬性;
  • 如果你是一個舊的項目,那麼推薦縮小副作用聲明的範圍,除非你有一定把握不會有問題;

3.3.3 構建調試

什麼樣的模塊會被Tree-shaking去除呢?通過官方教程,我們了解到:

  • dev 模式下模塊/方法被標識成 unused harmonyexportYourComponent...
  • dist 模式下該模塊/方法將自動去除;

以上圖的 CourseDetail 組件為例,當編譯時環境變量 RUNTIME_ENV_EXPECT 注入為 APP 時,相關條件判斷代碼將被置為 true,借而產生不可到達的分支,而這種條件分支和相關依賴都會被 Tree-shaking 自動去除,也就達到了去除非本環境依賴代碼的效果。

到底有什麼辦法可以確認哪些模塊會被移除呢?這部分內容我們放到後文講解。

3.4 應用多輪代碼編譯

3.4.1 靜態資源打包

通過上面的三個步驟,我們可以走通指定一個運行平台的代碼構建打包過程。接下來要做的事情就是將該過程重複多輪,每一輪注入特定的編譯時環境變量用來指定運行平台。

// webpack-dist.config.js || webpack.config.js  // ...  const RUNTIME_ENV = {    H5: 'H5',    APP: 'APP',    IPAD: 'IPAD',    MINI_PROGRAM: 'MINI_PROGRAM'  };  // ...  module.exports = Object.keys(RUNTIME_ENV).map((env) => {    // 靜態資源打包    return buildDistConfigForEnv(env);  });

其中 buildDistConfigForEnv 根據輸入的參數生成指定運行平台的構建配置,需要做以下幾件事情:

  • 注入編譯時環境變量,通過聲明 webpack.DefinePlugin 注入;
  • 選定打包入口(entry),如果你的項目不是所有頁面都需要按照平台進行打包,則需要根據平台指定打包入口;
  • 標識輸出文件名,例如同一個頁面的代碼最後可以打包成 page.h5.jspage.app.jspage.ipad.js等;
  • 其他需要根據平台設置的配置、插件列表等 … ;

3.4.2 直出代碼打包

直出代碼打包同理,需要根據編譯時環境變量打包出多個平台使用的模板代碼和組件。

// webpack-dist.config.js || webpack.config.js  // ...  const RUNTIME_ENV = {    H5: 'H5',    APP: 'APP',    IPAD: 'IPAD',    MINI_PROGRAM: 'MINI_PROGRAM'  };  // ...  // webpack-dist.config.js  module.exports = Object.entries(RUNTIME_ENV)    .map(([_, env]) => {      return [        // 靜態資源打包        buildDistConfigForEnv(env),        // 直出代碼打包        ...getSSRConfigs({ mode, output }, env),      ]    }).flat();

最後打包進直出 templates 的模板有多個,例如 騰訊課堂App內嵌課詳頁時是使用course.app.html。所以需要一個直出服務的路由邏輯,在訪問同一個URL時,自動根據請求帶的用戶環境信息選擇對應合適的模板文件(指向不同的靜態資源)進行渲染。

// 根據運行時環境獲取模板文件名  const runtimeEnv = getRuntimeEnv(ctx); // 這裡可以通過 url 結合 UA 進行判斷  let pageNameByEnv = `${pageName}.${runtimeEnv.toLowerCase()}`; // page.env  pageNameByEnv = templates[pageNameByEnv] ? pageNameByEnv : pageName;  // 獲取資源路徑  const reducerPath = path.resolve(rootPath, `reducers/${pageNameByEnv}.js`);  const componentPath = path.resolve(rootPath, `components/${pageNameByEnv}.js`);  const templatePath = path.resolve(rootPath, `templates/${pageNameByEnv}.html`);

四、優化和總結

Tree-shaking是個玄學的過程 — shijisun

4.1 構建性能優化

每個平台都需要進行靜態資源 + 直出資源的打包,總共累計 平台數*2 的編譯過程,這個過程是串行執行的,一旦打包平台增加不免需要等待更長的構建事件。

parallel-webpack allows you to run multiple webpack builds in parallel, spreading the work across your processors and thus helping to significantly speed up your build.

我們可以利用 parallel-webpack 同時啟動多個打包構建過程,例如:

parallel-webpack --config=webpack-dist.config.js

但是以前無往不利的構建配置似乎出現了異常,最後輸出的文件夾只有一個平台的打包代碼,這是為什麼呢?原因很簡單,在構建打包的各個階段我們使用了不同的插件,其中 CleanWebpackPluginEndWebpackPlugin 造成了破壞性結果,前者會在構建開始前清除構建輸出目錄,後者會在構建結束階段允許用戶執行腳本。

於是我們的多進程並行打包過程就受影響了,後一個啟動的進程把前一個進程的結果給破壞了,最後構建結束階段做的工作也被重複了多次。

我們可以通過 parallel-webpack 提供的 Node.js API,手動控制打包過程,特別是打包前置操作和打包後置操作,例如:

4.2 代碼分包結果

頁面

common(KB)

vendor(KB)

page(KB)

all (KB)

%

course(原)

125

142

125

392

100%

pkg(原)

125

142

94

361

100%

course

124

142

110

376

95.9%

pkg

124

142

86

352

97.5%

course.app

79

111

132

322

82.1%

pkg.app

79

111

82

273

75.3%

抽調了 WebApp 兩個平台中的兩個頁面進行分析,其中:

  • 代碼壓縮率可以達到 4.1% – 24.7%,隨着支撐平台數增多,跨平台功能邏輯複雜度的上升,這裡的優化效果會越來越明顯;
  • 其中 App平台的頁面邏輯(page.js)上升,公共邏輯(common.js)下降,其主要原因是因為在該平台僅部分頁面開啟了多平台打包過程,抽取的公共模塊(即大於兩個頁面共同引用的模塊)比較少;
  • Web上的基礎依賴(vendor.js)沒有下降,其主要原因為基礎依賴的模塊並為標識為 sideEffects=false縮小Tree-shaking影響的範圍,降低本次重構造成的風險,當然如果把這部分模塊也開啟,可以得到更加明顯的優化效果;
  • App上的基礎依賴(vendor.js)下降 21.8%,其主要原因是App中對比H5端少了部分功能組件,而這些功能組件依賴的一些基礎模塊也被 Tree-shaking 消除了;

4.3 Tree-shaking 模塊

到底有什麼辦法可以確認哪些模塊會被移除呢?

這是前文留下的一個疑問,先拋出結論:沒有一個簡單快捷的方式來確認模塊到底會不會被Tree-shaking

不過還是有一些實踐總結出來的大方向可供參考:

1. 未被引用的模塊成員unused harmony export

這個也是官方教程中給的例子,如果這個模塊的成員被標誌成 unused harmonyexport,就說明該成員沒有外部引用使用到該成員,那麼是可以將其安全去除的。當然這裡還有一種情況就是該成員沒有被外部引用,但是被內部調用了,那這種情況也會把export語句和聲明語句分離,只將export語句去除。

2. 沒有提供導出成員的模塊

/*! exports provided: [^/]+ */n/***

通過上圖我們還可以得知每個模塊在 dev 模式下有兩行註解:

  • exports provided
  • exports used

有部分模塊是只有暴露的成員,但是沒有被引用的成員,這種模塊會被直接消除。

// ./src/modules/edu-discount/seckill/index.ts  import * as SeckillTypes from './types';  export { SeckillTypes };

3. 自執行的模塊import

部分模塊是自執行的,即本身自帶副作用的模塊,而我們通常會使用 import'xxx'來進行模塊引用,而不進行顯示的調用。

這種模塊有兩種處理方式:

  • 1、加入到有副作用的模塊聲明中,避免 Tree-shaking 將其消除;
  • 2、模塊改造,暴露成員支持顯式調用;

4. 部分被標註為 harmony export 的模塊成員

沒錯,這個第四個分類你沒看錯,部分被標註為 harmony export 的模塊成員依舊會被消除掉。當然 Tree-shaking 最後是由著名壓縮工具 UglifyJS 做的。如果你真的對這裡的細節感興趣,可以看一下 UglifyJS 跟以下壓縮選項配置相關的代碼:

  • dead_code: remove unreachable code
  • unused: drop unreferenced functions and variables

IMWeb 團隊隸屬騰訊公司,是國內最專業的前端團隊之一。

我們專註前端領域多年,負責過 QQ 資料、QQ 註冊、QQ 群等億級業務。目前聚焦於在線教育領域,精心打磨 騰訊課堂、企鵝輔導 及 ABCMouse 三大產品。