R·ex / Zeng


音遊狗、安全狗、攻城獅、業餘設計師、段子手、苦學日語的少年。

多版本共存——巨型專案元件庫升級的必經之路

Update 2023-07-19

目前已經有更簡單的方式:PNPM 已經支援帶有 alias 的 peer dependencies 查詢,不需要用 hooks 了;透過實現跟 Ant Design 類似的 prefixClsmodifyVars 功能,也可以控制組件庫選擇器的字首以避免樣式衝突。

元件庫升級是一個令開發者頭痛的事情,即使像 Ant Design 這種經歷了多年發展的元件庫,升級也不是一件容易的事情——輕則 API 不相容,重則影響自定義樣式和使用者體驗。(為什麼 API 不相容反而影響小呢?因為 Ant Design 提供了 codemod 工具輔助解決 API 的遷移,並且如果使用了 TypeScript,型別不匹配是不會編譯成功的。)

我們部門的專案,為了符合部門設計規範,用的是自己開發的元件庫,而我是元件庫的主負責人。在去年 Q3、Q4 我們做了大量的走查問題修復和功能開發,其中有些修改無法向下相容,這就導致了我們的元件庫升級成了一個大問題。經過簡單的測試,暴力升級伴隨的最顯著問題是行高變小和表單錯位,這對於一個 to B 的專案來說是致命的。

暴力升級元件庫可能出現的問題

元件庫升級的難點

我們的專案從兩年前開始使用自己的元件庫,當時元件庫才開發了半年,功能不是很完善,但是業務需求又很緊急,因此很多功能是先在業務裡實現,UI 的走查問題也是從業務中覆蓋 CSS 來解決。隨著業務規模的擴大,我們經歷了一段人力緊缺、需求百花繚亂的時期,因此業務專案中留下了大量的樣式覆蓋,這些樣式覆蓋在元件庫升級時就成了我們的大敵。目前來說,除了透過逐頁面走查、修復的方式,還沒有找到更好的解決方案。

由於業務樣式覆蓋可能導致的問題

在兩年半以前,為了避免專案成為一個巨石應用,我們做了微前端拆分,元件庫這類公共庫由基座提供,每個子應用共享。這樣做的好處是可以避免重複打包,但是也帶來了一個問題,就是元件庫的升級需要所有子應用一起升級。我們的專案有 30+ 個子應用、上百個頁面,一次性升級難如登天。

因此,必須找到一個方式,讓新舊兩套元件庫共存,這樣就可以每個子應用單獨升級了。我們的元件庫有 基礎元件庫業務元件庫(僅釋出在內網,下文就用這兩個中文作為它們的包名吧),可以類比於 Ant Design 和 Ant Design Pro,前者是後者的基礎,升級的時候必須同時考慮到這兩者。

為業務定製的解決方案

專案的微前端架構

