此类文章近两年已被各路大牛写烂了,在这里,我只是谈谈自己的理解。以及如何快速的实现一个基于 Vue.js 的简单易用的微前端。
缘起何处
我们团队在公司内部主要为国内某 TOP3 建筑公司编写 ERP 中后台应用,由于是建筑领域,其 ERP 系统十分庞杂。在我于 2018 年底入职时,该系统就已编写了三年。但是由于需求方依然在提需求,所以依然在编写新应用,维护,迭代和重构。
当时团队内部的前端编写模式是,原始项目 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 私仓。以给子应用在开发环境中将其作为依赖安装。在需要修改公共代码或者提供公共服务的时候,只需要重新构建和部署主应用即可,解决了一大痛点。
然后将各个子模块拆成独立的业务模块,使其都在主运行时中被运行。从而实现子应用的独立开发、独立维护、独立部署。
不过我们的实现细节上有所不同,我们将 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
上传构建后的文件到 CDNpublish
发布应用通知更新前端资源
build 的主要代码如下:
- 删除了 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()
})
- 配置子应用打包成 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: [
// 配置 CDN 下载地址
args.downloadUrl &&
new WebpackRequireFrom({
path: args.downloadUrl + args.name + "/",
}),
// 生成 manifest.json 文件
new WebpackManifest(),
// 将构建后的资源打包成 .tar.gz 包
new WebpackArchiver({
source: api.resolve(args.output),
destination: outputPath,
format: "tar",
}),
].filter(Boolean),
})
upload 的主要代码如下:
// filePath 是打包后的 .tar.gz 包路径
const name = args.name
const url = args.uploadUrl
const download = args.downloadUrl || args.uploadUrl
const fileSize = fs.statSync(filePath).size
log()
log(
JSON.stringify({
name,
moduleName,
uploadUrl: url,
downloadUrl: download,
file: filePath,
fileSize,
})
)
log()
request
.post({
url,
formData: {
file: fs.createReadStream(filePath),
moduleName,
},
})
.on("error", (error) => {
stopSpinner(false)
log(chalk.red(`Upload module ${chalk.cyan(name)} failed`))
log(chalk.red(JSON.stringify(error)))
reject(error)
})
.on("complete", (res, body) => {
stopSpinner(false)
if (res.statusCode !== 200) {
log(chalk.red(`Upload module ${chalk.cyan(name)} failed`))
log(
chalk.red(
`Remote server ${url} status error. Code: ${chalk.red(
res.statusCode
)}, Body: ${chalk.red(body ? JSON.stringify(body) : "")}`
)
)
reject(body)
} else {
done(`Upload module ${chalk.yellow(name)} complete.`)
info(`Checkout it out on package-server ${chalk.cyan(`${download}`)}`)
resolve(res.resume())
}
})
publish 的主要代码如下:
我们的发版只是通过 RESTful API 接口向客户端的 socket 推送一条更新消息:
const name = args.name
const publishUrl = args.publishUrl
const arr = publishUrl.split('?')
let url = arr[0]
const querystring = arr.slice(1).join('')
let data = qs.parse(querystring)
return request
.get(url)
.on('error', (error) => {
stopSpinner(false)
log(chalk.red(`Publish module ${chalk.cyan(name)} failed`))
log(chalk.red(error))
reject(error)
})
.on('complete', (res, body) => {
stopSpinner(false)
if (res.statusCode !== 200) {
log(chalk.red(`Publish module ${chalk.cyan(name)} failed`))
log(
chalk.red(
`Remote server ${url} status error. Code: ${chalk.red(
res.statusCode
)}, Body: ${chalk.red(JSON.stringify(body))}`
)
)
reject(body)
} else {
done(`Publish module ${chalk.yellow(name)} complete.`)
info('Hey, Congratulations!')
resolve(res.resume())
}
构建 & 部署过程的核心代码既如上所示。
回顾历程
截止目前为止,团队内已使用微前端架构 1 年多了。虽然不够完美,或者一些概念在日新月异的前端领域可能已经过时。但是这套方案,在团队内部切切实实解决了开篇提到的种种问题。
如果你有什么更好的建议,欢迎联系我。谢谢!