基于微前端qiankun的多頁(yè)簽緩存方案實(shí)踐
作者|vivo 互聯(lián)網(wǎng)前端團(tuán)隊(duì)- Tang Xiao
本文梳理了基于阿里開(kāi)源微前端框架qiankun,實(shí)現(xiàn)多頁(yè)簽及子應(yīng)用緩存的方案,同時(shí)還類比了多個(gè)不同方案之間的區(qū)別及優(yōu)劣勢(shì),為使用微前端進(jìn)行多頁(yè)簽開(kāi)發(fā)的同學(xué),提供一些參考。
一、多頁(yè)簽是什么?
我們常見(jiàn)的瀏覽器多頁(yè)簽、編輯器多頁(yè)簽,從產(chǎn)品角度來(lái)說(shuō),就是為了能夠?qū)崿F(xiàn)用戶訪問(wèn)可記錄,快速定位工作區(qū)等作用;那對(duì)于單頁(yè)應(yīng)用,可以通過(guò)實(shí)現(xiàn)多頁(yè)簽,對(duì)用戶的訪問(wèn)記錄進(jìn)行緩存,從而提供更好的用戶體驗(yàn)。
前端可以通過(guò)多種方式實(shí)現(xiàn)多頁(yè)簽,常見(jiàn)的方案有兩種:
- 通過(guò)CSS樣式display:none來(lái)控制頁(yè)面的顯示隱藏模塊的內(nèi)容;
- 將模塊序列化緩存,通過(guò)緩存的內(nèi)容進(jìn)行渲染(與vue的keep-alive原理類似,在單頁(yè)面應(yīng)用中應(yīng)用廣泛)。
相對(duì)于第一種方式,第二種方式將DOM格式存儲(chǔ)在序列化的JS對(duì)象當(dāng)中,只渲染需要展示的DOM元素,減少了DOM節(jié)點(diǎn)數(shù),提升了渲染的性能,是當(dāng)前主流的實(shí)現(xiàn)多頁(yè)簽的方式。
那么相對(duì)于傳統(tǒng)的單頁(yè)面應(yīng)用,通過(guò)微前端qiankun進(jìn)行改造后的前端應(yīng)用,在多頁(yè)簽上實(shí)現(xiàn)會(huì)有什么不同呢?
1.1 單頁(yè)面應(yīng)用實(shí)現(xiàn)多頁(yè)簽
改造前的單頁(yè)面應(yīng)用技術(shù)棧是Vue全家桶(vue2.6.10 + element2.15.1 + webpack4.0.0+vue-cli4.2.0)。
vue框架提供了keep-alive來(lái)支持緩存相關(guān)的需求,使用keep-alive即可實(shí)現(xiàn)多頁(yè)簽的基本功能,但是為了支持更多的功能,我們?cè)谄浠A(chǔ)上重新封裝了vue-keep-alive組件。
相對(duì)較于keep-alive通過(guò)include、exclude對(duì)緩存進(jìn)行控制,vue-keep-alive使用更原生的發(fā)布訂閱方式來(lái)刪除緩存,可以實(shí)現(xiàn)更完整的多頁(yè)簽功能,例如同個(gè)路由可以根據(jù)參數(shù)的不同派生出多個(gè)路由實(shí)例(如打開(kāi)多個(gè)詳情頁(yè)頁(yè)簽)以及動(dòng)態(tài)刪除緩存實(shí)例等功能。
下面是vue-keep-alive自定義的拓展實(shí)現(xiàn):
created() {
// 動(dòng)態(tài)刪除緩存實(shí)例監(jiān)聽(tīng)
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,支持同一匹配路由中派生多個(gè)實(shí)例。
// 傳入`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進(jìn)行微前端改造后,多頁(yè)簽緩存有什么不同
qiankun是由螞蟻金服推出的基于Single-Spa實(shí)現(xiàn)的前端微服務(wù)框架,本質(zhì)上還是路由分發(fā)式的服務(wù)框架,不同于原本 Single-Spa采用JS Entry用的方案,qiankun采用HTML Entry 方式進(jìn)行了替代優(yōu)化。
使用qiankun進(jìn)行微前端改造后,頁(yè)面被拆分為一個(gè)基座應(yīng)用和多個(gè)子應(yīng)用,每個(gè)子應(yīng)用都運(yùn)行在獨(dú)立的沙箱環(huán)境中。
相對(duì)于單頁(yè)面應(yīng)用中通過(guò)keep-alive管控組件實(shí)例的方式,拆分后的各個(gè)子應(yīng)用的keep-alive并不能管控到其他子應(yīng)用的實(shí)例,我們需要緩存對(duì)所有的應(yīng)用生效,那么只能將緩存放到基座應(yīng)用中。
這個(gè)就存在幾個(gè)問(wèn)題:
- 加載:主應(yīng)用需要在什么時(shí)候,用什么方式來(lái)加載子應(yīng)用實(shí)例?
- 渲染:通過(guò)緩存實(shí)例來(lái)渲染子應(yīng)用時(shí),是通過(guò)DOM顯隱方式渲染子應(yīng)用還是有其他方式?
- 通信:關(guān)閉頁(yè)簽時(shí),如何判斷是否完全卸載子應(yīng)用,主應(yīng)用應(yīng)該使用什么通信方式告訴子應(yīng)用?
二、方案選擇
通過(guò)在Github issues及掘金等平臺(tái)的一系列資料查找和對(duì)比后,關(guān)于如何在qiankun框架下實(shí)現(xiàn)多頁(yè)簽,在不修改qiankun源碼的前提下,主要有兩種實(shí)現(xiàn)的思路。
2.1 方案一:多個(gè)子應(yīng)用同時(shí)存在
實(shí)現(xiàn)思路:
- 在dom上通過(guò)v-show控制顯示哪一個(gè)子應(yīng)用,及display:none;控制不同子應(yīng)用dom的顯示隱藏。
- url變化時(shí),通過(guò)loadMicroApp手動(dòng)控制加載哪個(gè)子應(yīng)用,在頁(yè)簽關(guān)閉時(shí),手動(dòng)調(diào)用unmount方法卸載子應(yīng)用。
示例:
<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: 'http://localhost:1111',
container: '#appContainer1',
props: { data : { store, router } }
},
{
name: 'app-vue-history',
entry: 'http://localhost:2222',
container: '#appContainer2',
props: { data : store }
}
]
export default {
mounted() {
// 優(yōu)先加載當(dāng)前的子項(xiàng)目
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 的生命周期函數(shù)數(shù)組
const loadApps = apps.map(item => loadMicroApp(item))
// 當(dāng) tab 頁(yè)關(guān)閉時(shí),調(diào)用 loadApps 中 app 的 unmount 函數(shù)即可
},
}
</script>
具體的DOM展示(通過(guò)display:none;控制不同子應(yīng)用DOM的顯隱):
方案優(yōu)勢(shì):
- loadMicroApp是qiankun提供的API,可以方便快速接入;
- 該方式不卸載子應(yīng)用,頁(yè)簽切換速度比較快。
方案不足:
- 子應(yīng)用切換時(shí)不銷毀DOM,會(huì)導(dǎo)致DOM節(jié)點(diǎn)和事件監(jiān)聽(tīng)過(guò)多,嚴(yán)重時(shí)會(huì)造成頁(yè)面卡頓;
- 子應(yīng)用切換時(shí)未卸載,路由事件監(jiān)聽(tīng)也未卸載,需要對(duì)路由變化的監(jiān)聽(tīng)做特殊的處理。
2.2 方案二:同一時(shí)間僅加載一個(gè)子應(yīng)用,同時(shí)保存其他應(yīng)用的狀態(tài)
實(shí)現(xiàn)思路:
- 通過(guò)registerMicroApps注冊(cè)子應(yīng)用,qiankun會(huì)通過(guò)自動(dòng)加載匹配的子應(yīng)用;
- 參考keep-alive實(shí)現(xiàn)方式,每個(gè)子應(yīng)用都緩存自己實(shí)例的vnode,下次進(jìn)入子應(yīng)用時(shí)可以直接使用緩存的vnode直接渲染為真實(shí)DOM。
方案優(yōu)勢(shì):
- 同一時(shí)間,只是展示一個(gè)子應(yīng)用的active頁(yè)面,可減少DOM節(jié)點(diǎn)數(shù);
- 非active子應(yīng)用卸載時(shí)同時(shí)會(huì)卸載DOM及不需要的事件監(jiān)聽(tīng),可釋放一定內(nèi)存。
方案不足:
- 沒(méi)有現(xiàn)有的API可以快速實(shí)現(xiàn),需要自己管理子應(yīng)用緩存,實(shí)現(xiàn)較為復(fù)雜;
- DOM渲染多了一個(gè)從虛擬DOM轉(zhuǎn)化為真實(shí)DOM的一個(gè)過(guò)程,渲染時(shí)間會(huì)比第一種方案稍多。
vue組件實(shí)例化過(guò)程簡(jiǎn)介
這里簡(jiǎn)單的回顧下vue的幾個(gè)關(guān)鍵的渲染節(jié)點(diǎn):
vue關(guān)鍵渲染節(jié)點(diǎn)(來(lái)源:掘金社區(qū))
- compile:對(duì)template進(jìn)行編譯,將AST轉(zhuǎn)化后生成render function;
- render:生成VNODE虛擬DOM;
- patch :將虛擬DOM轉(zhuǎn)換為真實(shí)DOM;
因此,方案二相對(duì)于方案一,就是多了最后patch的過(guò)程。
2.3 最終選擇
根據(jù)兩種方案優(yōu)勢(shì)與不足的評(píng)估,同時(shí)根據(jù)我們項(xiàng)目的具體情況,最終選擇了方案二進(jìn)行實(shí)現(xiàn),具體原因如下:
- 過(guò)多的DOM及事件監(jiān)聽(tīng),會(huì)造成不必要的內(nèi)存浪費(fèi),同時(shí)我們的項(xiàng)目主要以編輯器展示和數(shù)據(jù)展示為主,單個(gè)頁(yè)簽內(nèi)內(nèi)容較多,會(huì)更傾向于關(guān)注內(nèi)存使用情況;
- 方案二在子應(yīng)用二次渲染時(shí)多了一個(gè)patch過(guò)程,渲染速度不會(huì)慢多少,在可接受范圍內(nèi)。
三、具體實(shí)現(xiàn)
在上面一部分我們簡(jiǎn)單的描述了方案二的一個(gè)實(shí)現(xiàn)思路,其核心思想就是是通過(guò)緩存子應(yīng)用實(shí)例的vnode,那么這一部分,就來(lái)看下它的一個(gè)具體的實(shí)現(xiàn)的過(guò)程。
3.1 從組件級(jí)別的緩存到應(yīng)用級(jí)別的緩存
在vue中,keep-alive組件通過(guò)緩存vnode的方式,實(shí)現(xiàn)了組件級(jí)別的緩存,對(duì)于通過(guò)vue框架實(shí)現(xiàn)的子應(yīng)用來(lái)說(shuō),它其實(shí)也是一個(gè)vue實(shí)例,那么我們同樣也可以做到通過(guò)緩存vnode的方式,實(shí)現(xiàn)應(yīng)用級(jí)別的緩存。
通過(guò)分析keep-alive源碼,我們了解到keep-alive是通過(guò)在render中進(jìn)行緩存命中,返回對(duì)應(yīng)組件的vnode,并在mounted和upda
// 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
}
// 設(shè)置keep-alive,防止再次觸發(fā)created等生命周期
vnode.data.keepAlive = true
}
return vnode || (slot && slot[0])
}
// mounted和updated時(shí)緩存當(dāng)前組件的vnode
mounted() {
this.cacheVNode()
}
updated() {
this.cacheVNode()
}
相對(duì)于keep-alive需要在mounted和updated兩個(gè)生命周期中對(duì)vnode緩存進(jìn)行更新,在應(yīng)用級(jí)的緩存中,我們只需要在子應(yīng)用卸載時(shí),主動(dòng)對(duì)整個(gè)實(shí)例的vnode進(jìn)行緩存即可。
// 父應(yīng)用提供unmountCache方法
function unmountCache() {
// 此處永遠(yuǎn)只會(huì)保存首次加載生成的實(shí)例
const needCached = this.instance?.cachedInstance || this.instance;
const cachedInstance = {};
cachedInstance._vnode = needCached._vnode;
// keepalive設(shè)置為必須 防止進(jìn)入時(shí)再次created,同keep-alive實(shí)現(xiàn)
if (!cachedInstance._vnode.data.keepAlive) cachedInstance._vnode.data.keepAlive = true;
// 省略其他代碼...
// loadedApplicationMap用于是key-value形式,用于保存當(dāng)前應(yīng)用的實(shí)例
loadedApplicationMap[this.cacheKey] = cachedInstance;
// 省略其他代碼...
// 卸載實(shí)例
this.instance.$destroy();
// 設(shè)置為null后可進(jìn)行垃圾回收
this.instance = null;
}
// 子應(yīng)用在qiankun框架提供的卸載方法中,調(diào)用unmountCache
export async function unmount() {
console.log('[vue] system app unmount');
mainService.unmountCache();
}
3.2 移花接木——將vnode重新掛載到一個(gè)新實(shí)例上
將vnode緩存到內(nèi)存中后,再將原有的instance卸載,重新進(jìn)入子應(yīng)用時(shí),就可以使用緩存的vnode進(jìn)行render渲染。
// 創(chuàng)建子應(yīng)用實(shí)例,有緩存的vnode則使用緩存的vnode
function newVueInstance(cachedNode) {
const config = {
router: this.router,
store: this.store,
render: cachedNode ? () => cachedNode : instance.render, // 優(yōu)先使用緩存vnode
});
return new Vue(config);
}
// 實(shí)例化子應(yīng)用實(shí)例,根據(jù)是否有緩存vnode確定是否傳入cachedNode
this.instance = newVueInstance(cachedNode);
this.instance.$mount('#app');
那么,這里不禁就會(huì)有些疑問(wèn):
- 如果我們每次進(jìn)入子應(yīng)用時(shí),都重新創(chuàng)建一個(gè)實(shí)例,那么為什么還要卸載,直接不卸載就可以了嗎?
- 將緩存vnode使用到一個(gè)新的實(shí)例上,不會(huì)有什么問(wèn)題嗎?
首先我們回答一下第一個(gè)問(wèn)題,為什么在切換子應(yīng)用時(shí),要卸載掉原來(lái)的子應(yīng)用實(shí)例,有兩個(gè)考慮方面:
- 其一,是對(duì)內(nèi)存的考量,我們需要的其實(shí)僅僅是vnode,而不是整個(gè)實(shí)例,緩存整個(gè)實(shí)例是方案一的實(shí)現(xiàn)方案,所以,我們僅需要緩存我們需要的對(duì)象即可;
- 其二,卸載子應(yīng)用實(shí)例可以移除不必要的事件監(jiān)聽(tīng),比如vue-router對(duì)popstate事件就進(jìn)行了監(jiān)聽(tīng),我們?cè)谄渌討?yīng)用操作時(shí),并不希望原來(lái)的子應(yīng)用也對(duì)這些事件進(jìn)行響應(yīng),那么在子應(yīng)用卸載時(shí),就可以移除掉這些監(jiān)聽(tīng)。
對(duì)于第二個(gè)問(wèn)題,情況會(huì)更加復(fù)雜一點(diǎn),下面一個(gè)部分,就主要來(lái)看下主要遇到了哪些問(wèn)題,又該如何去解決。
3.3 解決應(yīng)用級(jí)緩存方案的問(wèn)題
3.3.1 vue-router相關(guān)問(wèn)題
- 在實(shí)例卸載后對(duì)路由變化監(jiān)聽(tīng)失效;
- 新的vue-router對(duì)原有的router params等參數(shù)記錄失效。
首先我們需要明確這兩個(gè)問(wèn)題的原因:
- 第一個(gè)是因?yàn)樵谧討?yīng)用卸載時(shí)移除了對(duì)popstate事件的監(jiān)聽(tīng),那么我們需要做的就是重新注冊(cè)對(duì)popstate事件的監(jiān)聽(tīng),這里可以通過(guò)重新實(shí)例化一個(gè)vue-router解決;
- 第二問(wèn)題是因?yàn)橥ㄟ^(guò)重新實(shí)例化vue-router解決第一個(gè)問(wèn)題之后,實(shí)際上是一個(gè)新的vue-router,我們需要做的就是不僅要緩存vnode,還需要緩存router相關(guān)的信息。
大致的解決實(shí)現(xiàn)如下:
// 實(shí)例化子應(yīng)用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);
}
// 創(chuàng)建子應(yīng)用實(shí)例,有緩存的vnode則使用緩存的vnode
function newVueInstance(cachedNode) {
const config = {
router: this.router, // 在vue init過(guò)程中,會(huì)重新調(diào)用vue-router的init方法,重新啟動(dòng)對(duì)popstate事件監(jiān)聽(tīng)
store: this.store,
render: cachedNode ? () => cachedNode : instance.render, // 優(yōu)先使用緩存vnode
});
return new Vue(config);
}
function render() {
if(isCache) {
// 場(chǎng)景一、重新進(jìn)入應(yīng)用(有緩存)
const cachedInstance = loadedApplicationMap[this.cacheKey];
// router使用緩存命中
this.router = cachedInstance.$router;
// 讓當(dāng)前路由在最初的Vue實(shí)例上可用
this.router.apps = cachedInstance.catchRoute.apps;
// 使用緩存vnode重新實(shí)例化子應(yīng)用
const cachedNode = cachedInstance._vnode;
this.instance = this.newVueInstance(cachedNode);
} else {
// 場(chǎng)景二、首次加載子應(yīng)用/重新進(jìn)入應(yīng)用(無(wú)緩存)
this.initRouter();
// 正常實(shí)例化
this.instance = this.newVueInstance();
}
}
function unmountCache() {
// 省略其他代碼...
cachedInstance.$router = this.instance.$router;
cachedInstance.$router.app = null;
// 省略其他代碼...
}
3.3.2父子組件通信
多頁(yè)簽的方式增加了父子組件通信的頻率,qiankun有提供setGlobalState通信方式,但是在單應(yīng)用模式下,同一時(shí)間僅支持和一個(gè)子應(yīng)用進(jìn)行通行,對(duì)于unmount 的子應(yīng)用來(lái)說(shuō),無(wú)法接收到父應(yīng)用的通信,因此,對(duì)于不同的場(chǎng)景,我們需要更加靈活的通信方式。
子應(yīng)用——父應(yīng)用:使用qiankun自帶通信方式;
從子到父的通信場(chǎng)景較為簡(jiǎn)單,一般只有路由變化時(shí)進(jìn)行上報(bào),并且僅為激活狀態(tài)的子應(yīng)用才會(huì)上報(bào),可直接使用qiankun自帶通信方式;
父應(yīng)用——子應(yīng)用:使用自定義事件通信;
父應(yīng)用到子應(yīng)用,不僅需要和active狀態(tài)的子應(yīng)用通信,還需要和當(dāng)前處于緩存中子應(yīng)用通信;
因此,父應(yīng)用到子應(yīng)用,通過(guò)自定義事件的方式,能夠?qū)崿F(xiàn)父應(yīng)用和多個(gè)子應(yīng)用的通信。
// 自定義事件發(fā)布
const evt = new CustomEvent('microServiceEvent', {
detail: {
action: { name: action, data },
basePath, // 用于子應(yīng)用唯一標(biāo)識(shí)
},
});
document.dispatchEvent(evt);
// 自定義事件監(jiān)聽(tīng)
document.addEventListener('microServiceEvent', this.listener);
3.3.3 緩存管理,防止內(nèi)存泄露
使用緩存最重要的事項(xiàng)就是對(duì)緩存的管理,在不需要的時(shí)候及時(shí)清理,這在JS中是非常重要但很容易被忽略的事項(xiàng)。
應(yīng)用級(jí)緩存
- 子應(yīng)用vnode、router等屬性,子應(yīng)用切換時(shí)緩存;
頁(yè)面級(jí)緩存
- 通過(guò)vue-keep-alive緩存組件的vnode;
- 刪除頁(yè)簽時(shí),監(jiān)聽(tīng)remove事件,刪除頁(yè)面對(duì)應(yīng)的vnode;
- vue-keep-alive組件中所有緩存均被刪除時(shí),通知?jiǎng)h除整個(gè)子應(yīng)用緩存;
3.4 整體框架
最后,我們從整體的視角來(lái)了解下多頁(yè)簽緩存的實(shí)現(xiàn)方案。
因?yàn)椴粌H僅需要對(duì)子應(yīng)用的緩存進(jìn)行管理,還需要將vue-keep-alive組件注冊(cè)到各個(gè)子應(yīng)用中等事項(xiàng),我們將這些服務(wù)統(tǒng)一在主應(yīng)用的mainService中進(jìn)行管理,在registerMicroApps注冊(cè)子應(yīng)用時(shí)通過(guò)props傳入子應(yīng)用,這樣就能夠?qū)崿F(xiàn)同一套代碼,多處復(fù)用。
// 子應(yīng)用main.js
let mainService = null;
export async function mount(props) {
mainService = null;
const { MainService } = props;
// 注冊(cè)主應(yīng)用服務(wù)
mainService = new MainService({
// 傳入對(duì)應(yīng)參數(shù)
});
// 實(shí)例化vue并渲染
mainService.render(props);
}
export async function unmount() {
mainService.unmountCache();
}
最后對(duì)關(guān)鍵流程進(jìn)行梳理:
四、現(xiàn)有問(wèn)題
4.1 暫時(shí)只支持vue框架的實(shí)例緩存
該方案也是基于vue現(xiàn)有特性支持實(shí)現(xiàn)的,在react社區(qū)中對(duì)于多頁(yè)簽實(shí)現(xiàn)并沒(méi)有統(tǒng)一的實(shí)現(xiàn)方案,筆者也沒(méi)有過(guò)多的探索,考慮到現(xiàn)有項(xiàng)目是以vue技術(shù)棧為主,后期升級(jí)也會(huì)只升級(jí)到vue3.0,在一段時(shí)間內(nèi)是可以完全支持的。
五、總結(jié)
相較于社區(qū)上大部分通過(guò)方案一進(jìn)行實(shí)現(xiàn),本文提供了另一種實(shí)現(xiàn)多頁(yè)簽緩存的一種思路,主要是對(duì)子應(yīng)用緩存處理上有些許的不同,大致的思路及通信的方式都是互通的。
另外本文對(duì)qiankun框架的使用沒(méi)有做太多的發(fā)散總結(jié),官網(wǎng)和Github上已經(jīng)有很多相關(guān)問(wèn)題的總結(jié)和踩坑經(jīng)驗(yàn)可供參考。