前言
在上篇 2022 你还不会微前端吗 (上) — 从巨石应用到微应用 中已经了解了微前端的由来和基本使用,也提到了一些相关的原理,本篇文章为下篇主要从原理层面进行解析,然后再自己实现一个包含核心部分的微前端框架。
微前端核心原理
当然在正式开始自己实现之前,有且非常有必要先了解一下已有的微前端框架是如何实现其核心功能的,这里我们以 qiankun
来作为目标来了解一下其中的核心点:
- 路由劫持
- 加载子应用
- 独立运行时,即沙箱
- 应用通信
路由劫持
qiankun
中路由劫持是通过 single-spa
实现的,而它本身则提供了另外两种核心功能,即 子应用的加载 和 沙箱隔离。
监听 hash 路由 和 history 路由
我们知道路由会分为 hash
路由 和 history
路由,因此要监听路由变化就得注册 hashchange
和 popstate
事件:
- 当通过类似
window.location.href = xxx
或的方式修改
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(
apps: Array>,
lifeCycles?: FrameworkLifeCycles,
) {
// 每个子应用自会被注册一次
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(
app: LoadableApp,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles,
): Promise {
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) : "")); 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); }
独立运行时 —— 沙箱
沙箱 的目的是 为了隔离子应用间
脚本
和样式
的影响,即需要针对子应用的