前言
在上篇 2022 你还不会微前端吗 (上) — 从巨石应用到微应用 中已经了解了微前端的由来和基本使用,也提到了一些相关的原理,本篇文章为下篇主要从原理层面进行解析,然后再自己实现一个包含核心部分的微前端框架。
微前端核心原理
当然在正式开始自己实现之前,有且非常有必要先了解一下已有的微前端框架是如何实现其核心功能的,这里我们以 qiankun
来作为目标来了解一下其中的核心点:
- 路由劫持
- 加载子应用
- 独立运行时,即沙箱
- 应用通信
路由劫持
qiankun
中路由劫持是通过 single-spa
实现的,而它本身则提供了另外两种核心功能,即 子应用的加载 和 沙箱隔离。
监听 hash 路由 和 history 路由
我们知道路由会分为 hash
路由 和 history
路由,因此要监听路由变化就得注册 hashchange
和 popstate
事件:
- 当通过类似
window.location.href = xxx
或<a href="#xxx"></a>
的方式修改hash
值时会直接hashchange
事件 - 当使用原生的
pushState
和replaceState
改变当前history
路由时,是并不会触发popstate
事件,因此需要对原生的pushState
和replaceState
进 重写/增强,这样在重写/增强后的方法中,就可以通过手动派发popstate
的方式实现当调用pushState
和replaceState
方法时能够触发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
的方式来注册 hashchange
和 popstate
事件,那么这样一来导航事件就会有多个,为了在实现对导航事件的控制,达到路由变化时对应的子应用能够正确的 卸载 和 挂载,需要对 addEventListener
注册的 hashchange
和 popstate
进行拦截,并将对应的事件给存储起来,便于后续在特定的时候能够实现手动触发。
源码位置: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
字符串进行处理,即通过正则去匹配获其中的js
、css
、entry js
等等内容 processTpl()
函数会返回如下结果- template:
html
模板内容 - scripts:
js
脚本包含内联和外联 - styles:
css
样式表,包含内联和外联 - entry:子应用入口
js
脚本,若没有则默认为scripts[scripts.length - 1]
- template:
- 通过
// 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
的值选择LegacySandbox
和ProxySandbox
方式 - 若当前环境不支持
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.configurable
为true
拷贝到新对象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 你还不会微前端吗 (上) — 从巨石应用到微应用,下面一步步开始实现自己的微前端框架吧!
前置处理
简单回顾一下项目的大致结构:
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 './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
- 监听路由变化
- 匹配子应用路由
- 加载子应用
- 渲染子应用
监听路由变化 & 匹配子应用路由
通过注册 hashchange
和 popstate
事件就可以实现对 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
脚本,包含内联和外链的脚本 - 加载其他资源文件,比如
css
、img
等
在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.exports
和 exports
对象来达到目的。
效果演示
最后
本篇文章的结束也算是抓住了 2022
的尾巴,把微前端的内容过了一遍,也算是完成了自己定下的一个 flag
,大前端太大,各种概念、各种技术层出不穷,容易让人摸不着头脑,其实不必要过分追逐,需要用到自然会去学习,想要了解自然就会去学习!!!
希望本篇文章对你有所帮助!!!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。