有些时候,架构设计不需要大张旗鼓,一些简单的技术组合也能解决问题。
为了用户体验
去年 Q1 我所负责的项目有一个需求,当用户点击编辑页面内的某些按钮时,会弹出一个弹窗,用于配置其它域的内容。这里以最经典的用户配置流程举例(只是举例,实际需求不是用户管理,域的内容远比这个复杂,而且域之间的关联也不止这么简单):
- 系统中有用户、角色、权限码三个域,分别有各自的管理页面;
- 三个域之间有关联:一个用户可能拥有多个角色、一个角色可能拥有多个权限码;前者可以在用户管理页面中配置,后者可以在角色管理页面中配置。
那么需求来了:PM 希望在用户管理页面也可以管理角色,也就是说用户详情页面中,不光可以配置该用户的角色,还可以弹出角色管理页面,对角色做增删改查。同理在角色管理页面(包括刚才弹出的那个页面)中,也可以弹出权限码管理页面,对权限码做增删改查。
这个需求看似奇怪,但有一些合理之处:
- 三个域之间有关联,允许在一个域的页面中弹窗配置另一个域的内容,可以防止用户思路被打断,提升用户体验;
- 对于开发来说,只要找到合适的方法复用页面,开发量就可以大大减少。
当然,还有一个无法拒绝的理由:这是老板提出来的建议……
始于微前端
在讲到关键问题之前,需要先了解一下这个项目现有的实现方式。
项目是团队的一个运营平台,特点就是页面多且杂,但页面间通常没有太多关联。在 2020 年 Q2,有一个全新的模块开始开发(为期一个月的专项),为了提升本地开发和构建效率、隔离历史债,我们决定将这个模块独立为一个项目,并采用某些方法嵌入到主项目中。
虽然彼时 qiankun 已经初具雏形,但微前端的概念还没有那么火,并且我们的项目规模不算大,所以我觉得没有必要引入那么多概念,只需要一个简单的解决方案就好了。经过同事的一番调研和实践,最终我们大幅参考了 美团外卖的微前端方案,实现方式是这样的:
- 将项目启动相关的代码抽离成基座,并对外暴露若干函数(
addRouter
、addStore
、bootstrap
等); - 子项目可以单独打包部署,在开发环境中
main.ts
会调用基座的addRouter
、addStore
、bootstrap
等函数以方便本地单独开发,但在构建环境中则是只 export 了两个内容:router
、store
; - 主项目通过
import-html-entry
加载子项目的index.html
,这会自动解析里面的 CSS、JS 文件,将 CSS 插入到页面中、执行 JS 并获取其 export; - 主项目通过与子项目相同的方式,调用基座的
addRouter
、addStore
、bootstrap
启动,这次没有区分环境。也就是说,线上环境实际上只有主项目调用了这些方法; - 对于
react
、react-router
等依赖,主项目和子项目都会安装,但子项目会在打包时将其设置为external
,因此线上始终用的是主项目提供的版本。
这个方案跟 qiankun 相比非常轻量(甚至有点“简陋”),但已经足够使用了。我们也一直坚持认为这不是真正的“微前端”,只是一个基于 JS 特性的解决方案而已。
但有意思的是,随着团队业务不断发展,新的后端团队和模块不断增加。目前这个方案支撑着项目的 30+ 个模块,并且由于接入简单、基座和配套的 BFF 演变出来的基础能力强,其它项目组的运营平台也通过同样的方法接入了进来。这已经成为了事实上的“微前端”。
基于微前端
回到最开始的需求,我们需要在用户管理页面中(user
域)弹出角色管理页面(role
域),这两个域在前端都是独立的子项目,并且弹窗内的“页面”不止有简单的浏览功能,还有跳转、内部再弹窗,以及别的功能:
- 切换市场:有个用户有跨境管理权限,给他配置一个其它国家的角色不过分吧?那么我在当前页面(新加坡)弹个窗配置其它国家的角色也不过分吧?
- 切换项目:项目 A 在 B 的上游,但是管理平台都嵌入在了我的系统中;用户在 A 配置一些内容的时候,可以在当前页面弹窗直接配置 B 的内容,是不是也很有道理?
问题分解
整个需求最简单的应该就是弹窗本身了,如果用 Ant Design,就是这样实现:
import { Modal } from 'antd';
<Modal open={open} footer={null}>
<Navigator /> {/* 这个该怎么写?*/}
<SubAppPage /> {/* 如何在这里挂载另一个子项目?*/}
</Modal>
其余的问题有:
- 如何在
SubAppPage
组件内挂载另一个子项目? - 如何在
Navigator
组件内展示/控制弹窗内子项目路由? - 如何确保弹窗内子项目的路由切换不会影响到主项目?
- 如何加载弹窗内子项目的 store?
- 如何确保弹窗关闭后,子项目的 store 不会继续影响到主项目?
- 后端 BFF 如何知道当前请求是在哪个团队系统的哪个国家发起的?
接下来的工作,就是尽可能复用已有的微前端方案,解决上面的问题。
复用子项目加载流程
子项目加载本身是比较简单的,因为 import-html-entry
已经足够好用了:
import importHTML from 'import-html-entry';
const handleExports = loadedExports => {
/* ...处理 export,包括路由、store 等内容... */
};
// 如果已有缓存,则直接读取
if (cachedExports[entryUrl]) {
handleExports(cachedExports[entryUrl]);
return;
}
// 否则就加载
importHTML(entryUrl).then(res => {
/* ...设置状态为 loading... */
// 获取 CSS 并插入到页面中
res.getExternalStyleSheets().then(styles => {
for (const href of styles) {
/* ...插入 link 标签... */
}
});
/* ...设置状态为 loaded... */
// 执行 JS,在后文中会获取到 export
return res.execScripts();
}).then(exportedValues => {
// 缓存 export 内容,避免重复加载
cachedExports[entryUrl] = exportedValues;
handleExports(exportedValues);
/* ...设置状态为 done... */
});
在实现的过程中,需要考虑更多的边界场景,例如超时、加载失败等。此外也可以将加载状态传出组件外,让外部展示进度条、刷新按钮等内容。
避免路由切换对弹窗外的影响
项目使用的是 react-router
中的 BrowserRouter
(彼时 v6 还未发布,因此下文均以 v5 为例),当路由切换时会触发浏览器的前进/后退行为。但是作为弹窗内的子项目,路由切换不应该影响到主项目。
阅读了 react-router
文档后,发现除了我熟知的 BrowserRouter
和 HashRouter
外,还有两个不太常见的 MemoryRouter
和 StaticRouter
。可以发现这四个组件只有前缀不同,其实前缀就表明了它们存储路由信息的方式:
BrowserRouter
使用window.location
存储路由信息,通常用于优雅链接;HashRouter
使用window.location.hash
存储路由信息,它的兼容性更好;MemoryRouter
使用内存存储路由信息,可以用于单元测试,以及非浏览器的环境(如 React Native);StaticRouter
是静态路由,信息不会变化,通常用于服务端渲染。
并且如果有留意过 react-router
的实现,会发现它提供了 useHistory
之类的 hooks,那么必然意味着它使用到了 context!打开 React Devtools 一看,果不其然:
Router
Router.Provider
Router-History.Provider
App
...
这样就好办了!我们只需要在弹窗内套一层 MemoryRouter
,就可以避免路由切换对弹窗外的影响了:
<MemoryRouter
initialEntries={[initialRoute]} {/* 这里使用方式有点特殊,只是赋了初始值 */}
initialIndex={0} {/* 后续将通过 useHistory 来控制路由值 */}
>
<AppRouterConfig {/* 业务组件,不是 react-router 的部分 */}
routes={subAppRoutes} {/* 加载到的子项目路由配置 */}
historyRef={historyRef} {/* 获取到 history 实例以供外层控制 */}
/>
</MemoryRouter>
这样做的好处是,子项目的路由代码完全不需要修改——因为在 React 看来,子项目的 <Route>
组件和页面级组件都被包在了 <MemoryRouter>
内,因此它们的路由信息(useHistory
之类)都是从 MemoryRouter
中获取的,而不是 BrowserRouter
,执行 history.push
也只会修改 MemoryRouter
的状态。
脱离组件渲染周期的 store
由于加载的子项目不一定属于我们团队管控,其中导出的 Redux store(那个年代主要都是用 Redux)可能会覆盖掉现有的内容。虽然 Redux 也提供了 context,但 store 与 router 不同,它可以在任何地方(包括组件渲染周期外)被调用,例如:
const roles = await api.getAllRoles();
// 通过封装的 storeAPI 直接从 store 拿数据
const currentCountry = storeAPI.getCurrentCountry();
const rolesInCurrentCountry = [];
const rolesInOtherCountries = [];
for (const role of roles) {
if (role.country === currentCountry) {
rolesInCurrentCountry.push(role);
} else {
rolesInOtherCountries.push(role);
}
}
return {
rolesInCurrentCountry,
rolesInOtherCountries,
};
在这段代码中,我们需要根据“是否属于当前国家”将 role 分成两波,而 currentCountry
不是在组件渲染周期内获取的,此时 context 就无能为力了,必须修改全局 store,例如将全局的 currentCountry
修改为子项目的国家。在 Redux 已经启动后,想要替换 reducer,只需使用 replaceReducer
:
store.replaceReducer(
combineReducers(newReducerObj),
);
然而,修改全局 store 就面临着一个问题:如何确保弹窗关闭后,子项目的 store 不会继续影响到主项目?为了实现这一点,需要了解一点点“可持久化数据结构”的知识。
可持久化数据结构与“时间旅行”
大概五年前,我写过一篇比较浅显的文章《对于不可变数据的思考》,里面有讲到不可变数据的实现思路:
每次尝试修改其中的内容,就创建一个新的数据。
利用它可以实现可持久化的数据结构,例如一棵可持久化的二叉树,每次修改一个节点就会导致创建一棵新树,但会尽量复用未被修改的节点:
图片来源:https://www.cnblogs.com/zinthos/p/3899565.html
Redux 正是基于这个思想,搞出了一个“时间旅行”的概念——你可以以 action 为单位,在不同状态间切换。
基于栈和继承的 store
了解了“时间旅行”的概念后,能否利用这个思想来实现一个“带有时间旅行功能的 store”呢?答案是肯定的,而且思路也很简单——基于栈和继承。
在子项目刚加载完时,我们可以从 export 中拿到子项目自己的 store,这时候“修改全局的 store”可以被认为是一个 action,我们可以基于修改后的 reducer object 创建一个新的 store,并将其压入一个栈中;然后在弹窗关闭时,将栈顶弹出。
那么,如何确保封装的 storeAPI
可以拿到正确的 store 呢?很简单,使用一个指针指向栈顶即可。
// 这里的 singleton 是一个对象,其中每个 key 对应着一个 store
// 而下面的 keyStack 就是前文所说的“栈”,其中只存储了 key
// 这样可以在调试时,直接通过 key 拿到 store 的内容,也可以直观地看到栈的变化
const reducers: ReducerSingletonStack<Store<any, AnyAction>> = {
singleton: { default: null },
keyStack: ['default'],
};
// 在子项目加载完后调用;这只会把 key 压栈,而实际的 store 是在用到的时候懒创建的
const pushReducerNamespace = (key: string) => {
reducers.keyStack.push(key);
};
// 在弹窗关闭时调用;这会把栈顶的 key 弹出,并删除对应的 store
const popReducerNamespace = () => {
const key = reducers.keyStack.pop();
if (key) {
delete reducers.singleton[key];
}
};
// 子项目资源加载完成后执行的代码
const handleExports = (exportedData: Record<string, any> = {}) => {
/* ...处理路由... */
// 处理 store
pushReducerNamespace(entryUrl);
};
针对 store 还剩最后一个问题,栈的每一层只会有子项目自身的 store,那如何确保 storeAPI
可以拿到主项目(甚至是上一层弹窗)中的 store 呢?这就需要用到继承了。可以覆盖一下 store.getState
方法,让其顺着栈往上不断 getState
,并将结果合并起来,这样栈顶修改过的数据(如有)会覆盖栈底数据,即可实现“继承”了。使用递归可以让代码变得很简洁:
const originalGetState = store.getState;
store.getState = (stackTop: number) => {
const prevState = stackTop
// 这里用到了递归
? reducers.singleton[reducers.keyStack[stackTop - 1]]!.getState()
: {};
const state = originalGetState();
return { ...prevState, ...state, ...extraData };
};
此外,为了给子项目提供一些额外信息,以便后续子项目针对弹窗的场景做单独优化,我还通过 extraData
返回了当前弹窗的嵌套状态。
当调用 storeAPI
时,会先从栈底开始逐层执行 getState
,直到 stackTop
指向的那一层;然后用后面的覆盖前面的。当弹窗被关闭时,栈顶被销毁,弹窗内的修改会被丢弃,getState
的结果又恢复到了先前的状态。
BFF 层的适配
项目设计初期,因为没有在一个页面内跨系统、跨国家操作的需求,所以我就简单用了 cookie 来存储用户当前所在的系统和国家。但如果弹窗内外的系统和国家不一致,就会导致 cookie 内容被覆盖。
因为 cookie 一定是全局唯一的,所以只能放弃 cookie 的方案,一个可以替代的方案就是通过请求头。幸好,各个子项目的 request
函数都来源于基座库的 axios
,可以通过 interceptor
来实现。
axios.interceptors.request.use(
(response: AxiosResponse) => {
const project = storeAPI.getCurrentProject();
const country = storeAPI.getCurrentCountry();
request.headers = {
...request.headers,
'X-Ops-Project': project,
'X-Ops-Country': country,
};
return request;
},
);
然后在 BFF 层简单判断一下就可以了,如果发现了 X-Ops-Project
和 X-Ops-Country
这两个请求头,就忽略 cookie 中的值。
在实现这需求的期间,我还遇到了一些细节问题,例如我需要给子项目包一层 Suspense
,否则子项目内部发一个请求,会导致弹窗遮罩下方的页面展示 loading 状态,因为主项目的最外层也有一个 Suspense
。此外还有一些数据问题、请求的细节问题,都是在实现过程中逐步解决的。
可能的优化方向
这个需求已经稳定运行了一年多,业务来来回回改动,但“弹窗内加载子项目”相关的内容一直没有变化,说明这个方案还是比较稳定的。当然,如果未来有足够的理由和时间,我觉得还可以从以下几个方面入手。
- 应用间通信:可以使用 event bus 的思想,基座提供一个带有 namespace 隔离的 event bus,子项目在发布/订阅的时候需要带上 namespace。
- CSS 隔离:实际上通过 Webpack 插件(如 postcss-prefix-selector)添加选择器前缀可以解决大部分问题,或者使用 CSS Modules、Styled Components 等技术就可以了。在运行时添加前缀理论上可行,但会影响性能。
window
隔离:window
应该尽可能禁止使用。如果真要做,目前我见过最好的方案应该是 qiankun 提供的沙箱,但将其移植过来的工作量可能会比较大。
总结
在弹窗内加载一个微前端的子项目,并且要做到路由和 store 的隔离,这看起来似乎是一个大工程;但全程做下来,会发现并没有那么复杂:
- 没有做任何的架构改造;
- 主项目和子项目完全没有为这个需求做适配;
- 添加了一个新组件,修改了 store 的
getState
方法,以及多了一个 axios 拦截器; - 后端只在 BFF 层添加了一个简单的判断。
我也再一次体会到 context 的设计以及持久化数据结构的优雅之处。因为有了它们,这个需求才能与项目中的其它代码几乎解耦,而且代码量也不大。