基於微前端qiankun的多頁簽快取方案實踐

作者:vivo 互聯網前端團隊- Tang Xiao

本文梳理了基於阿里開源微前端框架qiankun,實現多頁簽及子應用快取的方案,同時還類比了多個不同方案之間的區別及優劣勢,為使用微前端進行多頁簽開發的同學,提供一些參考。

一、多頁簽是什麼?

我們常見的瀏覽器多頁簽、編輯器多頁簽,從產品角度來說,就是為了能夠實現用戶訪問可記錄,快速定位工作區等作用;那對於單頁應用,可以通過實現多頁簽,對用戶的訪問記錄進行快取,從而提供更好的用戶體驗。

圖片

前端可以通過多種方式實現多頁簽,常見的方案有兩種:

  1. 通過CSS樣式display:none來控制頁面的顯示隱藏模組的內容;

  2. 將模組序列化快取,通過快取的內容進行渲染(與vue的keep-alive原理類似,在單頁面應用中應用廣泛)。

相對於第一種方式,第二種方式將DOM格式存儲在序列化的JS對象當中,只渲染需要展示的DOM元素,減少了DOM節點數,提升了渲染的性能,是當前主流的實現多頁簽的方式。

那麼相對於傳統的單頁面應用,通過微前端qiankun進行改造後的前端應用,在多頁簽上實現會有什麼不同呢?

1.1 單頁面應用實現多頁簽

改造前的單頁面應用技術棧是Vue全家桶(vue2.6.10 + element2.15.1 + webpack4.0.0+vue-cli4.2.0)。

vue框架提供了keep-alive來支援快取相關的需求,使用keep-alive即可實現多頁簽的基本功能,但是為了支援更多的功能,我們在其基礎上重新封裝了vue-keep-alive組件。

相對較於keep-alive通過include、exclude對快取進行控制,vue-keep-alive使用更原生的發布訂閱方式來刪除快取,可以實現更完整的多頁簽功能,例如同個路由可以根據參數的不同派生出多個路由實例(如打開多個詳情頁頁簽)以及動態刪除快取實例等功能。

下面是vue-keep-alive自定義的拓展實現:

created() {
  // 動態刪除快取實例監聽
  this.cache = Object.create(null);
  breadCompBus.$on('removeTabByKey', this.removeCacheByKey);
  breadCompBus.$on('removeTabByKeys', (data) => {
    data.forEach((item) => {
      this.removeCacheByKey(item);
    });
  });
}

vue-keep-alive組件即可傳入自定義方法,用於自定義vnode.key,支援同一匹配路由中派生多個實例。

// 傳入`vue-keep-alive`的自定義方法
function updateComponentsKey(key, name, vnode) {
  const match = this.$route.matched[1];

  if (match && match.meta.multiNodeKey) {
    vnode.key = match.meta.multiNodeKey(key, this.$route);
    return vnode.key;
  }

  return key;
}

1.2 使用qiankun進行微前端改造後,多頁簽快取有什麼不同

qiankun是由螞蟻金服推出的基於Single-Spa實現的前端微服務框架,本質上還是路由分髮式的服務框架,不同於原本 Single-Spa採用JS Entry用的方案,qiankun採用HTML Entry 方式進行了替代優化。

使用qiankun進行微前端改造後,頁面被拆分為一個基座應用和多個子應用,每個子應用都運行在獨立的沙箱環境中。

圖片

相對於單頁面應用中通過keep-alive管控組件實例的方式,拆分後的各個子應用的keep-alive並不能管控到其他子應用的實例,我們需要快取對所有的應用生效,那麼只能將快取放到基座應用中。

這個就存在幾個問題:

  1. 載入:主應用需要在什麼時候,用什麼方式來載入子應用實例?
  2. 渲染:通過快取實例來渲染子應用時,是通過DOM顯隱方式渲染子應用還是有其他方式?
  3. 通訊:關閉頁簽時,如何判斷是否完全卸載子應用,主應用應該使用什麼通訊方式告訴子應用?

二、方案選擇

通過在Github issues及掘金等平台的一系列資料查找和對比後,關於如何在qiankun框架下實現多頁簽,在不修改qiankun源碼的前提下,主要有兩種實現的思路。

2.1 方案一:多個子應用同時存在

實現思路:

在dom上通過v-show控制顯示哪一個子應用,及display:none;控制不同子應用dom的顯示隱藏。

url變化時,通過loadMicroApp手動控制載入哪個子應用,在頁簽關閉時,手動調用unmount方法卸載子應用。

