基於 qiankun 的微前端實踐

前言

微前端(Micro-Frontends)是一種類似於微服務的架構,它將微服務的理念應用於瀏覽器端,即將 Web 應用由單一的單體應用轉變爲多個小型前端應用聚合爲一的應用。

微前端並不是前端領域的新概念。早期希望前端工程能夠像後臺的微服務一樣,項目分開自治,核心的訴求是:

1、兼容不同技術棧

2、將項目看作頁面、組件,能夠複用到不同的系統中

早期比較成熟的 single-spa,從早期 React 的現代框架組件生命週期中獲得靈感,將生命週期應用於這個應用程序,即將整個頁面作爲組件。

後來螞蟻金融團隊孵化了基於 single-spa 的 qiankun 架構,將微前端進一步的深耕,目標直指巨石應用業務難題,旨在解決單體應用在一個相對長的時間跨度下,由於參與的人員、團隊的增多、變遷,從一個普通應用演變成一個巨石應用 (Frontend Monolith) 後,隨之而來的應用不可維護的問題。這類問題在企業級 Web 應用中尤其常見。

本人在深入實踐微前端之後,深感 qiankun 受制於前端架構的定位,無法使用 Nodejs 等能力快速解決快速發佈,構建,管理的困境,因此在此基礎上做了一定程度的 APAAS 探索,將本文的項目作爲 APAAS 應用快速集成到其他業務系統。

qiankun 介紹

首先基於作者自己的思考,給大家梳理下 qiankun 微前端的渲染流程,方便不瞭解微前端的同學有個大體的認識。

假如你不瞭解微前端的話,對於這個新事物的學習和探索,一般是按照 5Ws 的學習規律來學習:

  1. Who is it about?

  2. What happened?

  3. When did it take place?

  4. Where did it take place?

  5. Why did it happen?

本文也按照類似的方式一個具體的案例去梳理 qiankun 的核心概念。

本文有一個業務系統 A,希望集成業務系統 B 的_**歡迎卡片配置**_功能頁面。

那麼 who 包含 qiankun 客戶端、主應用 A、微應用 B 三部分,三者協作完成了微前端的設計。從產品使用的角度來看,第一步則是註冊路由,確定微前端啓動的時機和啓動渲染的內容(when 和 where)。

import { registerMicroApps, start } from'qiankun';

registerMicroApps([
  {
    name: 'smart-contact-cms-index', // app name registered
    entry: '//localhost:7100',
    container: '#yourContainer',
    activeRule: '/yourActiveRule',
  },
]);

start();

上面的實例十分簡單,通過 name 來作爲微應用 B 的唯一標識, entry 作爲微應用 B 的資源加載地址。當路由映射到 '/yourActiveRule' 時,則請求微應用 B 的頁面資源地址,解析其中的 JS 和 CSS 資源,將微應用 B 的頁面渲染到 id 爲 #yourContainer 的 DOM 節點。

const packageName = require('./package.json').name;

module.exports = {
  output: {
    library: `${packageName}-[name]`,
    libraryTarget: 'umd',
    jsonpFunction: `webpackJsonp_${packageName}`,
  },
};

上面是一段微應用 webpack 的配置,library 就是註冊路由時的唯一標識 name 了。這裏也十分好理解爲啥 qiankun 要在構建資源時給 js 增加標識。

qiankun 客戶端 entry 配置的 HTML 頁面資源的地址,拉取到頁面資源之後劫持 HTML 解析,自行構建 Http 請求去加載 JS 文件。很明顯頁面中肯定不止一段 JS 代碼,就需要標識來標識那一段的入口 JS 代碼了。

下面截取了 index.js 頭部代碼

!function(e,t){
  "object"==typeofexports&&"object"==typeofmodule?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeofexports?exports["smart-contact-cms-index"]=t():e["smart-contact-cms-index"]=t()
}

