2022 你还不会微前端吗 (下) — 揭秘微前端核心原理

3,784 阅读15分钟

前言

在上篇 2022 你还不会微前端吗 (上) — 从巨石应用到微应用 中已经了解了微前端的由来和基本使用,也提到了一些相关的原理,本篇文章为下篇主要从原理层面进行解析,然后再自己实现一个包含核心部分的微前端框架。

48153E65.jpg

微前端核心原理

当然在正式开始自己实现之前,有且非常有必要先了解一下已有的微前端框架是如何实现其核心功能的,这里我们以 qiankun 来作为目标来了解一下其中的核心点:

  • 路由劫持
  • 加载子应用
  • 独立运行时,即沙箱
  • 应用通信

路由劫持

qiankun 中路由劫持是通过 single-spa 实现的,而它本身则提供了另外两种核心功能,即 子应用的加载沙箱隔离

监听 hash 路由 和 history 路由

我们知道路由会分为 hash 路由 和 history 路由,因此要监听路由变化就得注册 hashchangepopstate 事件:

  • 当通过类似 window.location.href = xxx<a href="#xxx"></a> 的方式修改 hash 值时会直接 hashchange 事件
  • 当使用原生的 pushStatereplaceState 改变当前 history 路由时,是并不会触发 popstate 事件,因此需要对原生的 pushStatereplaceState重写/增强,这样在重写/增强后的方法中,就可以通过手动派发 popstate 的方式实现当调用 pushStatereplaceState 方法时能够触发 replaceState 事件

源码位置:single-spa\src\navigation\navigation-events.js

function createPopStateEvent(state, originalMethodName) {
    // 省略代码
    if (isInBrowser) {
      // 分别为 hash 路由和 history 路由注册监听事件
      window.addEventListener("hashchange", urlReroute);
      window.addEventListener("popstate", urlReroute);

      // 省略代码

      // 重写/增强原有的 window.history.pushState 和 window.history.replaceState 方法
      window.history.pushState = patchedUpdateState(
        window.history.pushState,
        "pushState"
      );
      window.history.replaceState = patchedUpdateState(
        window.history.replaceState,
        "replaceState"
      );
      
    // 省略代码
  }
}


function patchedUpdateState(updateState, methodName) {
  return function () {
    const urlBefore = window.location.href;
    const result = updateState.apply(this, arguments);
    const urlAfter = window.location.href;

    if (!urlRerouteOnly || urlBefore !== urlAfter) {
      if (isStarted()) {
        // 子应用启动后,需要手动触发 popstate 事件,这样子应用就可以知道路由发生变化后需要如何匹配自身的路由
        window.dispatchEvent(
          createPopStateEvent(window.history.state, methodName)
        );
      } else {
        // 子应用启动之前不需要手动触发 popstate 事件,因为其他应用不需要了解在知识呢定义的路由之外的路由事件
        reroute([]);
      }
    }

    return result;
  };
}

拦截额外的导航事件

除了在微前端框架中需要监听对应的导航事件外,在微前端框架外部我们也可以通过 addEventListener 的方式来注册 hashchangepopstate 事件,那么这样一来导航事件就会有多个,为了在实现对导航事件的控制,达到路由变化时对应的子应用能够正确的 卸载挂载,需要对 addEventListener 注册的 hashchangepopstate 进行拦截,并将对应的事件给存储起来,便于后续在特定的时候能够实现手动触发。

源码位置:single-spa\src\navigation\navigation-events.js

// 捕获导航事件侦听器,以便确保对应的子应用正确的卸载和安装
const capturedEventListeners = {
  hashchange: [],
  popstate: [],
};

export const routingEventsListeningTo = ["hashchange", "popstate"];

function createPopStateEvent(state, originalMethodName) {
  // 保存原始方法
  const originalAddEventListener = window.addEventListener;
  const originalRemoveEventListener = window.removeEventListener;
  
  // 重写/增强 addEventListener
  window.addEventListener = function (eventName, fn) {
    if (typeof fn === "function") {
      // 拦截 hashchange 和 popstate 类型的事件 
      if (
        routingEventsListeningTo.indexOf(eventName) >= 0 &&
        !find(capturedEventListeners[eventName], (listener) => listener === fn)
      ) {
        capturedEventListeners[eventName].push(fn);
        return;
      }
    }

    return originalAddEventListener.apply(this, arguments);
  };
  
  // 重写/增强 removeEventListener
  window.removeEventListener = function (eventName, listenerFn) {
    if (typeof listenerFn === "function") {
      if (routingEventsListeningTo.indexOf(eventName) >= 0) {
        capturedEventListeners[eventName] = capturedEventListeners[
          eventName
        ].filter((fn) => fn !== listenerFn);
        return;
      }
    }

    return originalRemoveEventListener.apply(this, arguments);
  };
}

加载子应用

上篇文章 中其实不难发现,如果直接使用 single-spa 实现微前端那么在基座应用中注册子应用时,必须要指定每个子应用对应的 url,以及如何加载子应用依赖的 js 文件等,每个子应用信息大致如下:

{
        name: 'singleVue3', // 子应用注册时的 name
        async activeWhen() { // 当匹配到对应的 url 且子应用加载完毕时
            await loadScript('http://localhost:5000/js/chunk-vendors.js');
            await loadScript('http://localhost:5000/js/app.js');
            return window.singleVue3
        },
        app(location: Location) {
            return location.pathname.startsWith('/vue3-micro-app')
        },
        customProps: {
            container: '#micro-content'
        }
 }

相反,再看看 qiankun 注册子应用时,每个子应用的信息大致如下:

{
    name: 'singleVue3',
    entry: 'http://localhost:5000',
    container: '#micro-content',
    activeRule: '/vue3-micro-app',
}