我們的微前端方案基於 import-html-entry,有一個主應用、若干子應用、一個自己封裝的基座庫。其中絕大部分功能都在基座庫裡,包括:

  • 通用資料獲取(使用者資訊、當前市場等)
  • 新增自定義內容(路由、store、系統/市場切換選單、列舉值)
  • 自定義介面(Layout、首頁、選單圖示、導航欄按鈕)
  • 子應用彈窗(這個之後再寫文章講)
  • 在必要資料載入完後啟動應用(bootstrap()
  • 其它功能(資料上報、帶時區的時間庫、資料許可權檢查等)

主應用在啟動時,會根據當前使用者所處的系統,使用 import-html-entry 載入子應用,併合並子應用匯出的路由、Store 等資訊,最後執行 bootstrap() 結束 loading 介面。主應用負責提供全部的通用依賴(如 reactreact-router、基座庫、元件庫),子應用只需要打包自己的業務程式碼,以及一些特殊的庫。

主應用與子應用共用基座庫裡面的全部能力,也就是說,子應用在 NODE_ENV === 'development' 時也可以呼叫 bootstrap() 自行啟動,這極大方便了各業務的開發與除錯。

基於微前端的共存方案

先看大方向,如果要同時支援兩套元件庫,有兩種方案:

  1. 主應用同時提供兩套元件庫,升級後的子應用使用新的元件庫。
  2. 主應用不動,新的元件庫由子應用自行打包。

經過考慮,我選擇了第一種方案,因為主應用額外提供新的元件庫不會影響現有頁面,而且可以避免若干子應用分別打包元件庫導致的重複打包、後續版本升級工作量大等問題。

確定了大方向,就要考慮具體的實現了。在實現的過程中,我先後遇到了很多問題,這裡舉兩個最棘手的問題跟大家分享。

意料之外的技術問題

PNPM 如何安裝多個版本的包?

我剛確定大方向就遇到了一個問題:我們的專案使用了 PNPM,它要如何同時安裝兩個版本的同一個包?比如,我想同時安裝 v4v3,但是 pnpm i 基礎元件庫@4 之後會把 v3 的包給刪掉。

經過簡單的搜尋,我發現 NPM、Yarn、PNPM 都提供了 alias 功能,可以為另一個版本的包指定另一個名字,例如:pnpm i 基礎元件庫-next@npm:基礎元件庫@4,這樣不會把 v3 的包給刪掉,專案中匯入的時候也只需要使用新名字:

import xxxV3 from '基礎元件庫';
import xxxV4 from '基礎元件庫-next';

去看了一下 node_modules,發現 alias 的原理非常簡單,就是把裡面的目錄名給改了,其它 package.json 等內容完全沒動。這就導致了一個問題:我的業務元件庫依賴了基礎元件庫(類似於 Ant Design Pro 依賴了 Ant Design),使用 alias 只能把這兩個元件庫的名字換掉,但在業務元件庫程式碼中引入基礎元件庫還是使用的舊名字,因此版本是錯誤的。

這個問題在 NPM 和 Yarn 中似乎不太好解決,但 PNPM 是樹狀的 node_modules,能否透過這個特性來解決呢?我翻閱了 PNPM 的文件,發現它處理 peerDependencies 的方式不太一樣(peers 是如何被處理的):NPM 和 Yarn 對於 peerDependencies 只是做檢查、報警告,但 PNPM 會為每一種可能的 peerDependencies 組合單獨建立一個資料夾。

舉個例子,假如 [email protected] 依賴了 bar@^1,另外兩個包 xxxyyy 都依賴 [email protected] 但依賴了不同的 bar 版本,那麼 node_modules 的結構應當是這樣的:

node_modules
├── xxx/node_modules
│   ├── [email protected]
│   └── [email protected]
└── yyy/node_modules
    ├── [email protected]
    └── [email protected]

PNPM 為了處理這種情況,在 .pnpm 目錄中建立了這樣幾個目錄:

.pnpm
├── [email protected][email protected]/node_modules
│   ├── foo
│   └── bar -> ../../[email protected]/node_modules/bar
├── [email protected][email protected]/node_modules
│   ├── foo
│   └── bar -> ../../[email protected]/node_modules/bar
├── [email protected]
└── [email protected]

其中第一個目錄 [email protected][email protected] 會被硬連線到 xxx/node_modules,第二個目錄 [email protected][email protected] 會被硬連線到 yyy/node_modules,這樣就能保證 xxxyyy 中的 foo 依賴的 bar 版本是正確的。這看起來很完美,因為我們新版業務元件庫的 peerDependencies 中,基礎元件庫的版本也是新的,似乎 PNPM 可以正確處理這個事情。

但 PNPM 報了 missing peer 的警告,並且生成的資料夾是 基礎元件庫@3_業務元件庫@4,這顯然是不合邏輯的。經過追蹤 PNPM 程式碼,發現它為 peerDependencies 組合生成資料夾的前提是這個 peer 對應的版本必須已經被解析出來了,但我用了 alias 導致新版基礎元件庫的名字已經改變了,因此 PNPM 找不到它,就報了警告。PNPM 專案中有一個 issue 反映了此問題:An aliased package causes an unsolvable peer dependency warning

那是否有方法可以繞過這個 issue 呢?我想起 PNPM 提供了 hooks,可以讓我們控制依賴解析的過程,那我們只需要將業務元件庫的 peerDependencies 改掉就可以了:

// .pnpmfile.cjs
module.exports = {
  hooks: {
    readPackage: pkg => {
      if (pkg.name === '業務元件庫' && pkg.version.startsWith('4')) {
        pkg.peerDependencies['基礎元件庫-next'] = '~4.0';
        delete pkg.peerDependencies['基礎元件庫'];
      }
      return pkg;
    },
  },
};

這樣 PNPM 雖然不會報警告了,但最終生成的 node_modules 變成了這樣:

.pnpm
└── 基礎元件庫@4_業務元件庫@4/node_modules
    ├── 基礎元件庫-next -> ../../基礎元件庫@4/node_modules/基礎元件庫-next
    └── 業務元件庫      -> ../../業務元件庫@4/node_modules/業務元件庫

這會導致業務元件庫程式碼中的 import 找不到模組。不過這個解決起來很簡單,寫一段 Shell 程式碼,將指定目錄(.pnpm/*業務元件庫@4*/node_modules)下的 基礎元件庫-next 資料夾重新命名:

function replace_next() {
    local node_modules_dirs=$1
    local package=$2
    for item in $node_modules_dirs; do
      local prefix="node_modules/.pnpm/$item/node_modules"
      if [ -d $prefix/$package-next ]; then
          mv $prefix/$package-next $prefix/$package
          echo "Strip 'next' for $prefix/$package-next"
      fi
    done
}
業務元件庫_dir=$(ls node_modules/.pnpm | grep 業務元件庫@4)
replace_next "${業務元件庫_dir[*]}" 基礎元件庫

把這段程式碼加到 NPM 的 prepare 指令碼中,就可以在安裝依賴後自動執行了。至此,多版本的包和依賴問題完美解決。

解決多版本依賴問題的流程圖

元件庫的樣式隔離該如何實現?

我們的頁面是標準的後臺管理頁面,包括了 header、sidebar、content 區域。其中前兩者處於基座庫的 layout 元件中,content 則是 react-router 匹配到的頁面級元件。基於這樣的結構,我們可以將樣式隔離的粒度定為頁面級,即每個頁面都有自己的樣式,不會影響到其他頁面,至於 header、sidebar 則是基座庫的樣式,應當與頁面樣式隔離。

對於樣式隔離,一個最簡單的方法就是給 CSS 選擇器加上字首(帶空格)。例如我可以透過 PostCSS 的外掛 postcss-prefix-selector 來實現,思路是先給所有選擇器加上字首 .ui-isolate(一個不會被用到的 className),然後根據 CSS 的檔案路徑將其替換成 .ui-4.ui-3

{
  loader: 'postcss-loader',
  options: {
    postcssOptions: {
      plugins: [
        ['postcss-prefix-selector', {
          // use this string to identify prefixed selectors, which will be
          // replaced to .ui-4/3 according to the file path.
          prefix: '.ui-isolate',
          transform: (prefix, selector, prefixedSelector, filePath, rule) => {
            // v4 prefix
            if (filePath.includes('基礎元件庫@4') || filePath.includes('業務元件庫@4')) {
              // a possible result might be ".ui-4 .ui-button"
              return prefixedSelector.replace('.ui-isolate', '.ui-4');
            }
            // v3 prefix
            if (filePath.includes('基礎元件庫@3') || filePath.includes('業務元件庫@3')) {
              // a possible result might be ".ui-3 .ui-button"
              return prefixedSelector.replace('.ui-isolate', '.ui-3');
            }
            // default not prefix
            return selector;
          },
        }],
      ],
    },
  },
}

然後在 HTML 中給合適的容器加上 ui-4ui-3className 即可:header 和 sidebar 肯定是 ui-4,content 則可以透過路由來判斷,如果是升級完的頁面,就加上 ui-4

想法很美好,但執行起來發現了一個問題——很多 popup 類元件是透過 portalrender 直接掛載到 body 上的:

const popupContent = (
  <div className="ui-popup-container">...</div>
);

// 透過 portal 掛載到 body 上
ReactDOM.createPortal(popupContent, document.body);

// 透過 render 掛載到 body 上
const container = document.createElement('div');
container.className = 'ui-popups';
document.body.appendChild(container);
ReactDOM.render(popupContent, container);

如果只給 content 新增 className,那彈窗就不會有樣式了,但如果給 body 新增 className,那麼選擇器就無法區分元件庫版本了,這顯然是不行的。似乎只剩下了一個方法:給彈窗的容器新增 className。例如只要能給 .ui-popup-container 新增上 ui-4,就可以在 PostCSS 外掛中特判一下,給 .ui-popup-container 的所有選擇器都加上字首(不帶空格)。此外我也發現了有一些特殊的選擇器如 body:global,也不能加字首,雖然這可能會有樣式覆蓋的問題,但最終發現沒有明顯的影響。新增的部分程式碼如下:

+ const isOuterScopeSelector = selector => {
+   return selector.startsWith(':global')
+     || selector.startsWith(':local')
+     || /(^|\s|,)body(\s|,|.|$)/.test(selector);
+ };
+ const containerClassNames = [/* regexps of a huge amout of selectors */];
+ const hasContainerClassName = selector => {
+   return containerClassNames.some(re => re.test(selector));
+ };
  if (filePath.includes('基礎元件庫@4') || filePath.includes('業務元件庫@4')) {
+   if (isOuterScopeSelector(selector)) {
+     return selector;
+   }
+   if (hasContainerClassName(selector)) {
+     // a possible result might be ".ui-4.ui-button"
+     return `.ui-4${selector}`;
+   }
    // a possible result might be ".ui-4 .ui-button"
    return prefixedSelector.replace('.ui-isolate', '.ui-4');;
  }

憑藉對元件庫程式碼的瞭解,我知道,想脫離 React root 只有兩種方法(我們最多支援到 React 17):ReactDOM.createPortalReactDOM.render,於是我全域性搜了一下關鍵字,有幾類元件:

  • 封裝較好、基於底層 Popup 實現的元件,如 CascaderPopoverTooltip
  • 基於 rc 庫封裝的元件,如 Select
  • 使用了 createPortal 的元件,如 DrawerModal
  • 使用了 render 的元件,只有 messagemessage.success()),以及 Modal 的快捷彈窗(Modal.confirm())。

