基於 qiankun 的微前端應用改造踩坑記

前言

隨着業務發展,咱們的系統變得愈來愈龐大,給構建速度、靜態資源大小以及應用性能帶來了極大的挑戰css

一個系統是由衆多小模塊組成的,大部分用戶都不會擁有全部模塊的權限,因此咱們的第一個優化方式就是 code split,將每一個小模塊的代碼分割出來,按需加載,也取得了必定的效果html

然而,當系統數量愈來愈多時,用戶開始抱怨入口太多,但願由統一的入口來完成全部的功能,這個場景有幾種解決方案前端

  • 合併全部系統到一個大系統中webpack

    • 優勢
      • 用戶體驗能夠作到最好,一個單頁應用的操做流暢度較高
    • 缺陷
      • 容易變成一個巨石應用,開發、構建時都會產生性能問題
      • 任何一個小模塊的修改均可能致使整個大系統不可用
      • 限制了開發框架,將來難以升級
  • 作個應用框架,用 IFrame 嵌入目標系統git

    • 優勢
      • 改形成本低,只須要開發應用框架
      • 能夠支持同時打開多個系統並經過標籤進行切換
      • 切換系統時自然地能夠維持頁面的狀態,讓用戶繼續以前的操做路徑
      • 各應用獨立部署,互不干擾
    • 缺陷
      • IFrame 中的路由變化沒法體如今應用框架的 URL 上,用戶一刷新就會迴歸到初始頁面,影響體驗,需獨立開發一套通信機制讓應用框架保存 IFrame 中系統的路由,須要對現有系統作改造
      • IFrame 加載速度慢
      • 若界面上 IFrame 較多,dom 結構會變得複雜,影響系統性能
  • 開發統一導航欄,替換各系統的導航欄,在導航欄中經過 <a> 標籤實現系統切換github

    • 優勢
      • 改形成本相對較低,須要開發能夠快捷集成到不一樣系統中去的導航欄;如果須要統一域名,則各系統須要改造,全部請求須攜帶特有的子路徑
      • 基本不影響用戶使用單個系統的體驗
    • 缺陷
      • 系統間的切換本質上是打開了一個新的系統,加載性能會影響用戶體驗,用戶只是 看上去 像在使用一個系統,如果用戶切換的頻率較高,則感覺更強烈
      • 系統間的通信只能依賴 localStorage/sessionStorage 等瀏覽器存儲
      • 不支持同時打開多系統,沒法自然恢復頁面狀態
  • 採用微前端架構,對應用進行改造web

    • 優勢
      • 真正能夠作到在一個入口使用全部功能
      • 不一樣應用間的切換體驗較好,除了第一次切換須要消耗必定時間作 js 解析,後續的切換則較爲平滑
      • 主應用能夠提供通用功能供子應用使用
      • 不一樣應用能夠由不一樣團隊、使用不一樣的技術棧開發
    • 缺陷
      • 有必定的改造工做量
      • 主應用承載全部流量入口,無形中增大了系統壓力

再來梳理一下現狀redis

  • 全部系統都是基於內部的統一框架開發,擁有統同樣式的頂部欄和側邊欄json

  • 全部系統都擁有本身的 Nodejs 層,用於頁面渲染和 API 請求轉發bootstrap

  • 全部系統都擁有不一樣的域名,沒有特定的域名子路徑

  • 不一樣的系統有本身的小團隊在開發,部分使用不一樣版本的 React 和 Ant Design

  • 開發廣泛要求將來新功能模塊的開發可使用與時俱進的技術

基於現狀分析,微前端是一個能夠去嘗試的方向,因而便開始了踩坑之路,將現有的系統改形成爲微前端的子應用

爲了統一語言,現有的系統在下文稱爲子應用

踩坑之路

選型

咱們使用 qiankun 來做爲微前端的實現庫,(聽說)能夠快速實現改造

應用改造

增長子路徑

