本文来自OPPO互联网技术团队,转载请注名作者。同时欢迎关注我们的公众号:OPPO_tech,与你分享OPPO前沿互联网技术及活动。
微前端是指存在于浏览器中的微服务,通常由许多组件组成,并使用类似于 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
进行统一调度。
// We will trigger an app change for any routing events.
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
// Monkeypatch addEventListener so that we can ensure correct timing
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)); // 待卸载的应用先执行unmount
await Promise.all(appsToUnload.map(toUnloadPromise)); // 待销毁的应用先销毁
await Promise.all(appsToLoad.map(toLoadPromise)); // 待加载的应用先执行load
await Promise.all(appsToBootstrap.map(toBootstrapPromise)); // 待bootstrap的应用执行bootstrap
await Promise.all(appsMount.map(toMountPromise)); // 待挂载的应用执行mount
appChangeUnderway = false;
// 如果排队的队列中还有路由变更,则进行新的一轮reroute循环
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"; // 已加载源码,还未bootstrap
export const BOOTSTRAPPING = "BOOTSTRAPPING"; // bootstrap中
export const NOT_MOUNTED = "NOT_MOUNTED"; // bootstrap完毕,还未mount
export const MOUNTING = "MOUNTING"; // mount中
export const MOUNTED = "MOUNTED"; // mount结束
export const UPDATING = "UPDATING"; // updata中
export const UNMOUNTING = "UNMOUNTING"; // unmount中
export const UNLOADING = "UNLOADING"; // unload中
export const LOAD_ERROR = "LOAD_ERROR"; // 加载源码时加载失败
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN"; // 在load,bootstrap,mount,unmount阶段发生脚本错误
我们可以在开发时使用官方的调试工具快速查看每次路由变更后每个应用的状态:
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
); // PopStateEvent | HashChangeEvent | undefined
console.log(
"the new status for all applications after the reroute finishes",
newAppStatuses
); // { app1: MOUNTED, app2: NOT_MOUNTED }
console.log(
"the applications that changed, grouped by their status",
appsByNewStatus
); // { MOUNTED: ['app1'], NOT_MOUNTED: ['app2'] }
console.log(
"number of applications that changed status so far during this reroute",
totalAppChanges
); // 2
});
1.7 single-spa 亮点与不足
亮点
- 全异步编程,对于用户需要提供的 load,bootstrap,mount,unmount 均使用 promise 异步的形式处理,不管同步、异步都能 hold 住
- 通过劫持路由,可以在每次路由变更时先判断是否需要切换应用,再交给子应用去响应路由
- 标准化每个应用的挂载和卸载函数,不耦合任何框架,只要子应用实现了对应接口即可接入系统中
不足
load
方法需要知道子项目的入口文件- 把多个应用的运行时集成起来需要项目间自行处理内存泄漏,样式污染问题
- 没有提供父子数据通信的方式
2. qiankun 登场
为了解决single-spa
的一些不足,以及保留single-spa
中优秀的理念,所以qiankun
在single-spa
的基础上进行了更进一步的拓展。
以下是qiankun
官方给的能力图:
我们来看看qiankun
的使用方式
import { registerMicroApps, start } from "qiankun";
registerMicroApps([
{
name: "react app", // app name registered
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); // 通过应用的入口链接即可获取到应用的html, js, css内容
const sandboxInstance = createSandbox(); // 创建沙箱实例
const global = sandboxInstance.proxy; // 获取一个沙箱全局上下文
const mountSandbox = sandboxInstance.mount;
const unmountSandbox = sandboxInstance.unmount;
// 在这个沙箱全局上下文执行子项目的js代码
const scriptExports = await execScripts(global);
// 获取子项目导出的 bootstrap / mount / unmount
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global
);
// 初始化事件模块
const {
onGlobalStateChange,
setGlobalState,
offGlobalStateChange,
} = getMicroAppStateActions();
// 传递给single-spa的mount, unmount方法实际是qiankun包装过的函数
return {
bootstrap,
mount: async () => {
awaitrender(template); // 把模板渲染到挂载区域
mountSandbox(); // 挂载沙箱
await mount({ setGlobalState, onGlobalStateChange }); // 调用应用的mount函数
},
ummount: async () => {
await ummount(); // 调用应用的ummount函数
unmountSandbox(); // 卸载沙箱
offGlobalStateChange(); // 解除事件监听
render(null); // 把渲染区域清空
},
};
}
2.3 importEntry 的实现
看看 importEntry
的使用,这是一个独立的包 import-html-entry
,通过解析一个 html 内容,返回html
, css
,js
分离过的内容。
例如一个子应用的入口html
为如下
这里是标题
被 qiankun
加载到页面后,最终生成的 html 结构
为:
这里是标题
看看importEntry
返回的内容
export function importEntry(entry, opts = {}) {
... // parse html 过程忽略
return {
// 纯dom元素的内容
template,
// 一个可以接收自定义fetch方法的获取`], 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);
};
// 这里free不需要释放什么东西,因为style元素会随着内容区清除而自然消失
return function free() {
// 这里需要再下次继续挂载这个应用时重建style元素
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) {
// Don't prefetch if in a slow network or offline
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 提取公共库
qiankun
不建议共享依赖,担心原型链污染
等问题。single-spa
推荐共享大型依赖,需要小心处理污染问题,它们都是推荐使用webpack
的external
来共享依赖库。- 我们也推荐共享大的公共依赖,也是使用
webpack
的external
来共享依赖库,不过是每个子应用加载时都重复再加载一次库,相当于节省了相同库的下载时间,也保证了不同子应用间不会产生原型链污染
,属于折中
的方案。