qiankun是single-spa二開;使用場景:不同技術棧,不同團隊,獨立開發部署、增量升級;總結:解耦;
主應用: 具有整合-輸入子應用的html入口;
子應用 與single-spa基本一致,匯出了三個生命週期函式 (bootstrap mount unmout)
js沙箱: 三個沙箱(快照沙箱、支援單應用的代理沙箱、多應用代理沙箱)
Snapshot Sandbox 原始碼解析:裡面的iter方法,將遍歷window屬性的程式碼抽離出來了,呼叫這個工具方法後,我們只需要專注於迭代到相應屬性時候需要進行的處理。
import type { SandBox } from '../interfaces'; import { SandBoxType } from '../interfaces'; function iter(obj: typeof window, callbackFn: (prop: any) => void) { // eslint-disable-next-line guard-for-in, no-restricted-syntax for (const prop in obj) { // patch for clearInterval for compatible reason, see #1490 if (obj.hasOwnProperty(prop) || prop === 'clearInterval') { callbackFn(prop); } } } /** * 基於 diff 方式實現的沙箱,用於不支援 Proxy 的低版本瀏覽器 */ export default class SnapshotSandbox implements SandBox { proxy: WindowProxy; name: string; type: SandBoxType; sandboxRunning = true; private windowSnapshot!: Window; private modifyPropsMap: Record<any, any> = {}; constructor(name: string) { this.name = name; this.proxy = window; this.type = SandBoxType.Snapshot; } active() { // 記錄當前快照 this.windowSnapshot = {} as Window; iter(window, (prop) => { this.windowSnapshot[prop] = window[prop]; }); // 恢復之前的變更 Object.keys(this.modifyPropsMap).forEach((p: any) => { window[p] = this.modifyPropsMap[p]; }); this.sandboxRunning = true; } inactive() { this.modifyPropsMap = {}; iter(window, (prop) => { if (window[prop] !== this.windowSnapshot[prop]) { // 記錄變更,恢復環境 this.modifyPropsMap[prop] = window[prop]; window[prop] = this.windowSnapshot[prop]; } }); if (process.env.NODE_ENV === 'development') { console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap)); } this.sandboxRunning = false; } }
LegacySandbox 過三個變數來記住沙箱啟用後window發生變化過的所有屬性,這樣在後續的狀態還原時候就不再需要遍歷window的所有屬性來進行對比,提升了程式執行的效能。但是這仍然改變不了這種機制仍然汙染了window的狀態的事實,因此也就無法承擔起同時支援多個微應用執行的任務。
import type { SandBox } from '../../interfaces'; import { SandBoxType } from '../../interfaces'; import { getTargetValue } from '../common'; function isPropConfigurable(target: WindowProxy, prop: PropertyKey) { const descriptor = Object.getOwnPropertyDescriptor(target, prop); return descriptor ? descriptor.configurable : true; } /** * 基於 Proxy 實現的沙箱 * TODO: 為了相容性 singular 模式下依舊使用該沙箱,等新沙箱穩定之後再切換 */ export default class LegacySandbox implements SandBox { /** 沙箱期間新增的全域性變數 */ private addedPropsMapInSandbox = new Map<PropertyKey, any>(); /** 沙箱期間更新的全域性變數 */ private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>(); /** 持續記錄更新的(新增和修改的)全域性變數的 map,用於在任意時刻做 snapshot */ private currentUpdatedPropsValueMap = new Map<PropertyKey, any>(); name: string; proxy: WindowProxy; globalContext: typeof window; type: SandBoxType; sandboxRunning = true; latestSetProp: PropertyKey | null = null; private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) { if (value === undefined && toDelete) { // eslint-disable-next-line no-param-reassign delete (this.globalContext as any)[prop]; } else if (isPropConfigurable(this.globalContext, prop) && typeof prop !== 'symbol') { Object.defineProperty(this.globalContext, prop, { writable: true, configurable: true }); // eslint-disable-next-line no-param-reassign (this.globalContext as any)[prop] = value; } } active() { if (!this.sandboxRunning) { this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v)); } this.sandboxRunning = true; } inactive() { if (process.env.NODE_ENV === 'development') { console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [ ...this.addedPropsMapInSandbox.keys(), ...this.modifiedPropsOriginalValueMapInSandbox.keys(), ]); } // renderSandboxSnapshot = snapshot(currentUpdatedPropsValueMapForSnapshot); // restore global props to initial snapshot this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v)); this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true)); this.sandboxRunning = false; } constructor(name: string, globalContext = window) { this.name = name; this.globalContext = globalContext; this.type = SandBoxType.LegacyProxy; const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this; const rawWindow = globalContext; const fakeWindow = Object.create(null) as Window; const setTrap = (p: PropertyKey, value: any, originalValue: any, sync2Window = true) => { if (this.sandboxRunning) { if (!rawWindow.hasOwnProperty(p)) { addedPropsMapInSandbox.set(p, value); } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) { // 如果當前 window 物件存在該屬性,且 record map 中未記錄過,則記錄該屬性初始值 modifiedPropsOriginalValueMapInSandbox.set(p, originalValue); } currentUpdatedPropsValueMap.set(p, value); if (sync2Window) { // 必須重新設定 window 物件保證下次 get 時能拿到已更新的資料 (rawWindow as any)[p] = value; } this.latestSetProp = p; return true; } if (process.env.NODE_ENV === 'development') { console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`); } // 在 strict-mode 下,Proxy 的 handler.set 返回 false 會丟擲 TypeError,在沙箱解除安裝的情況下應該忽略錯誤 return true; }; const proxy = new Proxy(fakeWindow, { set: (_: Window, p: PropertyKey, value: any): boolean => { const originalValue = (rawWindow as any)[p]; return setTrap(p, value, originalValue, true); }, get(_: Window, p: PropertyKey): any { // avoid who using window.window or window.self to escape the sandbox environment to touch the really window // or use window.top to check if an iframe context // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13 if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') { return proxy; } const value = (rawWindow as any)[p]; return getTargetValue(rawWindow, value); }, // trap in operator // see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12 has(_: Window, p: string | number | symbol): boolean { return p in rawWindow; }, getOwnPropertyDescriptor(_: Window, p: PropertyKey): PropertyDescriptor | undefined { const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p); // A property cannot be reported as non-configurable, if it does not exists as an own property of the target object if (descriptor && !descriptor.configurable) { descriptor.configurable = true; } return descriptor; }, defineProperty(_: Window, p: string | symbol, attributes: PropertyDescriptor): boolean { const originalValue = (rawWindow as any)[p]; const done = Reflect.defineProperty(rawWindow, p, attributes); const value = (rawWindow as any)[p]; setTrap(p, value, originalValue, false); return done; }, }); this.proxy = proxy; } }
ProxySandbox 原始碼解析:它將window上的所有屬性遍歷複製生成一個新的fakeWindow物件,緊接著使用proxy代理這個fakeWindow,使用者對window操作全部被攔截下來,只作用於在這個fakeWindow之上
import type { SandBox } from '../interfaces'; import { SandBoxType } from '../interfaces'; import { nativeGlobal, nextTask } from '../utils'; import { getTargetValue, setCurrentRunningApp, getCurrentRunningApp } from './common'; type SymbolTarget = 'target' | 'globalContext'; type FakeWindow = Window & Record<PropertyKey, any>; /** * fastest(at most time) unique array method * @see https://jsperf.com/array-filter-unique/30 */ function uniq(array: Array<string | symbol>) { return array.filter(function filter(this: PropertyKey[], element) { return element in this ? false : ((this as any)[element] = true); }, Object.create(null)); } // zone.js will overwrite Object.defineProperty const rawObjectDefineProperty = Object.defineProperty; const variableWhiteListInDev = process.env.NODE_ENV === 'development' || window.__QIANKUN_DEVELOPMENT__ ? [ // for react hot reload // see https://github.com/facebook/create-react-app/blob/66bf7dfc43350249e2f09d138a20840dae8a0a4a/packages/react-error-overlay/src/index.js#L180 '__REACT_ERROR_OVERLAY_GLOBAL_HOOK__', ] : []; // who could escape the sandbox const variableWhiteList: PropertyKey[] = [ // FIXME System.js used a indirect call with eval, which would make it scope escape to global // To make System.js works well, we write it back to global window temporary // see https://github.com/systemjs/systemjs/blob/457f5b7e8af6bd120a279540477552a07d5de086/src/evaluate.js#L106 'System', // see https://github.com/systemjs/systemjs/blob/457f5b7e8af6bd120a279540477552a07d5de086/src/instantiate.js#L357 '__cjsWrapper', ...variableWhiteListInDev, ]; /* variables who are impossible to be overwrite need to be escaped from proxy sandbox for performance reasons */ const unscopables = { undefined: true, Array: true, Object: true, String: true, Boolean: true, Math: true, Number: true, Symbol: true, parseFloat: true, Float32Array: true, isNaN: true, Infinity: true, Reflect: true, Float64Array: true, Function: true, Map: true, NaN: true, Promise: true, Proxy: true, Set: true, parseInt: true, requestAnimationFrame: true, }; const useNativeWindowForBindingsProps = new Map<PropertyKey, boolean>([ ['fetch', true], ['mockDomAPIInBlackList', process.env.NODE_ENV === 'test'], ]); function createFakeWindow(globalContext: Window) { // map always has the fastest performance in has check scenario // see https://jsperf.com/array-indexof-vs-set-has/23 const propertiesWithGetter = new Map<PropertyKey, boolean>(); const fakeWindow = {} as FakeWindow; /* copy the non-configurable property of global to fakeWindow see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor > A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object. */ Object.getOwnPropertyNames(globalContext) .filter((p) => { const descriptor = Object.getOwnPropertyDescriptor(globalContext, p); return !descriptor?.configurable; }) .forEach((p) => { const descriptor = Object.getOwnPropertyDescriptor(globalContext, p); if (descriptor) { const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get'); /* make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return. see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get > The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable data property. */ if ( p === 'top' || p === 'parent' || p === 'self' || p === 'window' || (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop')) ) { descriptor.configurable = true; /* The descriptor of window.window/window.top/window.self in Safari/FF are accessor descriptors, we need to avoid adding a data descriptor while it was Example: Safari/FF: Object.getOwnPropertyDescriptor(window, 'top') -> {get: function, set: undefined, enumerable: true, configurable: false} Chrome: Object.getOwnPropertyDescriptor(window, 'top') -> {value: Window, writable: false, enumerable: true, configurable: false} */ if (!hasGetter) { descriptor.writable = true; } } if (hasGetter) propertiesWithGetter.set(p, true); // freeze the descriptor to avoid being modified by zone.js // see https://github.com/angular/zone.js/blob/a5fe09b0fac27ac5df1fa746042f96f05ccb6a00/lib/browser/define-property.ts#L71 rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor)); } }); return { fakeWindow, propertiesWithGetter, }; } let activeSandboxCount = 0; /** * 基於 Proxy 實現的沙箱 */ export default class ProxySandbox implements SandBox { /** window 值變更記錄 */ private updatedValueSet = new Set<PropertyKey>(); name: string; type: SandBoxType; proxy: WindowProxy; globalContext: typeof window; sandboxRunning = true; latestSetProp: PropertyKey | null = null; private registerRunningApp(name: string, proxy: Window) { if (this.sandboxRunning) { const currentRunningApp = getCurrentRunningApp(); if (!currentRunningApp || currentRunningApp.name !== name) { setCurrentRunningApp({ name, window: proxy }); } // FIXME if you have any other good ideas // remove the mark in next tick, thus we can identify whether it in micro app or not // this approach is just a workaround, it could not cover all complex cases, such as the micro app runs in the same task context with master in some case nextTask(() => { setCurrentRunningApp(null); }); } } active() { if (!this.sandboxRunning) activeSandboxCount++; this.sandboxRunning = true; } inactive() { if (process.env.NODE_ENV === 'development') { console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [ ...this.updatedValueSet.keys(), ]); } if (--activeSandboxCount === 0) { variableWhiteList.forEach((p) => { if (this.proxy.hasOwnProperty(p)) { // @ts-ignore delete this.globalContext[p]; } }); } this.sandboxRunning = false; } constructor(name: string, globalContext = window) { this.name = name; this.globalContext = globalContext; this.type = SandBoxType.Proxy; const { updatedValueSet } = this; const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext); const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>(); const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key); const proxy = new Proxy(fakeWindow, { set: (target: FakeWindow, p: PropertyKey, value: any): boolean => { if (this.sandboxRunning) { this.registerRunningApp(name, proxy); // We must kept its description while the property existed in globalContext before if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) { const descriptor = Object.getOwnPropertyDescriptor(globalContext, p); const { writable, configurable, enumerable } = descriptor!; if (writable) { Object.defineProperty(target, p, { configurable, enumerable, writable, value, }); } } else { // @ts-ignore target[p] = value; } if (variableWhiteList.indexOf(p) !== -1) { // @ts-ignore globalContext[p] = value; } updatedValueSet.add(p); this.latestSetProp = p; return true; } if (process.env.NODE_ENV === 'development') { console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`); } // 在 strict-mode 下,Proxy 的 handler.set 返回 false 會丟擲 TypeError,在沙箱解除安裝的情況下應該忽略錯誤 return true; }, get: (target: FakeWindow, p: PropertyKey): any => { this.registerRunningApp(name, proxy); if (p === Symbol.unscopables) return unscopables; // avoid who using window.window or window.self to escape the sandbox environment to touch the really window // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13 if (p === 'window' || p === 'self') { return proxy; } // hijack globalWindow accessing with globalThis keyword if (p === 'globalThis') { return proxy; } if ( p === 'top' || p === 'parent' || (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop')) ) { // if your master app in an iframe context, allow these props escape the sandbox if (globalContext === globalContext.parent) { return proxy; } return (globalContext as any)[p]; } // proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnProperty if (p === 'hasOwnProperty') { return hasOwnProperty; } if (p === 'document') { return document; } if (p === 'eval') { return eval; } const value = propertiesWithGetter.has(p) ? (globalContext as any)[p] : p in target ? (target as any)[p] : (globalContext as any)[p]; /* Some dom api must be bound to native window, otherwise it would cause exception like 'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation' See this code: const proxy = new Proxy(window, {}); const proxyFetch = fetch.bind(proxy); proxyFetch('https://qiankun.com'); */ const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext; return getTargetValue(boundTarget, value); }, // trap in operator // see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12 has(target: FakeWindow, p: string | number | symbol): boolean { return p in unscopables || p in target || p in globalContext; }, getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined { /* as the descriptor of top/self/window/mockTop in raw window are configurable but not in proxy target, we need to get it from target to avoid TypeError see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor > A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object. */ if (target.hasOwnProperty(p)) { const descriptor = Object.getOwnPropertyDescriptor(target, p); descriptorTargetMap.set(p, 'target'); return descriptor; } if (globalContext.hasOwnProperty(p)) { const descriptor = Object.getOwnPropertyDescriptor(globalContext, p); descriptorTargetMap.set(p, 'globalContext'); // A property cannot be reported as non-configurable, if it does not exists as an own property of the target object if (descriptor && !descriptor.configurable) { descriptor.configurable = true; } return descriptor; } return undefined; }, // trap to support iterator with sandbox ownKeys(target: FakeWindow): ArrayLike<string | symbol> { return uniq(Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target))); }, defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean { const from = descriptorTargetMap.get(p); /* Descriptor must be defined to native window while it comes from native window via Object.getOwnPropertyDescriptor(window, p), otherwise it would cause a TypeError with illegal invocation. */ switch (from) { case 'globalContext': return Reflect.defineProperty(globalContext, p, attributes); default: return Reflect.defineProperty(target, p, attributes); } }, deleteProperty: (target: FakeWindow, p: string | number | symbol): boolean => { this.registerRunningApp(name, proxy); if (target.hasOwnProperty(p)) { // @ts-ignore delete target[p]; updatedValueSet.delete(p); return true; } return true; }, // makes sure `window instanceof Window` returns truthy in micro app getPrototypeOf() { return Reflect.getPrototypeOf(globalContext); }, }); this.proxy = proxy; activeSandboxCount++; } }
css隔離 有2種方案,一種是基於shadowDom;(缺陷:樣式彈窗脫離shadow tree會汙染) 一種類似vue 的scoped給每一個子應用加一個約束;
預載入
html entry 透過http請求載入制定地址的首屏內容html,解析-template script entry styles 暴漏一個promise物件
{ // template 是link替換為style之後的template template: embedHTML, // 靜態資原始檔 assetPublicPath, // 獲取外部指令碼 getExternalScripts: () => getExternalScripts(scripts, fetch), // 獲取外部樣式檔案 getExternalStyleSheets: () => getExternalStylesSheets(styles, fetch), // 指令碼執行器, 讓js程式碼在指定上下文中執行 execScripts: (proxy, strictGlobal) => { if(!scripts.length){ return Promise.resolve() } return execScripts(entry, scripts, proxy, {fetch, strictGlobal}) } }
js entry 載入的不是應用本身,是js匯出檔案,並且含有生命週期;
應用通訊
主基座建立全域性的globalState物件,修改值變化方法:setGlobalState 監聽值變化:onGlobalStateChange
// ------------------主應用------------------ import { initGlobalState, MicroAppStateActions } from 'qiankun'; // 初始化 state const actions: MicroAppStateActions = initGlobalState(state); // 在當前應用監聽全域性狀態,有變更觸發 callback actions.onGlobalStateChange((state, prev) => { // state: 變更後的狀態; prev 變更前的狀態 console.log(state, prev); }); // 按一級屬性設定全域性狀態,微應用中只能修改已存在的一級屬性 actions.setGlobalState(state); // 移除當前應用的狀態監聽,微應用 umount 時會預設呼叫 actions.offGlobalStateChange(); // ------------------子應用------------------ // 從生命週期 mount 中獲取通訊方法,使用方式和 master 一致 export function mount(props) { props.onGlobalStateChange((state, prev) => { // state: 變更後的狀態; prev 變更前的狀態 console.log(state, prev); }); props.setGlobalState(state); }
原始碼解析:initGlobalState函式的執行中完成了一個釋出訂閱模式的建立工作,並返回了相關的訂閱/釋出/登出方法
import { cloneDeep } from 'lodash'; import type { OnGlobalStateChangeCallback, MicroAppStateActions } from './interfaces'; // 全域性狀態 let globalState: Record<string, any> = {}; // 快取相關的訂閱者 const deps: Record<string, OnGlobalStateChangeCallback> = {}; // 觸發全域性監聽 function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) { Object.keys(deps).forEach((id: string) => { if (deps[id] instanceof Function) { // 依次通知訂閱者 deps[id](cloneDeep(state), cloneDeep(prevState)); } }); } // 初始化 export function initGlobalState(state: Record<string, any> = {}) { if (state === globalState) { console.warn('[qiankun] state has not changed!'); } else { const prevGlobalState = cloneDeep(globalState); globalState = cloneDeep(state); emitGlobal(globalState, prevGlobalState); } // 返回相關方法,形成閉包儲存相關狀態 return getMicroAppStateActions(`global-${+new Date()}`, true); } export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions { return { /** * onGlobalStateChange 全域性依賴監聽 * * 收集 setState 時所需要觸發的依賴 * * 限制條件:每個子應用只有一個啟用狀態的全域性監聽,新監聽覆蓋舊監聽,若只是監聽部分屬性,請使用 onGlobalStateChange * * 這麼設計是為了減少全域性監聽濫用導致的記憶體爆炸 * * 依賴資料結構為: * { * {id}: callback * } * * @param callback * @param fireImmediately 是否立即執行callback */ onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) { if (!(callback instanceof Function)) { console.error('[qiankun] callback must be function!'); return; } if (deps[id]) { console.warn(`[qiankun] '${id}' global listener already exists before this, new listener will overwrite it.`); } / 註冊訂閱 deps[id] = callback; if (fireImmediately) { const cloneState = cloneDeep(globalState); callback(cloneState, cloneState); } }, /** * setGlobalState 更新 store 資料 * * 1. 對輸入 state 的第一層屬性做校驗,只有初始化時宣告過的第一層(bucket)屬性才會被更改 * 2. 修改 store 並觸發全域性監聽 * * @param state */ setGlobalState(state: Record<string, any> = {}) { if (state === globalState) { console.warn('[qiankun] state has not changed!'); return false; } const changeKeys: string[] = []; const prevGlobalState = cloneDeep(globalState); globalState = cloneDeep( Object.keys(state).reduce((_globalState, changeKey) => { if (isMaster || _globalState.hasOwnProperty(changeKey)) { changeKeys.push(changeKey); return Object.assign(_globalState, { [changeKey]: state[changeKey] }); } console.warn(`[qiankun] '${changeKey}' not declared when init state!`); return _globalState; }, globalState), ); if (changeKeys.length === 0) { console.warn('[qiankun] state has not changed!'); return false; } // 觸發全域性監聽 emitGlobal(globalState, prevGlobalState); return true; }, // 登出該應用下的依賴 offGlobalStateChange() { delete deps[id]; return true; }, }; }
iframe那不行?
1.url不同步,瀏覽器重新整理iframe url 狀態丟失、後退前進按鈕無法使用;
2.全文上下文完全隔離,記憶體變數不共享;iframe 內外系統的通訊、資料同步等需求,主應用的 cookie 要透傳到根域名都不同的子應用中實現免登效果。
3.慢。每次子應用進入都是一次瀏覽器上下文重建、資源重新載入的過程。