qiankun 是基於 single-spa 封裝的,其內部實現的子應用加載機制,是基於瀏覽器 url 來實現的,經過第一段子路徑來決定要加載哪一個子應用,好比

  • ${你的域名}/appA/......:表示加載 a 應用
  • ${你的域名}/appB/......:表示加載 b 應用

因此,爲每一個子應用改造使得全部的訪問都增長子路徑,是咱們要作的第一步

  • 爲每一個路由增長前綴,koa 的代碼示例以下

    // 直接訪問根路徑,轉發增長路由前綴
    router.get('/', controller.redirect);
    
    // 渲染頁面,這裏的 authMiddleware 是校驗中間件,實現登錄校驗邏輯
    router.get('/appA/*', authMiddleware, controller.index);
    
    // 這裏使用 ${子應用名} + '_apis' 來表示特定應用的 api 請求,
    // 方便在主應用中作區分進行轉發,同時也方便 Nginx 配置轉發(共享域名)
    router.use('/appA_apis/*', authMiddleware, controller.transfer);
    
    // 剩下的路由忽略
    ...
    複製代碼
  • 修改每一個在頁面上的 api 請求,使之匹配 ${子應用名} + '_apis'

    這一步相對比較麻煩,現有的子應用在頁面代碼中都寫了 /apis 的前綴,若是不是在統一的地方處理的,改動起來會很是麻煩。基於現狀,咱們用了一個取巧的方式:攔截全部 Ajax 請求,並根據須要修改其前綴。具體代碼以下

    (() => {
      if (!XMLHttpRequest.prototype['nativeOpen']) {
        XMLHttpRequest.prototype['nativeOpen'] = XMLHttpRequest.prototype.open;
        const customizeOpen = function(method, url, ...params) {
          if (
            // 不須要修改前綴的請求,若是狀況比較多,能夠單獨抽取出來
            url.indexOf('hot-update.json') < 0
          ) {
            // 將 /apis 前綴轉化爲 /appA_apis 前綴,這裏是在框架裏
            // 處理成 routerPrefix 注入到 window 對象的
            url = `${window['routerPrefix']}_${url.slice(1)}`;
          }
          this.nativeOpen(method, url, ...params);
        };
    
        XMLHttpRequest.prototype.open = customizeOpen;
      }
    })();
    複製代碼
  • 修改靜態文件的路徑,修改原先的 /statics 路徑,使之匹配 ${子應用名} + '_statics'

    這一步大體就是 webpack 的配置了,主要是修改 output 和 publicPath 相關的配置,根據項目實際去操做便可,此處再也不贅述

通過以上步驟,子應用已經能夠支持子路徑的訪問了,但這裏還少了一步比較關鍵的,它不影響你的改造,可是會影響你改造以後用戶的正常訪問。好比,用戶在收藏夾中保存了你的系統某個頁面的地址,例如 xxx.site.com/pages/user,此時若是你進行了部署,則會致使用戶的訪問出現 404,因此還須要在路由文件進行兼容

// 直接經過 URL 訪問舊路由時,重定向到新的匹配路由,redirectToNewPrefix 的實現很簡單,取出 ctx.url 而且替換掉原先的路由前綴便可
router.get('/pages/*', controller.redirectToNewPrefix);
複製代碼

至此,咱們算是完成了 爲子應用增長路由前綴 的工做。

增長主應用

參考官網,搭建一個最簡單的主應用,只須要有一個用於掛載子應用的節點

<div id="subViewport"></div>
複製代碼

而後調用 registerMicroApps 方法註冊一會兒應用便可

registerMicroApps(
  [
    {
      name: 'appA',
      entry: appAEntryMap[process.env.NODE_ENV], // 根據運行環境,加載應用對應的入口,如 'http://localhost:3000/appA'
      container: '#subViewport',
      activeRule: '/appA'
    },
    {
      name: 'appB', // app name registered
      entry: appBEntryMap[process.env.NODE_ENV],
      container: '#subViewport',
      activeRule: '/appB'
    }
  ]
);

