一、项目背景
转转商品管理中心是一个管理商品原子信息的多项目融合后台,涉及到大量的新增、编辑操作。系统的稳定和运营的操作效率是这个系统最重要衡量指标。
时常会有运营提及:
- 点击左侧菜单的时候能不能将当前展示的二级页面切回到最开始这个菜单对应的页面?
- 能不能像我们其他的内部系统一样,拥有系统内tab页签功能,方便他们切换和操作?
- ......
有些看似很简单且十分有收益的功能点,在iframe方案下却是难以实现的,因此在受够了数次的无奈后,我们终于下定决心改造现有架构,拥抱微前端。
二、技术选型
由于团队的后台项目都是使用的umi框架,所以很容易想到优先使用同宗同源的qiankun来做微前端;
虽然看qiankun的文档中接入方法很简单,但是在接入后还是遇到了很多难以解决的问题,以至于最后放弃了qiankun转而使用micro-app。
三、qiankun踩坑
以下是最初使用qiankun所遇到的比较难以处理的问题:
1.样式隔离
由于基座和子应用使用的antd版本不同,导致基座的菜单和顶部导航样式异常;
沙箱(sandbox:true)
默认情况qiankun会开启沙以确保单实例场景子应用之间的样式隔离,但该方法无法确保主应用跟子应用、或者多实例场景的子应用样式隔离,就会导致当子应用中的antd样式影响到基座的样式;
严格样式隔离(strictStyleIsolation:true)
其原理是shadow dom,可以有效对样式进行隔离,但在react场景下,开启后会导致事件失效,所有的事件回调无法执行,严重影响系统功能;这一点在qiankun官方文档中亦有提及。

在issus中有大佬提出了解决方案,其原理是将所有可能的事件类型绑定到容器,然后,通过查找"__reactInternalInstances"并在事件范围/路径内查找相应的事件处理程序来调度正确的 React 事件。但看上去仍然存在一些尚未解决的问题,并且已经许久未更新了。
其实现方式大致如下:
jsx
retargetEvents() {
let events = ["onClick", "onContextMenu", "onDoubleClick", "onDrag", "onDragEnd",
"onDragEnter", "onDragExit", "onDragLeave", "onDragOver", "onDragStart", "onDrop",
"onMouseDown", "onMouseEnter", "onMouseLeave","onMouseMove", "onMouseOut",
"onMouseOver", "onMouseUp"];
function dispatchEvent(event, eventType, itemProps) {
if (itemProps[eventType]) {
itemProps[eventType](event);
} else if (itemProps.children && itemProps.children.forEach) {
itemProps.children.forEach(child => {
child.props && dispatchEvent(event, eventType, child.props);
})
}
}
// Compatible with v0.14 & 15
function findReactInternal(item) {
let instance;
for (let key in item) {
if (item.hasOwnProperty(key) && ~key.indexOf('_reactInternal')) {
instance = item[key];
break;
}
}
return instance;
}
events.forEach(eventType => {
let transformedEventType = eventType.replace(/^on/, '').toLowerCase();
this.el.addEventListener(transformedEventType, event => {
for (let i in event.path) {
let item = event.path[i];
let internalComponent = findReactInternal(item);
if (internalComponent
&& internalComponent._currentElement
&& internalComponent._currentElement.props
) {
dispatchEvent(event, eventType, internalComponent._currentElement.props);
}
if (item == this.el) break;
}
});
});
}
实验性的样式隔离(experimentalStyleIsolation:true)
这种策略下qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围。
虽然不会有事件失效的问题,但是在对弹窗这类挂载在body上的元素 仍然会面临跟严格样式隔离下同样的样式丢失的问题;
对于这一点,可以通过antd的全局化配置ConfigProvider来统一修改这类元素的挂载节点,以较低的改造成本解决该问题。

jsx
// 子应用
<ConfigProvider
getPopupContainer={(triggerNode) =>
if(window.__POWERED_BY_QIANKUN__) {
return getMicoAppContainer()
}
return document.body
}
>
<App />
</ConfigProvider>
2.sentry相关
我们的所有项目都会接入sentry,但是在qiankun下子应用销毁时,sentry实例并没有一同被销毁,导致控制台出现大量警告,导致控制台卡死。相关issus中提到的办法尝试后均无果,由于时间关系目前还没深入探究。

3.多实例
由于需要在改造后的系统中实现多页签的功能,免不了会用到qiankun多实例的功能,但qiankun在多实例场景下会遇到一些奇奇怪怪的问题。
以下是一个使用了antd-tab组件 + qiankun的最简demo。功能就是可以新增app1-1和app1-2,这俩是同一个app。期望效果是能够同时展示多个子应用实例,且切换、关闭tab功能正常。
但是在实际开发时,却发现实例之间会互相影响:
操作一:先打开app1-1,再打开app1-2,然后关闭app1-1后会导致app1-2也消失,然后关闭app1-2的tab,控制台有报错;

操作二:先打开app1-2,再打开app1-1,关闭app1-2,再打开app1-2,新打开的app1-2加载失败,内容空白,控制台有报错;

