R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

另一种形式的“微前端”——弹窗内嵌子应用

以下语言可用: 简体中文 正體中文 English
有些时候,架构设计不需要大张旗鼓,一些简单的技术组合也能解决问题。

为了用户体验

去年 Q1 我所负责的项目有一个需求,当用户点击编辑页面内的某些按钮时,会弹出一个弹窗,用于配置其它域的内容。这里以最经典的用户配置流程举例(只是举例,实际需求不是用户管理,域的内容远比这个复杂,而且域之间的关联也不止这么简单):

  • 系统中有用户、角色、权限码三个域,分别有各自的管理页面;
  • 三个域之间有关联:一个用户可能拥有多个角色、一个角色可能拥有多个权限码;前者可以在用户管理页面中配置,后者可以在角色管理页面中配置。

那么需求来了:PM 希望在用户管理页面也可以管理角色,也就是说用户详情页面中,不光可以配置该用户的角色,还可以弹出角色管理页面,对角色做增删改查。同理在角色管理页面(包括刚才弹出的那个页面)中,也可以弹出权限码管理页面,对权限码做增删改查。

页面调用关系

这个需求看似奇怪,但有一些合理之处:

  • 三个域之间有关联,允许在一个域的页面中弹窗配置另一个域的内容,可以防止用户思路被打断,提升用户体验;
  • 对于开发来说,只要找到合适的方法复用页面,开发量就可以大大减少。

当然,还有一个无法拒绝的理由:这是老板提出来的建议……

始于微前端

在讲到关键问题之前,需要先了解一下这个项目现有的实现方式。

项目是团队的一个运营平台,特点就是页面多且杂,但页面间通常没有太多关联。在 2020 年 Q2,有一个全新的模块开始开发(为期一个月的专项),为了提升本地开发和构建效率、隔离历史债,我们决定将这个模块独立为一个项目,并采用某些方法嵌入到主项目中。

虽然彼时 qiankun 已经初具雏形,但微前端的概念还没有那么火,并且我们的项目规模不算大,所以我觉得没有必要引入那么多概念,只需要一个简单的解决方案就好了。经过同事的一番调研和实践,最终我们大幅参考了 美团外卖的微前端方案,实现方式是这样的:

  • 将项目启动相关的代码抽离成基座,并对外暴露若干函数(addRouteraddStorebootstrap 等);
  • 子项目可以单独打包部署,在开发环境中 main.ts 会调用基座的 addRouteraddStorebootstrap 等函数以方便本地单独开发,但在构建环境中则是只 export 了两个内容:routerstore
  • 主项目通过 import-html-entry 加载子项目的 index.html,这会自动解析里面的 CSS、JS 文件,将 CSS 插入到页面中、执行 JS 并获取其 export;
  • 主项目通过与子项目相同的方式,调用基座的 addRouteraddStorebootstrap 启动,这次没有区分环境。也就是说,线上环境实际上只有主项目调用了这些方法;
  • 对于 reactreact-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 文档后,发现除了我熟知的 BrowserRouterHashRouter 外,还有两个不太常见的 MemoryRouterStaticRouter。可以发现这四个组件只有前缀不同,其实前缀就表明了它们存储路由信息的方式:

  • 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 的结果又恢复到了先前的状态。

基于栈和继承的 store 结构

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-ProjectX-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 的设计以及持久化数据结构的优雅之处。因为有了它们,这个需求才能与项目中的其它代码几乎解耦,而且代码量也不大。

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3087 天