您好,登錄后才能下訂單哦!
本文小編為大家詳細介紹“微前端框架qiankun隔離方法怎么使用”,內容詳細,步驟清晰,細節處理妥當,希望這篇“微前端框架qiankun隔離方法怎么使用”文章能幫助大家解決疑惑,下面跟著小編的思路慢慢深入,一起來學習新知識吧。
在基于single-spa開發的微前端應用中,子應用開發者需要特別注意的是:
要謹慎修改和使用全局變量上的屬性(如window、document等),以免造成依賴該屬性的自身應用或其它子應用運行時出現錯誤;
要謹慎控制CSS規則的生效范圍,避免覆蓋污染其它子應用的樣式;
但這樣的低級人為保證機制是無法在大規模的團隊開發過程中對應用的獨立性起到完善保護的,而qiankun框架給我們提供的最便利和有用的功能就是其基于配置的自動化沙箱隔離機制了。有了框架層面的子應用隔離支持,用戶無論是在編寫JS代碼還是修改CSS樣式時都不必再擔心代碼對于全局環境的污染問題了。沙箱機制一方面提升了微應用框架運行的穩定性和獨立性,另一方面也降低了微前端開發者的心智負擔,讓其只需專注于自己的子應用代碼開發之中。
在JS隔離方面,qiankun為開發者提供了三種不同模式的沙箱機制,分別適用于不同的場景之中。
該沙箱主要用于不支持Proxy對象的低版本瀏覽器之中,不能由用戶手動指定該模式,qiankun會自動檢測瀏覽器的支持情況并降級到Snapshot沙箱實現。由于這種實現方式在子應用運行過程中實際上修改了全局變量,因此不能用于多例模式之中(同時存在多個已掛載的子應用)。
該沙箱實現方式非常簡潔,下面我們給出其簡化后的實現
// 基于 diff 方式實現的沙箱,用于不支持 Proxy 的低版本瀏覽器 export default class SnapshotSandbox implements SandBox { private windowSnapshot!: Window; private modifyPropsMap: Record<any, any> = {}; constructor() {} 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]; }); } inactive() { this.modifyPropsMap = {}; iter(window, (prop) => { if (window[prop] !== this.windowSnapshot[prop]) { // 記錄變更,恢復環境 this.modifyPropsMap[prop] = window[prop]; window[prop] = this.windowSnapshot[prop]; } }); } }
沙箱內部存在兩個對象變量windowSnapshot
和modifyPropsMap
,分別用來存儲子應用掛載前原始window
對象上的全部屬性以及子應卸載時被其修改過的window
對象上的相關屬性。
Snapshot沙箱會在子應用mount
前將modifyPropsMap
中存儲的屬性重新賦值給window
以恢復該子應用之前執行時的全局變量上下文,并在子應用unmount
后將windowSnapshot
中存儲的屬性重新賦值給window
以恢復該子應用運行前的全局變量上下文,從而使得兩個不同子應用的window
相互獨立,達到JS隔離的目的。
當用戶手動配置sandbox.loose: true
時該沙箱被啟用。Legacy沙箱同樣會對window
造成污染,但是其性能比要比snapshot沙箱好,因為該沙箱不用遍歷window對象。同樣legacy沙箱也只適用于單例模式之中。
下面一起來看一下其簡化后的大致實現方式
/** * 基于 Proxy 實現的沙箱 * TODO: 為了兼容性 singular 模式下依舊使用該沙箱,等新沙箱穩定之后再切換 */ export default class LegacySandbox implements SandBox { /** 沙箱代理的全局變量 */ proxy: WindowProxy; /** 沙箱期間新增的全局變量 */ private addedPropsMapInSandbox = new Map<PropertyKey, any>(); /** 沙箱期間更新的全局變量 */ private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>(); /** 持續記錄更新的(新增和修改的)全局變量的 map,用于在任意時刻做 snapshot */ private currentUpdatedPropsValueMap = new Map<PropertyKey, any>(); constructor() { const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this; const rawWindow = window; const fakeWindow = Object.create(null) as Window; const setTrap = (p: PropertyKey, value: any, originalValue: any) => { if (!rawWindow.hasOwnProperty(p)) { // 當前 window 對象不存在該屬性,將其記錄在新增變量之中 addedPropsMapInSandbox.set(p, value); } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) { // 如果當前 window 對象存在該屬性,且 record map 中未記錄過,則記錄該屬性初始值 modifiedPropsOriginalValueMapInSandbox.set(p, originalValue); } // 無論何種修改都記錄在currentUpdatedPropsValueMap中 currentUpdatedPropsValueMap.set(p, value); // 必須重新設置 window 對象保證下次 get 時能拿到已更新的數據 (rawWindow as any)[p] = value; }; 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 if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') { return proxy; } const value = (rawWindow as any)[p]; return value; }, }); this.proxy = proxy } active() { // 激活時將子應用之前的所有改變重新賦予window,恢復其運行時上下文 this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v)); } inactive() { // 卸載時將window上修改的值復原,新添加的值刪除 this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v)); this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true)); } private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) { if (value === undefined && toDelete) { delete (this.globalContext as any)[prop]; } else { (this.globalContext as any)[prop] = value; } } }
Legacy沙箱為一個空對象fakewindow
使用proxy
代理攔截了其全部的set/get等操作,并在loader
中用其替換了window
。當用戶試圖修改window
屬性時,fakewindow
上代理的set
操作生效捕獲了相關修改,其分別將新增的屬性和修改前的值存入addedPropsMapInSandbox
和modifiedPropsOriginalValueMapInSandbox
這兩個Map之中,此外還將所有修改記錄在了currentUpdatedPropsValueMap
之中,并改變了window對象。
這樣當子應用掛載前,legacy沙箱會將currentUpdatedPropsValueMap
之中記錄的子應用相關修改重新賦予window,恢復其運行時上下文。當子應用卸載后,legacy沙箱會遍歷addedPropsMapInSandbox
和modifiedPropsOriginalValueMapInSandbox
這兩個Map并將window上的相關值恢復到子應用運行之前的狀態。最終達到了子應用間JS隔離的目的。
Proxy沙箱是qiankun框架中默認使用的沙箱模式(也可以通過配置sandbox.loose: false
來開啟),只有該模式真正做到了對window的無污染隔離(子應用完全不能修改全局變量),因此可以被應用在單/多例模式之中。
Proxy沙箱的原理也非常簡單,它將window上的所有屬性遍歷拷貝生成一個新的fakeWindow對象,緊接著使用proxy代理這個fakeWindow,用戶對window操作全部被攔截下來,只作用于在這個fakeWindow之上。
// 便利window拷貝創建初始代理對象 function createFakeWindow(globalContext: Window) { const fakeWindow = {} as FakeWindow; Object.getOwnPropertyNames(globalContext) .forEach((p) => { const descriptor = Object.getOwnPropertyDescriptor(globalContext, p); rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor)); }); return { fakeWindow }; } /** * 基于 Proxy 實現的沙箱 */ export default class ProxySandbox implements SandBox { // 標志該沙箱是否被啟用 sandboxRunning = true; constructor() { const { fakeWindow } = createFakeWindow(window); const proxy = new Proxy(fakeWindow, { set: (target: FakeWindow, p: PropertyKey, value: any): boolean => { if(this.sandboxRunning){ // 修改代理對象的值 target[p] = value; return true; } } get: (target: FakeWindow, p: PropertyKey): any => { // avoid who using window.window or window.self to escape the sandbox environment to touch the really window if (p === 'window' || p === 'self' || p === 'globalThis') { return proxy; } // 獲取代理對象的值 const value = target[p]; return value; }, }) } active() { if (!this.sandboxRunning) activeSandboxCount++; this.sandboxRunning = true; } inactive() { this.sandboxRunning = false; } }
對于CSS隔離的方式,在默認情況下由于切換子應用時,其相關的CSS內外連屬性會被卸載掉,所以可以確保單實例場景子應用之間的樣式隔離,但是這種方式無法確保主應用跟子應用、或者多實例場景的子應用樣式隔離。不過,qiankun也提供了兩種可配置生效的內置方式供使用者選擇。
當用戶配置sandbox.strictStyleIsolation: true
時,ShadowDOM樣式沙箱會被開啟。在這種模式下 qiankun 會為每個微應用的容器包裹上一個 shadow dom 節點,從而確保微應用的樣式不會對全局造成影響。
// 在子應用的DOM樹最外層進行一次包裹 function createElement( appContent: string, strictStyleIsolation: boolean, scopedCSS: boolean, appInstanceId: string, ): HTMLElement { // 包裹節點 const containerElement = document.createElement('div'); containerElement.innerHTML = appContent; // 子應用最外層節點 const appElement = containerElement.firstChild as HTMLElement; // 當開啟了ShadowDOM沙箱時 if (strictStyleIsolation) { const { innerHTML } = appElement; appElement.innerHTML = ''; let shadow: ShadowRoot; // 判斷瀏覽器兼容的創建ShadowDOM的方式,并使用該方式創建ShadowDOM根節點 if (appElement.attachShadow) { shadow = appElement.attachShadow({ mode: 'open' }); } else { // createShadowRoot was proposed in initial spec, which has then been deprecated shadow = (appElement as any).createShadowRoot(); } // 將子應用內容掛在ShadowDOM根節點下 shadow.innerHTML = innerHTML; } // 。。。。。。 return appElement; }
這種方式雖然看起來清晰簡單,還巧妙利用了瀏覽器對于ShadowDOM的CSS隔離特性,但是由于ShadowDOM的隔離比較嚴格,所以這并不是一種無腦使用的方案。例如:如果子應用內存在一個彈出時會掛在document根元素的彈窗,那么該彈窗的樣式是否會受到ShadowDOM的影響而失效?所以開啟該沙箱的用戶需要明白自己在做什么,且可能需要對子應用內部代碼做出一定的調整。
因為ShadowDOM存在著上述的一些問題,qiankun貼心的為用戶提供了另一種更加無腦簡便的樣式隔離方式,那就是Scoped CSS。通過配置sandbox.experimentalStyleIsolation: true
,Scoped樣式沙箱會被開啟。
在這種模式下,qiankun會遍歷子應用中所有的CSS選擇器,通過對選擇器前綴添加一個固定的帶有該子應用標識的屬性選擇器的方式來限制其生效范圍,從而避免子應用間、主應用與子應用的樣式相互污染。
export const QiankunCSSRewriteAttr = 'data-qiankun'; // 在子應用的DOM樹最外層進行一次包裹 function createElement( appContent: string, strictStyleIsolation: boolean, scopedCSS: boolean, appInstanceId: string, ): HTMLElement { // 包裹節點 const containerElement = document.createElement('div'); containerElement.innerHTML = appContent; // 子應用最外層節點 const appElement = containerElement.firstChild as HTMLElement; // 。。。。。。 // 當開啟了Scoped CSS沙箱時 if (scopedCSS) { // 為外層節點添加qiankun自定義屬性,其值設定為子應用id標識 const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr); if (!attr) { appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId); } // 獲取子應用中全部樣式并進行處理 const styleNodes = appElement.querySelectorAll('style') || []; forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => { css.process(appElement!, stylesheetElement, appInstanceId); }); } return appElement; }
qiankun首先對子應用最外層的包裹節點(一般為div節點)添加一個屬性名為data-qiankun
,值為appInstanceId
的屬性。接著遍歷處理子應用中的所有樣式。
export const process = ( appWrapper: HTMLElement, stylesheetElement: HTMLStyleElement | HTMLLinkElement, appName: string, ): void => { // lazy singleton pattern if (!processor) { processor = new ScopedCSS(); } // !!!注意,對于link標簽引入的外聯樣式不支持。qiankun在初期解析使用的import-html-entry在解析html模版時會將所有外聯樣式拉取并轉換為style標簽包裹的內聯樣式,所以這里不再處理link的外聯樣式。 if (stylesheetElement.tagName === 'LINK') { console.warn('Feature: sandbox.experimentalStyleIsolation is not support for link element yet.'); } const mountDOM = appWrapper; if (!mountDOM) { return; } // 獲取包裹元素標簽 const tag = (mountDOM.tagName || '').toLowerCase(); if (tag && stylesheetElement.tagName === 'STYLE') { // 生成屬性選擇器前綴,準備將其添加在選擇器前(如div[data-qiankun=app1]) const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`; processor.process(stylesheetElement, prefix); } }; // 。。。。。。 process(styleNode: HTMLStyleElement, prefix: string = '') { if (styleNode.textContent !== '') { // 獲取相關css規則rules const textNode = document.createTextNode(styleNode.textContent || ''); this.swapNode.appendChild(textNode); const sheet = this.swapNode.sheet as any; // type is missing const rules = arrayify<CSSRule>(sheet?.cssRules ?? []); // 重寫這些CSS規則,將前綴添加進去 const css = this.rewrite(rules, prefix); // 用重寫后的CSS規則覆蓋之前的規則 styleNode.textContent = css; // 標志符,代表該節點已經處理過 (styleNode as any)[ScopedCSS.ModifiedTag] = true; return; } // 監聽節點變化 const mutator = new MutationObserver((mutations) => { for (let i = 0; i < mutations.length; i += 1) { const mutation = mutations[i]; // 忽略已經處理過的節點 if (ScopedCSS.ModifiedTag in styleNode) { return; } // 如果新增了未處理過的子節點(代表了用戶新注入了一些屬性),那么會再次重寫所有的CSS規則以確保新增的CSS不會污染子應用外部 if (mutation.type === 'childList') { const sheet = styleNode.sheet as any; const rules = arrayify<CSSRule>(sheet?.cssRules ?? []); const css = this.rewrite(rules, prefix); styleNode.textContent = css; (styleNode as any)[ScopedCSS.ModifiedTag] = true; } } }); // 注冊監聽 mutator.observe(styleNode, { childList: true }); } // 具體CSS規則重寫方式 private rewrite(rules: CSSRule[], prefix: string = '') { // 。。。。。。 // 這里省略其實現方式,整體實現思路簡單但步驟很繁瑣,主要就是對字符串的正則判斷和替換修改。 // 1. 對于根選擇器(html/body/:root等),直接將其替換為prefix // 2. 對于其它選擇器,將prefix放在最前面( selector1 selector2, selector3 =》 prefix selector1 selector2,prefix selector3) }
可以看到,qiankun通過為子應用的外層包裹元素注入屬性并將子應用全部樣式的作用范圍都限制在該包裹元素下(通過添加指定的屬性選擇器作為前綴)實現了scoped樣式沙箱隔離。需要注意的是,如果用戶在運行時對內聯樣式進行修改,qiankun是可以偵測到并幫助用戶限制其作用范圍,但如果用戶在運行時引入了新的外聯樣式或者自行創建了新的內聯標簽,那么qiankun并不會做出反應,相關的CSS規則還是可能會污染全局樣式。
對于微前端來說,除了應用間的隔離外,應用間的通信也是非常重要的部分。這里,single-spa提供了從主應用向子應用傳遞customProps
的方式實現了最基礎的參數傳遞。但是真實的開發場景需要的信息傳遞是非常復雜的,靜態的預設參數傳遞只能起到很小的作用,我們還需要一種更加強大的通信機制來幫助我們開發應用。
這里,qiankun在框架內部預先設計實現了完善的發布訂閱模式,降低了開發者的上手門檻。我們首先來看一下qiankun中的通信是如何進行的。
// ------------------主應用------------------ 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); }
接下來,讓我們一起來看一下它是如何實現的。
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; }, }; }
可以看到在initGlobalState
函數的執行中完成了一個發布訂閱模式的創建工作,并返回了相關的訂閱/發布/注銷方法。接著qiankun將這些返回的方法通過生命周期函數mount傳遞給子應用,這樣子應用就能夠拿到并使用全局狀態了,從而應用間的通信就得以實現了。此外offGlobalStateChange會在子應用unmount時自動調用以解除該子應用的訂閱,避免內存泄露。
讀到這里,這篇“微前端框架qiankun隔離方法怎么使用”文章已經介紹完畢,想要掌握這篇文章的知識點還需要大家自己動手實踐使用過才能領會,如果想了解更多相關內容的文章,歡迎關注億速云行業資訊頻道。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。