简介
随着项目的发展,前端SPA应用的规模不断加大、业务代码耦合、编译慢,导致日常的维护难度日益增加。同时前端技术的发展迅猛,导致功能扩展吃力,重构成本高,稳定性低。
为了能够将前端模块解耦,通过相关技术调研,最终选择了无界微前端框架作为物流客服系统解耦支持。为了更好的使用无界微前端框架,我们对其运行机制进行了相关了解,以下是对无界运行机制的一些认识。
基本用法
主应用配置
import WujieVue from 'wujie-vue2';const { setupApp, preloadApp, bus } = WujieVue;
/*设置缓存*/
setupApp({
});
/*预加载*/
preloadApp({name: 'vue2'
})
<WujieVue width="100%" height="100%" name="vue2" :url="vue2Url" :sync="true" :alive="true"></WujieVue
具体实践详细介绍参考:
京东(JD.COM)-正品低价、品质保障、配送及时、轻松购物!
快速上手 | 无界
无界源码解析
1 源码包目录结构
packages 包里包含无界框架核心代码wujie-core和对应不同技术栈应用包
examples 使用案例,main-xxx对应该技术栈主应用的使用案例,其他代表子应用的使用案例
2 wujie-vue2组件
该组件默认配置了相关参数,简化了无界使用时的一些配置项,作为一个全局组件被主引用使用
这里使用wujie-vue2示例,其他wujie-react,wujie-vue3大家可自行阅读,基本作用和wujie-vue2相同都是用来简化无界配置,方便快速使用
import Vue from "vue";
import { bus, preloadApp, startApp as rawStartApp, destroyApp, setupApp } from "wujie";const wujieVueOptions = {name: "WujieVue",props: {/*传入配置参数*/},data() {return {startAppQueue: Promise.resolve(),};},mounted() {bus.$onAll(this.handleEmit);this.execStartApp();},methods: {handleEmit(event, ...args) {this.$emit(event, ...args);},async startApp() {try {// $props 是vue 2.2版本才有的属性,所以这里直接全部写一遍await rawStartApp({name: this.name,url: this.url,el: this.$refs.wujie,loading: this.loading,alive: this.alive,fetch: this.fetch,props: this.props,attrs: this.attrs,replace: this.replace,sync: this.sync,prefix: this.prefix,fiber: this.fiber,degrade: this.degrade,plugins: this.plugins,beforeLoad: this.beforeLoad,beforeMount: this.beforeMount,afterMount: this.afterMount,beforeUnmount: this.beforeUnmount,afterUnmount: this.afterUnmount,activated: this.activated,deactivated: this.deactivated,loadError: this.loadError,});} catch (error) {console.log(error);}},execStartApp() {this.startAppQueue = this.startAppQueue.then(this.startApp);},destroy() {destroyApp(this.name);},},beforeDestroy() {bus.$offAll(this.handleEmit);},render(c) {return c("div", {style: {width: this.width,height: this.height,},ref: "wujie",});},
};const WujieVue = Vue.extend(wujieVueOptions);WujieVue.setupApp = setupApp;
WujieVue.preloadApp = preloadApp;
WujieVue.bus = bus;
WujieVue.destroyApp = destroyApp;
WujieVue.install = function (Vue) {Vue.component("WujieVue", WujieVue);
};
export default WujieVue;
3 入口defineWujieWebComponent和StartApp
首先从入口文件index看起,defineWujieWebComponent
import { defineWujieWebComponent } from "./shadow";
// 定义webComponent容器
defineWujieWebComponent();// 定义webComponent 存在shadow.ts 文件中
export function defineWujieWebComponent() {class WujieApp extends HTMLElement {connectedCallback(){if (this.shadowRoot) return;const shadowRoot = this.attachShadow({ mode: "open" });const sandbox = getWujieById(this.getAttribute(WUJIE_DATA_ID));patchElementEffect(shadowRoot, sandbox.iframe.contentWindow);sandbox.shadowRoot = shadowRoot;}disconnectedCallback() {const sandbox = getWujieById(this.getAttribute(WUJIE_DATA_ID));sandbox?.unmount();}}customElements?.define("wujie-app", WujieApp);
}
startApp方法
startApp(options) {const newSandbox = new WuJie({ name, url, attrs, degradeAttrs, fiber, degrade, plugins, lifecycles });const { template, getExternalScripts, getExternalStyleSheets } = await importHTML({url,html,opts: {fetch: fetch || window.fetch,plugins: newSandbox.plugins,loadError: newSandbox.lifecycles.loadError,fiber,},});const processedHtml = await processCssLoader(newSandbox, template, getExternalStyleSheets);await newSandbox.active({ url, sync, prefix, template: processedHtml, el, props, alive, fetch, replace });await newSandbox.start(getExternalScripts);return newSandbox.destroy;
4 实例化
4-1, wujie (sandbox.ts)
// wujie
class wujie {constructor(options) {/** iframeGenerator在 iframe.ts中**/this.iframe = iframeGenerator(this, attrs, mainHostPath, appHostPath, appRoutePath);if (this.degrade) { // 降级模式const { proxyDocument, proxyLocation } = localGenerator(this.iframe, urlElement, mainHostPath, appHostPath);this.proxyDocument = proxyDocument;this.proxyLocation = proxyLocation;} else { // 非降级模式const { proxyWindow, proxyDocument, proxyLocation } = proxyGenerator();this.proxy = proxyWindow;this.proxyDocument = proxyDocument;this.proxyLocation = proxyLocation;}this.provide.location = this.proxyLocation;addSandboxCacheWithWujie(this.id, this);}
}
4-2.非降级Proxygenerator
非降级模式window、document、location代理
window代理拦截,修改this指向
export function proxyGenerator(iframe: HTMLIFrameElement,urlElement: HTMLAnchorElement,mainHostPath: string,appHostPath: string
): {proxyWindow: Window;proxyDocument: Object;proxyLocation: Object;
} {const proxyWindow = new Proxy(iframe.contentWindow, {get: (target: Window, p: PropertyKey): any => {// location进行劫持/*xxx*/// 修正this指针指向return getTargetValue(target, p);},set: (target: Window, p: PropertyKey, value: any) => {checkProxyFunction(value);target[p] = value;return true;},/**其他方法属性**/});// proxy documentconst proxyDocument = new Proxy({},{get: function (_fakeDocument, propKey) {const document = window.document;const { shadowRoot, proxyLocation } = iframe.contentWindow.__WUJIE;const rawCreateElement = iframe.contentWindow.__WUJIE_RAW_DOCUMENT_CREATE_ELEMENT__;const rawCreateTextNode = iframe.contentWindow.__WUJIE_RAW_DOCUMENT_CREATE_TEXT_NODE__;// need fix/* 包括元素创建,元素选择操作等createElement,createTextNode, documentURI,URL,querySelector,querySelectorAlldocumentElement,scrollingElement ,forms,images,links等等*/// from shadowRootif (propKey === "getElementById") {return new Proxy(shadowRoot.querySelector, {// case document.querySelector.callapply(target, ctx, args) {if (ctx !== iframe.contentDocument) {return ctx[propKey]?.apply(ctx, args);}return target.call(shadowRoot, `[id="${args[0]}"]`);},});}},});// proxy locationconst proxyLocation = new Proxy({},{get: function (_fakeLocation, propKey) {const location = iframe.contentWindow.location;if (propKey === "host" || propKey === "hostname" || propKey === "protocol" || propKey === "port" ||propKey === "origin") {return urlElement[propKey];}/** 拦截相关propKey, 返回对应lication内容propKey =="href","reload","replace"**/return getTargetValue(location, propKey);},set: function (_fakeLocation, propKey, value) {// 如果是跳转链接的话重开一个iframeif (propKey === "href") {return locationHrefSet(iframe, value, appHostPath);}iframe.contentWindow.location[propKey] = value;return true;}});return { proxyWindow, proxyDocument, proxyLocation };
}
4-3,降级模式localGenerator
export function localGenerator(
){// 代理 documentObject.defineProperties(proxyDocument, {createElement: {get: () => {return function (...args) {const element = rawCreateElement.apply(iframe.contentDocument, args);patchElementEffect(element, iframe.contentWindow);return element;};},},});// 普通处理const {modifyLocalProperties,modifyProperties,ownerProperties,shadowProperties,shadowMethods,documentProperties,documentMethods,} = documentProxyProperties;modifyProperties.filter((key) => !modifyLocalProperties.includes(key)).concat(ownerProperties, shadowProperties, shadowMethods, documentProperties, documentMethods).forEach((key) => {Object.defineProperty(proxyDocument, key, {get: () => {const value = sandbox.document?.[key];return isCallable(value) ? value.bind(sandbox.document) : value;},});});// 代理 locationconst proxyLocation = {};const location = iframe.contentWindow.location;const locationKeys = Object.keys(location);const constantKey = ["host", "hostname", "port", "protocol", "port"];constantKey.forEach((key) => {proxyLocation[key] = urlElement[key];});Object.defineProperties(proxyLocation, {href: {get: () => location.href.replace(mainHostPath, appHostPath),set: (value) => {locationHrefSet(iframe, value, appHostPath);},},reload: {get() {warn(WUJIE_TIPS_RELOAD_DISABLED);return () => null;},},});return { proxyDocument, proxyLocation };
}
实例化化主要是建立起js运行时的沙箱iframe, 通过非降级模式下proxy和降级模式下对document,location,window等全局操作属性的拦截修改将其和对应的js沙箱操作关联起来
5 importHTML入口文件解析
importHtml方法(entry.ts)
export default function importHTML(params: {url: string;html?: string;opts: ImportEntryOpts;
}): Promise<htmlParseResult> {/*xxxx*/const getHtmlParseResult = (url, html, htmlLoader) =>(html? Promise.resolve(html): fetch(url).then( /** 使用fetch Api 加载子应用入口**/(response) => response.text(),(e) => {embedHTMLCache[url] = null;loadError?.(url, e);return Promise.reject(e);})).then((html) => {const assetPublicPath = getPublicPath(url);const { template, scripts, styles } = processTpl(htmlLoader(html), assetPublicPath);return {template: template,assetPublicPath,getExternalScripts: () =>getExternalScripts(scripts.filter((script) => !script.src || !isMatchUrl(script.src, jsExcludes)).map((script) => ({ ...script, ignore: script.src && isMatchUrl(script.src, jsIgnores) })),fetch,loadError,fiber),getExternalStyleSheets: () =>getExternalStyleSheets(styles.filter((style) => !style.src || !isMatchUrl(style.src, cssExcludes)).map((style) => ({ ...style, ignore: style.src && isMatchUrl(style.src, cssIgnores) })),fetch,loadError),};});if (opts?.plugins.some((plugin) => plugin.htmlLoader)) {return getHtmlParseResult(url, html, htmlLoader);// 没有html-loader可以做缓存} else {return embedHTMLCache[url] || (embedHTMLCache[url] = getHtmlParseResult(url, html, htmlLoader));}
}
importHTML结构如图:
注意点: 通过Fetch url加载子应用资源,这里也是需要子应用支持跨域设置的原因
6 CssLoader和样式加载优化
export async function processCssLoader(sandbox: Wujie,template: string,getExternalStyleSheets: () => StyleResultList
): Promise<string> {const curUrl = getCurUrl(sandbox.proxyLocation);/** css-loader */const composeCssLoader = compose(sandbox.plugins.map((plugin) => plugin.cssLoader));const processedCssList: StyleResultList = getExternalStyleSheets().map(({ src, ignore, contentPromise }) => ({src,ignore,contentPromise: contentPromise.then((content) => composeCssLoader(content, src, curUrl)),}));const embedHTML = await getEmbedHTML(template, processedCssList);return sandbox.replace ? sandbox.replace(embedHTML) : embedHTML;
}
7 子应用active
active方法主要用于做 子应用激活, 同步路由,动态修改iframe的fetch, 准备shadow, 准备子应用注入
7-1, active方法(sandbox.ts)
public async active(options){/** options的检查 **/// 处理子应用自定义fetch// TODO fetch检验合法性const iframeWindow = this.iframe.contentWindow;iframeWindow.fetch = iframeFetch;this.fetch = iframeFetch;// 处理子应用路由同步if (this.execFlag && this.alive) {// 当保活模式下子应用重新激活时,只需要将子应用路径同步回主应用syncUrlToWindow(iframeWindow);} else {// 先将url同步回iframe,然后再同步回浏览器urlsyncUrlToIframe(iframeWindow);syncUrlToWindow(iframeWindow);}// inject templatethis.template = template ?? this.template;/* 降级处理 */if (this.degrade) {return;}if (this.shadowRoot) {this.el = renderElementToContainer(this.shadowRoot.host, el);if (this.alive) return;} else {// 预执行无容器,暂时插入iframe内部触发Web Component的connect// rawDocumentQuerySelector.call(iframeWindow.document, "body") 相当于Document.prototype.querySelector('body')const iframeBody = rawDocumentQuerySelector.call(iframeWindow.document, "body") as HTMLElement;this.el = renderElementToContainer(createWujieWebComponent(this.id), el ?? iframeBody);}await renderTemplateToShadowRoot(this.shadowRoot, iframeWindow, this.template);this.patchCssRules();// inject shadowRoot to appthis.provide.shadowRoot = this.shadowRoot;}
7-2,createWujieWebComponent, renderElementToContainer, renderTemplateToShadowRoot
// createWujieWebComponent
export function createWujieWebComponent(id: string): HTMLElement {const contentElement = window.document.createElement("wujie-app");contentElement.setAttribute(WUJIE_DATA_ID, id);contentElement.classList.add(WUJIE_IFRAME_CLASS);return contentElement;
}/*** 将准备好的内容插入容器*/
export function renderElementToContainer(element: Element | ChildNode,selectorOrElement: string | HTMLElement
): HTMLElement {const container = getContainer(selectorOrElement);if (container && !container.contains(element)) {// 有 loading 无需清理,已经清理过了if (!container.querySelector(`div[${LOADING_DATA_FLAG}]`)) {// 清除内容clearChild(container);}// 插入元素if (element) {// rawElementAppendChild = HTMLElement.prototype.appendChild;rawElementAppendChild.call(container, element);}}return container;
}
/*** 将template渲染到shadowRoot*/
export async function renderTemplateToShadowRoot(shadowRoot: ShadowRoot,iframeWindow: Window,template: string
): Promise<void> {const html = renderTemplateToHtml(iframeWindow, template);// 处理 css-before-loader 和 css-after-loaderconst processedHtml = await processCssLoaderForTemplate(iframeWindow.__WUJIE, html);// change ownerDocumentshadowRoot.appendChild(processedHtml);const shade = document.createElement("div");shade.setAttribute("style", WUJIE_SHADE_STYLE);processedHtml.insertBefore(shade, processedHtml.firstChild);shadowRoot.head = shadowRoot.querySelector("head");shadowRoot.body = shadowRoot.querySelector("body");// 修复 html parentNodeObject.defineProperty(shadowRoot.firstChild, "parentNode", {enumerable: true,configurable: true,get: () => iframeWindow.document,});patchRenderEffect(shadowRoot, iframeWindow.__WUJIE.id, false);
}
/*** 将template渲染成html元素*/
function renderTemplateToHtml(iframeWindow: Window, template: string): HTMLHtmlElement {const sandbox = iframeWindow.__WUJIE;const { head, body, alive, execFlag } = sandbox;const document = iframeWindow.document;let html = document.createElement("html");html.innerHTML = template;// 组件多次渲染,head和body必须一直使用同一个来应对被缓存的场景if (!alive && execFlag) {html = replaceHeadAndBody(html, head, body);} else {sandbox.head = html.querySelector("head");sandbox.body = html.querySelector("body");}const ElementIterator = document.createTreeWalker(html, NodeFilter.SHOW_ELEMENT, null, false);let nextElement = ElementIterator.currentNode as HTMLElement;while (nextElement) {patchElementEffect(nextElement, iframeWindow);const relativeAttr = relativeElementTagAttrMap[nextElement.tagName];const url = nextElement[relativeAttr];if (relativeAttr) nextElement.setAttribute(relativeAttr, getAbsolutePath(url, nextElement.baseURI || ""));nextElement = ElementIterator.nextNode() as HTMLElement;}if (!html.querySelector("head")) {const head = document.createElement("head");html.appendChild(head);}if (!html.querySelector("body")) {const body = document.createElement("body");html.appendChild(body);}return html;
}
/*
// 保存原型方法
// 子应用的Document.prototype已经被改写了
export const rawElementAppendChild = HTMLElement.prototype.appendChild;
export const rawElementRemoveChild = HTMLElement.prototype.removeChild;
export const rawHeadInsertBefore = HTMLHeadElement.prototype.insertBefore;
export const rawBodyInsertBefore = HTMLBodyElement.prototype.insertBefore;
export const rawAddEventListener = Node.prototype.addEventListener;
export const rawRemoveEventListener = Node.prototype.removeEventListener;
export const rawWindowAddEventListener = window.addEventListener;
export const rawWindowRemoveEventListener = window.removeEventListener;
export const rawAppendChild = Node.prototype.appendChild;
export const rawDocumentQuerySelector = window.__POWERED_BY_WUJIE__? window.__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__: Document.prototype.querySelector;
*/
8 子应用启动执行start
start 开始执行子应用,运行js,执行无界js插件列表
public async start(getExternalScripts: () => ScriptResultList): Promise<void> {this.execFlag = true;// 执行脚本const scriptResultList = await getExternalScripts();const iframeWindow = this.iframe.contentWindow;// 标志位,执行代码前设置iframeWindow.__POWERED_BY_WUJIE__ = true;// 用户自定义代码前 const beforeScriptResultList: ScriptObjectLoader[] = getPresetLoaders("jsBeforeLoaders", this.plugins);// 用户自定义代码后const afterScriptResultList: ScriptObjectLoader[] = getPresetLoaders("jsAfterLoaders", this.plugins);// 同步代码const syncScriptResultList: ScriptResultList = [];// async代码无需保证顺序,所以不用放入执行队列const asyncScriptResultList: ScriptResultList = [];// defer代码需要保证顺序并且DOMContentLoaded前完成,这里统一放置同步脚本后执行const deferScriptResultList: ScriptResultList = [];scriptResultList.forEach((scriptResult) => {if (scriptResult.defer) deferScriptResultList.push(scriptResult);else if (scriptResult.async) asyncScriptResultList.push(scriptResult);else syncScriptResultList.push(scriptResult);});// 插入代码前beforeScriptResultList.forEach((beforeScriptResult) => {this.execQueue.push(() =>this.fiber? requestIdleCallback(() => insertScriptToIframe(beforeScriptResult, iframeWindow)): insertScriptToIframe(beforeScriptResult, iframeWindow));});// 同步代码syncScriptResultList.concat(deferScriptResultList).forEach((scriptResult) => {/**xxxxx**/});// 异步代码asyncScriptResultList.forEach((scriptResult) => {scriptResult.contentPromise.then((content) => {this.fiber? requestIdleCallback(() => insertScriptToIframe({ ...scriptResult, content }, iframeWindow)): insertScriptToIframe({ ...scriptResult, content }, iframeWindow);});});//框架主动调用mount方法this.execQueue.push(this.fiber ? () => requestIdleCallback(() => this.mount()) : () => this.mount());//触发 DOMContentLoaded 事件const domContentLoadedTrigger = () => {eventTrigger(iframeWindow.document, "DOMContentLoaded");eventTrigger(iframeWindow, "DOMContentLoaded");this.execQueue.shift()?.();};this.execQueue.push(this.fiber ? () => requestIdleCallback(domContentLoadedTrigger) : domContentLoadedTrigger);// 插入代码后afterScriptResultList.forEach((afterScriptResult) => {/**xxxxx**/});//触发 loaded 事件const domLoadedTrigger = () => {eventTrigger(iframeWindow.document, "readystatechange");eventTrigger(iframeWindow, "load");this.execQueue.shift()?.();};this.execQueue.push(this.fiber ? () => requestIdleCallback(domLoadedTrigger) : domLoadedTrigger);// 由于没有办法准确定位是哪个代码做了mount,保活、重建模式提前关闭loadingif (this.alive || !isFunction(this.iframe.contentWindow.__WUJIE_UNMOUNT)) removeLoading(this.el);this.execQueue.shift()();// 所有的execQueue队列执行完毕,start才算结束,保证串行的执行子应用return new Promise((resolve) => {this.execQueue.push(() => {resolve();this.execQueue.shift()?.();});});}
// getExternalScripts
export function getExternalScripts(scripts: ScriptObject[],fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response> = defaultFetch,loadError: loadErrorHandler,fiber: boolean
): ScriptResultList {// module should be requested in iframereturn scripts.map((script) => {const { src, async, defer, module, ignore } = script;let contentPromise = null;// asyncif ((async || defer) && src && !module) {contentPromise = new Promise((resolve, reject) =>fiber? requestIdleCallback(() => fetchAssets(src, scriptCache, fetch, false, loadError).then(resolve, reject)): fetchAssets(src, scriptCache, fetch, false, loadError).then(resolve, reject));// module || ignore} else if ((module && src) || ignore) {contentPromise = Promise.resolve("");// inline} else if (!src) {contentPromise = Promise.resolve(script.content);// outline} else {contentPromise = fetchAssets(src, scriptCache, fetch, false, loadError);}return { ...script, contentPromise };});
}// 加载assets资源
// 如果存在缓存则从缓存中获取
const fetchAssets = (src: string,cache: Object,fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>,cssFlag?: boolean,loadError?: loadErrorHandler
) =>cache[src] ||(cache[src] = fetch(src).then((response) => {/**status > 400按error处理**/return response.text();})}));// insertScriptToIframe
export function insertScriptToIframe(scriptResult: ScriptObject | ScriptObjectLoader,iframeWindow: Window,rawElement?: HTMLScriptElement
) {const { src, module, content, crossorigin, crossoriginType, async, 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));// 内联脚本处理if (content) {// patch locationif (!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,
);`;}} else {// 外联自动触发onloadonload && (scriptElement.onload = onload as (this: GlobalEventHandlers, ev: Event) => any);src && scriptElement.setAttribute("src", src);crossorigin && scriptElement.setAttribute("crossorigin", crossoriginType);}// esm 模块加载module && scriptElement.setAttribute("type", "module");scriptElement.textContent = code || "";// 执行script队列检测nextScriptElement.textContent ="if(window.__WUJIE.execQueue && window.__WUJIE.execQueue.length){ window.__WUJIE.execQueue.shift()()}";const container = rawDocumentQuerySelector.call(iframeWindow.document, "head");if (/^<!DOCTYPE html/i.test(code)) {error(WUJIE_TIPS_SCRIPT_ERROR_REQUESTED, scriptResult);return !async && container.appendChild(nextScriptElement);}container.appendChild(scriptElement);// 调用回调callback?.(iframeWindow);// 执行 hooksexecHooks(plugins, "appendOrInsertElementHook", scriptElement, iframeWindow, rawElement);// 外联转内联调用手动触发onloadcontent && onload?.();// async脚本不在执行队列,无需next操作!async && container.appendChild(nextScriptElement);
}
9 子应用销毁
/** 销毁子应用 */public destroy() {this.bus.$clear();// thi.xxx = null;// 清除 domif (this.el) {clearChild(this.el);this.el = null;}// 清除 iframe 沙箱if (this.iframe) {this.iframe.parentNode?.removeChild(this.iframe);}// 删除缓存deleteWujieById(this.id);}
主应用,无界,子应用之间的关系
主应用创建自定义元素和创建iframe元素
无界将子应用解析后的html,css加入到自定义元素,进行元素和样式隔离
同时建立iframe代理,将iframe和自定义元素shadowDom进行关联,
将子应用中的js放入iframe执行,iframe中js执行的结果被代理到修改shadowDom结构和数据
文章转载自:京东云技术团队
原文链接:https://www.cnblogs.com/jingdongkeji/p/17812320.html
体验地址:引迈 - JNPF快速开发平台_低代码开发平台_零代码开发平台_流程设计器_表单引擎_工作流引擎_软件架构