编辑推荐: |
本文主要讲解了对微前端的理解。以及如何快速的实现一个基于
Vue.js 的简单易用的微前端。
本文来自于博客园,由火龙果软件Anna编辑推荐。 |
|
缘起何处
我们团队在公司内部主要为国内某 TOP3 建筑公司编写 ERP 中后台应用,由于是建筑领域,其 ERP
系统十分庞杂。在我于 2018 年底入职时,该系统就已编写了三年。但是由于需求方依然在提需求,所以依然在编写新应用,维护,迭代和重构。
团队内部后端技术栈后端主要是 Java,前端主要是 Vue.js,CI/CD 是 Jenkins
+ GitLab,监控系统使用 Sentry,包管理器使用的是 Nexus (支持 npm &
maven)。
因为团队中很多 CRUD 界面都是后端同学自己码了之后调接口。甚至很多同学在使用的时候连 Vue.js
文档都没看过。不要问为什么,问就是 Vue.js 好上手。
当时团队内部的前端编写模式是,原始项目 A 使用 Vue-CLI2 创建。现在需求方提交新模块 B
的需求给到产品,当产品交付原型图后,项目 Leader 复制项目 A 改改配置成了新的项目 B。
由于在我们的项目中存在许多不同 domain,比如 /notify, /workflow, /construction,
/appMgt, /form, /auth, /print, /pan, /market, /hr,
/asset 等。之前我们使用 nginx 反向代理实现 domain 分发到对应项目。在应用布局中顶部菜单直接分成了
notify.example.com, workflow.example.com 来实现不同项目 domain
的分发。
随着项目越来越多,暴露的问题越发明显:
技术债堆积
代码重复和冗余
相同依赖的版本问题
重复依赖的加载和执行
项目个数跟维护成本正相关
公共代码的更新需要复制粘贴并构建所有项目
找到问题,接下来就是解决问题了。在2018 年恰逢其时地了解到 Micro-FrontEnds 概念后,替我们指明了方向,我们也需要在项目中实现类似微前端的概念来解决上述问题。
于是,开始在社区调研。当时写微前端的文章并不多,实践的团队也比较少,留下印象最深刻的是这篇phodal/microfrontends文章,文中提到了微前端的各种实现方式、实现成本、工程成本等问题,比较全面。
真正意义上的微前端应该是框架无关的,现在社区中首推 Single-SPA。但是在 18 年的时候,Single-SPA
还未完全 Production Ready,于是我们决定实现我们团队内部的微前端框架。
因为技术栈很统一,所以无需做到框架无关,我们最终的选择是微前端:微应用化。它不仅完美解决了上述问题,同时实施成本低、技术难度小、维护成本低。
微应用化
在多 domain 时通过 nginx 反向代理的情况下,维护公共代码很痛苦。将各个业务模块拆成子应用后,其各个业务的公共部分则被拆成独立的应用。社区称之为基座应用,或者主应用。
我们将所有 domain 即子应用的公共部分封装到主应用中,单独维护,同时发布到内部
npm 私仓。以给子应用在开发环境中将其作为依赖安装。在需要修改公共代码或者提供公共服务的时候,只需要重新构建和部署主应用即可,解决了一大痛点。
图片摘自phodal/microfrontends
不过我们的实现细节上有所不同,我们将 URL Change 交由 VueRouter 的 beforeHook
处理,将 应用管理服务、Loader、install 等交由 VueMfe 处理。
主应用 App
主运行时本身也是一个独立完整的应用,独立运行、开发和部署。我们的项目中,主应用包含了下列内容(通常都包含了以下内容):
公共依赖
在我们的项目中使用 UMD 引入公共依赖,同时维护了一份公共的 Webpack externals
配置,以避免主应用和各个子应用在打包时重复构建公共依赖。
公共配置,比如:Webpack 配置,Env Variables 配置,App 配置等
公共插件,比如:ProgressBar, MicroFrontend, LazyLoad, Vuex,
VueRouter, Element-UI 等
公共服务,比如:Utils, Http, Socket, Storage 等
公共数据,比如:Auth, Config, Message 等 Vuex modules,我们通过
Vuex 实现全局 Store 共享,借助其 dynamic-module-registration
的能力,实现子应用之间共享数据的注册和销毁。
公共鉴权和校验,比如:路由权限校验 Router before/after Hook、用户角色校验
AuthManager.hasAuth(authKey) 等公共状态校验
公共资源,比如:样式、字体、图标、图片、Theme 变量
公共组件,比如:<ContentBlock />,<FilePreview />
等
公共布局,比如:<DefaultContainer />, <DetailContrainer
/> 等
公共路由,比如:/index, /error/401,/error/404 等
子应用 SubApp
在提出了所有公共代码之后,子应用变成了纯业务代码的容器被主应用在运行时加载执行。因此在启动子应用之前需要先启动主应用,以拥有主应用运行时的能力。
在开发环境下,将子应用的入口设置为主应用,兼容主应用的 Webpack 配置,将 devServer
的 contentBase 也设置为主运行时的 public 目录,以保持主应用/子应用开发和生产环境下的一致性。
vue.config.js:
子应用通过主应用的中心化路由,动态加载执行。而在 Vue.js 中,如何实现中心化路由呢?Vue-Router本身提供了router.addRoutes(routes:
Array<RouteConfig>)的API,但是这个 API 有一个很致命的缺点,就是不支持嵌套路由,而在实际业务中,子应用通常都是某个
Layout 下的嵌套路由。具体可以参考这个 ISSUE 的讨论,Dynamically add child
routes to an existing route,根据 vuejs/rfcs Dynamic
routing ,官方团队也正在征集社区意见和实现这个功能。
为了解决这个问题,需要我们自己打补丁增强一下 VueRouter 的 addRoutes 功能,实现支持动态嵌套路由、动态加载应用等功能。这便是
vue-mfe 的由来。
动态嵌套路由
vue-mfe 内部维护了一套独立的pathList和pathMap,虽然独立维护,会增加内存开销成本,好处是不会对VueRouter本身的功能造成任何影响。
当调用 router.addRoutes(routes: RouteConfig[], parentPath:
string)时,深度优先找到 parentPath 所在的旧路由 oldRoute,并将其 children
与新的 routes 合并后生成新的路由 options: newRouterOptions。
再使用 options: newRouterOptions 重新实例化 new VueRouter(options:
newRouterOptions),拿到新的 router.matcher 并将其赋值给app.$router.matcher
以达到支持动态嵌套路由、动态更新应用路由注册表的目的。
动态加载应用
使用VueMfe.createApp(AppConfig)注册微前端主应用App,初始化 Router,刷新
VueMfe 内部路由注册表pathList和pathMap。
注册 beforeEach 钩子,拦截路由to是否已存在于当前路由中,若不存在则认为这是一个需要被动态加载的子应用。
执行getAppPrefix(to)获取当前路由的子应用prefix前缀,执行 install 方法。
install会尝试优先获取 SubApp 自身的 resources 配置 config.resources[prefix],其次取主应用的
resources 配置。如果都获取不到,则会抛出无法找到 prefix资源的异常。
获取到 SubApp 资源后,广播加载开始LOAD_START事件,开始安装 SubApp 的静态资源和路由,执行
SubApp 的 init 初始化方法,加载成功后广播加载成功LOAD_SUCCESS事件。
执行 next(to) 跳转到用户访问的路由prefix实现完整闭环。
构建子应用
因为不同的 App 由不同的 webpack build context 构建,无法共享 chunkId
和 moduleId。所以需要将子应用打包成 umd 格式的 library,暴露 SubApp 的配置项到
root 全局变量供 VueMfe 安装。而且后续其他资源控制权则继续交由 webpack 控制。
而在 19 年末有了 webpack5 提供的 module-federation ,正式为了解决这个问题提出,但目前还是
beta 版本。新曙光,而且很多大佬已经开始了探索。后续,会继续跟上 webpack5 的升级。
构建步骤:
将子应用打包成 umd 格式的 library。
构建的入口必须是 export default VueMfe.createSubApp(SubAppConfig)的文件,以保证
root 的 全局变量是 SubAppConfig 供 VueMfe 直接安装。
如果有使用 CDN 则将 CDN 地址配置到 SubApp 或者 App 的 resources 即可。
在我们团队中,在更新到 vue-cli3 之后,因为 cli3 封装了所有的 webpack 配置,通过
service api 形式暴露,所以写了一个插件 vue-cli-plugin-mfe 来构建子应用。我们分别拆分了
3 个 command:
build构建 umd 格式文件
upload上传构建后的文件到 CDN
publish发布应用通知更新前端资源
build 的主要代码如下:
1.删除了 vue-cli3 自带的相关插件,这些插件对主应用生效即可,子应用并不需要:
api.chainWebpack((config)
=> {
config.plugins
.delete("html") // for cli-3.2+
.delete("html-index") // for cli-3.5+
.delete("prefetch")
.delete("prefetch-index")
.delete("preload")
.delete("preload-index")
.delete("workbox")
.delete("workbox-index")
.delete("copy")
.delete("pwa")
.end()
}) |
2.配置子应用打包成 umd 格式,及其全局变量名称
api.configureWebpack({
// 打包入口
entry: "./src/portal.entry.js" ||
options.entry, // options.entry
devtool: args.disableSourceMap ? false : "source-map",
// disable all
output: {
path: api.resolve(args.output),
library: {
root: "__domain__app__" + camelizedName,
amd: packageName,
commonjs: packageName,
},
libraryTarget: "umd",
filename: "js/" + camelizedName +
"-[chunkhash:8].umd.js",
// libraryExport: name,
chunkLoadTimeout: 120000,
crossOriginLoading: "anonymous",
},
optimization: {
splitChunks: {
cacheGroups: {
vendors: {
name: camelizedName + "-" + "chunk-vendors",
// eslint-disable-next-line no-useless-escape
test: /[\\\/]node_modules[\\\/]/,
priority: -10,
chunks: "initial",
},
common: {
name: camelizedName + "-" + "chunk-common",
minChunks: 2,
priority: -20,
chunks: "initial",
reuseExistingChunk: true,
},
},
},
},
plugins: [
args.downloadUrl &&
new WebpackRequireFrom({
path: args.downloadUrl + args.name + "/",
}),
new WebpackManifest(),
new WebpackArchiver({
source: api.resolve(args.output),
destination: outputPath,
format: "tar",
}),
].filter(Boolean),
}) |
回顾历程
截止目前为止,团队内已使用微前端架构 1 年多了。虽然不够完美,或者一些概念在日新月异的前端领域可能已经过时。但是这套方案,在团队内部切切实实解决了开篇提到的种种问题 |