您好,登錄后才能下訂單哦!
這篇文章主要介紹“無界微前端是怎么渲染子應(yīng)用的demo解析”的相關(guān)知識,小編通過實際案例向大家展示操作過程,操作方法簡單快捷,實用性強,希望這篇“無界微前端是怎么渲染子應(yīng)用的demo解析”文章能幫助大家解決問題。
無界與其他微前端框架(例如qiankun)的主要區(qū)別在于其獨特的 JS 沙箱機制。無界使用 iframe 來實現(xiàn) JS 沙箱,由于這個設(shè)計,無界在以下方面表現(xiàn)得更加出色:
應(yīng)用切換沒有清理成本
允許一個頁面同時激活多個子應(yīng)用
性能相對更優(yōu)
無界渲染子應(yīng)用,主要分為以下幾個步驟:
創(chuàng)建子應(yīng)用 iframe
解析入口 HTML
創(chuàng)建 webComponent,并掛載 HTML
運行 JS 渲染 UI
要在 iframe 中運行 JS,首先得有一個 iframe。
export function iframeGenerator( sandbox: WuJie, attrs: { [key: string]: any }, mainHostPath: string, appHostPath: string, appRoutePath: string ): HTMLIFrameElement { // 創(chuàng)建 iframe 的 DOM const iframe = window.document.createElement("iframe"); // 設(shè)置 iframe 的 attr setAttrsToElement(iframe, { // iframe 的 url 設(shè)置為主應(yīng)用的域名 src: mainHostPath, style: "display: none", ...attrs, name: sandbox.id, [WUJIE_DATA_FLAG]: "" }); // 將 iframe 插入到 document 中 window.document.body.appendChild(iframe); const iframeWindow = iframe.contentWindow; // 停止 iframe 的加載 sandbox.iframeReady = stopIframeLoading(iframeWindow).then(() => { // 省略其他內(nèi)容 } // 注入無界的變量到 iframeWindow,例如 __WUJIE patchIframeVariable(iframeWindow, sandbox, appHostPath); // 省略其他內(nèi)容 return iframe; }
創(chuàng)建 iframe 主要有以下流程:
創(chuàng)建 iframe 的 DOM,并設(shè)置屬性
將 iframe 插入到 document 中(此時 iframe 會立即訪問 src)
停止 iframe 的加載(stopIframeLoading)
為什么要停止 iframe 的加載?
因為要創(chuàng)建一個純凈的 iframe,防止 iframe 被污染,假如該 url 的 JS 代碼,聲明了一些全局變量、函數(shù),就可能影響到子應(yīng)用的運行(假如子應(yīng)用也有同名的變量、函數(shù))
為什么 iframe 的 src 要設(shè)置為主應(yīng)用的域名
為了實現(xiàn)應(yīng)用間(iframe 間)通訊,無界子應(yīng)用 iframe 的 url 會設(shè)置為主應(yīng)用的域名(同域)
主應(yīng)用域名為 a.com
子應(yīng)用域名為 b.com
,但它對應(yīng)的 iframe 域名為 a.com
,所以要設(shè)置 b.com
的資源能夠允許跨域訪問
因此 iframe 的 location.href
并不是子應(yīng)用的 url。
iframe 中運行 js,首先要知道要運行哪些 js
我們可以通過解析入口 HTML 來確定需要運行的 JS 內(nèi)容
假設(shè)有以下HTML
<!DOCTYPE html> <html lang="en"> <head> <script defer="defer" src="./static/js/main.4000cadb.js"></script> <link href="./static/css/main.7d8ad73e.css" rel="external nofollow" rel="stylesheet"> </head> <body> <div id="root"></div> </body> </html>
經(jīng)過 importHTML
處理后,結(jié)果如下:
template 模板部分,去掉了所有的 script 和 style
<!DOCTYPE html> <html lang="en"> <head> <!-- defer script https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js replaced by wujie --> <!-- link https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css replaced by wujie --> </head> </head> <body> <div id="root"></div> </body> </html>
getExternalScripts,獲取所有內(nèi)聯(lián)和外部的 script
[ { async: false, defer: true, src: 'https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js', module: false, crossorigin: false, crossoriginType: '', ignore: false, contentPromise: // 獲取 script 內(nèi)容字符串的 Promise } ]
getExternalStyleSheets,獲取所有內(nèi)聯(lián)和外部的 style
[ { src: "https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css", ignore: false, contentPromise: // 獲取 style 內(nèi)容字符串的 Promise } ]
為什么要將 script 和 style 從 HTML 中分離?
HTML 要作為 webComponent 的內(nèi)容,掛載到微前端掛載點上
因為無界有插件機制,需要單獨對 js/style 進行處理,再插入到 webComponent 中
script 除了需要經(jīng)過插件處理外,還需要放到 iframe 沙箱中執(zhí)行,因此也要單獨分離出來
external 是外部的意思,為什么 getExternalScripts 拿到的卻是所有的 script,而不是外部的非內(nèi)聯(lián) script?
external 是相對于解析后的 HTML 模板來說的,由于解析后的 HTML 不帶有任何的 js 和 css,所以這里的 external,就是指模板外的所有 JS
無界與 qiankun 的在解析 HTML 上區(qū)別?
無界和 qiankun 都是以 HTML 為入口的微前端框架。qiankun 基于 import-html-entry
解析 HTML,而無界則是借鑒 import-html-entry
代碼,實現(xiàn)了自己的 HTML 的解析,因此兩者在解析 HTML 上的不同,主要是在importHTML
的實現(xiàn)上。
由于無界支持執(zhí)行 esModule script,需要在分析的結(jié)果中,保留更多的信息
[ { async: false, defer: true, src: 'https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js', module: false, crossorigin: false, crossoriginType: '', ignore: false, contentPromise: // 獲取 script 內(nèi)容字符串的 Promise } ]
而 import-html-entry
的分析結(jié)果中,只有 script 的 js 內(nèi)容字符串。
無界是如何獲取 HTML 的外部的 script、style 內(nèi)容的?
分析 HTML,可以拿到外部 script
、style
的 url,用 fetch
發(fā)起 ajax 就可以獲取到 script
、style
的內(nèi)容。
但是 fetch
相對于原來 HTML script
標(biāo)簽,有一個壞處,就是 ajax 不能跨域,因此在使用無界的時候必須要給請求的資源設(shè)置允許跨域
單獨將 CSS 分離出來,是為了讓無界插件能夠?qū)?對 CSS 代碼進行修改,下面是一個 CSS loader 插件:
const plugins = [ { // 對 css 腳本動態(tài)的進行替換 // code 為樣式代碼、url為樣式的地址(內(nèi)聯(lián)樣式為'')、base為子應(yīng)用當(dāng)前的地址 cssLoader: (code, url, base) => { console.log("css-loader", url, code.slice(0, 50) + "..."); // do something return code; }, }, ];
無界會用以下代碼遍歷插件修改 CSS
// 將所有 plugin 的 CSSLoader 函數(shù),合成一個 css-loader 處理函數(shù) const composeCssLoader = compose(sandbox.plugins.map((plugin) => plugin.cssLoader)); const processedCssList: StyleResultList = getExternalStyleSheets().map(({ src, contentPromise }) => { return { src, // 傳入 CSS 文本處理處理函數(shù) contentPromise: contentPromise.then((content) => composeCssLoader(content, src, curUrl)), }; });
修改后的 CSS,會存儲在 processedCssList
數(shù)組中,需要遍歷該數(shù)組的內(nèi)容,將 CSS 重新嵌入到 HTML 中。
舉個例子,這是我們之前的 HTML
<!DOCTYPE html> <html lang="en"> <head> <!-- defer script https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js replaced by wujie --> <!-- link https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css replaced by wujie --> </head> </head> <body> <div id="root"></div> </body> </html>
嵌入 CSS 之后的 HTML 是這樣子的
<!DOCTYPE html> <html lang="en"> <head> <!-- defer script https://wujie-micro.github.io/demo-react16/static/js/main.4000cadb.js replaced by wujie --> - <!-- link https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css replaced by wujie --> + <style> + /* https://wujie-micro.github.io/demo-react16/static/css/main.7d8ad73e.css */. + 省略內(nèi)容 + <style/> </head> </head> <body> <div id="root"></div> </body> </html>
將原來的 Link 標(biāo)簽替換成 style 標(biāo)簽,并寫入 CSS 。
在執(zhí)行 JS 前,需要先把 HTML
的內(nèi)容渲染出來。
無界子應(yīng)用是掛載在 webComponent
中的,其定義如下:
class WujieApp extends HTMLElement { // 首次被插入文檔 DOM 時調(diào)用 connectedCallback(): void { if (this.shadowRoot) return; // 創(chuàng)建 shadowDOM const shadowRoot = this.attachShadow({ mode: "open" }); // 通過 webComponent 的標(biāo)簽 WUJIE_DATA_ID,拿到子應(yīng)用 id,再通過 id 拿到無界實例對象 const sandbox = getWujieById(this.getAttribute(WUJIE_DATA_ID)); // 保存 shadowDOM sandbox.shadowRoot = shadowRoot; } // 從文檔 DOM 中刪除時,被調(diào)用 disconnectedCallback(): void { const sandbox = getWujieById(this.getAttribute(WUJIE_DATA_ID)); sandbox?.unmount(); } } customElements?.define("wujie-app", WujieApp);
于是就可以這樣創(chuàng)建 webComponent
export function createWujieWebComponent(id: string): HTMLElement { const contentElement = window.document.createElement("wujie-app"); // 設(shè)置 WUJIE_DATA_ID 標(biāo)簽,為子應(yīng)用的 id‘ contentElement.setAttribute(WUJIE_DATA_ID, id); return contentElement; }
然后為 HTML
創(chuàng)建 DOM
,這個非常簡單
let html = document.createElement("html"); html.innerHTML = template; // template 為解析處理后的 HTML
直接用 innerHTML
設(shè)置 html
的內(nèi)容即可
然后再插入 CSS
(上一小節(jié)的內(nèi)容)
// processCssLoaderForTemplate 返回注入 CSS 的 html DOM 對象 const processedHtml = await processCssLoaderForTemplate(iframeWindow.__WUJIE, html)
最后掛載到 shadowDOM
中
shadowRoot.appendChild(processedHtml);
這樣就完成了 HTML
和 CSS 的掛載了,CSS 由于在 shadowDOM
內(nèi),樣式也不會影響到外部,也不會受外部樣式影響。
把 HTML
渲染到 webComponent
之后,我們就可以執(zhí)行 JS 了
export function insertScriptToIframe( scriptResult: ScriptObject | ScriptObjectLoader, iframeWindow: Window, ) { const { content, // js 的代碼字符串 } = scriptResult; const scriptElement = iframeWindow.document.createElement("script"); scriptElement.textContent = content || ""; // 獲取 head 標(biāo)簽 const container = rawDocumentQuerySelector.call(iframeWindow.document, "head"); // 在 head 中插入 script 標(biāo)簽,就會運行 js container.appendChild(scriptElement); }
創(chuàng)建 script
標(biāo)簽,并插入到 iframe 的 head 中,就在 iframe 中能運行對應(yīng)的 JS 代碼。
這樣雖然能運行 JS,但是產(chǎn)生的副作用(例如渲染的 UI),也會留在 iframe 中。
如何理解這句話?
當(dāng)我們在 iframe
中,使用 document.querySelector
查找 #app
的 DOM 時,它只能在 iframe
中查找(副作用留在 iframe
中),但 UI 是渲染到 webComponent 中的,webComponent
不在 iframe
中,且 iframe
不可見。
因此在 iframe
中就會找不到 DOM
。
那要怎么辦呢?
我們先來看看現(xiàn)代的前端框架,是如何渲染 UI 的
以 Vue 為例,需要給 Vue 指定一個 DOM 作為掛載點,Vue 會將組件,掛載到該 DOM 上
import Comp from './comp.vue' // 傳入根組件 const app = createApp(Comp) // 指定掛載點 app.mount('#app')
掛載到 #app
,實際上使用 document.querySelector
查找 DOM,然后掛載到 DOM 里面
但是正如上一小節(jié)說的,在無界微前端會有問題:
如果在 iframe
中運行 document.querySelector
,就會在 iframe
中查找就會查找不到,因為子應(yīng)用的 HTML
是渲染到外部的 shadowRoot
的
因此這里必須要對 iframe
的 document.querySelector
進行改造,改為從 shadowRoot
里面查找,才能使 Vue 組件能夠正確找到掛載點,偽代碼如下:
const proxyDocument = new Proxy( {}, { get: function (_, propKey) { if (propKey === "querySelector" || propKey === "querySelectorAll") { // 代理 shadowRoot 的 querySelector/querySelectorAll 方法 return new Proxy(shadowRoot[propKey], { apply(target, ctx, args) { // 相當(dāng)于調(diào)用 shadowRoot.querySelector return target.apply(shadowRoot, args); }, }); } }, } );
這樣修改之后,調(diào)用 proxyDocument.querySelector
就會從 shadowRoot
中查找元素,就能掛載到 shadowRoot
中的 DOM
中了。
Vue 的根組件,就能成功掛載上去,其他子組件,因為是掛載到根節(jié)點或它的子節(jié)點上,不需要修改掛載位置,就能夠正確掛載。
到此為止,如果不考慮其他 js 非視圖相關(guān)的 js 代碼,整個DOM 樹就已經(jīng)掛載成功,UI 就已經(jīng)能夠渲染出來了。
上一小節(jié),通過 proxyDocument.querySelector
,就能從 shadowRoot 查找元素
但這樣有一個壞處,就是要將 document
改成 proxyDocument
,代碼才能正確運行。但這是有方法解決的。
假如我們要運行的是以下代碼:
const app = document.querySelector('#app') // do something
我們可以包一層函數(shù):
(function (document){ const app = document.querySelector('#app') // do something })(proxyDocument)
這樣就不需要修改子應(yīng)用的源碼,直接使用 document.querySelector
但是,這樣做又會有新的問題:
esModule 的 import 必須要在函數(shù)最外層
var 聲明的變量,原本是全局變量,包一層函數(shù)后,變量會被留在函數(shù)內(nèi)
于是就有了下面的方案:
// 挾持 iframeWindow.Document.prototype 的 querySelector // 從 proxyDocument 中獲取 Object.defineProperty(iframeWindow.Document.prototype, 'querySelector', { enumerable: true, configurable: true, get: () => sandbox.proxyDocument['querySelector'], set: undefined, });
只要我們在 iframe 創(chuàng)建時(子應(yīng)用 JS),先通過 Object.defineProperty
重寫 querySelector
,挾持 document 的屬性/方法,然后從 proxyDocument
中取值,
這樣,就能直接執(zhí)行子應(yīng)用的 JS 代碼,不需要另外包一層函數(shù)執(zhí)行 JS
在無界微前端中,有非常多像 querySelector
的屬性/方法,需要對每個屬性方法的副作用進行修正。因此除了 proxyDocument
,還有 proxyWindow
、proxyLocation
很可惜的是,location 對象不能使用 Object.defineProperty
進行挾持,因此實際上,運行非 esModule 代碼時,仍然需要用函數(shù)包一層運行,傳入 proxyLocation 代替 location 對象。
但 esModule 由于不能在函數(shù)中運行,因此 esModule 代碼中獲取的 location 對象是錯誤的,這個無界的常見問題文檔也有提到。
接下來稍微介紹一下無界對 DOM 和 iframe 副作用的一些處理
無界通過創(chuàng)建代理對象、覆蓋屬性和函數(shù)等方式對原有的JavaScript對象進行挾持。需要注意的是,所有這些處理都必須在子應(yīng)用 JS 運行之前,也就是在 iframe 創(chuàng)建時執(zhí)行:
const iframe = window.document.createElement("iframe"); // 將 iframe 插入到 document 中 window.document.body.appendChild(iframe); const iframeWindow = iframe.contentWindow; // 停止 iframe 的加載 sandbox.iframeReady = stopIframeLoading(iframeWindow).then(() => { // 對副作用進行處理修正 }
在 stopIframeLoading
后,即停止 iframe 加載,獲得純凈的 iframe
后,再對副作用進行處理
無界微前端 JS 有非常多的副作用需要修正處理,文章不會一一列舉,這里會說一下大概,讓大家對這個有點概念。
下面是幾個例子
<img src = "./images/test.png" alt = "Test Image" />
當(dāng)我們在 DOM 中使用相對 url 時,會用 DOM 節(jié)點的 baseURI
作為基準(zhǔn),其默認(rèn)值為 document.location.href
。
但我們知道,子應(yīng)用的 UI 是掛載在 shadowRoot,跟主應(yīng)用是同一個 document 上下文,因此它的 baseURI
默認(rèn)是主應(yīng)用的 url
,但實際上應(yīng)該為子應(yīng)用的 url
才對,因此需要修正。
下面是部分修正的偽代碼:
// 重寫 Node 原型的 appendChild,在新增 DOM 時修正 iframeWindow.Node.prototype.appendChild = function(node) { const res = rawAppendChild.call(this, node); // 修正 DOM 的 baseURI patchElementEffect(node, iframeWindow); return res; };
事實上,除了 appendChild
,還有其他的函數(shù)需要修正,在每個能夠創(chuàng)建 DOM 的位置,都需要進行修正,例如 insertBefore
shadowRoot 可以視為子應(yīng)用的 document
在前端項目中,經(jīng)常會在 JS 中引入 CSS,實際上 CSS 文本會以 style 標(biāo)簽的形式注入到 docuement.head
中,偽代碼如下:
export default function styleInject(css) { const head = document.head const style = document.createElement('style') style.type = 'text/css' style.styleSheet.cssText = css head.appendChild(style) }
在 iframe 中使用 document.head
,需要用 Object.defineProperty
挾持 document 的 head 屬性,將其重定向到 shadowRoot 的 head
標(biāo)簽
Object.defineProperty(iframeWindow.document, 'head', { enumerable: true, configurable: true, // 改為從 proxyDocument 中取值 get: () => sandbox.proxyDocument['head'], set: undefined, });
proxyDocument
的 head 實際上為 shadowRoot
的 head
shadowRoot.head = shadowRoot.querySelector("head"); shadowRoot.body = shadowRoot.querySelector("body");
同樣的,很多組件庫的彈窗,都會往 document.body
插入彈窗的 DOM,因此也要處理
history
API 在 SPA 應(yīng)用中非常常見,例如 vue-router 就會使用到 history.pushState
、 history.replaceState
等 API。
當(dāng)前 url 改變時
需要改變 document.baseURI
,而它是個只讀的值,需要修改 document.head
中的 base
標(biāo)簽
需要將子應(yīng)用的 url,同步到父應(yīng)用的地址欄中
history.pushState = function (data: any, title: string, url?: string): void { // 當(dāng)前的 url const baseUrl = mainHostPath + iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash; // 根據(jù)當(dāng)前 url,計算出即將跳轉(zhuǎn)的 url 的絕對路徑 const mainUrl = getAbsolutePath(url?.replace(appHostPath, ""), baseUrl); // 調(diào)用原生的 history.pushState rawHistoryPushState.call(history, data, title, ignoreFlag ? undefined : mainUrl); // 更新 head 中的 base 標(biāo)簽 updateBase(iframeWindow, appHostPath, mainHostPath); // 同步 url 到主應(yīng)用地址欄 syncUrlToWindow(iframeWindow); };
有些屬性,應(yīng)該是使用主應(yīng)用 window 的屬性,例如:getComputedStyle
有些事件,需要掛載到主應(yīng)用,有些需要掛載到 iframe 中。這里直接舉個例子:
onunload 事件,需要掛載到 iframe 中
onkeyup 事件,需要掛載到主應(yīng)用的 window 下(iframe 中沒有 UI,UI 掛載到主應(yīng)用 document 的 shadowRoot 下)
因此要挾持 onXXX
事件和 addEventListener
,對每一個事件進行分發(fā),將事件掛載到 window
/ iframeWindow
中
將事件掛載到window
的代碼實現(xiàn)如下:
// 挾持 onXXX 函數(shù) Object.defineProperty(iframeWindow, 'onXXX', { enumerable: true, configurable: true, // 從 window 取 get: () => window['onXXX'], set: (handler) => { // 設(shè)置到 window window['onXXX'] = typeof handler === "function" ? handler.bind(iframeWindow) // 將函數(shù)的 this 設(shè)置為 iframeWindow : handler; } });
通過 Object.defineProperty
挾持 onXXX
,將事件設(shè)置到 window
上。
當(dāng)我們在子應(yīng)用 iframe 中獲取 location.href
, location.host
等屬性的時候,需要獲取的是子應(yīng)用的 href
和 host
(iframe 的 location href 并不是子應(yīng)用的 url),因此這里也是需要進行改造。
const proxyLocation = new Proxy( {}, { get: function (_, propKey) { if (propKey === "href") { return // 獲取子應(yīng)用真正的 url } // 省略其他屬性的挾持 }, } );
為什么 iframe 的 location href 不是子應(yīng)用的 url?
為了實現(xiàn)應(yīng)用間(iframe 間)通訊,無界子應(yīng)用 iframe 的 url 會設(shè)置為主應(yīng)用的域名(同域)
關(guān)于“無界微前端是怎么渲染子應(yīng)用的demo解析”的內(nèi)容就介紹到這里了,感謝大家的閱讀。如果想了解更多行業(yè)相關(guān)的知識,可以關(guān)注億速云行業(yè)資訊頻道,小編每天都會為大家更新不同的知識點。
免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。