對於第一種情況,只需要修改 Popup 即可,就變成了第三種情況;對於第二種情況,想辦法把 className 傳進 popupClassName 即可,也變成了第三種情況。這三種情況有一個通用的解決辦法,就是利用 React 的 context 來傳遞 className。剛好元件庫有一個 ConfigProvider 元件,可以在其中新增一個 prop 叫 containerClassName,這樣在業務程式碼裡面只需要這樣:

import { ConfigProvider as ConfigProvider3 } from '基礎元件庫';
import { ConfigProvider as ConfigProvider4 } from '基礎元件庫-next';

export default () => (
  <ConfigProvider4 containerClassName="ui-4">
    <ConfigProvider3 containerClassName="ui-3">
      <App />
    </ConfigProvider3>
  </ConfigProvider4>
);

元件庫程式碼中可以這樣讀取:

import classnames from 'classnames';
import { ConfigProvider } from '@src/components/config-provider';

export default () => {
  const { containerClassName } = React.useContext(ConfigProvider.Context);
  const classes = classnames('ui-button', containerClassName);
  return <div className={classes} />;
};

由於 ES 模組自帶隔離,因此舊元件庫會讀取 ConfigProvider3containerClassName,新元件庫則會讀取 ConfigProvider4containerClassName,問題解決。