示例:

<template>
  <div id="app">
  <header>
    <router-link to="/app-vue-hash/">app-vue-hash</router-link>
    <router-link to="/app-vue-history/">app-vue-history</router-link>
    <router-link to="/about">about</router-link>
  </header>
  <div id="appContainer1" v-show="$route.path.startsWith('/app-vue-hash/')"></div>
  <div id="appContainer2" v-show="$route.path.startsWith('/app-vue-history/')"></div>
  <router-view></router-view>
</div>
</template>

<script>
import { loadMicroApp } from 'qiankun';

const apps = [
{
  name: 'app-vue-hash',
  entry: '//localhost:1111',
  container: '#appContainer1',
  props: { data : { store, router } }
},
{
  name: 'app-vue-history',
  entry: '//localhost:2222',
  container: '#appContainer2',
  props: { data : store }
}
]

export default {
  mounted() {
    // 優先載入當前的子項目
    const path = this.$route.path;
    const currentAppIndex = apps.findIndex(item => path.includes(item.name));
    if(currentAppIndex !== -1){
      const currApp = apps.splice(currentAppIndex, 1)[0];
      apps.unshift(currApp);
    }
    // loadMicroApp 返回值是 app 的生命周期函數數組
    const loadApps = apps.map(item => loadMicroApp(item))
    // 當 tab 頁關閉時,調用 loadApps 中 app 的 unmount 函數即可
  },
}
</script>

具體的DOM展示(通過display:none;控制不同子應用DOM的顯隱):

圖片

方案優勢:

  1. loadMicroApp是qiankun提供的API,可以方便快速接入;

  2. 該方式不卸載子應用,頁簽切換速度比較快。

方案不足:

  1. 子應用切換時不銷毀DOM,會導致DOM節點和事件監聽過多,嚴重時會造成頁面卡頓;

  2. 子應用切換時未卸載,路由事件監聽也未卸載,需要對路由變化的監聽做特殊的處理。

2.2 方案二:同一時間僅載入一個子應用,同時保存其他應用的狀態

實現思路:

  1. 通過registerMicroApps註冊子應用,qiankun會通過自動載入匹配的子應用;

  2. 參考keep-alive實現方式,每個子應用都快取自己實例的vnode,下次進入子應用時可以直接使用快取的vnode直接渲染為真實DOM。

方案優勢:

  1. 同一時間,只是展示一個子應用的active頁面,可減少DOM節點數;

  2. 非active子應用卸載時同時會卸載DOM及不需要的事件監聽,可釋放一定記憶體。

方案不足:

  1. 沒有現有的API可以快速實現,需要自己管理子應用快取,實現較為複雜;

  2. DOM渲染多了一個從虛擬DOM轉化為真實DOM的一個過程,渲染時間會比第一種方案稍多。

vue組件實例化過程簡介

這裡簡單的回顧下vue的幾個關鍵的渲染節點:

圖片

vue關鍵渲染節點(來源:掘金社區)

compile:對template進行編譯,將AST轉化後生成render function;

render:生成VNODE虛擬DOM;

patch :將虛擬DOM轉換為真實DOM;

因此,方案二相對於方案一,就是多了最後patch的過程。

2.3 最終選擇

根據兩種方案優勢與不足的評估,同時根據我們項目的具體情況,最終選擇了方案二進行實現,具體原因如下:

  • 過多的DOM及事件監聽,會造成不必要的記憶體浪費,同時我們的項目主要以編輯器展示和數據展示為主,單個頁簽內內容較多,會更傾向於關注記憶體使用情況;

  • 方案二在子應用二次渲染時多了一個patch過程,渲染速度不會慢多少,在可接受範圍內。

三、具體實現

在上面一部分我們簡單的描述了方案二的一個實現思路,其核心思想就是是通過快取子應用實例的vnode,那麼這一部分,就來看下它的一個具體的實現的過程。

3.1 從組件級別的快取到應用級別的快取

在vue中,keep-alive組件通過快取vnode的方式,實現了組件級別的快取,對於通過vue框架實現的子應用來說,它其實也是一個vue實例,那麼我們同樣也可以做到通過快取vnode的方式,實現應用級別的快取。

通過分析keep-alive源碼,我們了解到keep-alive是通過在render中進行快取命中,返回對應組件的vnode,並在mounted和updated兩個生命周期鉤子中加入對子組件vnode的快取。

