微前端学习笔记(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