微前端是指存在于浏览器中的微服务,通常由许多组件组成,并使用类似于 React、Vue 和 Angular 等框架来渲染组件,每个微前端可以由不同的团队进行管理,并可以自主选择框架。
每个微前端都拥有独立的 git 仓库、package.json 和构建工具配置。因此,可以拆分一些巨石应用为多个独立的模块再组合起来,应用间独立维护及上线,互不干扰。
本文通过一些精简代码的方式介绍微前端框架qiankun的原理及OPPO云在这上面的一些实践。
注:本文默认读者使用过qiankun框架,且文中使用的qiankun版本为:2.0.9。
1. qiankun 的前身 single-spa
qiankun 是一个基于 single-spa 的微前端实现库,在qiankun还未诞生前,用户通常使用single-spa来解决微前端的问题,所以我们先来了解single-spa。
我们先来上一个例子,并逐步分析每一步发生了什么。
import { registerApplication, start } from "single-spa";registerApplication( "foo", () => System.import("foo"), (location) => location.pathname.startsWith("foo"));registerApplication({ name: "bar", loadingFn: () => import("bar.js"), activityFn: (location) => location.pathname.startsWith("bar"),});start();
appName: string 应用的名字将会在 single-spa 中注册和引用, 并在开发工具中标记
loadingFn: () => 必须是一个加载函数,返回一个应用或者一个 Promise
activityFn: (location) => boolean 判断当前应用是否活跃的方法
customProps?: Object 可选的传递自定义参数
1.1 元数据处理
首先,single-spa会对上述数据进行标准化处理,并添加上状态,最终转化为一个元数据数组,例如上述数据会被转为:
[{ name: 'foo', loadApp: () => System.import('foo'), activeWhen: location => location.pathname.startsWith('foo'), customProps: {}, status: 'NOT_LOADED'},{ name: 'bar', loadApp: () => import('bar.js'), activeWhen: location => location.pathname.startsWith('bar') customProps: {}, status: 'NOT_LOADED'}]
1.2 路由劫持
single-spa内部会对浏览器的路由进行劫持,所有的路由方法和路由事件都确保先进入single-spa进行统一调度。
window.addEventListener("hashchange", urlReroute);window.addEventListener("popstate", urlReroute);const originalAddEventListener = window.addEventListener;window.addEventListener = function(eventName, fn) { if (typeof fn === "function") { if ( ["hashchange", "popstate"].indexOf(eventName) >= 0 && !find(capturedEventListeners[eventName], (listener) => listener === fn) ) { capturedEventListeners[eventName].push(fn); return; } } return originalAddEventListener.apply(this, arguments);};
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) { urlReroute(createPopStateEvent(window.history.state, methodName)); } };}window.history.pushState = patchedUpdateState( window.history.pushState, "pushState");window.history.replaceState = patchedUpdateState( window.history.replaceState, "replaceState");
以上是劫持代码的精简版,可以看到,所有的劫持都指向了一个出口函数urlReroute。
1.3 urlReroute 统一处理函数
每次路由变化,都进入一个相同的函数进行处理:
let appChangeUnderway = false, peopleWaitingOnAppChange = [];export async function reroute(pendingPromises = [], eventArguments) { const { appsToUnload, appsToUnmount, appsToLoad, appsToMount, } = getAppChanges();
if (appChangeUnderway) { return new Promise((resolve, reject) => { peopleWaitingOnAppChange.push({ resolve, reject, eventArguments }); }); } appChangeUnderway = true;
await Promise.all(appsToUnmount.map(toUnmountPromise)); await Promise.all(appsToUnload.map(toUnloadPromise)); await Promise.all(appsToLoad.map(toLoadPromise)); await Promise.all(appsToBootstrap.map(toBootstrapPromise)); await Promise.all(appsMount.map(toMountPromise));
appChangeUnderway = false; reroute(peopleWaitingOnAppChange);}
接下来看看分组函数在做什么。
1.4 getAppChanges 应用分组
每次路由变更都先根据应用的activeRule规则把应用分组。
export function getAppChanges() { const appsToUnload = [], appsToUnmount = [], appsToLoad = [], appsToMount = []; apps.forEach((app) => { const appShouldBeActive = app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app); switch (app.status) { case LOAD_ERROR: case NOT_LOADED: if (appShouldBeActive) appsToLoad.push(app); case NOT_BOOTSTRAPPED: case NOT_MOUNTED: if (!appShouldBeActive) { appsToUnload.push(app); } else if (appShouldBeActive) { appsToMount.push(app); } case MOUNTED: if (!appShouldBeActive) appsToUnmount.push(app); } }); return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };}
1.5 关于状态字段的枚举
single-spa对应用划分了一下的状态
export const NOT_LOADED = "NOT_LOADED"; export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; export const BOOTSTRAPPING = "BOOTSTRAPPING"; export const NOT_MOUNTED = "NOT_MOUNTED"; export const MOUNTING = "MOUNTING"; export const MOUNTED = "MOUNTED"; export const UPDATING = "UPDATING"; export const UNMOUNTING = "UNMOUNTING"; export const UNLOADING = "UNLOADING"; export const LOAD_ERROR = "LOAD_ERROR"; export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN";
我们可以在开发时使用官方的调试工具快速查看每次路由变更后每个应用的状态:
![]()
single-spa使用了有限状态机的设计思想:
有限状态机的其他例子:Promise 、 红绿灯
1.6 single-spa 的事件系统
基于浏览器原生的事件系统,无框架耦合,全局开箱可用。
window.addEventListener("single-spa:before-routing-event", (evt) => { const { originalEvent, newAppStatuses, appsByNewStatus, totalAppChanges, } = evt.detail; console.log( "original event that triggered this single-spa event", originalEvent ); console.log( "the new status for all applications after the reroute finishes", newAppStatuses ); console.log( "the applications that changed, grouped by their status", appsByNewStatus ); console.log( "number of applications that changed status so far during this reroute", totalAppChanges ); });
1.7 single-spa 亮点与不足
亮点
全异步编程,对于用户需要提供的 load,bootstrap,mount,unmount 均使用 promise 异步的形式处理,不管同步、异步都能 hold 住
通过劫持路由,可以在每次路由变更时先判断是否需要切换应用,再交给子应用去响应路由
标准化每个应用的挂载和卸载函数,不耦合任何框架,只要子应用实现了对应接口即可接入系统中
不足
2. qiankun 登场
为了解决single-spa的一些不足,以及保留single-spa中优秀的理念,所以qiankun在single-spa的基础上进行了更进一步的拓展。
以下是qiankun官方给的能力图:
![]()
我们来看看qiankun的使用方式
import { registerMicroApps, start } from "qiankun";registerMicroApps([ { name: "react app", entry: "//localhost:7100", container: "#yourContainer", activeRule: "/yourActiveRule", }, { name: "vue app", entry: { scripts: ["//localhost:7100/main.js"] }, container: "#yourContainer2", activeRule: "/yourActiveRule2", },]);start();
是不是有点像single-spa的注册方式?
2.1 传递注册信息给 single-spa
实际上qiankun内部会把用户的应用注册信息包装后传递给single-spa
import { registerApplication } from "single-spa";export function registerMicroApps(apps) { apps.forEach((app) => { const { name, activeRule, loader = noop, props, ...appConfig } = app; registerApplication({ name, app: async () => { loader(true); const { mount, ...otherMicroAppConfigs } = await loadApp( { name, props, ...appConfig }, frameworkConfiguration ); return { mount: [ async () => loader(true), ...toArray(mount), async () => loader(false), ], ...otherMicroAppConfigs, }; }, activeWhen: activeRule, customProps: props, }); });}
可以看到mount和unmount函数是由loadApp返回的。
2.2 loadApp 的实现
export async function loadApp(app, configuration) { const { template, execScripts } = await importEntry(entry); const sandboxInstance = createSandbox(); const global = sandboxInstance.proxy; const mountSandbox = sandboxInstance.mount; const unmountSandbox = sandboxInstance.unmount; const scriptExports = await execScripts(global); const { bootstrap, mount, unmount, update } = getLifecyclesFromExports( scriptExports, appName, global ); const { onGlobalStateChange, setGlobalState, offGlobalStateChange, } = getMicroAppStateActions(); return { bootstrap, mount: async () => { awaitrender(template); mountSandbox(); await mount({ setGlobalState, onGlobalStateChange }); }, ummount: async () => { await ummount(); unmountSandbox(); offGlobalStateChange(); render(null); }, };}
2.3 importEntry 的实现
看看 importEntry 的使用,这是一个独立的包 import-html-entry,通过解析一个 html 内容,返回html, css,js分离过的内容。
例如一个子应用的入口html为如下
<!DOCTYPE html><html> <head> <meta charset="utf-8" /> <title>这里是标题</title> <link rel="stylesheet" href="./css/admin.css" /> <style> .div { color: red; }</style> </head> <boyd> <div id="wrap"> <div id="app"></div> </div> <script src="/static/js/app.12345.js"></script> <script> console.log("1");</script> </boyd></html>
被 qiankun 加载到页面后,最终生成的 html 结构为:
<meta charset="utf-8" /><title>这里是标题</title><link rel="stylesheet" href="./css/admin.css" /><style> .div { color: red; }</style><div id="wrap"> <div id="app"></div></div>
看看importEntry返回的内容
export function importEntry(entry, opts = {}) { ... return { template, getExternalScripts: () => getExternalScripts(scripts, fetch), getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), execScripts: (proxy) => {} }}
看看getExternalScripts的实现,实际上是用并行fetch模拟浏览器加载<style>标签的过程(注意此时还没有执行这些脚本), getExternalStyleSheets与这个类似。
export getExternalScripts(scripts, fetch = defaultFetch) { return Promise.all(scripts.map(script => { return fetch(scriptUrl).then(response => { return response.text(); })); }))}
然后看看execScripts的实现,可以通过给定的一个假window来执行所有<script>标签的脚本,这样就是真正模拟了浏览器执行<script>标签的行为。
export async execScripts(proxy) { const scriptsTexts = await getExternalScripts(scripts) window.proxy = proxy; for (let scriptsText of scriptsTexts) { const sourceUrl = '//# sourceURL=${scriptSrc}\n'; eval(` ;(function(window, self){ ;${scriptText} ${sourceUrl} }).bind(window.proxy)(window.proxy, window.proxy); `;) }}
2.4 全局变量污染与内存泄漏
看沙箱功能前先聊一聊沙箱,沙箱主要用于解决程序的全局变量污染和内存泄漏问题。
下面我们看看qiankun要如何解决上面的问题。
2.5 qiankun 如何使用沙箱
可以结合上文loadApp的逻辑看看,本文讨论的是LegacySandbox沙箱。
export function createSandbox() { const sandbox = new LegacySandbox(); const bootstrappingFreers = patchAtBootstrapping(); let sideEffectsRebuilders = []; return { proxy: sandbox.proxy, async mount() { sandbox.active(); const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice( 0, bootstrappingFreers.length ); sideEffectsRebuildersAtBootstrapping.forEach((rebuild) => rebuild()); mountingFreers = patchAtMounting( appName, elementGetter, sandbox, singular, scopedCSS, excludeAssetFilter ); sideEffectsRebuilders = []; }, async unmount() { sideEffectsRebuilders = [...bootstrappingFreers].map((free) => free()); sandbox.inactive(); }, };}
看看LegacySandbox沙箱的实现,这个沙箱的作用主要处理全局变量污染,使用一个proxy来替换window来劫持所有的 window 操作。
class SingularProxySandbox { addedPropsMapInSandbox = new Map(); modifiedPropsOriginalValueMapInSandbox = new Map(); currentUpdatedPropsValueMap = new Map(); sandboxRunning = true; active() { this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v)); this.sandboxRunning = true; } inactive() { this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v)); this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true)); this.sandboxRunning = false; } constructor(name) { const proxy = new Proxy(window, { set(_, p, value) { if (!window.hasOwnProperty(p)) { addedPropsMapInSandbox.set(p, value); } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) { const originalValue = window[p]; modifiedPropsOriginalValueMapInSandbox.set(p, originalValue); } currentUpdatedPropsValueMap.set(p, value); window[p] = value; } }, get(_, p) { return window[p] }, }) }}
除了全局变量污染的问题,还有其他的泄漏问题需要处理,这些泄漏问题qiankun使用不同的patch函数来劫持。
export function patchAtMounting() { return [ patchInterval(), patchWindowListener(), patchHistoryListener(), patchDynamicAppend(), ];}export function patchAtBootstrapping() { return [patchDynamicAppend()];}
一个patch的例子如下:
const rawWindowInterval = window.setInterval;const rawWindowClearInterval = window.clearInterval;export default function patchInterval(global) { let intervals = []; global.clearInterval = (intervalId) => { intervals = intervals.filter((id) => id !== intervalId); return rawWindowClearInterval(intervalId); }; global.setInterval = (handler, timeout, ...arg) => { const intervalId = rawWindowInterval(handler, timeout, ...args); intervals = [...intervals, intervalId]; return intervalId; }; return function free() { intervals.forEach((id) => global.clearInterval(id)); global.setInterval = rawWindowInterval; global.clearInterval = rawWindowClearInterval; return function rebuild() {}; };}
这种返回取消功能的设计很精妙,在 vue 中也能找到类似设计。
const unwatch = this.$watch("xxx", () => {});const rewatch = unwatch();
我们来看最复杂的patchDynamicAppend实现,用于处理代码里动态插入script和link的场景。
const rawHeadAppendChild = HTMLHeadElement.prototype.appendChild;export default function patchDynamicAppend(mounting, proxy) { let dynamicStyleSheetElements = []; HTMLHeadElement.prototype.appendChild = function(element) { switch (element.tagName) { case LINK_TAG_NAME: case STYLE_TAG_NAME: { dynamicStyleSheetElements.push(stylesheetElement); return rawHeadAppendChild.call(appWrapperGetter(), stylesheetElement); } case SCRIPT_TAG_NAME: { const { src, text } = element; execScripts(null, [src ? src : `<script>${text}</script>`], proxy); const dynamicScriptCommentElement = document.createComment( src ? `dynamic script ${src} replaced by qiankun` : "dynamic inline script replaced by qiankun" ); return rawHeadAppendChild.call( appWrapperGetter(), dynamicScriptCommentElement ); } } return rawHeadAppendChild.call(this, element); }; return function free() { return function rebuild() { dynamicStyleSheetElements.forEach((stylesheetElement) => { document.head.appendChild.call(appWrapperGetter(), stylesheetElement); }); if (mounting) dynamicStyleSheetElements = []; }; };}
2.6 父子应用通信
qiankun实现了一个简单的全局数据存储,作为single-spa事件的补充,父子应用都可以共同读写这个存储里的数据。
let globalState = {};export function getMicroAppStateActions(id, isMaster) { return { onGlobalStateChange(callback, fireImmediately) { deps[id] = callback; const cloneState = cloneDeep(globalState); if (fireImmediately) { callback(cloneState, cloneState); } }, setGlobalState(state) { const prevGlobalState = cloneDeep(globalState); Object.keys(deps).forEach((id) => { deps[id](cloneDeep(globalState), cloneDeep(prevGlobalState)); }); return true; }, offGlobalStateChange() { delete deps[id]; }, };}
2.7 关于预请求
预请求充分利用了importEntry把获取资源和执行资源分离的点来提前加载所有子应用的资源。
function prefetch(entry, opts) { if (!navigator.onLine || isSlowNetwork) { return; } requestIdleCallback(async () => { const { getExternalScripts, getExternalStyleSheets } = await importEntry( entry, opts ); requestIdleCallback(getExternalStyleSheets); requestIdleCallback(getExternalScripts); });}apps.forEach(({ entry }) => prefetch(entry, opts));
以上分享了qiankun和single-spa的原理,总的来说qiankun更面向一些子项目不可控,并且开发者不会刻意处理污染和内存泄漏的场景,而single-spa则更纯粹的是一个路由控制器,所有的污染和泄漏问题都需要开发者自行约束。
3. OPPO 云实践
OPPO云在实践qiankun微前端的落地过程中,也摸索出一些经验可进行分享。
3.1 关于沙箱
qiankun 的沙箱不是万能的
沙箱只有一层的劫持,例如 Date.prototype.xxx 这样的改动是不会被还原的
目前沙箱对于全局变量的作用在于屏蔽,而不是清除,并且屏蔽后这部分内存是保留的,后续会开放自定义沙箱的能力
关于内存泄漏的概念,可以了解一下“常驻内存”的概念
常驻内存是一种辅助工具程序,能假装退出,而仍驻留于内存当中,让你运行其它的应用,当你再切回应用时,可以立即应用这些内存,而不需要再次耗时创建
排查内存问题时请使用无痕模式以及不使用任何 chrome 拓展,也推荐使用生产构建来排查
3.2 提取公共库
参考链接
1. qiankun: https://qiankun.umijs.org/zh/
2. single-spa: https://single-spa.js.org/
3. qiankun 2.0: https://juejin.cn/post/6844904128922009607
☆ END ☆
OPPO互联网基础技术团队招聘一大波岗位,涵盖C++、Go、OpenJDK、Java、DevOps、Android、ElasticSearch等多个方向,请点击这里查看详细信息及JD。