僅僅修改微應用 B 的 webpack 是遠遠不夠的,常規前端頁面工程會在 index.js 文件中直接調用類似 ReactDOM.render API,直接去渲染到特定節點。那 qiankun 根本無法干預渲染過程,因此需要將渲染的實際交給 qiankun 客戶端來控制,qiankun 受前端組件化的影響,也希望將頁面做成一個暴露生命週期的組件,導出相關的生命週期鉤子。

// 單獨 App 運行
if (!isMicroApp) {
  // 非微前端渲染
  renderApp({});
}

// 導出 qiankun 生命週期
export const bootstrap = async () => {
  console.log('[smart-contact-cms] bootstrap');
};

export const mount = async (props: any) => {
  console.log('[smart-contact-cms] mount', props);
  normalizeObject(props.mainAppState);
  // 作爲微應用運行
  renderApp(props);
};

export const unmount = async (props: any) => {
  const { container } = props;
  ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
};

細心的同學會發現有個 isMicroApp 的變量可以控制啓動微前端渲染還是獨立 APP 渲染,這個實際是 qiankun 客戶端啓動後,會在 window 上掛在 POWERED_BY_QIANKUN 變量標識運行時環境。

這裏還有個小細節就是微應用的 JS CSS 文件請求是屬於 Ftech/XHR 類型,說明 js 文件的請求是 qiankun 客戶端自行構造的。

至於爲何要這麼做?我的理解是爲了實現微前端的沙箱功能。

qiankun 客戶端干預 script 標籤和 link 標籤的加載過程的難度,和上面的方式相比明顯是更簡單的,但是這種方式很明顯是缺乏安全性,特別是在面臨第三方 SDK 加載的時候,例如微信 web SDK 的引入,就無法通過其安全校驗,具體可以詳細看下 FAQ 1 的案例介紹。

一句話總結上述過程:

在 qiankun 的框架下,一個頁面集成到另外一個頁面系統中,最關鍵的核心點就是將微應用封裝成具有生命週期的頁面組件,使得 qiankun 可以調用 React 或者 Vue 的 render 能力,將頁面渲染到對應的 DOM 節點。

APAAS 架構介紹

本文由於篇幅限制,只介紹 Client 端、Server 端的接口協議代理、微應用改造,其他部分更多是偏向於自動工程化和項目管理的方面,之後有時間給大家詳細介紹下。

Client 端

Client 端是經過業務封裝後的 qiankun SDK,內部集成了經過 qiankun 改造的各自子應用系統,對外暴露以下接口:

1、initial(appInfo)

appInfo 參數

{
    "app_id": "xxx",
    "prefetch": true,
    "signUrl": "remote-url"
}

這個接口主要是爲了初始化部分信息,包括微應用的鑑權配置、是否啓動預加載、微應用的標識。其中鑑權配置可能會讓大家感到疑惑。

在介紹 qiankun 的渲染過程中提到

這裏還有個小細節就是微應用的 JS CSS 文件請求是屬於 Ftech/XHR 類型,說明 js 文件的請求是 qiankun 客戶端自行構造的。

這裏必然要涉及前端的跨域問題,尤其是當主應用和微應用的域名不一致時,qiankun 客戶端如何能夠在跨域的限制之下獲取到微應用的頁面資源?本文的解決方案是主應用提供一個鑑權祕鑰下發的接口 signUrl,這個接口由微應用提供也可以,將祕鑰信息下發到 cookie 中,通過配置 qiankin 自定義 fetch 方法,帶上這些鑑權信息。

本文只是其中一個解決方案,官方也提供了一些通用方案以供參考《微應用靜態資源一定要支持跨域嗎》。

2、load (FloadConfig) 接口

動態加載微應用,其 FloadConfig 參數如下:

