微前端学习笔记(2): 无界方案分析
Author:zhoulujun Date:
无界:https://wujie-micro.github.io/doc/
无界的核心思想:利用Iframe特性实现沙箱,让子应用的脚本在iframe内运行(如果子应用与主应用存在跨域,需要做cors设置处理),利用shadow dom实现样式隔离,子应用的dom在主应用容器下的webcomponent内。通过代理 iframe的document到webcomponent,可以实现两者的互联。
无界的实现方案
wujie的核心代码也十分简单,总共14个文件,入口在index.ts,可以顺着入口一点一点深入源码进行了解。
wujie-core核心代码思维导图
思维导图:https://www.kdocs.cn/view/l/cdZmhNpp4rIA
应用加载机制和 js 沙箱机制
将子应用的js注入主应用同域的iframe中运行,iframe是一个原生的window沙箱,内部有完整的history和location接口,子应用实例instance运行在iframe中,路由也彻底和主应用解耦,可以直接在业务组件里面启动应用。
创建iframe的逻辑
iframe将web应用完美隔离,无论是dom、css还是js都完全的隔离了起来,但dom隔离太严重,子应用的dom无法突破iframe的限制,比如一个fixed定位的元素也只能在iframe区域展示。估计无界也是因此只采用iframe来实现JS沙箱,而是使用 Web Components 来隔离html、css。
iframe 连接机制和 css 沙箱机制
无界采用webcomponent来实现页面的样式隔离,无界会创建一个wujie自定义元素,然后将子应用的完整结构渲染在内部
子应用的实例instance在iframe内运行,dom在主应用容器下的webcomponent内,通过代理 iframe的document到webcomponent,可以实现两者的互联。
将document的查询类接口:getElementsByTagName、getElementsByClassName、getElementsByName、getElementById、querySelector、querySelectorAll、head、body全部代理到webcomponent,这样instance和webcomponent就精准的链接起来。
当子应用发生切换,iframe保留下来,子应用的容器可能销毁,但webcomponent依然可以选择保留,这样等应用切换回来将webcomponent再挂载回容器上,子应用可以获得类似vue的keep-alive的能力.
注册自定义标签在无界加载的时候就注册了而且只执行一次,connect里的逻辑则是在自定义元素链接到dom文档中时执行
WuJie类是无界沙箱的核心,沙箱实例就是WuJie类的实例,里面存储着shadowroot、模板以及各种子应用的配置属性
子应用执行window的函数时,期望函数的this指向子应用的window,但是由于代理window是在基座生成的,所以this指向的基座的window,所以需要修正this指向
分析出document的所有属性以及方法,有些属性需要代理到全局的document,有些需要代理到沙箱的shadow root节点上【这里是子应用js和Webcomponents链接的关键步骤】
这一步和qiankun都一样,解析子应用模板的脚本、样式、模板
将解析的脚本插入到子应用iframe内,这里如果开启了fiber并且浏览器支持,可以在预加载时优化一些性能,在空闲时间去执行该操作
插入脚本时,并不是直接插入,需要对其进行改造。需要将代理的window、location作用于子应用脚步的执行环境,你可能会说这里怎么没有将代理对象proxyDocument传入? 这里我也无法理解
最后将HTML以及CSS添加到shadowroot中
wujie引起无法预知的bug主要还是围绕着 iframe 和 shadow dom之间的通信发生的。
路由同步机制
在iframe内部进行history.pushState,浏览器会自动的在joint session history中添加iframe的session-history,浏览器的前进、后退在不做任何处理的情况就可以直接作用于子应用
劫持iframe的history.pushState和history.replaceState,就可以将子应用的url同步到主应用的query参数上,当刷新浏览器初始化iframe时,读回子应用的url并使用iframe的history.replaceState进行同步
重写子应用的pushState实现子应用路由和基座路由的联动
子应用执行window的函数时,期望函数的this指向子应用的window,但是由于代理window是在基座生成的,所以this指向的基座的window,所以需要修正this指向
通信机制
承载子应用的iframe和主应用是同域的,所以主、子应用天然就可以很好的进行通信,在无界我们提供三种通信方式
props 注入机制
子应用通过$wujie.props可以轻松拿到主应用注入的数据
window.parent 通信机制
子应用iframe沙箱和主应用同源,子应用可以直接通过window.parent和主应用通信
去中心化的通信机制
无界提供了EventBus实例,注入到主应用和子应用,所有的应用可以去中心化的进行通信
子应用如何加载,生命周期管理
子应用的加载与乾坤的方式相同,都通过一个importHTML函数进行加载;它的具体过程就是:
fetch我们传入的这个url,得到一个html字符串,然后通过正则表达式匹配到内部样式表、外部样式表、脚本;源码通过/<(link)\s+.*?>/gis匹配外部样式,通过/(<script[\s\S]*?>)[\s\S]*?<\/script>/gi匹配脚本;通过/<style[^>]*>[\s\S]*?<\/style>/gi匹配内部样式;我们尝试一下自己写一个importHTML,解析一下我们当前这一篇文章:
const STYLE_REG = /<style>(.*)<\/style>/gi; const SCRIPT_REG = /<script>(.*)<\/script>/gi; const LINK_REG = /<(link)\s+.*?>/gi async function imoprtHTML() { let html = await fetch("https://juejin.cn/post/7209162467928096825"); html = await html.text(); const ans = html.replace(STYLE_REG, match=>{ // ... 很多逻辑 return match; } ).replace(SCRIPT_REG, match=>{ // ... 很多逻辑 return match; } ).replace(LINK_REG, match=>{ // ... 很多逻辑 debugger return match; }) }
第二步对于外部样式表、外部脚本我们也需要通过fetch获取到内容然后将代码存储起来
将合并的样式表添加到页面上
/** * convert external css link to inline style for performance optimization * @return embedHTML */ async function getEmbedHTML(template, styleResultList: StyleResultList): Promise<string> { let embedHTML = template; return Promise.all( styleResultList.map((styleResult, index) => styleResult.contentPromise.then((content) => { if (styleResult.src) { embedHTML = embedHTML.replace( genLinkReplaceSymbol(styleResult.src), styleResult.ignore ? `<link href="${styleResult.src}" rel="stylesheet" type="text/css">` : `<style>/* ${styleResult.src} */${content}</style>` ); } else if (content) { embedHTML = embedHTML.replace( getInlineStyleReplaceSymbol(index), `<style>/* inline-style-${index} */${content}</style>` ); } }) ) ).then(() => embedHTML); }
执行js,这个详细过程我们后文分析
/** * iframe插入脚本 * @param scriptResult script请求结果 * @param iframeWindow * @param rawElement 原始的脚本 */ export function insertScriptToIframe( scriptResult: ScriptObject | ScriptObjectLoader, iframeWindow: Window, rawElement?: HTMLScriptElement ) { const { src, module, content, crossorigin, crossoriginType, async, attrs, callback, onload } = scriptResult as ScriptObjectLoader; const scriptElement = iframeWindow.document.createElement("script"); const nextScriptElement = iframeWindow.document.createElement("script"); const { replace, plugins, proxyLocation } = iframeWindow.__WUJIE; const jsLoader = getJsLoader({ plugins, replace }); let code = jsLoader(content, src, getCurUrl(proxyLocation)); // 添加属性 attrs && Object.keys(attrs) .filter((key) => !Object.keys(scriptResult).includes(key)) .forEach((key) => scriptElement.setAttribute(key, String(attrs[key]))); // 内联脚本 if (content) { // patch location if (!iframeWindow.__WUJIE.degrade && !module) { code = `(function(window, self, global, location) { ${code} }).bind(window.__WUJIE.proxy)( window.__WUJIE.proxy, window.__WUJIE.proxy, window.__WUJIE.proxy, window.__WUJIE.proxyLocation, );`; } const descriptor = Object.getOwnPropertyDescriptor(scriptElement, "src"); // 部分浏览器 src 不可配置 取不到descriptor表示无该属性,可写 if (descriptor?.configurable || !descriptor) { // 解决 webpack publicPath 为 auto 无法加载资源的问题 try { Object.defineProperty(scriptElement, "src", { get: () => src || "" }); } catch (error) { console.warn(error); } } } else { src && scriptElement.setAttribute("src", src); crossorigin && scriptElement.setAttribute("crossorigin", crossoriginType); } module && scriptElement.setAttribute("type", "module"); scriptElement.textContent = code || ""; nextScriptElement.textContent = "if(window.__WUJIE.execQueue && window.__WUJIE.execQueue.length){ window.__WUJIE.execQueue.shift()()}"; const container = rawDocumentQuerySelector.call(iframeWindow.document, "head"); const execNextScript = () => !async && container.appendChild(nextScriptElement); const afterExecScript = () => { onload?.(); execNextScript(); }; }
子应用加载完毕
这个过程中涉及到哪些生命周期呢?
beforeLoad:子应用开始加载静态资源前触发,也就是importHTML之前触发
if (alive) { // 保活 await sandbox.active({ url, sync, prefix, el, props, alive, fetch, replace }); // 预加载但是没有执行的情况 if (!sandbox.execFlag) { sandbox.lifecycles?.beforeLoad?.(sandbox.iframe.contentWindow); const { getExternalScripts } = await importHTML({ url, html, opts: { fetch: fetch || window.fetch, plugins: sandbox.plugins, loadError: sandbox.lifecycles.loadError, fiber, }, }); await sandbox.start(getExternalScripts); }
beforeMount:子应用渲染前触发 (生命周期改造专用)
参考文章:
极致的微前端方案_无界的源码剖析 https://juejin.cn/post/7158777745806196743
假如你是『无界』微前端框架的开发者 https://juejin.cn/post/7212597327578808380?from=search-suggest
微前端-无界源码实现原理解析 https://juejin.cn/post/7331180722214109196
转载本站文章《微前端学习笔记(2): 无界方案分析》,
请注明出处:https://www.zhoulujun.cn/html/webfront/engineer/Architecture/9052.html