前言
上一篇文章:從0實現一個single-spa的前端微服務(中)中我們已經實現了single-spa
+ systemJS
的前端微服務以及完善的開發和打包配置,今天主要講一下這個方案存在的細節問題,以及qiankun
框架的一些研究對比。
single-spa + systemJs 方案存在的問題及解決辦法
single-spa
的三個生命週期函式bootstrap
、 mount
、 unmount
分別表示初始化、載入時、解除安裝時。
- 子系統匯出
bootstrap
、mount
和unmount
函式是必需的,但是unload
是可選的。 - 每個生命週期函式必須返回
Promise
。 - 如果匯出一個函式陣列(而不只是一個函式),這些函式將一個接一個地呼叫,等待一個函式的
promise
解析後再呼叫下一個。
css汙染問題的解決
我們知道,子系統解除安裝之後,其引入的css
並不會被刪掉,所以在子系統解除安裝時刪掉這些css
,是一種解決css
汙染的辦法,但是不太好記錄子系統引入了哪些css
。
我們可以藉助換膚的思路來解決css
汙染,首先css-scoped
解決95%的樣式汙染,然後就是全域性樣式可能會造成汙染,我們只需要將全域性樣式用一個id/class
包裹著就可以了,這樣這些全域性樣式僅在這個id/class
範圍內生效。
具體做法就是:在子系統載入時(mount
)給<body>
加一個特殊的id/class
,然後在子系統解除安裝時(unmount
)刪掉這個id/class
。而子系統的全域性樣式都僅在這個id/class
範圍內生效,如果子系統獨立執行,只需要在子系統的入口檔案index.html
裡面給<body>
手動加上這個id/class
即可。
程式碼如下:
async function mount(props){
//給body加class,以解決全域性樣式汙染
document.body.classList.add('app-vue-history')
}
async function unmount(props){
//去掉body的class
document.body.classList.remove('app-vue-history')
}
複製程式碼
js汙染問題的解決
暫時沒有很好的辦法解決,但是可以靠編碼規範來約束:頁面銷燬之前清除自己頁面上的定時器/全域性事件,必要的時候,全域性變數也應該銷燬。
如何實現切換系統更換favicon.ico圖示
這是一個比較常見的需求,類似還有某個系統需要插入一段特殊的js/css
,而其他系統不需要,解決辦法任然是在子系統載入時(mount
)插入需要的js/css
,在子系統解除安裝時(unmount
)刪掉。
const headEle = document.querySelector('head');
let linkEle = null ;
// 因為新插入的icon會覆蓋舊的,所以舊的不用刪除,如果需要刪除,可以在unmount時再插入進來
async function mount(props){
linkEle = document.createElement("link");
linkEle.setAttribute('rel','icon');
linkEle.setAttribute('href','https://gold-cdn.xitu.io/favicons/favicon.ico');
headEle.appendChild(linkEle);
}
async function unmount(props){
headEle.removeChild(linkEle);
linkEle = null;
}
複製程式碼
系統之間如何通訊
系統之間通訊一般有兩種方式:自定義事件和本地儲存。如果是兩個系統相互跳轉,可以用URL
傳資料。
一般來說,不會同時存在A、B兩個子系統,常見的資料共享就是登陸資訊,登陸資訊一般使用本地儲存記錄。另外一個常見的場景就是子系統修改了使用者資訊,主系統需要重新請求使用者資訊,這個時候一般用自定義事件通訊,自定義事件具體如何操作,可以看上一篇文章的例子。
另外,single-spa
的註冊函式registerApplication
,第四個引數可以傳遞資料給子系統,但傳遞的資料必須是一個物件。
註冊子系統的時候:
singleSpa.registerApplication(
'appVueHistory',
() => System.import('appVueHistory'),
location => location.pathname.startsWith('/app-vue-history/'),
{ authToken: "d83jD63UdZ6RS6f70D0" }
)
複製程式碼
子系統(appVueHistory
)接收資料:
export function mount(props) {
//官方文件寫的是props.customProps.authToken,實際上發現是props.authToken
console.log(props.authToken);
return vueLifecycles.mount(props);
}
複製程式碼
關於子系統的生命週期函式:
- 生命週期函式
bootstrap
,mount
,unmount
均包含引數props
- 引數
props
是一個物件,包含name
,singleSpa
,mountParcel
,customProps
。不同的版本可能略有差異 - 引數物件中
customProps
就是註冊的時候傳遞過來的引數
子系統如何實現keep-alive
檢視single-spa-vue
原始碼可以發現,在unmount
生命週期,它將vue
例項destroy
(銷燬了)並且清空了DOM
。所以實現keep-alive
的關鍵在於子系統的unmount
週期中不銷燬vue
例項並且不清空DOM
,採用display:none
來隱藏子系統。而在mount
週期,先判斷子系統是否存在,如果存在,則去掉其display:none
即可。
我們需要修改single-spa-vue
的部分原始碼:
function mount(opts, mountedInstances, props) {
let instance = mountedInstances[props.name];
return Promise.resolve().then(() => {
//先判斷是否已載入,如果是,則直接將其顯示出來
if(!instance){
//這裡面都是其原始碼,生成DOM並例項化vue的部分
instance = {};
const appOptions = { ...opts.appOptions };
if (props.domElement && !appOptions.el) {
appOptions.el = props.domElement;
}
let domEl;
if (appOptions.el) {
if (typeof appOptions.el === "string") {
domEl = document.querySelector(appOptions.el);
if (!domEl) {
throw Error(
`If appOptions.el is provided to single-spa-vue, the dom element must exist in the dom. Was provided as ${appOptions.el}`
);
}
} else {
domEl = appOptions.el;
}
} else {
const htmlId = `single-spa-application:${props.name}`;
// CSS.escape的文件(需考慮相容性):https://developer.mozilla.org/zh-CN/docs/Web/API/CSS/escape
appOptions.el = `#${CSS.escape(htmlId)}`;
domEl = document.getElementById(htmlId);
if (!domEl) {
domEl = document.createElement("div");
domEl.id = htmlId;
document.body.appendChild(domEl);
}
}
appOptions.el = appOptions.el + " .single-spa-container";
// single-spa-vue@>=2 always REPLACES the `el` instead of appending to it.
// We want domEl to stick around and not be replaced. So we tell Vue to mount
// into a container div inside of the main domEl
if (!domEl.querySelector(".single-spa-container")) {
const singleSpaContainer = document.createElement("div");
singleSpaContainer.className = "single-spa-container";
domEl.appendChild(singleSpaContainer);
}
instance.domEl = domEl;
if (!appOptions.render && !appOptions.template && opts.rootComponent) {
appOptions.render = h => h(opts.rootComponent);
}
if (!appOptions.data) {
appOptions.data = {};
}
appOptions.data = { ...appOptions.data, ...props };
instance.vueInstance = new opts.Vue(appOptions);
if (instance.vueInstance.bind) {
instance.vueInstance = instance.vueInstance.bind(instance.vueInstance);
}
mountedInstances[props.name] = instance;
}else{
instance.vueInstance.$el.style.display = "block";
}
return instance.vueInstance;
});
}
function unmount(opts, mountedInstances, props) {
return Promise.resolve().then(() => {
const instance = mountedInstances[props.name];
instance.vueInstance.$el.style.display = "none";
});
}
複製程式碼
而子系統內部頁面則和正常vue
系統一樣使用<keep-alive>
標籤來實現快取。
如何實現子系統的預請求(預載入)
vue-router
路由配置的時候可以使用按需載入(程式碼如下),按需載入之後路由檔案就會單獨打包成一個js
和css
。
path: "/about",
name: "about",
component: () => import( "../views/About.vue")
複製程式碼
而 vue-cli3
生成的模板打包後的index.html
中是有使用prefetch
和preload
來實現路由檔案的預請求的:
<link href=/js/about.js rel=prefetch>
<link href=/js/app.js rel=preload as=script>
複製程式碼
prefetch
預請求就是:瀏覽器網路空閒的時候請求並快取檔案
systemJs
只能拿到入口檔案,其他的路由檔案是按需載入的,無法實現預請求。但是如果你沒有使用路由的按需載入,則所有路由檔案都打包到一個檔案(app.js
),則可以實現預請求。
上述完整demo
檔案地址:github.com/gongshun/si…
qiankun框架
qiankun
是螞蟻金服開源的基於single-spa
的一個前端微服務框架。
js沙箱(sandbox)是如何實現的
我們知道所有的全域性的方法(alert
,setTimeout
,isNaN
等)、全域性的變/常量(NaN
,Infinity
,var
宣告的全域性變數等)和全域性物件(Array
,String
,Date
等)都屬於window
物件,而能導致js
汙染的也就是這些全域性的方法和物件。
所以qiankun
解決js
汙染的辦法是:在子系統載入之前對window
物件做一個快照(拷貝),然後在子系統解除安裝的時候恢復這個快照,即可以保證每次子系統執行的時候都是一個全新的window
物件環境。
那麼如何監測window
物件的變化呢,直接將window
物件進行一下深拷貝,然後深度對比各個屬性顯然可行性不高,qiankun
框架採用的是ES6
新特性,proxy
代理方法。
具體程式碼如下(原始碼是ts
版的,我簡化修改了一些):
// 沙箱期間新增的全域性變數
const addedPropsMapInSandbox = new Map();
// 沙箱期間更新的全域性變數
const modifiedPropsOriginalValueMapInSandbox = new Map();
// 持續記錄更新的(新增和修改的)全域性變數的 map,用於在任意時刻做 snapshot
const currentUpdatedPropsValueMap = new Map();
const boundValueSymbol = Symbol('bound value');
const rawWindow = window;
const fakeWindow = Object.create(null);
const sandbox = new Proxy(fakeWindow, {
set(target, propKey, value) {
if (!rawWindow.hasOwnProperty(propKey)) {
addedPropsMapInSandbox.set(propKey, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(propKey)) {
// 如果當前 window 物件存在該屬性,且 record map 中未記錄過,則記錄該屬性初始值
const originalValue = rawWindow[propKey];
modifiedPropsOriginalValueMapInSandbox.set(propKey, originalValue);
}
currentUpdatedPropsValueMap.set(propKey, value);
// 必須重新設定 window 物件保證下次 get 時能拿到已更新的資料
rawWindow[propKey] = value;
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 會丟擲 TypeError,在沙箱解除安裝的情況下應該忽略錯誤
return true;
},
get(target, propKey) {
if (propKey === 'top' || propKey === 'window' || propKey === 'self') {
return sandbox;
}
const value = rawWindow[propKey];
// isConstructablev :監測函式是否是建構函式
if (typeof value === 'function' && !isConstructable(value)) {
if (value[boundValueSymbol]) {
return value[boundValueSymbol];
}
const boundValue = value.bind(rawWindow);
Object.keys(value).forEach(key => (boundValue[key] = value[key]));
Object.defineProperty(value, boundValueSymbol, { enumerable: false, value: boundValue });
return boundValue;
}
return value;
},
has(target, propKey) {
return propKey in rawWindow;
},
});
複製程式碼
大致原理就是記錄window
物件在子系統執行期間新增、修改和刪除的屬性和方法,然後會在子系統解除安裝的時候復原這些操作。
這樣處理之後,全域性變數可以直接復原,但是事件監聽和定時器需要特殊處理:用addEventListener
新增的事件,需要用removeEventListener
方法來移除,定時器也需要特殊函式才能清除。所以它重寫了事件繫結/解綁和定時器相關函式。
重寫定時器(setInterval
)部分程式碼如下:
const rawWindowInterval = window.setInterval;
const hijack = function () {
const timerIds = [];
window.setInterval = (...args) => {
const intervalId = rawWindowInterval(...args);
intervalIds.push(intervalId);
return intervalId;
};
return function free() {
window.setInterval = rawWindowInterval;
intervalIds.forEach(id => {
window.clearInterval(id);
});
};
}
複製程式碼
由於qiankun在js沙箱功能中使用了proxy新特性,所以它的相容性和vue3一樣,不支援IE11及以下版本的IE。不過作者說可以嘗試禁用沙箱功能來提高相容性,但是不保證都能執行。去掉了js沙箱功能,就變得索然無味了。
css汙染他是如何解決的
它解決css
汙染的辦法是:在子系統解除安裝的時候,將子系統引入css
使用的<link>
、<style>
標籤移除掉。移除的辦法是重寫<head>
標籤的appendChild
方法,辦法類似定時器的重寫。
子系統載入時,會將所需要的js/css
檔案插入到<head>
標籤,而重寫的appendChild
方法會記錄所插入的標籤,然後子系統解除安裝的時候,會移除這些標籤。
預請求是如何實現的
解決子系統預請求的的根本在於,我們需要知道子系統有哪些js/css
需要載入,而藉助systemJs
載入子系統,只知道子系統的入口檔案(app.js
)。qiankun
不僅支援app.js
作為入口檔案,還支援index.html
作為入口檔案,它會用正則匹配出index.html
裡面的js/css
標籤,然後實現預請求。
網路不好和移動端訪問的時候,qiankun
不會進行預請求,移動端大多是使用資料流量,預請求則會浪費使用者流量,判斷程式碼如下:
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const isSlowNetwork = navigator.connection
? navigator.connection.saveData || /(2|3)g/.test(navigator.connection.effectiveType)
: false;
複製程式碼
請求js/css
檔案它採用的是fetch
請求,如果瀏覽器不支援,還需要polyfill
。
以下程式碼就是它請求js
並進行快取:
const defaultFetch = window.fetch.bind(window);
//scripts是用正則匹配到的script標籤
function getExternalScripts(scripts, fetch = defaultFetch) {
return Promise.all(scripts.map(script => {
if (script.startsWith('<')) {
// 內聯js程式碼塊
return getInlineCode(script);
} else {
// 外鏈js
return scriptCache[script] ||
(scriptCache[script] = fetch(script).then(response => response.text()));
}
}));
}
複製程式碼
用qianklun框架實現微前端
qiankun
的原始碼中已經給出了使用示例,使用起來也非常簡單好用。接下來我演示下如何從0開始用qianklun
框架實現微前端,內容改編自官方使用示例。
主專案main
vue-cli3
生成一個全新的vue
專案,注意路由使用history
模式。- 安裝
qiankun
框架:npm i qiankun -S
- 修改
app.vue
,使其成為選單和子專案的容器。其中兩個資料,loading
就是載入的狀態,而content
則是子系統生成的HTML
片段(子系統獨立執行時,這個HTML
片段會被插入到#app
裡面的)
<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>
</header>
<div v-if="loading" class="loading">loading</div>
<div class="appContainer" v-html="content">content</div>
</div>
</template>
<script>
export default {
props: {
loading: {
type: Boolean,
default: false
},
content: {
type: String,
default: ''
},
},
}
</script>
複製程式碼
- 修改
main.js
,註冊子專案,子專案入口檔案採用index.html
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import { registerMicroApps, start } from 'qiankun';
Vue.config.productionTip = false
let app = null;
function render({ appContent, loading }) {
if (!app) {
app = new Vue({
el: '#container',
router,
data() {
return {
content: appContent,
loading,
};
},
render(h){
return h(App, {
props: {
content: this.content,
loading: this.loading,
},
})
}
});
} else {
app.content = appContent;
app.loading = loading;
}
}
function initApp() {
render({ appContent: '', loading: false });
}
initApp();
function genActiveRule(routerPrefix) {
return location => location.pathname.startsWith(routerPrefix);
}
registerMicroApps([
{ name: 'app-vue-hash', entry: 'http://localhost:80', render, activeRule: genActiveRule('/app-vue-hash') },
{ name: 'app-vue-history', entry: 'http://localhost:1314', render, activeRule: genActiveRule('/app-vue-history') },
]);
start();
複製程式碼
注意:主專案中的index.html
模板裡面的<div id="app"></div>
需要改為<div id="container"></div>
子專案app-vue-hash
vue-cli3
生成一個全新的vue
專案,注意路由使用hash
模式。- 在
src
目錄新增檔案public-path.js
,注意用於修改子專案的publicPath
。
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
複製程式碼
- 修改
main.js
,配合主專案匯出single-spa
需要的三個生命週期。注意:路由例項化需要在main.js裡面完成,以便於路由的銷燬,所以路由檔案只需要匯出路由配置即可(原模板匯出的是路由例項)
import './public-path';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';
Vue.config.productionTip = false;
let router = null;
let instance = null;
function render() {
router = new VueRouter({
routes,
});
instance = new Vue({
router,
store,
render: h => h(App),
}).$mount('#appVueHash');// index.html 裡面的 id 需要改成 appVueHash,否則子專案無法獨立執行
}
if (!window.__POWERED_BY_QIANKUN__) {//全域性變數來判斷環境
render();
}
export async function bootstrap() {
console.log('vue app bootstraped');
}
export async function mount(props) {
console.log('props from main framework', props);
render();
}
export async function unmount() {
instance.$destroy();
instance = null;
router = null;
}
複製程式碼
- 修改打包配置檔案
vue.config.js
,主要是允許跨域、關閉熱更新、去掉檔案的hash
值、以及打包成umd
格式
const path = require('path');
const { name } = require('./package');
function resolve(dir) {
return path.join(__dirname, dir);
}
const port = 7101; // dev port
module.exports = {
filenameHashing: true,
devServer: {
hot: true,
disableHostCheck: true,
port,
overlay: {
warnings: false,
errors: true,
},
headers: {
'Access-Control-Allow-Origin': '*',
},
},
// 自定義webpack配置
configureWebpack: {
output: {
// 把子應用打包成 umd 庫格式
library: `${name}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${name}`,
},
},
};
複製程式碼
子專案app-vue-history
history
模式的vue
專案與hash
模式只有一個地方不同,其他的一模一樣。
即main.js
裡面路由例項化的時候需要加入條件判斷,注入路由字首
function render() {
router = new VueRouter({
base: window.__POWERED_BY_QIANKUN__ ? '/app-vue-history' : '/',
mode: 'history',
routes,
});
instance = new Vue({
router,
store,
render: h => h(App),
}).$mount('#appVueHistory');
}
複製程式碼
其他
- 如果想關閉
js
沙箱和預請求,在start
函式中配置即可
start({
prefetch: false, //預設是true,可選'all'
jsSandbox: false, //預設是true
})
複製程式碼
- 子專案註冊函式
registerMicroApps
也可以傳遞資料給子專案,並且可以設定全域性的生命週期函式
// 其中app物件的props屬性就是傳遞給子專案的資料,預設是空物件
registerMicroApps(
[
{ name: 'app-vue-hash', entry: 'http://localhost:80', render, activeRule: genActiveRule('/app-vue-hash') , props: { data : 'message' } },
{ name: 'app-vue-history', entry: 'http://localhost:1314', render, activeRule: genActiveRule('/app-vue-history') },
],
{
beforeLoad: [
app => { console.log('before load', app); },
],
beforeMount: [
app => { console.log('before mount', app); },
],
afterUnmount: [
app => { console.log('after unload', app); },
],
},
);
複製程式碼
-
qiankun
的官方文件:qiankun.umijs.org/zh/api/#reg… -
上述
demo
的完整程式碼github.com/gongshun/qi…
總結
-
js
沙箱並不能解決所有的js
汙染,例如我給<body>
新增了一個點選事件,js
沙箱並不能消除它的影響,所以說,還得靠程式碼規範和自己自覺。 -
拋開相容性,我覺得
qiankun
真的太好用了,無需對子專案做過多的修改,開箱即用。也不需要對子專案的開發部署做任何額外的操作。 -
qiankun
框架使用index.html
作為子專案的入口,會將裡面的style/link/script
標籤以及註釋程式碼解析並插入,但是他沒有考慮meta
和title
標籤,如果切換系統,其中meta
標籤有變化,則不會解析並插入,當然了,meta
標籤不影響頁面展示,這樣的場景並不多。而切換系統,修改頁面的title
,則需要通過全域性鉤子函式來實現。 -
qiankun
框架不好實現keep-alive
需求,因為解決css/js
汙染的辦法就是刪除子系統插入的標籤和劫持window
物件,解除安裝時還原成子系統載入前的樣子,這與keep-alive
相悖:keep-alive
要求保留這些,僅僅是樣式上的隱藏。
最後,有什麼問題或者錯誤歡迎指出,互相成長,感謝!