但對於第四種情況,由於 message.success()Modal.confirm() 這類方法可以在任意地方呼叫,很可能不處於 React 的元件生命週期中,無法使用 context 來做隔離,只能透過 ES 模組來隔離。我首先在 messageModal 中各添加了一個方法 setContainerClassName 用於設定它們的彈窗 className,將其存放至閉包中,然後在 ConfigProvider 裡面添加了一個 useEffect,當 containerClassName 發生變化時,就呼叫這兩個方法:

React.useEffect(() => {
  if (props.containerClassName) {
    message.setContainerClassName(props.containerClassName);
    Modal.setContainerClassName(props.containerClassName);
  }
}, [props.containerClassName]);

至此,本問題得以解決。經過一段時間的除錯,我把頁面快速測試了一遍,所有元件/彈窗都能正常顯示了。

解決樣式隔離問題的流程圖

後記

其實在實現過程中還有很多其它問題,例如:

  • 如何根據路由判斷是否需要使用新元件庫:我們自行實現過路由守衛,可以在守衛中讀取 meta 資訊,在路由切換時修改 content 的 className
  • 如何將新增 className 的操作隔離在業務專案而非元件庫/基座庫中:基座庫提供 addRootElementProps 功能,在主應用中判斷並傳入 className 屬性,再由基座庫新增到頁面各區域。這樣一旦全部頁面升級完成,只需要刪除主應用的 PostCSS 程式碼以及 addRootElementProps 呼叫即可。
  • 升級完成後如何快速去除子應用的相容程式碼:將相容程式碼放到部門統一的構建器(類似於 react-scripts,但封裝了部門專案的很多配置)中處理,構建器會在 Jenkins 執行時先被更新,因此全部頁面遷移完後只需要移除構建器的相容程式碼即可。