interface FLoadConfig {
  container: string;
  pageId: string;
  props?: FMicroProps; // 傳入對應微應用所需要的參數
}
interface FMicroProps {
  loginUrl?: string, // 登錄的重定向地址
  baseApiUrl?: string,
  dispatch?: (value: { type: string; data: any }) =>void,
  pageInfo?: FPageConfig,
  useNativeRoute?: number;
  extra?: any,
}

loginUrl 是鑑權失敗跳轉的登錄地址;baseApiUrl 是後臺請求的地址;useNativeRoute 決定微前端採用何種路由方式加載。

本文在這個階段主要做兩方面的突破:

  1. 解決了後臺請求的跨域和鑑權

  2. 解決了主應用和子應用的 path 衝突問題

baseApiUrl 這裏默認提供了基於騰訊雲的鑑權下發能力,各個業務系統只需要按照規範去對接騰訊雲 API 即可,比較遠離前端,在這裏不做過多解讀。

在實際的業務場景中,主應用和微應用互相無法感知到對方,因此其路由有可能會互相沖突,這裏通過 useNativeRoute 參數來控制微應用的路由模式。

1、useNativeRoute = 0 採用配置端數據返回的路由路徑
2、useNativeRoute = 1 採用當前頁面 hash 前綴 + 配置端數據返回的路由路徑作爲新路由
3、useNativeRoute = 3 採用當前的頁面路由不做任何改動, 默認 0

底層的實現邏輯則是在 renderApp 傳入主應用的路由前綴,改造相對比較簡單,但是很實用。

exportconst mount = async (props: any) => {
  console.log('[smart-contact-cms] mount', props);

  // 刪除不能序列化的內容
  normalizeObject(props.mainAppState);

  // 作爲微應用運行
  renderApp(props);
};

返回 MicroApp 實例:

詳情參考參考 qiankun 官方文檔。

3、subscribe(callback: (value: FMicroEvent) => void): Unsubscribe

監聽主子應用的事件和數據,callback 函數 ({type: string; data: any}) => { 處理程序 },目前通用返回加載完成事件 type: load,其他自定義事件由主子應用自行定義。

返回值:返回取消訂閱的句柄。

其他接口在這裏不做贅述了。

Server 端的接口協議代理

實現頁面低成本接入是微前端的重要願景之一,也是吸引大家持續探索的核心原因。
理想業務場景下,一個被微應用改造之後的微應用集成到其他業務系統,應該無需關心後臺接口,開箱即用。但是後臺業務系統具有各自獨立的鑑權、賬戶、業務邏輯,相互之間差異性極大,完全無法做到開箱即用。

傳統的方式是主應用去主動接入微應用的後臺系統,缺點很明顯,主應用要了解微應用系統的後臺邏輯,且每接入一個系統就要重複上述的工作,成本高且低效。

微前端脫胎於微應用,我們也希望微應用自己也實現微服務級別的邏輯自治,依託於騰訊雲 API 託管,對外提供微應用自身的服務。這樣的好處是主應用只需要要對接騰訊雲生態,即可實現鑑權、賬號轉化、監控等能力。主應用和子應用只需要做一次,無需重複,缺點就是必須要依託於騰訊雲生態。假如你的業務本身就是依託於微信、企業微信、騰訊雲發展起來的,上述方式值得引入。

微應用改造

一個業務系統並不是一開始就有被集成的價值的,往往是在業務發展到一定程度,經過市場驗證其價值之後,大家纔會明確這個業務系統具有微應用改造的價值,這就導致一個窘迫的境地:你需要改造已經成型的項目,使其成爲可快速接入的微應用。

qiankun 的微應用改造相對比較簡單,一般在開啓嚴格沙箱模式之後,微應用和主應用之間建立比較好的環境隔離,你並不需要太多的工作。

首先樣式隔離,參考下面 FAQ 2、css 隔離 章節的介紹。其核心的麻煩在於 qiankun 啓動嚴格沙箱木事之後,會導致 dialog、Modal 等組件無法找到 body 節點,進而無法掛在到 DOM 中。