// keep-alive核心程式碼
render () {
  const slot = this.$slots.default
  const vnode: VNode = getFirstComponentChild(slot)
  const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
  if (componentOptions) {
    // 更多程式碼...
    // 快取命中
    if (cache[key]) {
      vnode.componentInstance = cache[key].componentInstance
      // make current key freshest
      remove(keys, key)
      keys.push(key)
    } else {
      // delay setting the cache until update
      this.vnodeToCache = vnode
      this.keyToCache = key
    }
    // 設置keep-alive,防止再次觸發created等生命周期
    vnode.data.keepAlive = true
  }
  return vnode || (slot && slot[0])
}
// mounted和updated時快取當前組件的vnode
mounted() {
  this.cacheVNode()
}
updated() {
  this.cacheVNode()
}

相對於keep-alive需要在mounted和updated兩個生命周期中對vnode快取進行更新,在應用級的快取中,我們只需要在子應用卸載時,主動對整個實例的vnode進行快取即可。

// 父應用提供unmountCache方法
function unmountCache() {
  // 此處永遠只會保存首次載入生成的實例
  const needCached = this.instance?.cachedInstance || this.instance;
  const cachedInstance = {};
  cachedInstance._vnode = needCached._vnode;
  // keepalive設置為必須 防止進入時再次created,同keep-alive實現
  if (!cachedInstance._vnode.data.keepAlive) cachedInstance._vnode.data.keepAlive = true;
  // 省略其他程式碼...

  // loadedApplicationMap用於是key-value形式,用於保存當前應用的實例
  loadedApplicationMap[this.cacheKey] = cachedInstance;
  // 省略其他程式碼...

  // 卸載實例
  this.instance.$destroy();
  // 設置為null後可進行垃圾回收
  this.instance = null;
}

// 子應用在qiankun框架提供的卸載方法中,調用unmountCache
export async function unmount() {
  console.log('[vue] system app unmount');
  mainService.unmountCache();
}

3.2 移花接木——將vnode重新掛載到一個新實例上

將vnode快取到記憶體中後,再將原有的instance卸載,重新進入子應用時,就可以使用快取的vnode進行render渲染。

// 創建子應用實例,有快取的vnode則使用快取的vnode
function newVueInstance(cachedNode) {
  const config = {
    router: this.router,
    store: this.store,
    render: cachedNode ? () => cachedNode : instance.render, // 優先使用快取vnode
  });
  return new Vue(config);
}

// 實例化子應用實例,根據是否有快取vnode確定是否傳入cachedNode
this.instance = newVueInstance(cachedNode);
this.instance.$mount('#app');

那麼,這裡不禁就會有些疑問:

  1. 如果我們每次進入子應用時,都重新創建一個實例,那麼為什麼還要卸載,直接不卸載就可以了嗎?

  2. 將快取vnode使用到一個新的實例上,不會有什麼問題嗎?

首先我們回答一下第一個問題,為什麼在切換子應用時,要卸載掉原來的子應用實例,有兩個考慮方面:

  • 其一,是對記憶體的考量,我們需要的其實僅僅是vnode,而不是整個實例,快取整個實例是方案一的實現方案,所以,我們僅需要快取我們需要的對象即可;

  • 其二,卸載子應用實例可以移除不必要的事件監聽,比如vue-router對popstate事件就進行了監聽,我們在其他子應用操作時,並不希望原來的子應用也對這些事件進行響應,那麼在子應用卸載時,就可以移除掉這些監聽。

對於第二個問題,情況會更加複雜一點,下面一個部分,就主要來看下主要遇到了哪些問題,又該如何去解決。

3.3 解決應用級快取方案的問題

3.3.1 vue-router相關問題

  • 在實例卸載後對路由變化監聽失效;

  • 新的vue-router對原有的router params等參數記錄失效。

首先我們需要明確這兩個問題的原因:

  • 第一個是因為在子應用卸載時移除了對popstate事件的監聽,那麼我們需要做的就是重新註冊對popstate事件的監聽,這裡可以通過重新實例化一個vue-router解決;

  • 第二問題是因為通過重新實例化vue-router解決第一個問題之後,實際上是一個新的vue-router,我們需要做的就是不僅要快取vnode,還需要快取router相關的資訊。

大致的解決實現如下:


// 實例化子應用vue-router
function initRouter() {
  const { router: originRouter } = this.baseConfig;
  const config = Object.assign(originRouter, {
    base: `app-kafka/`,
  });
  Vue.use(VueRouter);
  this.router = new VueRouter(config);
}