这个问题在issus中能找到,但其中的解决办法如手动卸载、降低qiankun版本等尝试后依然无果。并且也有其他开发者反馈问题仍未解决。
在遇到种种难以处理的问题后,我对最初的"无脑"选择失去了信心,在我当下的场景中,接入qiankun好像并不是那么的轻而易举,仍然需要处理非常多的意料之外的状况。
因此,最终我把目光投向了接入成本更低,使用更加简便,且理论上隔离问题更少的micro-app。
四、使用micro-app完成改造
micro-app 是由京东出品,一款基于WebComponent的思想,轻量、高效、功能强大的微前端框架。接入方法跟iframe一样简单!
jsx
<micro-app name='my-app' url='http://localhost:3000/'></micro-app>

优势
1、兼容所有框架。
2、使用简单,将所有功能都封装到一个类WebComponent组件中,从而实现一行代码即可渲染一个微前端应用。
3、功能强大,提供了js沙箱、样式隔离、元素隔离、路由隔离、预加载、数据通信等一系列完善的功能。
1. 前期配置
默认情况下,micro-app通过fetch加载子应用的html、js等静态资源时默认不带cookie。

这里有两个注意点,一个是通过fetch请求,这需要保证子应用的静态资源是要支持跨域的,另一个就是默认不带cookie。因此根据文档要求,我们需要完成两项前期任务:
- 自定义fetch并配置credentials以在请求时带上cookie,可参考官方文档配置。
- 将子应用的资源请求
Access-Control-Allow-Origin设置为指定域名(不能为*),同时设置Access-Control-Allow-Credentials: true
2. 动态路由与MyMiCroApp实现
由于后台系统涉及多个项目,且同一个菜单下也会出现多个系统的页面,为了尽可能的简化配置,但又能保证链接上保留一些必要的应用信息,最终设计出的系统访问路径大概长这样:
- zone来圈定需要展示在当前系统下的顶部菜单
- #/0/1/AppC/route1,表示第0个顶部菜单下第1个父菜单下的/AppC/route1菜单,这里的AppC-1的路由就是/AppC/route1,其中route1是AppC定义在项目中的路由,AppC表明是哪个应用。

此处路由设计不一定是比较好的,尽可能地复用了原有逻辑,不过这不是本文讨论的重点,大家可以依据自己的业务场景自行设计,这里主要是结合下文说明如何使用一个MicroApp组件来加载不同的子应用和不同的页面。
实现MyMiCroApp组件
1. 路径解析splitPath
jsx
// 对于https://pc.zhuan.com/?zone=commodity#/0/1/AppC/route1?name=Bob
// 取/0/1/AppC/route1?name=Bob作为入参传给splitPath
// 解析出以下三个字段
// indexPath: 路由索引部分,对MicroApp没用,但是后边做路由跳转会用到
// appName: 要渲染的应用
// appUrl: 子应用的页面路由+参数
const splitPath = (path) => {
if (!path) return { indexPath: "", appName: "", appUrl: "" };
// 正则:捕获数字索引、appName、appUrl
// ^(\/(?:\d+\/?)*) 捕获以/开头的数字段(可带/),为indexPath
// (\/[^\/]+)? 捕获第一个非数字段(包含/),为appName
// (\/.*)? 捕获剩余部分(包含/),为appUrl
// eslint-disable-next-line no-useless-escape
const regex = /^(\/(?:\d+\/?)*)?(\/[^\/]+)?(\/.*)?$/;
const match = path.match(regex);
if (match) {
const indexPath = match[1] ? match[1].replace(/\/$/, "") : "";
const appName = match[2] || "";
const appUrl = match[3] || "";
return { indexPath, appName, appUrl };
}
return { indexPath: "", appName: "", appUrl: "" };
};
2.micro-app配置项
name:需要唯一,这里是通过拼接应用名+tabKey的方式。tabKey的值是pathname + search。
data:全局数据,如用户信息、权限,全局方法等。
url:指向子应用的index.html,由于后台系统通常都会有菜单,而在当前场景下菜单是基座生成的,因此需要隐藏子应用的菜单。
defaultPage:默认展示的页面,通过splitPath得到子应用的实际路径和页面参数。
router-mode:在当前场景下选择pure或state都可以。下边详细介绍下。
jsx
<micro-app
router-mode="state"
name={`${appName.slice(1)}-${tabKey}`}
data={globalState}
url={replaceUrl(microAppMap[appName.slice(1)]) + "?hideSidebar=true"}
defaultPage={`#${appUrl}`}
/>
3.router-mode
micro-app提供了多种虚拟路由模式,以最大程度适应不同的用户所需场景。
当前的场景是多页签,他的原理是监听路由变化,然后自动创建新的tab页。
另外在实际测试中发现,如果子应用调用自己的history实例去触发路由变更,会导致子应用的页面内容也会展示新的页面内容,而期望效果是,当前页面不发生变化,在新打开的tab页展示新的页面内容。
通过上述的分析,最终得出一个设计方向:子应用不要调用自己的history,而是调用基座的history来统一执行跳转、统一调度tab页签相关的能力,这样会使得多页签+微前端两个与路由关系十分紧密的功能最大程度的避免出现冲突。
mode选择:
search:会将应用信息拼接在链接的query位置,不美观,而且子应用的路由信息已经包含在基座的链接中了。没必要再多存放到query一份。
native:开路由隔离,子应用和主应用共同基于浏览器路由进行渲染,不符合当前场景,比如#/0/0/AppA要渲染A应用,而#/0/0/AppB要渲染B应用;而且还需要改造子应用的路由配置,比较复杂,不予选用。
natve-scope:基本同上。
pure:子应用独立于浏览器路由系统进行渲染,即不修改浏览器地址,也不增加路由堆栈,使得子应用更像是一个组件,符合需求。
state:基于浏览器history.state进行渲染的路由模式,在不修改浏览器地址的情况下模拟路由行为,与iframe类似,符合需求。
pure和state模式下子应用的路由相对独立,不会影响基座路由同时也不会被影响,因此在当前多页签+微前端的场景下,两者均可选用。
3.子应用跳转改造
在 Iframe 模式下,内嵌页面拥有"浏览器级"的完整上下文,直接调用 history.pushState/replaceState 或框架层的 router.push/router.replace 就能独立驱动地址栏变化,每个 Iframe 的 URL 与父页面天然隔离,彼此互不干扰。
而在微前端架构下,这种"各自为政"的导航方式不可直接套用,原因在于路由需要由主应用统一编排与调度,如果子应用直接维护浏览器级 history 会与主应用的路由状态产生竞争关系。一旦 router-link 或 router.push/replace 按子应用自身 base 计算路径,最终的 URL 将脱离主应用的索引前缀,导致页面失效、404等问题。
因此按照上文router-mode中提到的整体设计方案,需要对子应用的跳转进行相应的改造处理。