其次 JS 作用域隔離,這裏主要是一些第三方庫會在 window 上掛在單例實例,導致主應用和微應用之間單例配置相互覆蓋,常見於日誌上報、微信 SDK、QQ SDK 等第三方應用。解決方案分爲兩個方向:

  1. 假如主應用存在則沿用主應用的配置
    這種方式對主應用比較有利。以日誌上報的配置爲例,微應用的日誌會上報到主應用空間下,那麼主應用的日誌監控會很完整。缺點則是微應用本身失去了這些監控信息。

  2. 微應用對自身使用的單例進行隔離
    這種方式對微應用比較有利。以 Axios 的配置爲例,子應用可以實現類似中臺應用的效果,可以探知到微應用在不同的主應用中的實際使用場景和數據統計。

本文采用的是第二種方式,假如主應用需要進行數據共享或者配置共享,可以通過主應用和微應用之間的參數和數據傳遞的方式來實現共享,微應用提供豐富的監聽 hooks。

FAQ

1、如何解決 第三方 SDK JS 文件加載失敗問題

微信和企業微信的 SDK 是不可以自行構建 Http 請求加載的,這是由於其安全策略導致的,且每次返回的內容有安全限制的改動,無法複用。
因此必須要在 html 的 header 中引入,但 qiankun 會對 html header 所有 script link 資源構造請求鏈接,進而導致獲取第三方 SD K 的請求報錯,整個 qiankun 客戶端加載微應用進程報錯,無法加載出對應頁面。

官網在常見問題給了三個解決方案:

  1. 使用 getTemolate 過濾異常腳本;

  2. 使用自定義 fetch 阻斷 script 腳本;

  3. 終極方案 - 修改 html 的 content-type;

前兩種方案需要在乾坤渲染函數中增加一個對應的參數,這裏面有個坑點,則是 prefetchApps 不支持這些參數,因此一旦啓用預加載函數,則會導致渲染函數的傳入配置失效,因此需要關閉使用預加載函數。

2、css 隔離

微前端核心理念:解耦 / 技術棧無關,簡單來說就是希望微應用之間,基站應用和微應用之間的技術棧可以互相隔離,從而各種定製自己的技術體系來實現開發效率和產品質量的最優化配置,這也是微前端的核心價值體現。

然而理想是豐滿的,現實是骨感的。主流的沙箱模式是通過創建一個獨立的作用域隔離作用域鏈,同時克隆全局變量來實現的,但是這種隔離 + 克隆方案並不完美,在複雜運行場景中,無論性能還是安全性都是難以保證的,特別是 CSS 的隔離。

本文基於乾坤的微前端架構,在此基礎上做了一些查漏補缺的補充。

首先是基礎頁面的 CSS,採用的是成熟的 CSS module 方案,簡單來說就是將 CSS 變成局部生效,每個 class 生成一個獨一無二的名字。從最早的 Less、SASS,到後來的 PostCSS,再到最近的 CSS in JS,都是爲了解決 CSS 全局生效帶來的副作用。

css: {
  loaderOptions: {
    less: {
      javascriptEnabled: true,
      modifyVars: {
        '@ant-prefix': 'industryAnt', // 前綴
        'primary-color': '#0052d9',
        'link-color': '#0052d9',
        'btn-primary-color': '#ffffff',
        'btn-danger-bg': '#e34d59',
        'disabled-color': 'rgba(15, 24, 41, .3);',
        'btn-disable-bg': '#eaedf2',
        'text-color': '#0F1829',
      },
      module: true,
    },
    postcss: {
      plugins: [
        AddAntClass({ prefix: 'industryAnt' })
      ],
    }
  },
}

參考上面的配置,直接開啓即可。

在前端開發過程中,我不可避免的會使用到各種前端的組件庫,例如 antd、echarts,且都支持自定義主題配置,假如基站應用和微應用之間主題配置衝突了,就需要我們採用類似 CSS module 的方案,將各自應用的 CSS 應用範圍控制在各自的組件控件內。

