- A+
简介
随着项目的发展,前端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
具体实践详细介绍参考:
https://wujie-micro.github.io/doc/guide/start.html
无界源码解析
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 = value; return true; }, /**其他方法属性**/ }); // proxy document const 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,querySelectorAll documentElement,scrollingElement ,forms,images,links等等 */ // from shadowRoot if (propKey === "getElementById") { return new Proxy(shadowRoot.querySelector, { // case document.querySelector.call apply(target, ctx, args) { if (ctx !== iframe.contentDocument) { return ctx[propKey]?.apply(ctx, args); } return target.call(shadowRoot, `[id="${args[0]}"]`); }, }); } }, } ); // proxy location const 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) { // 如果是跳转链接的话重开一个iframe if (propKey === "href") { return locationHrefSet(iframe, value, appHostPath); } iframe.contentWindow.location[propKey] = value; return true; } } ); return { proxyWindow, proxyDocument, proxyLocation }; }
4-3,降级模式localGenerator
export function localGenerator( ){ // 代理 document Object.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; }, }); }); // 代理 location const 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 = 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 || (embedHTMLCache = 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,然后再同步回浏览器url syncUrlToIframe(iframeWindow); syncUrlToWindow(iframeWindow); } // inject template this.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 app this.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-loader const processedHtml = await processCssLoaderForTemplate(iframeWindow.__WUJIE, html); // change ownerDocument shadowRoot.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 parentNode Object.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,保活、重建模式提前关闭loading if (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 iframe return scripts.map((script) => { const { src, async, defer, module, ignore } = script; let contentPromise = null; // async if ((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 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, );`; } } else { // 外联自动触发onload onload && (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); // 执行 hooks execHooks(plugins, "appendOrInsertElementHook", scriptElement, iframeWindow, rawElement); // 外联转内联调用手动触发onload content && onload?.(); // async脚本不在执行队列,无需next操作 !async && container.appendChild(nextScriptElement); }
9 子应用销毁
/** 销毁子应用 */ public destroy() { this.bus.$clear(); // thi.xxx = null; // 清除 dom if (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结构和数据
作者:京东物流 张燕燕、刘海鼎
来源:京东云开发者社区 自猿其说Tech 转载请注明来源