微前端架构设计之基于 Vue.js 的微前端实现
Situation
19年之前团队内部前端编写模式是:原始项目 A 使用 Vue-CLI2 创建,现在需求方提交新模块 B 的需求给到产品。当产品交付原型图后,复制项目 A 改配置成新项目 B。项目 B 基于项目 A 的代码修修改改,待开发完之后打包到后端的 Java SpringBOOT 项目内部,通过 Jenkins 部署后端应用后通过 Eureka 注册微服务到 SpringCould,然后使用 nginx 反向代理来实现不同项目之间不同 domain 的分发。
由于在我们的项目中存在许多不同的 domain,比如 notify
, workflow
, construction
, appMgt
, form
, auth
, print
, pan
, market
, hr
, asset
, contract
, material
, commerce
, ...
等等。随着项目越来越多,前端开发暴露的问题如下:
- 不同项目之间相同功能存在不同的实现
- 不同项目之间相同依赖存在版本不兼容
- 不同项目之间相同依赖的修改,需要排查每个项目再手动去修改、复制粘贴,然后一一测试、部署、发版,浪费时间和开发效率,属于重复劳动
- 不同项目之间相同依赖被重复构建打包以及被客户端重复的加载和执行
- 被复制项目越来越臃肿
- 整体的维护成本随着时间长度和项目个数与其复杂度逞正相关
Task
2018 年底项目经理 Jimmy 通过 infoQ 了解到 Micro-FrontEnds 概念后,替前端团队重新划定了架构方向,于是我们便尝试在项目中实现微前端架构来解决上述问题。
于是,开始在社区调研。当时写微前端的文章并不多,实践的团队也比较少,留下印象最深刻的是这篇 phodal/microfrontends 文章,文中提到了微前端的各种实现方式、实现成本、工程成本等问题,比较全面。
真正意义上的微前端应该是框架无关的,现在社区中首推 Single-SPA,以及蚂蚁前端团队基于此框架封装的 qiankun。
但在 18 年底时,Single-SPA 还未 Production Ready,于是我们决定实现我们团队内部的微前端框架。加之团队技术栈统一使用 Vuejs,所以无需做到框架无关。于是我们最终的选择是 微前端:微应用化 。
不但解决了上述问题,同时实施成本低、技术难度小、维护成本低。
Action
微应用化
在各个 domain 时通过 nginx 反向代理的情况下,维护公共代码很痛苦。所以将各个业务模块拆为微应用后,公共部分则被组合成独立应用。社区称之为基座应用,或者主应用。
将所有 domain 即子应用的公共部分封装到主应用中单独维护,同时发布到内部 npm 私仓,以供子应用在开发环境中将其作为依赖安装。在需要修改公共代码或公共服务的时候,只需要修改、测试、构建、和部署主应用即可,解决了前文提到的关于公共依赖的几大痛点。
然后将各个子模块拆成独立的业务模块,使其都在主运行时中 被 运行。从而实现子应用的独立开发、独立维护、独立部署。
基础流程
我们拆分了主应用 App(即基座应用)和各个 domain 子应用 SubApp。使用 Vue-MFE 为 router 做了功能增强,并通过 AppConfig 的 resources 配置从 Package Server 动态加载子应用代码以实现子应用的加载、执行和渲染。
主应用 App
App 是一个独立的完整的 Vuejs 应用,独立运行、开发和部署。我们的项目中,主应用除了包含了下列公共内容之外,还包含了 VueMfe 和 PackageServer (后文介绍).
-
公共资源,比如:样式、字体、图标、图片、Theme 等
-
公共数据,比如:Auth, Config, Message 等 Vuex modules,我们通过 Vuex 实现全局 Store 共享,借助其 dynamic-module-registration 的能力,实现子应用之间共享数据的注册和销毁。
-
公共路由,比如:
/index
,/error/401
,/error/404
等 -
公共布局,比如:
<DefaultContainer />
,<DetailContrainer />
等 -
公共插件,比如:ProgressBar, MicroFrontend, LazyLoad, Vuex, VueRouter, Element-UI 等
-
公共服务,比如:Utils, Http, Socket, Storage 等
-
公共组件,比如:
<ContentBlock />
,<FilePreview />
等 -
公共依赖:在我们的项目中直接在 publlic/index.html 手动引入了构建好的公共依赖,同时维护了一份公共的 webpack externals 配置,以避免主应用和各个子应用在打包时重复构建公共依赖。
publlic/index.html:
external.config.js:
-
公共配置,比如:vue.config.js,.prettierrc,.eslintrc, .babelrc, .stylelintrc 等配置
-
鉴权和校验,比如:路由权限校验 Router before/after Hook 等公用状态校验
Vue-MFE
Vue-MFE 的核心由两部分组成 EnhanceRouter + MicroAppLoader. EnhanceRouter 提供了围绕路由的核心功能:支持添加嵌套路由,支持动态安装路由,监听未匹配路由。MicroAppLoader 提供了围绕微前端的核心功能:创建主应用,创建子应用,懒加载应用。
EnhanceRouter:
Vue.js 官方提供的 Vue-Router 虽然提供了 router.addRoutes(routes: Array
在实际业务中,子应用通常是某个 Layout 下的嵌套子路由。子应用 SubApp 也通常继承主应用 App 的布局 layout。所以在 vue-mfe 内部重写了 router.addRoutes
方法以实现支持嵌套路由的目的。
- 在 Vue-MFE 内部维护了独立的
pathList
和pathMap
,虽然增加了内存开销的成本,但好处是不会对VueRouter
本身功能造成任何影响。 - 当调用
router.addRoutes(routes: RouteConfig[], parentPath: string)
时,深度优先找到 parentPath 所在的旧路由 oldRoute,并将其 children 与新的 routes 合并后生成新路由的参数options: newRouterOptions
。 - 再使用 newRouterOptions 重新实例化
new VueRouter(options: newRouterOptions)
拿到新的router.matcher
并替换 app 的原 matcherapp.$router.matcher
便达到了支持动态嵌套路由、动态更新应用路由注册表(动态安装路由)的目的。
而监听未匹配路由则是通过注册 beforeEach 钩子,拦截路由 to
是否已存在于当前路由表中,若不存在则认为这可能是一个需要被动态加载的子应用。
MicroAppLoader:
VueMfe 的 createApp 和 createSubApp 的核心目的是注册微前端配置项。懒加载应用则是和 EnhanceRouter
的功能配合使用。
VueMfe 完整流程如下:
- 使用
VueMfe.createApp(AppConfig)
注册 主应用 App,初始化 Router,刷新 VueMfe 内部路由注册表pathList
和pathMap
。 - 注册 beforeEach 钩子,以拦截路由
to
是否已存在于当前路由中,若不存在则认为这是一个需要被动态加载的子应用。 - 拦截到未匹配路由后根据路径获取
prefix
前缀getAppPrefix(to)
, - 然后通过 MicroAppLoader 动态加载和执行 resources[prefix] 的资源,如果获取不到则会抛出无法找到
prefix
资源的异常。 - 获取到 SubApp 资源后,广播加载开始
LOAD_START
事件,开始安装 SubApp 的静态资源和路由,执行 SubApp 的init
初始化方法,并将执行结果result
返回的routes
动态安装到parentPath
下,加载成功后广播加载成功LOAD_SUCCESS
事件。 - 执行
next(to)
跳转到用户访问的路由prefix
实现完整闭环。
vue-cli-plugin-mfe
基于微应用架构设计的 CLI 插件,由 subapp generator + cli commands 组成。
使用 vue add mfe --registry={proviteNpmRegistryLink}
添加 mfe 插件同时生成 SubApp Template。并在 vue-cli-service runtime 为 SubApp 注册 package, upload, publish 三个命令,分别负责 SubApp 的打包、上传和发布。
SubApp 在 package 阶段做了特殊处理,其被 webpack 打包成 umd 格式的包。因为不同的 App 由不同的 webpack build context 构建,无法共享 chunkId 和 moduleId。
因此 build 的入口 必须是执行 VueMfe.createSubApp({}: SubAppConfig)
的文件,而执行该方法返回的 SubAppConfig 配置项在打包后被作为全局变量供 VueMfe 的 loader 动态加载、执行和安装。而后续其他资源的控制权则交还给 _webpack_require_
控制,无论是 code-splitting 还是 VueMfe.Lazy 均被 Webpack 按需加载。
而 19 年末 webpack5 提供的 module-federation,正是为了解决这个问题提出,但当时还是 beta 版本。
出现了新曙光,而且业界很多大佬已经开始了探索。后续,会酌情决定是否跟上 Webpack5 的升级。
Package Server
插件执行 package 打包完成后执行 upload 命令上传其静态资源 js/css/img 到 package server。它可以是一个接口,也可以是 oss 服务器,也可以是 CDN。只要能在网络中被正常访问即可。
准确来说,package server 是一个抽象层,可以做复杂实现,可以做简单实现,也可以不用实现。
但在 ibuild-portal-lte 中,package server 的实现是后端的 RPC(Remote Procedure Call)接口。上传文件使用的是 /api/mfe/uplaod
,查询所有 SubApp 资源使用的是 /api/mfe/resources
接口,发布的实现是 /api/mfe/publish
接口。
每次发版,vue-cli-mfe-plugin 会根据时间戳生成对应的版本号和包名 {subAppName}@{timeStamp}
。后端会记录当前版本号及其对应的资源入口文件,用于实现版本的历史记录、当前版本、回滚版本等操作。
子应用 SubApp
在提出了所有公共代码之后,子应用变成了纯业务代码的容器被主应用在运行时加载执行。因此在启动子应用之前需要先启动主应用,以拥有主应用运行时的能力。
在开发环境下,将子应用的入口设置为主应用,将 devServer 的 contentBase 也设置为主运行时的 public
目录,以保证 主应用App/子应用SubApp 在开发和生产环境下的一致性。
修改子应用的构建入口 construction/frontend/vue.config.js:
模式对比
Result
1968年,计算机学家梅尔文·E·康威发表了一篇著名论文,后来被称为康威定律(Conway's law)。
"软件系统的架构,反映了公司的组织结构。"
我们团队也是,团队内部成员根据不同 domain 被分为各个子团队。现行的开发模式是公共代码由前端同学一起维护,各个 SubApp 的代码则由各个子团队独立开发和维护。如果需要暴露组件和模块给其他团队,则在 SubApp 内部单独暴露即可,除非其能被通用才会集成到 App 中暴露。
截止目前为止,团队内已使用微前端之微应用架构快 2 年了。由 Jimmy 指导架构的设计,我来编码实现的微前端架构体系,虽不算完美,或者一些概念在日新月异的前端领域已经过时。但是这套方案,在团队内部切切实实解决了开篇提到的种种问题,也有效的提升了团队之间及团队成员的沟通协同和开发效率。
谢谢团队同事对我工作的帮助、支持和配合。