細心的同學已經發現,我上面的代碼就包含了 antd 的類名定製的配置 - '@ant-prefix': 'xxxAnt' ,給所有 antd 組件增加類名前綴。

你以爲到這裏就完美解決了嗎?不,這纔剛開始。回到我們的業務場景中去,很多頁面的複用並不是一開始就設計好的,往往是產品中後期發現業務體系之間存在高度的複用性,你需要對老的項目進行改造使它支持微應用架構模式。經驗豐富的你發現已有項目中,存在很多全局 class 樣式,甚至全局地方類庫的組件 class 樣式,如果手動去調整,那絕對 boom 。

這裏給大家提供一些工程化的方法。第三方類庫的樣式類名往往都是有通用類名前綴,這是我們能夠解決這個問題的基本前提條件,我們在在 CSS 構建階段對 css class 進行替換和調整。有興趣的同學可以看下 less、 postcss 提供的插件機制,以下代碼僅供參考。

const postcss = require('postcss');

module.exports = postcss.plugin('postcss-add-css-prefix', function(opts = {}) {
  const {
    prefix = 'xxxAnt'
  } = opts

  // 接收兩個參數,第一個是每個css文件的ast,第二個參數中可獲取轉換結果相關信息(包括當前css文件相關信息)
  function plugin(css, result) {
    if (!prefix) return; // 沒傳入prefix,不執行下面的邏輯
    css.walkRules(rule => { // 遍歷當前ast所有rule節點
      const {
        selector
      } = rule;
      // 只有當節點是ast根節點直屬子節點時才添加前綴
      // 簡單做了容錯處理,只要帶有根選擇器的都不添加前綴,本身帶有前綴了也不添加
      // 加了個flag,防止節點更新後重復執行該邏輯進入死循環
      if (rule.parent.type === 'root' && selector.indexOf('.ant-') >= 0 && selector.indexOf(`${prefix}-`) < 0 && !rule.flag) {
        rule.flag = true
        const clone = rule.clone();
        clone.selector = selector.split(' ').map(item => item.replace('.ant-', `.${prefix}-`)).join(' ');
        rule.replaceWith(clone)
      }
    })
  }

  return plugin
})

經過以上調整,樣式終於隔離成功,微前端接入後展示完美,此時測試的反饋打破了你的幻想:confirm 的彈窗提示不顯示了。經過排查發現 ant-prefix + css 插件的方案,無法對動態生成的組件樣式進行調整,因此它的樣式會丟失。
最終在 Ant Design of React 官方的 FAQ 中找到了線索,

但是這個方案並不適用於本文使用 antdv 1.x 版本的微應用項目,不支持這些 API。在 3.x 版本 FAQ 中給了一個推薦方案。

這種方案是適用於 vue 3.x,對於 vue 2.x 的項目則需要使用 "@vue/composition-api" 來兼容 getCurrentInstance 等 API。經過實踐發現本文的項目的 antv 的版本是 1.x 的,無法支持 appContext 參數,最終探索了最終的解決方案。

this.$confirm({
  prefixCls: `${ANT_PREFIX}-modal`,
  title: '正在上傳,確定要停止嗎?',
  cancelText: '取消',
  cancelButtonProps: {
    props: {
      prefixCls: `${ANT_PREFIX}-btn`,
    },
  },
  okText: '停止',
  centered: true,
  okButtonProps: {
    props: {
      type: 'danger',
      prefixCls: `${ANT_PREFIX}-btn`,
    },
  } asany,
  onOk: () => {
  },
} asany)

confirm 類型定義中並不包含 prefixCls,因此需要使用 as any 忽略其類型校驗,實際會透傳到底層 rc-dialog 組件中。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/S8eFeKaRKT6LxfIMtN9L5g