会发现更加简洁,并且也不用在手动指定子应用依赖的 js 文件,那么 qiankun 是怎么知道当前子应用需要依赖什么 js 文件呢?

通过 import-html-entry 加载并解析子应用的 HTML

在基座应用中通过调用 registerMicroApps(...) 函数注册子应用时,其内部实际上是通过 single-spa 中的 registerApplication(...) 函数来实现的,其内容如下:

// qiankun\src\apis.ts

import { mountRootParcel, registerApplication, start as startSingleSpa } from 'single-spa';
import { loadApp } from './loader';

export function registerMicroApps<T extends ObjectType>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // 每个子应用自会被注册一次
  const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));

  microApps = [...microApps, ...unregisteredApps];

  unregisteredApps.forEach((app) => {
    const { name, activeRule, loader = noop, props, ...appConfig } = app;

    // 真正注册子应用的地方,通过 loadApp 加载并解析子应用对应的 html 模板
    registerApplication({
      name,
      app: async () => {
        loader(true);
        await frameworkStartedDefer.promise;

        const { mount, ...otherMicroAppConfigs } = (
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )();

        return {
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },
      activeWhen: activeRule,
      customProps: props,
    });
  });
}

其中比较核心的就是 loadApp(...) 函数:

  • 会通过 import-html-entry 中的 importEntry(...) 函数获取入口的 HTML 内容和 script 的执行器
    • 通过 fetch() 请求到子应用的 html 字符串
    • 通过 processTpl() 函数将对应的 html 字符串进行处理,即通过正则去匹配获其中的 jscssentry js 等等内容
    • processTpl() 函数会返回如下结果
      • templatehtml 模板内容
      • scriptsjs 脚本包含内联和外联
      • stylescss 样式表,包含内联和外联
      • entry:子应用入口 js 脚本,若没有则默认为 scripts[scripts.length - 1]
// qiankun\src\loader.ts
import { importEntry } from 'import-html-entry';

export async function loadApp<T extends ObjectType>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  const { entry, name: appName } = app;

 // 省略代码

  const {
    singular = false,
    sandbox = true,
    excludeAssetFilter,
    globalContext = window,
    ...importEntryOpts
  } = configuration;

  // 获取入口的 HTML 内容 和 script 的执行器
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
  
  省略代码 
}

处理模板内的 CSS

上述已经获取到了 css 样式表相关的数据 styles,而样式又会区分 内联外联 样式:

  • 内联样式 通过查找 <> 的索引位置,最后使用 substring 方法来截取具体内容
  • 外链样式 则通过 fetch 请求对应的资源
// import-html-entry\src\index.js

// 获取内嵌的 HTML 内容
function getEmbedHTML(template, styles) {
  var opts = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
  var _opts$fetch = opts.fetch,
      fetch = _opts$fetch === void 0 ? defaultFetch : _opts$fetch;
  var embedHTML = template;
  return _getExternalStyleSheets(styles, fetch).then(function (styleSheets) {
    embedHTML = styles.reduce(function (html, styleSrc, i) {
      html = html.replace((0, _processTpl2.genLinkReplaceSymbol)(styleSrc), isInlineCode(styleSrc) ? "".concat(styleSrc) : "<style>/* ".concat(styleSrc, " */").concat(styleSheets[i], "</style>"));
      return html;
    }, embedHTML);
    return embedHTML;
  });
}

// 获取 css 资源
function _getExternalStyleSheets(styles) {
  var fetch = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : defaultFetch;
  return Promise.all(styles.map(function (styleLink) {
    if (isInlineCode(styleLink)) {
      // 内联样式
      return (0, _utils.getInlineCode)(styleLink);
    } else {
      // 外链样式
      return styleCache[styleLink] || (styleCache[styleLink] = fetch(styleLink).then(function (response) {
        return response.text();
      }));
    }
  }));
} // for prefetch

// import-html-entry\lib\utils.js

function getInlineCode(match) {
  var start = match.indexOf('>') + 1;
  var end = match.lastIndexOf('<');
  return match.substring(start, end);
}

处理模板中的 JavaScript

处理 js 脚本的方式和 css 样式表的方式大致相同,仍然是需要区分内联和外链两种:

  • 内联 script 通过查找 <> 的索引位置,最后使用 substring 方法来截取具体内容
  • 外链 script 则通过 fetch 请求对应的资源
  • 通过 eval() 来执行 script 脚本的内容
// import-html-entry\src\utils.js