順便一提,我在遷移一個子應用的過程中,還發現了 import-html-entry 的一個邊界場景:當子應用有做 split chunks,且 html-webpack-plugin 被設定為 deferred 模式(預設配置)時,會為 <script> 標籤新增 defer 屬性,並將它們都放置到文件的 <head> 標籤裡。雖然正常情況下被 defer 的指令碼載入順序不影響執行結果,但 import-html-entry 解析 exports 的原理是依次執行 HTML 檔案中所有的程式碼,並讀取最後一個掛到 window 上的屬性,如果 <script> 標籤的順序出錯,最後一個掛到 window 上的屬性就不再是我們期望的 exports 了。解決方法也很簡單,只需要給 html-webpack-plugin 設定 scriptLoading: 'blocking' 即可,或刪除 split chunks 的配置。這雖然不是 import-html-entry 的問題,但我覺得開啟 split chunks 且 html-webpack-plugin 只用預設配置的人應該不少,因此我提了個 這個 PR,希望在 import-html-entry 的 README 中新增解決方法。(不過截至目前這個 PR 還沒有新的動態,可能是作者還沒有返工吧。)

沒想到 OKR 中的一句話“升級元件庫版本”會帶出這麼多問題,我也從中學到了很多知識點。條條大路通羅馬,這次遇到的問題雖然看起來很棘手,但透過搜尋資料、追蹤程式碼的方式,基本可以搞清楚問題的成因——如果是個通用問題,一定有一個解決或繞過的方式,否則就結合自己的業務場景,找到解決方案,並將解決的方法儘可能隔離在業務專案中,保持技術專案的純粹性。發現並解決問題,就是身為工程師永遠的 OKR。

參考資料

Disqus 載入中……如未能載入,請將 disqus.com 和 disquscdn.com 加入白名單。

這是我們共同度過的

第 3851 天