setDefaultMountApp('/appA'); // 設置默認加載的應用,當路由匹配不到時會觸發

start();
複製代碼

這裏可能會出現 #subViewport 掛載的子應用沒有佔滿容器的現象,查閱官方 issue,給出一個可解決的方案是經過 css 去控制,讓該節點下渲染的子 div 佔滿容器(該 div 會注入 hash,故沒法根據 id 或 class 去處理)

#subViewport {
  width: 100%;
  height: 100%;
  > div {
    width: 100%;
    height: 100%;
  }
}
複製代碼

子應用暴露生命週期函數,UMD 格式打包

此步驟參考官方文檔便可

另外,若是但願子應用也能單獨訪問,則能夠在入口 js 處增長代碼

// 不是在 qiankun 框架中裝載的時候,直接渲染
if (!window['__POWERED_BY_QIANKUN__']) {
  bootstrap().then(mount);
}
複製代碼

跨域問題

啓動主應用,訪問頁面,發現一片空白,查看控制檯,出現了跨域問題

Access to fetch at 'http://localhost:3000/appA' from origin 'http://localhost:4001' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled. 複製代碼

qiankun 是使用 fetch 來獲取子應用的 html 文件的,因此出現了跨域問題。處理起來也比較簡單,因爲自己是由 Nodejs 渲染出來的,只須要增長 koa2-cors 中間件便可解決問題

這裏注意,若是是在開發模式下,須要 webpack-dev-server 也支持跨域,可參考 這篇文章

請求轉發和 API 校驗問題

終於來到了很是關鍵的一個環節,API 請求的處理,這也是官網和 Demo 沒有說起的環節,但倒是最重要的,決定了你的微前端改造是否成功

若是主應用、子應用以及後端 API 都是同一個域名,則自然地不用解決這個問題

如下方案都基於一個大前提:主應用、子應用都有各自的 Node 端處理頁面渲染、登錄校驗和 API 轉發工做

首先要清楚,qiankun 子應用在瀏覽器端發 api 請求時,其實是請求了主應用的 Node 端,url 爲 /appA_apis/xxx /appB_apis/xxx 這樣的格式,而主應用的 Node 端是沒有處理這些路由的邏輯的,故須要添加轉發邏輯,把這些請求都轉發到子應用的 Node 端去

先在主應用的配置文件添加子應用配置

subApps: [
  {
    name: 'appA',
    prefix: '/appA_apis',
    // 子應用的 host,例如 http://localhost:3000
    host: process.env['subApps.appA.host']
  },
  {
    name: 'appB',
    prefix: '/appB_apis',
    host: process.env['subApps.appB.host']
  }
]
複製代碼

而後在主應用的路由配置處,增長轉發

subApps.forEach(subApp => {
  router.all(`${subApp.prefix}/*`, (ctx, next) => {
    // 轉發請求到 `${subApp.host}/${ctx.url}`,注意參數要透傳,content-type 也要保持一致,此處實現方式多種,不在此贅述
    ...
  })
})
複製代碼

轉發後會發現,API 請求在子應用的 Node 端沒法經過校驗,咱們先來看下 API 請求的校驗過程

  • 從請求的 cookie 中取出 x-auth-token(這個 key 是咱們的項目規定的,不是固定的)
  • 經過這個 token,判斷是否有與之對應的有效的 session,若是有,則取出用戶信息
  • 經過用戶信息生成 jwt,並透傳其餘參數,轉發到真正的後端 API

不難看出,主應用登錄後生成的 x-auth-token 並無辦法被子應用的 Node 端識別爲有效的 session id