export function execScripts(entry, scripts, proxy = window, opts = {}) {
	...
	return getExternalScripts(scripts, fetch, error)
		.then(scriptsText => {

			const geval = (scriptSrc, inlineScript) => {
				const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;
                                // 获取可执行的 code
				const code = getExecutableScript(scriptSrc, rawCode, { proxy, strictGlobal, scopedGlobalVariables });
                                
                                // 执行代码
				evalCode(scriptSrc, code);

				afterExec(inlineScript, scriptSrc);
			};

			function exec(scriptSrc, inlineScript, resolve) {

				...

				if (scriptSrc === entry) {
					noteGlobalProps(strictGlobal ? proxy : window);

					try {
						// bind window.proxy to change `this` reference in script
						geval(scriptSrc, inlineScript);
						const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
						resolve(exports);
					} catch (e) {
						// entry error must be thrown to make the promise settled
						console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`);
						throw e;
					}
				} else {
					if (typeof inlineScript === 'string') {
						try {
							// bind window.proxy to change `this` reference in script
							geval(scriptSrc, inlineScript);
						} catch (e) {
							// consistent with browser behavior, any independent script evaluation error should not block the others
							throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`);
						}
					} else {
						// external script marked with async
						inlineScript.async && inlineScript?.content
							.then(downloadedScriptText => geval(inlineScript.src, downloadedScriptText))
							.catch(e => {
								throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`);
							});
					}
				}
                                ...
			}

			function schedule(i, resolvePromise) {

				if (i < scripts.length) {
					const scriptSrc = scripts[i];
					const inlineScript = scriptsText[i];

					exec(scriptSrc, inlineScript, resolvePromise);
					// resolve the promise while the last script executed and entry not provided
					if (!entry && i === scripts.length - 1) {
						resolvePromise();
					} else {
						schedule(i + 1, resolvePromise);
					}
				}
			}

			return new Promise(resolve => schedule(0, success || resolve));
		});
}

// 通过 eval() 执行脚本内容
export function evalCode(scriptSrc, code) {
	const key = scriptSrc;
	if (!evalCache[key]) {
		const functionWrappedCode = `(function(){${code}})`;
                // eval 函数
		evalCache[key] = (0, eval)(functionWrappedCode);
	}
	const evalFunc = evalCache[key];
	evalFunc.call(window);
}

// import-html-entry\src\index.js

export function getExternalScripts(scripts, fetch = defaultFetch, errorCallback = () => {
}) {

	const fetchScript = scriptUrl => scriptCache[scriptUrl] ||
		(scriptCache[scriptUrl] = fetch(scriptUrl).then(response => {
			// 通常浏览器将脚本加载的 4xx 和 5xx 响应视为错误并会触发脚本错误事件
			// https://stackoverflow.com/questions/5625420/what-http-headers-responses-trigger-the-onerror-handler-on-a-script-tag/5625603
			if (response.status >= 400) {
				errorCallback();
				throw new Error(`${scriptUrl} load failed with status ${response.status}`);
			}
                        
			return response.text();
		}).catch(e => {
			errorCallback();
			throw e;
		}));

	return Promise.all(scripts.map(script => {

			if (typeof script === 'string') {
				if (isInlineCode(script)) {
					// 内联 script
					return getInlineCode(script);
				} else {
					// 外链 script
					return fetchScript(script);
				}
			} else {
				// 使用空闲时间加载 async script
				const { src, async } = script;
				if (async) {
					return {
						src,
						async: true,
						content: new Promise((resolve, reject) => requestIdleCallback(() => fetchScript(src).then(resolve, reject))),
					};
				}

				return fetchScript(src);
			}
		},
	));
}

// import-html-entry\lib\utils.js

function getInlineCode(match) {
  var start = match.indexOf('>') + 1;
  var end = match.lastIndexOf('<');
  return match.substring(start, end);
}

独立运行时 —— 沙箱

沙箱 的目的是 为了隔离子应用间 脚本样式 的影响,即需要针对子应用的 <style>、<link>、<script> 等类型的标签进行特殊处理,而处理时机分为两种:

  • 初始化加载时,因为初始化加载子应用时,需要 加载其对应的 脚本样式
  • 子应用正在运行时,因为子应用运行时可能会 动态添加 脚本样式

重写 appendChild、insertBefore、removeChild 方法

qiankun 中重写 appendChild、insertBefore、removeChild 等原生方法,以便于可以监听 新添加/删除 的节点,,并对 <style>、<link>、<script> 等标签进行处理。

// qiankun\src\sandbox\patchers\dynamicAppend\common.ts

export function patchHTMLDynamicAppendPrototypeFunctions(
  isInvokedByMicroApp: (element: HTMLElement) => boolean,
  containerConfigGetter: (element: HTMLElement) => ContainerConfig,
) {
  // 只在 appendChild 和 insertBefore 没有被重写时进行重写
  if (
    HTMLHeadElement.prototype.appendChild === rawHeadAppendChild &&
    HTMLBodyElement.prototype.appendChild === rawBodyAppendChild &&
    HTMLHeadElement.prototype.insertBefore === rawHeadInsertBefore
  ) {
    // 重写方法
    HTMLHeadElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawHeadAppendChild,
      containerConfigGetter,
      isInvokedByMicroApp,
      target: 'head',
    }) as typeof rawHeadAppendChild;
    
    // 重写方法
    HTMLBodyElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawBodyAppendChild,
      containerConfigGetter,
      isInvokedByMicroApp,
      target: 'body',
    }) as typeof rawBodyAppendChild;
    
    // 重写方法
    HTMLHeadElement.prototype.insertBefore = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawHeadInsertBefore as any,
      containerConfigGetter,
      isInvokedByMicroApp,
      target: 'head',
    }) as typeof rawHeadInsertBefore;
  }

  // 只在 removeChild 没有被重写时进行重写
  if (
    HTMLHeadElement.prototype.removeChild === rawHeadRemoveChild &&
    HTMLBodyElement.prototype.removeChild === rawBodyRemoveChild
  ) {
    // 重写方法
    HTMLHeadElement.prototype.removeChild = getNewRemoveChild(rawHeadRemoveChild, containerConfigGetter, 'head');
    HTMLBodyElement.prototype.removeChild = getNewRemoveChild(rawBodyRemoveChild, containerConfigGetter, 'body');
  }

  // 恢复重写前的方法
  return function unpatch() {
    HTMLHeadElement.prototype.appendChild = rawHeadAppendChild;
    HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild;
    HTMLBodyElement.prototype.appendChild = rawBodyAppendChild;
    HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild;

    HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore;
  };
}

CSS 样式隔离

shadowDom 实现隔离

若开启了 strictStyleIsolation 模式,并且当前环境支持 Shadow DOM,则直接通过 Shadow DOM 来实现隔离效果,有关 Shadow DOM 的内容可参考之前的一篇文章:Web Components —— Web 组件

function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appInstanceId: string,
): HTMLElement {
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // appContent always wrapped with a singular div
  const appElement = containerElement.firstChild as HTMLElement;
  
  // strictStyleIsolation 模式
  if (strictStyleIsolation) {
    if (!supportShadowDOM) {
      console.warn(
        '[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
      );
    } else {
      const { innerHTML } = appElement;
      appElement.innerHTML = '';
      let shadow: ShadowRoot;
      
      // 若当前环境支持 Shadow DOM,则通过 Shadow DOM 实现样式隔离
      if (appElement.attachShadow) {
        shadow = appElement.attachShadow({ mode: 'open' });
      } else {
        // createShadowRoot was proposed in initial spec, which has then been deprecated
        shadow = (appElement as any).createShadowRoot();
      }
      shadow.innerHTML = innerHTML;
    }
  }

  // 通过 css.process 处理 css 规则
  if (scopedCSS) {
    const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
    if (!attr) {
      appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
    }

    const styleNodes = appElement.querySelectorAll('style') || [];
    forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
      css.process(appElement!, stylesheetElement, appInstanceId);
    });
  }

  return appElement;
}

prefix 限定 CSS 规则

CSS 样式分为 内联样式外链样式,而在 qiankun 中选择把外链的方式处理成 <style> 包裹的形式,目的是提供符合 postProcess(styleElement) 处理的数据格式,即符合 css.process(...) 的数据格式,因为外部传入的 postProcess 形参就是包含了 css.process() 的方法:

// qiankun\src\sandbox\patchers\dynamicAppend\common.ts

function convertLinkAsStyle(
  element: HTMLLinkElement,
  postProcess: (styleElement: HTMLStyleElement) => void,
  fetchFn = fetch,
): HTMLStyleElement {
  // 创建 style 标签 
  const styleElement = document.createElement('style');
  const { href } = element;
  // add source link element href
  styleElement.dataset.qiankunHref = href;

  //  通过 fetch 请求 link.href 指向的 css 资源
  fetchFn(href)
    .then((res: any) => res.text())
    .then((styleContext: string) => {
      // 将得到的 css 文本作为文本节点添加到 style 节点中
      styleElement.appendChild(document.createTextNode(styleContext));
      // 方便统一通过 postProcess 进行处理,本质上就是 css.process() 方法
      postProcess(styleElement);
      manualInvokeElementOnLoad(element);
    })
    .catch(() => manualInvokeElementOnError(element));

  return styleElement;
}

CSS 样式隔离核心本质其实就是 css.process() 方法,而这其实就是通过为每个 css 规则添加 特定的前缀 来实现 样式隔离 的作用:

  • 创建一个临时的 style 节点用来后续处理
  • 通过 process() 方法来处理 style 规则, 即通过 style.sheet 属性来获取所有的规则
  • 通过 ruleStyle() 方法进行转换,即通过正则进行匹配然后替换,如子应用中的 h1{color: red;} 变为 [.appName] h1{color: red;}
  • 将重写后的 css 内容替换到原有的 style 节点中
// qiankun\src\sandbox\patchers\css.ts

let processor: ScopedCSS;

export const QiankunCSSRewriteAttr = 'data-qiankun';
export const process = (
  appWrapper: HTMLElement,
  stylesheetElement: HTMLStyleElement | HTMLLinkElement,
  appName: string,
): void => {
  // 惰性单例模式
  if (!processor) {
    processor = new ScopedCSS();
  }

  if (stylesheetElement.tagName === 'LINK') {
    console.warn('Feature: sandbox.experimentalStyleIsolation is not support for link element yet.');
  }

  const mountDOM = appWrapper;
  if (!mountDOM) {
    return;
  }

  const tag = (mountDOM.tagName || '').toLowerCase();

  if (tag && stylesheetElement.tagName === 'STYLE') {
    // 根据当前子应用的 appName 生成自定义前缀
    const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
    processor.process(stylesheetElement, prefix);
  }
};

export class ScopedCSS {
  private static ModifiedTag = 'Symbol(style-modified-qiankun)';

  private sheet: StyleSheet;

  private swapNode: HTMLStyleElement;

  constructor() {
    const styleNode = document.createElement('style');
    rawDocumentBodyAppend.call(document.body, styleNode);

    this.swapNode = styleNode;
    this.sheet = styleNode.sheet!;
    this.sheet.disabled = true;
  }

  process(styleNode: HTMLStyleElement, prefix: string = '') {
    if (ScopedCSS.ModifiedTag in styleNode) {
      return;
    }

    // style 中文本节点不为空时进行处理
    if (styleNode.textContent !== '') {
      const textNode = document.createTextNode(styleNode.textContent || '');
      this.swapNode.appendChild(textNode);
      const sheet = this.swapNode.sheet as any; // type is missing
      const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
      // 重写 css 内容
      const css = this.rewrite(rules, prefix);
      // eslint-disable-next-line no-param-reassign
      styleNode.textContent = css;

      // cleanup
      this.swapNode.removeChild(textNode);
      (styleNode as any)[ScopedCSS.ModifiedTag] = true;
      return;
    }

   // 省略代码
  }
 
  // 根据 prefix 来限定 css 选择器
  private rewrite(rules: CSSRule[], prefix: string = '') {
    let css = '';

    rules.forEach((rule) => {
      switch (rule.type) {
        case RuleType.STYLE:
          css += this.ruleStyle(rule as CSSStyleRule, prefix);
          break;
        case RuleType.MEDIA:
          css += this.ruleMedia(rule as CSSMediaRule, prefix);
          break;
        case RuleType.SUPPORTS:
          css += this.ruleSupport(rule as CSSSupportsRule, prefix);
          break;
        default:
          css += `${rule.cssText}`;
          break;
      }
    });

    return css;
  }
}

JavaScript 脚本隔离

从如下源码中不难看出,qiankun 中的 JS 沙箱有 LegacySandbox、ProxySandbox、SnapshotSandbox 三种方式,但是其实就分为 代理(Proxy)沙箱快照(Snapshot)沙箱,并且是根据情况来选择创建:

  • 若当前环境支持 window.Proxy,则通过 useLooseSandbox 的值选择 LegacySandboxProxySandbox 方式
  • 若当前环境不支持 window.Proxy,则直接使用 SnapshotSandbox 方式
// qiankun\src\loader.ts

export async function loadApp<T extends ObjectType>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  
  省略代码
  
  let sandboxContainer;
  if (sandbox) {
    // 创建沙箱
    sandboxContainer = createSandboxContainer(
      appInstanceId,
      // FIXME 应该在重新挂载时使用严格的沙盒逻辑: https://github.com/umijs/qiankun/issues/518
      initialAppWrapperGetter,
      scopedCSS,
      useLooseSandbox,
      excludeAssetFilter,
      global,
      speedySandbox,
    );

    // 用沙箱的代理对象作为接下来使用的全局对象
    global = sandboxContainer.instance.proxy as typeof window;
    mountSandbox = sandboxContainer.mount;
    unmountSandbox = sandboxContainer.unmount;
  }
  
  省略代码
}

// qiankun\src\sandbox\index.ts

export function createSandboxContainer(
  appName: string,
  elementGetter: () => HTMLElement | ShadowRoot,
  scopedCSS: boolean,
  useLooseSandbox?: boolean,
  excludeAssetFilter?: (url: string) => boolean,
  globalContext?: typeof window,
  speedySandBox?: boolean,
) {
  let sandbox: SandBox;
  
  if (window.Proxy) {
    sandbox = useLooseSandbox ? new LegacySandbox(appName, globalContext) : new ProxySandbox(appName, globalContext);
  } else {
    sandbox = new SnapshotSandbox(appName);
  }

  省略代码
}

代理(Proxy)沙箱

为了避免 多个子应用 操作或者修改 基座应用 的全局对象 window,而导致微应用间运行状态可能相互影响的问题,Proxy 沙箱 本质就是基于 Proxy 来实现代理:

  • 通过 createFakeWindow(window) 将原 window 上的一些 descriptor.configurabletrue 拷贝到新对象 fakeWindow
  • 通过 new Proxy(fakeWindow, {...}) 的方式创建代理对象
    • 读取属性时优先从 proxy 上查找,若没有查到则再到原始的 window 上查找
    • 设置属性时会设置到 proxy 对象里,即不会修改原始的 window 实现隔离
// qiankun\src\sandbox\proxySandbox.ts

export default class LegacySandbox implements SandBox {
      省略代码
    constructor(name: string, globalContext = window) {
      const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext);
      省略代码
      const proxy = new Proxy(fakeWindow, {
          set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
            if (this.sandboxRunning) {
              this.registerRunningApp(name, proxy);
              // 必须保留它的描述,而该属性之前存在于 globalContext 中
              if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
                const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
                const { writable, configurable, enumerable, set } = descriptor!;
                // 这里只有可写属性可以被覆盖,忽略 globalContext 的访问器描述符,因为触发它的逻辑没有意义(这可能会使沙箱转义)强制通过数据描述符设置值
                if (writable || set) {
                  Object.defineProperty(target, p, { configurable, enumerable, writable: true, value });
                }
              } else {
                target[p] = value;
              }

              // 将属性同步到 globalContext
              if (typeof p === 'string' && globalVariableWhiteList.indexOf(p) !== -1) {
                this.globalWhitelistPrevDescriptor[p] = Object.getOwnPropertyDescriptor(globalContext, p);
                // @ts-ignore
                globalContext[p] = value;
              }
              updatedValueSet.add(p);
              this.latestSetProp = p;
              return true;
            }

            if (process.env.NODE_ENV === 'development') {
              console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
            }

            // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
            return true;
          },

          get: (target: FakeWindow, p: PropertyKey): any => {
            this.registerRunningApp(name, proxy);

            if (p === Symbol.unscopables) return unscopables;
            // 避免使用 window.window 或 window.self 逃离沙箱环境去触碰真实 window
            if (p === 'window' || p === 'self') {
              return proxy;
            }

            // 使用 globalThis 关键字劫持 globalWindow 访问
            if (p === 'globalThis') {
              return proxy;
            }

            if (
              p === 'top' ||
              p === 'parent' ||
              (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
            ) {
              // 如果主应用程序在 iframe 上下文中,允许逃离沙箱
              if (globalContext === globalContext.parent) {
                return proxy;
              }
              return (globalContext as any)[p];
            }

            // proxy.hasOwnProperty 将首先调用 getter,然后将其值表示为 globalContext.hasOwnProperty
            if (p === 'hasOwnProperty') {
              return hasOwnProperty;
            }

            if (p === 'document') {
              return document;
            }

            if (p === 'eval') {
              return eval;
            }

            const actualTarget = propertiesWithGetter.has(p) ? globalContext : p in target ? target : globalContext;
            const value = actualTarget[p];

            // 冻结值应该直接返回
            if (isPropertyFrozen(actualTarget, p)) {
              return value;
            }

            /* 某些dom api必须绑定到native window,否则会导致异常:'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
            */
            const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
            return getTargetValue(boundTarget, value);
          },

          has(target: FakeWindow, p: string | number | symbol): boolean {
            return p in unscopables || p in target || p in globalContext;
          },

          getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {
            /*
             由于原始窗口中 top/self/window/mockTop 的描述符是可配置的,但在代理目标中不可配置,需要从目标中获取它以避免 TypeError
             */
            if (target.hasOwnProperty(p)) {
              const descriptor = Object.getOwnPropertyDescriptor(target, p);
              descriptorTargetMap.set(p, 'target');
              return descriptor;
            }

            if (globalContext.hasOwnProperty(p)) {
              const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
              descriptorTargetMap.set(p, 'globalContext');
              // 如果属性不作为目标对象的自有属性存在,则不能将其报告为不可配置
              if (descriptor && !descriptor.configurable) {
                descriptor.configurable = true;
              }
              return descriptor;
            }

            return undefined;
          },

          ownKeys(target: FakeWindow): ArrayLike<string | symbol> {
            return uniq(Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target)));
          },

          defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {
            const from = descriptorTargetMap.get(p);
            /*
             Descriptor 必须通过 Object.getOwnPropertyDescriptor(window, p) 来自本地窗口时定义到本地窗口,否则会导致 TypeError 非法调用
             */
            switch (from) {
              case 'globalContext':
                return Reflect.defineProperty(globalContext, p, attributes);
              default:
                return Reflect.defineProperty(target, p, attributes);
            }
          },

          deleteProperty: (target: FakeWindow, p: string | number | symbol): boolean => {
            this.registerRunningApp(name, proxy);
            if (target.hasOwnProperty(p)) {
              // @ts-ignore
              delete target[p];
              updatedValueSet.delete(p);

              return true;
            }

            return true;
          },

          // 确保 `window instanceof Window` 在微应用中返回 true
          getPrototypeOf() {
            return Reflect.getPrototypeOf(globalContext);
          },
        });
    }
}

快照(Snapshot)沙箱

所谓 快照沙箱 其实就是基于 diff 方式实现的沙箱:

  • 激活子应用 时优先将当前的 window 对象进行拷贝存储,再从上一次记录的 modifyPropsMap 中恢复该应用 上次的修改window
  • 离开子应用 时会与原有的 window 与 快照对象 windowSnapshot 进行 diff,将 变更的属性 保存到 modifyPropsMap 中,便与下次该 应用激活时 进行数据恢复,即把有变更的属性值同步之前的状态
// qiankun\src\sandbox\snapshotSandbox.ts

/**
 * 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
 */
export default class SnapshotSandbox implements SandBox {
  省略代码
  constructor(name: string) {
    this.name = name;
    this.proxy = window;
    this.type = SandBoxType.Snapshot;
  }

  active() {
    // 记录当前快照
    this.windowSnapshot = {} as Window;
    iter(window, (prop) => {
      this.windowSnapshot[prop] = window[prop];
    });

    // 恢复之前的变更
    Object.keys(this.modifyPropsMap).forEach((p: any) => {
      window[p] = this.modifyPropsMap[p];
    });

    this.sandboxRunning = true;
  }

  inactive() {
    this.modifyPropsMap = {};

    iter(window, (prop) => {
      if (window[prop] !== this.windowSnapshot[prop]) {
        // 记录变更,恢复环境
        this.modifyPropsMap[prop] = window[prop];
        window[prop] = this.windowSnapshot[prop];
      }
    });

    if (process.env.NODE_ENV === 'development') {
      console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));
    }

    this.sandboxRunning = false;
  }
}

function iter(obj: typeof window, callbackFn: (prop: any) => void) {
  for (const prop in obj) {
    // 出于兼容原因,为 clearInterval 打补丁
    if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {
      callbackFn(prop);
    }
  }
}

应用通信

qiankun 中应用通信可以通过 initGlobalState(state) 的方式实现,它用于定义全局状态,并返回通信方法,官方建议在主应用使用,微应用通过 props 获取通信方法。

原理是什么呢,相信你下面用法,即便不看源码也猜得到是怎么实现的:

// 主应用
import { initGlobalState, MicroAppStateActions } from 'qiankun';

const actions: MicroAppStateActions = initGlobalState(state);// 初始化 state

actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();

// 微应用

// 从生命周期 mount 中获取通信方法,使用方式和 master 一致
export function mount(props) {
  props.onGlobalStateChange((state, prev) => {
    // state: 变更后的状态; prev 变更前的状态
    console.log(state, prev);
  });
  
  props.setGlobalState(state);
}

这不就是妥妥的 发布订阅模式 嘛!!!

是的,毕竟发布订阅模式非常适用于需要通信的场景,就和在 vue2 中使用的 EventBus 核心原理是一样的。

实现微前端框架

这里还是直接沿用在上篇文章中创建的项目内容,具体可见 2022 你还不会微前端吗 (上) — 从巨石应用到微应用,下面一步步开始实现自己的微前端框架吧!

点此查看源代码

34F31541.jpg

前置处理

简单回顾一下项目的大致结构:

  • single-spa
    • vue2-main-app(基座应用)
    • vue3-micro-app(子应用)
    • react-micro-app(子应用)

基座应用 — 入口文件

首先要做的就是修改基座应用的入口文件,将里面用于导入 registerMicroApps、start 方法的部分替换成自己定义的微前端模块 micro-fe,具体如下:

// single-spa\vue2-main-app\src\registerApplication.ts

//- import { registerMicroApps, start } from 'qiankun';
+ import { registerMicroApps, start } from './micro-fe';

// 默认子应用
export const applications = [
    {
        name: 'singleVue3', // app name registered
        entry: 'http://localhost:5000',
        container: '#micro-content',
        activeRule: '/vue3-micro-app',
      },
      {
        name: 'singleReact', // app name registered
        entry: 'http://localhost:3000',
        container: '#micro-content',
        activeRule: '/react-micro-app',
      },
]

// 注册子应用
export const registerApps = (apps: any[] = applications) => {
    registerMicroApps(apps);

    start();
}

定义 micro-fe

src 目录下新建 micro-fe 目录,其中 index.ts 为入口,文件内容如下:

// single-spa\vue2-main-app\src\micro-fe\index.ts

export const registerMicroApps = (apps?: any[]) => {
    ...
}

export const start = () => {
    ...
}

注册应用 — registerMicroApps

registerMicroApps 函数核心要做的事情很简单,注册子应用 其实就是 保存子应用,这里把外部传入的子应用保存在全局变量 _apps 中,并向外部提供一个可以访问 _apps 的方法 getApps,具体如下:

// single-spa\vue2-main-app\src\micro-fe\index.ts

// 保存子应用
let _apps: any[]

// 获取子应用
export const getApps = () => _apps

// 注册子应用
export const registerMicroApps = (apps: any[] = []) => {
    _apps = apps;
}

启动子应用 — start

启动子应用需要做的核心内容如下:

  • 监听路由变化
  • 匹配子应用路由
  • 加载子应用
  • 渲染子应用

监听路由变化 & 匹配子应用路由

通过注册 hashchangepopstate 事件就可以实现对 hash 路由 和 history 路由 的监听,这里以 history 路由来实现,因为对其需要做特殊处理。

popstate 事件 可以监听window.history.[go | forward | back]() 等方法引起的路由变化,但 无法监听window.history.[pushState | replaceState]() 等方法引起的路由变化

因此,需要对 window.history.[pushState | replaceState]() 这两个方法进行 重写,便于在外部调用这两个方法时,也能达到路由监听的效果。

同时需要定义 子应用路由匹配路逻辑,需要在上述重写的方法和监听路由变化的事件中执行,并且匹配到对应的子应用后就需要进行 子应用的加载

这里将与 history 路由相关的内容都放置到了 src\micro-fe\historyRoute.ts 中:

// src\micro-fe\historyRoute.ts

import { getApp } from './index'
import { loadApp } from './application'

// 监听路由变化
export const listenHistoryRoute = () => {

    // 监听路由变化
    window.addEventListener('popstate', () => {
        // 匹配路由
        matchHistoryRoute()
    })

    // 重写 pushState
    const rawPushState = window.history.pushState;
    window.history.pushState = (...args) => {
        // 调用原始方法
        rawPushState.apply(window.history, args)
        // 匹配路由
        matchHistoryRoute()
    }

    // 重写 replaceState
    const rawReplaceState = window.history.pushState;
    window.history.replaceState = (...args) => {
        // 调用原始方法
        rawReplaceState.apply(window.history, args)
        // 匹配路由
        matchHistoryRoute()
    }
}

// 匹配路由
export const matchHistoryRoute = () => {
    const apps = getApp();
    const { pathname } = window.location;
    const app = apps.find(item => pathname.startsWith(item.activeRule))

    if (!app) return

    // 加载子应用
    loadApp(app)
}

加载子应用

这里将和应用相关的内容放到 src\micro-fe\application.ts 文件中。

加载子应用 实际上对应的是上述的 loadApp(app) 方法,而它需要做的内容如下:

  • 加载子应用 html 模板
  • 加载并执行 html 中的 JS 脚本,包含内联和外链的脚本
  • 加载其他资源文件,比如 cssimg

qiankun 中使用的是 import-html-entry 这个库来处理的,这里我们也自己来实现一下,并将相关内容存放在 src\micro-fe\importHtmlEntry.ts 文件中

加载子应用 HTML 模板

加载子应用可以通过 fetch 和 注册子应用时配置的 entry 来实现,具体如下:

// src\micro-fe\importHtmlEntry.ts

import { fectResource } from './fetch'
import type { MicroApp } from './type'

export const importEntry = async (app: MicroApp) => {
    // 获取模板
    const html = await fectResource(app.entry)

    // 字符串模板 -> DOM 结构,目的是方便使用 DOM API
    const template = document.createElement('div')
    template.innerHTML = html
    
    // 加载模板中对应的 script 脚本内容
    function getExternalScripts() {

    }

    // 执行模板中的 script 脚本内容
    function execScripts() {

    }

    return {
        template,
        getExternalScripts,
        execScripts
    }
}

// src\micro-fe\fetch.ts

export const fectResource = (url:string) => {
   return fetch(url).then(res => res.text())
}

加载并执行JS 脚本

获取到模板内容之后,把模板内容作为一个 DOM 节点 innerHTML 的内容,方便通过 DOM API 的方式直接获取所有需要的 script 标签,而不需要通过字符串或正则匹配的模式来进行这个操作,具体如下:

  • getExternalScripts 方法负责获取模板中所有的 script 标签,并根据其 src 属性是否有值区分 内联外链 脚本
    • 内联脚本 直接获取其对应的脚本字符串,即通过 innerHTML 的方式直接获取
    • 外链脚本 要区分第三方链接和当前微应用的链接,本质还是通过 fetch 去加载对应的脚本内容
  • execScripts 方法负责执行获取到的脚本内容,这里为了简便选择通过 eval 的方式执行上述获取到的代码字符串,当然也可以通过 new Function 的形式
// src\micro-fe\importHtmlEntry.ts

import { fectResource } from './fetch'
import type { MicroApp } from './type'

const Noop = (props?: any) => props

export const importEntry = async (app: MicroApp) => {
    // 获取模板
    const html = await fectResource(app.entry)

    // 字符串模板 -> DOM 结构,目的是方便使用 DOM API
    const template = document.createElement('div')
    template.innerHTML = html

    // 获取模板中所有的 scripts
    const scripts = Array.from(template.querySelectorAll('script'))

    // 加载模板中对应的 script 脚本内容 
    function getExternalScripts() {
        return Promise.all(scripts.map(script => {
            const src = script.getAttribute('src')

            if (!src) return Promise.resolve(script.innerHTML)

            return fectResource(src.indexOf('//') > -1 ? src : app.entry + src)
        }))
    }

    // 执行模板中对应的 script 脚本内容
    async function execScripts() {
        const scripts = await getExternalScripts();
        window.__Micro_App__ = true;

        // 手动构建 CommonJS 规范
        const module = { exports: { bootstrap: Noop, mount: Noop, unmount: Noop } }
        const exports = module.exports

        scripts.forEach((code) => {
            eval(code)
        });

        return module.exports
    }

    return {
        template,
        getExternalScripts,
        execScripts
    }
}

加载其他资源文件

其他资源文件其实就是外链的样式、图片等,通常情况下只要配置了对应的微应用的 publicPath 自然就能够被正确加载,这一点在 qiankun 中其实有提及,其实还是通过 webpack 来设置运行时的 publicPath

值得注意的是,微应用的样式和基座应用冲突的问题,而这个其实也很好解决:

  • shadow DOM 可将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,最简单的隔离方案
  • 为每个微应用定义一个唯一的 css 选择器(如:app.name)来限定样式的作用范围
    • 可以在微应用中就定义好这个唯一标识
    • 可以在基座应用加载微应用时在动态为其定义唯一标识
  • css in js 本质是通过 JavaScript 来声明,维护样式
    • 方式一:styled-components
          const Button = styled.button`
        border-radius: 3px;
        padding: 0.25em 1em; 
        color: palevioletred;
        border: 2px solid palevioletred; 
      `;
      
      function Buttons() {
        return (
          <Button>Normal Button</Button>
          <Button primary>Primary Button</Button>
        );
      }
      
    • 方式二:内联样式
      var styles = { base: { color: '#fff', }, primary: { background: '#0074D9' }, warning: { background: '#FF4136' } }; 
      class Button extends React.Component { 
          render() { 
              return ( <button style={[ styles.base, styles[this.props.kind] ]}> {this.props.children} </button> ); 
          } 
      }
      

扩展:为什么要手动构造 CommonJS 规范?

在实现微应用 HTML 模板解析后,需要执行对应的微应用脚本时,人为的手动构造了符合 CommonJS 规范的环境,其目的是为了更普适的获取在微应用中暴露出来的 bootstrap、mount、unmount 等生命周期钩子,便于在特定时机去执行。

// src\micro-fe\importHtmlEntry.ts

import { fectResource } from './fetch'
import type { MicroApp } from './type'

const Noop = (props?: any) => props

export const importEntry = async (app: MicroApp) => {

    // 执行模板中对应的 script 脚本内容
    async function execScripts() {
        const scripts = await getExternalScripts();
        window.__Micro_App__ = true;

        // 手动构建 CommonJS 规范
        const module = { exports: { bootstrap: Noop, mount: Noop, unmount: Noop } }
        const exports = module.exports

        // 执行代码
        scripts.forEach((code) => {
            eval(code)
        });

        return module.exports
    }
}

但毕竟这里选择了 eval 的方式来执行脚本,那么该如何获取其中的导出方法呢?

首先这和在微应用中设置的打包格式为 umd 的方式有关,不防先看一看打包后的具体内容是什么样子的,这里以 vue3-micro-app 项目为例,运行 npm run build 后在 dist 目录下查看和 app.xxx.js 相关的内容:

// dist\js\app.6fa1a50e.js

;(function (t, n) {
  // CommonJS 规范
  'object' === typeof exports && 'object' === typeof module
    ? (module.exports = n())

    // AMD 规模
    : 'function' === typeof define && define.amd
            ? define([], n)
            : 'object' === typeof exports

                // ESModule 规范
                ? (exports['vue3-micro-app'] = n())

                // 全局属性
                : (t['vue3-micro-app'] = n())
})(window, function () {
  return (function () {
      return ...
  })()
})

会发现 umd 的格式对当前运行时环境做了各种判断:

  • 是否符合 CommonJS 规范,若符合则把其内部的返回值赋值给 module.exports,若不符合进入下一个判断
  • 是否符合 AMD 规范,若符合则通过 define([], n) 实现数据传递,若不符合进入下一个判断
  • 是否符合 ESModule 规范,若符合则把其内部的返回值赋值给 exports[ouput.library],若不符合进入下一个判断
  • 上述条件不符合则会直接通过把返回值赋值给 window[ouput.library]

看起来,要获取微应用入口文件中导出的生命周期钩子方式很多呀,为什么要选则 CommonJS 的方式呢?

  • AMD 规范很少使用了,直接排除
  • ESModule 规范 和 window[ouput.library] 的方式,都非常依赖于在微应用和 webpack 打包输出时指定的 ouput.library 的值,意味着若在微前端框架内部不知道微应用真正的 ouput.library 的值,那岂不是没办法获取到其导出的内容了

综上,其实只有 CommonJS 的方式满足不需要提前知道微应用的导出内容时真正对应的名称,也可以获取到其返回值的结果,但运行时环境复杂,并不一定是支持 CommonJS 规范,于是需要手动提供 module.exportsexports 对象来达到目的。

效果演示

点此查看源代码

最后

本篇文章的结束也算是抓住了 2022 的尾巴,把微前端的内容过了一遍,也算是完成了自己定下的一个 flag,大前端太大,各种概念、各种技术层出不穷,容易让人摸不着头脑,其实不必要过分追逐,需要用到自然会去学习,想要了解自然就会去学习!!!

希望本篇文章对你有所帮助!!!

3F995B29.jpg