写在最前:
本文面向对无界和微前端有一定了解的人群,不再对微前端的概念和无界的基础使用方式做解释说明
前言
掘金上搜wujie,那么大眼一看,好像全都是介绍的,并没有几个落地方案的分享。正好我上个月把部门内三个业务系统用wujie整合了一下,记录成文章和大家分享一下。(为什么不用qiankun?qiankun之前做了好多次了,这次想尝个鲜~)
背景说明
笔者部门内有三个管理系统,技术栈分别是:
A: Vue2 + Webpack4 + ant-design-vue@1.7.8
:该项目是部门内"司龄"最长的,从部门成立之初起,所有的业务都堆在里边。
B: Vue3 + Webpack5 + ant-desgin-vue@3.2.20
:由于业务目标不清晰以及前端开发各自为战,部分需求被拆出来了一个单独的项目进行开发,但实际上然并卵。
C: Vue3 + Vite2 + ant-design-vue@3.2.20
:为了响应领导"统一前端UI规范"和"低代码降本增效"的号召,这个项目应运而生,使用JSON Scheme渲染列表页 + 手写Form表单的形式开发需求。
没错,就是3个纯业务向的管理系统。对接我们部门的大部分业务人员,日常都至少需要操作3个系统,甚至有些人还会用到别的部门的系统,甚至有的人习惯打开多个浏览器tab页来回切换对比同个页面的数据。。。poor guy。。。浏览器密密麻麻的全是tab页。。。
契机
某天,发生了如下对话:
- 领导:业务部门老大说,系统间来回切换太麻烦了,有没有办法解决这个问题?
- 我:有,微前端。
- 领导:之前XXX不是用qiankun做过吗,问题很多,不了了之了。
- 我:我看过他的代码,没有什么大问题,都是一些细节方面的小bug,而且还有别的微前端方案可以选择。
- 领导:行,你安排一下,尽快上线
- 我:好的。( 打工人被安排任务就是这么朴实,无华,且枯燥。。。)
为什么选择无界?
(此处省略万字长文对比分析qiankun、micro app、single-app...)
直接摆出站在个人角度以及团队技术、业务背景下选择无界的原因:
- 喜欢吃螃蟹:之前有过多次qiankun的落地经验,直接上qiankun,一点都不酷。(第一次了解到无界是22年的10月份左右,彼时的无界还在beta版,想尝尝鲜。况且就算使用无界出了岔子,也有信心能cover住)
- 子应用改造,侵入程度低:就像文档中宣传的那样,我用公司的项目跑demo,除去登录态的因素外,基本可以说是0改动接入,当时脑海中只有2个字----牛X!(当然,仅仅这样接入,离上生产的标准还相距甚远;而且最后我还是选择了类似qiankun根据宿主应用动态选择layout的布局方案,改造成本也可以说是不算低了,这个暂且按下不表)
- 方便独立开发、部署:与第2点相似但又不同:现有的项目有独立的域名、部署方案、且在生产环境已经稳定运行,在保留这些基础的前提下,无界的iframe方案算是最理想的出路(另外也有一点私心,如果生产环境的无界挂了,业务人员可以直接使用老的域名访问独立的子应用进行业务操作,毕竟出了生产事故是要通报批评的)
综上所述,确实没经过太多深思熟虑,想用就用,干就完了
干货区
下面,就是在我接入文章开头提到的3个系统后,总结出来的大致接入步骤:
- 准备主应用,在接入第一个系统之前,不出意外的要先准备宿主应用。
- 子系统登录态管理
- 根据宿主环境,选择layout方案
- 安装wujieEventBus(基于无界去中心化的通信系统做的二次封装)
- 子应用afterMount生命周期
- 子系统网络请求管理
- UI组件定位修复
- 公共状态提升
1.准备主应用
一个比较常规、纯净的管理系统,没有过多的封装,因为宿主应用本身,也不需要什么内容。技术栈为Vue3 + Vite2 + ant-design-vue@3.2.20
(没错,和系统C的技术栈一致,主打的就是一个偷懒),放张目录结构大家就明白了,没什么特殊的,有些细节后边会提到。
2.子系统登录态管理
简单来说,对于一个子应用,无论你是基于JWT还是Cookie的用户鉴权方案,在他单独运行时发生登陆态失效的情况,是要被redirect到自己的Login页面去;而当集成到了无界中运行的时候,登录态失效则应该被redirect到主应用的Login页面。
一般情况下,有两个地方需要做处理:
- http响应拦截,以axios为例:
javascript
if (response.status === 401) {
if (window.__POWERED_BY_WUJIE__) {
wujieEventBus.$emit("LOGIN_EXPIRED", APP_NAME_IN_WUJIE);
} else {
message.error("登录失效,请重新登录");
router.replace("/login");
}
}
window.__POWERED_BY_WUJIE__
是无界注入到子应用window当中的一个全局变量。
而wujieEventBus
是我对无界自带的去中心化通信方式eventBus
的封装,具体内容放在第四点展开讲,这里只需要知道,是通知主应用"我"登录失效了,并且附上"我"在主应用中的身份标识(对应组件方式使用无界的<WujieVue />
所需的name
属性)
- 路由守卫:可根据你的需要更改路由钩子,这里以
beForeEach
为例:
javascript
router.beforeEach((to, from, next) => {
if(validToken()) {
// some your logic ...
next();
}else {
wujieEventBus.$emit("LOGIN_EXPIRED", APP_NAME_IN_WUJIE);
}
}
当然,通过路由守卫拦截下登录态失效的情况可能很少很少,但操作和上面是一样的:通知主应用"我"登录失效了,并且附上"我"在主应用中的身份标识
3.根据宿主环境,子应用动态选择layout方案
如果你的主应用布局是打算这样:
子应用甚至不用切换layout方案,在下方content区域中保留子应用所有的模块;上方的Menu区作为一个应用级的切换菜单。
但如果你的主应用是打算像这样常规布局:
想实现应用级的切换,大体上有三种思路:
- 主应用不设任何layout模块:即Header、Menu、Content全都是子应用的模块。那么就需要所有子应用都是这种布局,且每个子应用的Menu菜单都必须是所有应用菜单的集合,当切换到非自身的路由时,与宿主通信进行应用切换。
- 与1相同,Header、Menu、Content全都是子应用的模块,但Menu仍是自己的菜单。你问我怎么切换应用?加个
position: fixed
的悬浮球呗(或类似的可折叠菜单)。
通过hover悬浮球,展开/折叠菜单,点击进行应用切换。
说实话,这方案我自己都不相信有人会用。
- 而第三个,也就是我选择的方案:主应用设有Header和Menu,剔除所有子应用的Header和Menu,只保留子应用的Content模块接入进来。熟悉吗?就是接qiankun那套。
大概长这样:
xml
<template v-if="!isInWujieContainer">
<Menu />
<Layout>
<Header />
<Layout>
<keep-alive>
<router-view />
</keep-alive>
</Layout>
</Layout>
</template>
<template v-else>
<keep-alive>
<router-view />
</keep-alive>
</template>
// const isInWujieContainer = window.__POWERED_BY_WUJIE__
为什么选择方案3,在我看来:Menu维护在主应用中,相比于对每个子应用的Menu进行侵入式改造,开发成本和维护成本都更小。Header维护在主应用中,可以方便的管理路由栈(面包屑、tab页签,这里多提一下,我的子应用接入方式是保活+sync路由同步)
既然Menu维护在了主应用中,那么问题来了:点击了Menu中的某个菜单,怎么通知子应用跳转到对应的路由?
我们都知道,当无界开启了url sync同步的时候,主应用、子应用的url变化规则是:子应用url发生变化时,子应用的iframe会与主应用进行通信,主应用同步更新url;当页面刷新时,子应用iframe会从主应用的url中读取路由信息,保证子应用路由状态不丢失。但是并没有一种规则是主应用主动发起改变url、并且子应用能同步更新路由的方案。
我的做法其实也很简单,点击主应用Menu中的菜单时,通过wujieEventBus进行广播,对应的子应用收到消息时,切换路由:
javascript
// 主应用中点击Menu菜单
export const openChildRoute = (
_router: RouterObj,
app: AppCollection,
) => {
// 通知子应用路由已改变,registerMountedQueue可以理解为给子应用注册一个mounted后需要立即执行的事件,防止出现跳转到一个还未初始化的子应用时,$emit miss的问题。
EventBus.$registerMountedQueue(
app,
"CHANGE_ROUTE",
{ path: _router.path, app }
);
// 更新主应用自己的url和tab页签
router.push(fullPath);
store.commit("tabs/setList", {
fullPath,
name: _router?.name || "",
title: _router?.name,
});
setActiveKey(fullPath);
};
javascript
// 子应用收到消息
wujieEventBus.$on("CHANGE_ROUTE", function ({ path, query, app }) {
if (app !== APP_NAME_IN_WUJIE) return;
router.push({ path, query });
});
并且CHANGE_ROUTE
这个事件可以是双向的:可以由主应用主动发起,通知子应用改变路由;也可以由子应用主动发起,通知主应用改变url和tab页签的显示状态。
之所以这样设计,是因为我们的系统中存在一种特殊的路由页面,他不存在于Menu菜单中,是必须通过点击页面中的指定按钮才能进入。所以对于这类页面,必须是由子应用主动发起的。
4.安装wujieEventBus
无界提供了一套去中心化的通信方案,去中心化的优点显而易见:
- 不关心发送方和接收方是谁,可以是不同应用之间通信,可以是一个应用内不同路由通信,可以是一个应用内不同组件通信
- 可以很方便的一对多通信
但同时也有一个致命的缺点:通信成功的前提是建立在通信双方都online的情况下。
假设这样一个场景:用户从站外的某个带参链接进入系统,参数的目的是告诉系统要重定向到指定子应用的指定路由,甚至具体要打开某个弹框。
正常情况下,主应用判断url参数做跳转的逻辑不管放在哪里,都存在子应用未加载完成的可能性。
(如果你说每个子应用component的afterMount事件里都写一遍,fine,你赢了)
这个时候,只需要对无界的eventBus稍作改动,即可满足需求:
javascript
import WujieVue from "wujie-vue3";
import { AppCollection } from "@/constant";
import store from '@/store';
const { bus } = WujieVue;
type EventList = "LOGIN_EXPIRED" | "EVENT_NAME1" | "EVENT_NAME2"; // 一些事件类型涉及到公司业务,这里省去了
type EventBusInstance = {
$emit: (e: EventList, params: Record<string, any>) => void;
$on: (e: EventList, fn: (...args: any[]) => void) => void;
$registerMountedQueue: (
app: AppCollection,
e: EventList,
params: Record<string, any>
) => void; // 将事件注册到子应用mount成功的的事件队列中
$cleanMountedQueue: (app: AppCollection) => void; // 清空子应用mount事件队列
};
type Queue = {
[app in AppCollection]?: any[];
};
let instance: EventBusInstance | undefined = undefined;
export default () => {
const queue: Queue = {};
if (!instance) {
instance = {
$emit: (event, params) => bus.$emit(event, params),
$on: (event, fn) => bus.$on(event, fn),
$registerMountedQueue: (app, event, params) => {
const isMounted = store.state.globalState.appMounted[app]; // store中存储了子应用是否mount完成的状态
const fn = () => bus.$emit(event, params);
// 子应用已挂载完成可以直接通信
if (isMounted) return fn();
if (queue[app] && queue[app]!.length) {
queue[app]!.push(fn);
} else {
queue[app] = [fn];
}
},
$cleanMountedQueue: (app) => {
while (queue[app] && queue[app]!.length) {
const fn = queue[app]!.shift();
fn();
}
},
};
}
return instance;
};
为每个子应用都维护一个事件队列,主应用通过$registerMountedQueue
注册事件时,若对应子应用已经mount完成,则直接emit进行通信;若子应用没有mount完成,则将注册的事件推入队列中。
子应用afterMount钩子中调用$cleanMountedQueue
,清空属于自己的事件队列。
目前根据业务需要,只做了这一点封装,后续有可能会继续补充。
当然前边提到的这个场景,肯定还有许多不同的解决方案,根据自己的项目因地制宜才是最重要的。
5.子应用afterMount生命周期
上边第4点已经提到过,子应用afterMount
钩子中要做两件事情:
store
中保存自己mount完成的状态。- 调用
$cleanMountedQueue
清空自己的事件队列。
6.子系统网络请求管理
网络请求管理,主要解决的是跨域问题,分两种:
-
调用后端服务跨域 如果你的用户鉴权是基于cookie的,那最方便的就是使用无界推荐的方法:将主应用的fetch自定义改写后传给子应用。如果你的用户鉴权是基于JWT或者你使用了其他的http请求库,赶快买上两杯咖啡贿赂一下运维大佬,给子应用对应的服务配置下Response Header,支持主应用域名的跨域资源共享。但是要切记,生产环境不要使用
Access-Control-Allow-Origin: *
。 -
请求子应用静态资源跨域
刚才为啥要让买两杯咖啡,因为一杯是改后端服务支持跨域,还有一杯是改前端静态资源服务器(比如Nginx)支持跨域。
至此,你(wo)的无界微前端方案已经落地大半了,不出意外的话,除了个别地方的样式比较古怪,业务流程已经没啥大问题了,下面的工作就是各个页面点一点,修一修奇怪的样式问题。
7.UI组件定位修复
无界官方针对element-plus
冒泡系列组件弹出位置不正确的解决方案是给子应用的body
添加position: relative
,但我这边使用ant-design-vue@1.7.8
的项目并不是弹出位置不正确,而是弹出方向不对,只能暂时通过调整组件位置+修改placement的方式见一个改一个。
我这边还有一些使用左弹出的drawer组件也会有问题,起始位置并不是屏幕最左边,而是content区域的最左边。
不知是否是无界的bug,drawer有个fixed定位的包裹容器,按理来说,创建这个包裹容器的时候会使用webcomponent代理的appendChild
方法,可以突破iframe的区域限制,但通过审查元素发现,这个position: fixed; left: 0
的元素,开始位置还是iframe的左侧。。。导致drawerposition: absolute
的主体开始位置也只能是iframe的左侧。但又不是所有的左弹出drawer都有这个问题,很神奇。。。没办法,只好把这些有问题的暂且改为右弹出。。。有解决方案的朋友也可以交流一下。。。
8.公共状态提升
其实从这里开始,就属于优化的范畴了,目前只做了这一趴,后续有其他优化会持续补充。
做公共状态提升的原因,简单来讲就是:除了登录用户的信息以外,我们不同系统中也有着很多相同的枚举数据,这些数据本身也是从同样的接口中读的,存在vuex/pinia中。所以当一个系统独立运行时,他数据获取的逻辑不变;当作为子应用接入了微前端体系中时,只需要从主应用中等待数据同步,不需要自己再调接口去取。
javascript
// 主应用
export default () => {
const duties = [
// some http request callbacks
];
duties.forEach(async (d) => {
const { action, type, commition } = d;
const data = await action();
store.commit(commition, data);
bus.$registerMountedQueue(
'APP_NAME', // 业务系统name标识
"SYNC_STATE",
{
type,
data: toRaw(data),
}
);
});
};
javascript
// 子应用
const state = {
// a vuex state
}
const mutations = {
// a vuex mutation
}
const actions = {
// a vuex action
}
if(window.__POWERED_BY_WUJIE__){
wujieEventBus.$on("SYNC_STATE", ({ type, data }) => {
const [updateFn, stateKey, ...restPath] = type;
let config = state[stateKey];
if (restPath && restPath.length) {
set(config, restPath, data); // lodash set
} else {
config = data;
}
mutations[updateFn](state, config);
});
}else {
// old logic, init all states by actions
}
结语
这篇文章从开篇到写下结语,中间经历了一整个星期。后半部分整体写的比较仓促,可能有些地方和起笔之初的设想有所出入;并且许多的细节之处涉及到公司业务也没有做过多的说明。有不明白的地方、或者有想交流的同学也可以留言,我会尽可能的做答复。
另外做个说明,其实最开始的时候文章标题叫【无界(wujie-micro)微前端落地方案分享】,后来才改成现在这个名字,原因有二:
- 这并不是一套完整的落地方案,只是我对我落地整个过程中,值得记录、分享的一些点的总结
- 原先的名字有种让人一看就不想点进来的感觉
行吧,第一版先到这里,欢迎真诚交流,但如果你来抬杠?阿,对对对~ 你说的都对~