這裏有兩種作法

  • 主應用和全部子應用共享同一個 session 存儲,咱們項目用的是 redis,因此就是讓全部應用共用同一個 redis

    • 優勢:簡單粗暴,工做量較小

    • 缺陷:共用存儲可能會產生一些衝突,某一子應用的開發不注意時可能錯誤地覆蓋掉其餘子應用的關鍵數據;各子應用沒法擁有特殊的用戶信息(好比在 subA 的用戶信息裏面有一個主應用和其餘子應用都沒有的特別的字段)

  • 子應用提供一個特殊的 SSO 接口,主應用在登錄後,調用全部子應用的 SSO 接口並傳輸這個 x-auth-token 和加密後的用戶帳號,讓各子應用生成各自的 session

    • 優勢:存儲分離;各子應用能夠根據須要維護特殊的用戶信息

    • 缺陷:須要開發新接口;子應用數量較多時,登錄動做的響應時間變長(須要確保每一個子應用的 SSO 接口都成功)

基於現狀,咱們選擇的是第二個方案,對用戶帳號採用 RC4 對稱加密,每一個子應用維護單獨的 salt,主應用維護全部的 salt,子應用配置變成了

subApps: [
  {
    name: 'appA',
    prefix: '/appA_apis',
    salt: 'appA',
    // 子應用的 host,例如 http://localhost:3000
    host: process.env['subApps.appA.host']
  }
]
複製代碼

而後,在主應用登錄完成後,調用子應用提供的 SSO 接口

for (const subApp of subApps) {
  // 阻塞調用接口,確保每一個請求都正確
  await ...
}
複製代碼

通過以上步驟,咱們的頁面請求問題就基本上解決了

子應用間切換問題

最後,是子應用間的切換。一開始使用 React Router 的 Link 標籤,發現沒法從一個子應用切換到另外一個子應用,由於每一個子應用都擁有本身的路由,而每個路由的 history 都是調用 createBrowserHistory() 方法建立的

再次查看 qiankun 的文檔,發現一句話

當微應用信息註冊完以後,一旦瀏覽器的 url 發生變化,便會自動觸發 qiankun 的匹配邏輯,全部 activeRule 規則匹配上的微應用就會被插入到指定的 container 中,同時依次調用微應用暴露出的生命週期鉤子。

關鍵就在於觸發這個瀏覽器 url 的變化。這裏使用 window.history.pushState 方法,達成目的

history.pushState(null, linkPath, linkPath);
複製代碼

完成了子應用的切換,又發現了另外一個現象:當子應用 A 切換到某一個路由時,切換到子應用 B 並進行操做;而後再次切換回子應用 A,url 並非子應用 A 剛剛卸載時的路徑,但子應用 A 從新裝載後會回到剛剛的頁面。這對用戶操做體驗是好的,可是產生了 url 地址和真實呈現的界面不一致的現象

解決思路就是切換到子應用時,跳轉至以前的路由,因此須要存儲當前路由。因爲只能影響當前打開的界面,故選擇將該值存儲到 sessionStorage

首先,須要切換子應用以前,記錄當前的路由

sessionStorage.setItem('appA-currentRoute', window.location.href);
複製代碼

而後,在子應用裝載後,獲取當前路由並跳轉,而後刪除記錄的路由

const currentRoute = sessionStorage.getItem('appA-currentRoute');
if (currentRoute) {
  history.pushState(null, currentRoute, currentRoute);
  sessionStorage.setItem('appA-currentRoute', '');
}
複製代碼

經過以上方案,實現了子應用切換的應用狀態維護和 url 的匹配

總結

至此,咱們完成了微前端的初步實踐,基於微前端框架 qiankun,經過對原有系統的改造,以及開發一個主應用來做爲容器,實現了多應用合併的效果,在應用間切換時的用戶體驗獲得了很大的提升;同時,也考慮了兼容的問題,支持子應用單獨訪問,也兼容了原有的連接,自動重定向到正確的連接

微前端不是銀彈,只有真正遇到業務問題,須要提升用戶體驗的時候,再考慮去引入。不過,在將來任何應用開發的初期,均可以預先考慮到 共享域名、微前端改造 等的需求,保證全部請求都有惟一子路徑

相關文章
相關標籤/搜索