我们需要确保子应用发起的导航遵循上述规则(如 /0/0/app-name/child-route...)。但是如果我们逐一重构路由配置去拼接前缀,不仅成本高、周期长,还容易引入不一致。因此我们最终将路由的解析和拼接规则统一收敛到基座中,然后分发给子应用去调用
jsx
// 通过前文splitPath得到indexPath和appName
const genPath = (oriTo) => {
if (typeof oriTo === "string") {
return `${indexPath}${appName}${oriTo}`;
}
return {
...oriTo,
pathname: `${indexPath}${appName}${oriTo.pathname}`
};
};
const customHistory = {
...umiHistory,
push: (to, state) => {
umiHistory.push(genPath(to), state);
},
replace: (to, state) => {
umiHistory.replace(genPath(to), state);
}
};
// 用ref存确保data的地址引用不变,防止组件更新时重新发送
const globalState = useRef({
history: customHistory,
microAppMap,
userInfo,
permissionList
});
在各个子应用,使用基座提供的history去替换umi本身的history
除此之外,子应用的权限也不需要单独去获取,会继承基座的权限逻辑
jsx
export async function getInitialState() {
// 在子应用中获取基座传递的数据
const data = window?.microApp?.getData()
if (data?.history) {
const { push, replace } = data.history
history.push = push
history.replace = replace
}
// 以子应用加载时,基座已经获取了权限信息,直接返回
if (data?.permissionList?.length) {
const { userInfo, permissionList } = data
return {
userInfo,
permissionList,
settings: defaultSettings
}
}
// 非微前端场景代码......
}
泛域名支持
在我们内部系统中,使用泛域名系统为开发、测试等环境提供统一、便捷的域名访问方式,替代原通过本地配置 host 访问测试环境的模式

在微前端场景下,使用泛域名默认只会对基座生效,为了保证基座和子应用系统环境的统一,需要将基座上的环境信息解析,在加载子应用时进行处理。

依据父域名解析测试标记 curTag,统一改写子应用入口 URL 的 host
jsx
// 替换子应用的url的host为测试环境的域名
const replaceUrl = (url) => {
if (!curTag) {
return url;
}
const host = url.split("/")[2];
const temp = host.split('.')[0]
const newHost = host.replace(temp, `${temp}-${curTag}.test`)
return url.replace(host, newHost);
}
return (
<micro-app
...
url={replaceUrl(microAppMap[appName.slice(1)]) + "?hideSidebar=true"}
/>
);
五、后续优化项与总结
1. 预加载, 基于用户行为分析,对用户可能访问的子应用页面进行提前加载。降低新页面的白屏时间。
2. 使用umd模式, 子应用暴露出mount、unmount方法,此时只在初次渲染时执行所有js,后续渲染只会执行这两个方法,在多次渲染时具有更好的性能和内存表现。
3. 其他类似系统接入, 将当前系统改造成微前端的经验沉淀下来,复制到其他有类似问题的系统中。
微前端架构的引入不仅解决了当前系统的技术债务问题,更为未来的业务发展奠定了坚实的技术基础。通过持续的技术优化和创新探索,我们相信能够构建出更加灵活、高效、可维护的企业级应用架构。