// 創建子應用實例,有快取的vnode則使用快取的vnode
function newVueInstance(cachedNode) {
  const config = {
    router: this.router, // 在vue init過程中,會重新調用vue-router的init方法,重新啟動對popstate事件監聽
    store: this.store,
    render: cachedNode ? () => cachedNode : instance.render, // 優先使用快取vnode
  });
  return new Vue(config);
}

function render() {
  if(isCache) {
    // 場景一、重新進入應用(有快取)
    const cachedInstance = loadedApplicationMap[this.cacheKey];

    // router使用快取命中
    this.router = cachedInstance.$router;
    // 讓當前路由在最初的Vue實例上可用
    this.router.apps = cachedInstance.catchRoute.apps;
    // 使用快取vnode重新實例化子應用
    const cachedNode = cachedInstance._vnode;
    this.instance = this.newVueInstance(cachedNode);
  } else {
    // 場景二、首次載入子應用/重新進入應用(無快取)
    this.initRouter();
    // 正常實例化
    this.instance = this.newVueInstance();
  }
}

function unmountCache() {
  // 省略其他程式碼...
  cachedInstance.$router = this.instance.$router;
  cachedInstance.$router.app = null;
  // 省略其他程式碼...
}

3.3.2 父子組件通訊

多頁簽的方式增加了父子組件通訊的頻率,qiankun有提供setGlobalState通訊方式,但是在單應用模式下,同一時間僅支援和一個子應用進行通行,對於unmount 的子應用來說,無法接收到父應用的通訊,因此,對於不同的場景,我們需要更加靈活的通訊方式。

子應用——父應用:使用qiankun自帶通訊方式;

從子到父的通訊場景較為簡單,一般只有路由變化時進行上報,並且僅為激活狀態的子應用才會上報,可直接使用qiankun自帶通訊方式;

父應用——子應用:使用自定義事件通訊;

父應用到子應用,不僅需要和active狀態的子應用通訊,還需要和當前處於快取中子應用通訊;

因此,父應用到子應用,通過自定義事件的方式,能夠實現父應用和多個子應用的通訊。

// 自定義事件發布
const evt = new CustomEvent('microServiceEvent', {
  detail: {
    action: { name: action, data },
    basePath, // 用於子應用唯一標識
  },
});
document.dispatchEvent(evt);

// 自定義事件監聽
document.addEventListener('microServiceEvent', this.listener);

3.3.3 快取管理,防止記憶體泄露

使用快取最重要的事項就是對快取的管理,在不需要的時候及時清理,這在JS中是非常重要但很容易被忽略的事項。

應用級快取

子應用vnode、router等屬性,子應用切換時快取;

頁面級快取

  • 通過vue-keep-alive快取組件的vnode;

  • 刪除頁簽時,監聽remove事件,刪除頁面對應的vnode;

  • vue-keep-alive組件中所有快取均被刪除時,通知刪除整個子應用快取;

3.4 整體框架

最後,我們從整體的視角來了解下多頁簽快取的實現方案。

因為不僅僅需要對子應用的快取進行管理,還需要將vue-keep-alive組件註冊到各個子應用中等事項,我們將這些服務統一在主應用的mainService中進行管理,在registerMicroApps註冊子應用時通過props傳入子應用,這樣就能夠實現同一套程式碼,多處復用。

圖片

// 子應用main.js
let mainService = null;

export async function mount(props) {
  mainService = null;
  const { MainService } = props;
  // 註冊主應用服務
  mainService = new MainService({
  // 傳入對應參數
  });
  // 實例化vue並渲染
  mainService.render(props);
}
export async function unmount() {
  mainService.unmountCache();
}

最後對關鍵流程進行梳理:

圖片

四、現有問題

4.1 暫時只支援vue框架的實例快取

該方案也是基於vue現有特性支援實現的,在react社區中對於多頁簽實現並沒有統一的實現方案,筆者也沒有過多的探索,考慮到現有項目是以vue技術棧為主,後期升級也會只升級到vue3.0,在一段時間內是可以完全支援的。

五、總結

相較於社區上大部分通過方案一進行實現,本文提供了另一種實現多頁簽快取的一種思路,主要是對子應用快取處理上有些許的不同,大致的思路及通訊的方式都是互通的。

另外本文對qiankun框架的使用沒有做太多的發散總結,官網和Github上已經有很多相關問題的總結和踩坑經驗可供參考。

最後,如果文章有什麼問題或錯誤,歡迎指出,謝謝。

參考閱讀