微前端实现原理、框架选型之类的文章比较泛滥,我不打算讲这些玩意,本文主要来源于笔者过去一年落地微前端的一手经验,尽量不讲技术细节,而是讲一个体系化的方案是怎么搭建起来。
文章较长,耐心看完保证会有收获。
首先来看下业务背景,方便读者了解我们为什么选择微前端,以及其他相关技术选型的原因。
前端在架构上面的变化远落后于后端,后端的架构已经经历了微服务、中台化、DDD 改造的腥风血雨…
在改造成微前端之前, 我们也是一个巨型的单体应用
,后面随着业务的复杂化,业务和团队进一步进行拆分, 我们的前端项目也根据康威定律
,进化成为了‘多页应用
’, 如下图所示:
我们主要做的是 2B 业务,做 POC(概念验证) 和私有化部署
是家常便饭,在已有的架构下,我们需要应用某些配置可能会牵扯多个项目,比如主题、文案、接口配置等信息的修改,需要针对多个项目进行创建分支、修改代码、构建、发布、部署… 一系列繁琐的流程
主要原因是我们的业务系统经过长期、多团队、多业态的迭代,积累了大量的技术债。
巨石应用
。💡 怎么理解 “应用按照菜单聚合,而不是按照业务聚合” 呢?
朴素的多页应用通常按照“菜单”来拆分应用,比如按照上图的顶级 Tab。 后面来这一个这样的需求,a 应用的某些功能菜单需要在 b Tab 下展示,这时候就傻眼了:
因此我们亟需一套新的架构,能统一管理不同团队业务线、同时能够保持原本的独立性和灵活性。这时候微前端架构就进入了我们的考察范围:
我们需要一个「底座」将不同的应用聚合起来,将原本离散
的应用通过一个基座
串联起来:
💡 使用微前端之后,子应用不再耦合菜单,菜单由基座来管理和组合,菜单可以被放在任意位置。
由于我们原本就是多页应用
的架构,所以基于路由分发
+ 基座形式
的微前端方案是一种比较自然的选择。整体项目架构如下:
我们构造了一整套体系化的方案: 从规范
到开发基础库
、从权限管理系统
到微前端基座
、从开发调试
到部署运维
。
权限管理平台
, 通过权限管理平台可以灵活地给不同业态、不同角色配置不同的菜单和权限。这是我们微前端方案的重要基础。 得益于运行容器
,我们可以实现前端部署的标准化,支持「一键部署」等能力。
基座主界面
如上所示,基座为子应用提供了基础的运行环境, 蓝色区域为子应用的运行范围。
基座的大概结构如上。
首先是会话管理
,基座会拦截
应用的所有请求,如果监听到 401 状态码,则跳转到登录页面进行授权。登录/注册页面也是由子应用提供,我们尽量不让基座耦合具体的业务。
基座启动后,就会从权限管理平台
拉取菜单、权限配置信息,渲染页面的菜单导航框架。同时也会对页面路由进行授权拦截,而细粒度的权限控制(比如按钮),基座也会暴露 API 供子应用适配。
至于子应用信息,则是由运行容器
自动发现并注入,避免在基座中硬编码了这些信息。基座底层基于 qiankun,根据路由匹配渲染指定的子应用。
💡
运行容器
是啥? 这个我们会在下文介绍,简单来说,它就是一个NodeJS
服务,会自动发现已经部署在服务器中的子应用,然后将这些信息注入到基座的启动代码中。
基座还统一管理了主题包
、多语言
。从而保证子应用可以有较为统一的呈现。主题包也可以在部署时动态切换,这对于 POC 或者私有化部署比较方便。
💡 主题包主要包含 CSS 变量、组件库样式、语言包、静态资源、甚至一些部署配置信息。
为了方便子应用使用基座的「服务
」, 基座也向子应用暴露了一系列的组件库和 API。
组件库基于使用 Web Component
的形式,实现框架无关, 基于 Vue 3 创建。Vue 3 构建自定义元素 也很方便,所以就没必要引入其他框架专门来编写这块了
这些 API 可以直接挂载在全局 window 对象上,子应用可以直接访问。
💡 实际上我们封装了一个套壳 npm 库,避免子应用直接访问 window 对象上的服务, 隐藏细节,另外可以提供类型提示。
简单、免侵入地改造子应用使我们要达成的主要目标。
为此,我们也提供了相应的 vue-cli
插件, 支持快速集成,避免开发者关心 Webpack 底层的各种配置细节
我们的微前端主要基于 qiankun,官方目前并不支持 Vite,并且我们大量项目主要以 Vue CLI 为主。
示例:
const { defineConfig } = require('@vue/cli-service')
const { defineMappChild } = require('@wakeadmin/vue-cli-plugin-mapp-child')
module.exports = defineConfig({
transpileDependencies: false,
pluginOptions: {
// 只需要简单的配置
...defineMappChild({
mapp: {
activeRule: '/dsp.html',
},
}),
},
lintOnSave: false,
})
多入口配置:
const { defineConfig } = require('@vue/cli-service')
const { defineMappChild } = require('@wakeadmin/vue-cli-plugin-mapp-child')
module.exports = defineConfig({
// 多页应用
pages: {
index: 'src/main.ts',
another: 'src/another.ts',
},
pluginOptions: {
// 微前端集成配置
...defineMappChild({
mapp: [
{
// entry 必须为上面 pages 中定义的 key
entry: 'index',
},
{
// entry 必须为上面 pages 中定义的 key
entry: 'another',
},
],
}),
},
})
接着调整应用挂载程序:
import { createApp, App as TApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import Bay from '@wakeadmin/bay'
import App from './App.vue'
import { routes } from './router'
import store from './store'
let app: TApp
Bay.createMicroApp({
async bootstrap() {
console.log('bootstrap vue3')
},
async mount(container, props) {
console.log('mount vue3', props)
const router = createRouter({
history: createWebHashHistory(),
routes,
})
app = createApp(App).use(store).use(router).use(Bay)
app.mount(container?.querySelector('#app') ?? '#app')
},
async unmount() {
console.log('unmount vue3')
app.unmount()
},
async update() {
console.log('update vue3')
},
})
运行起来后, 我们会在终端打印出子应用的相关信息,如下图:
接下来,只需要在基座的调试页面
,注册这个子应用就可以运行起来的:
💡 有了微前端之后,子应用的开发和调试也简化了很多,可以随时挂载到任意环境,不需要配置任何服务端代理。
网上很少关于微前端应用的部署和治理的介绍,下面介绍我们自己摸索出来一套方案, 这也是本文的重点。
在此之前,我们的前端项目都是扔到一台静态资源服务器,很多开发者会手动操作,项目之前通过目录隔离,手动维护 Nginx 进行分流,手段原始且容易出错,场面十分混乱。
在 2021 年,我们就开始推行前端项目的容器化,来解决这种混乱的状态。
为了标准化
、自动化
每个项目的构建操作、部署流程,我们和后端对齐, 使用容器和 K8S 来实现发布产物的封装和部署。这样的好处是:
这对我们来说是一个比较重要的升级。我们的工作不再局限于静态资源的伺服,我们可以使用 NodeJS 开发 API、自动化工作流、可以进行服务端渲染等等,拓展了能力的边界。
然而,很多配置信息在构建时就固定下来了,比如 CDN 域名,接口请求路径等等。而不同环境通常会使用不同的配置信息。这样就无法实现构建一次镜像,在不同环境运行。
后端程序的解决办法是将配置信息外置,比如通过环境变量
配置或者从配置中心
(比如 Nacos)获取。
这在前端行不通,所以我们引入了运行容器
的概念。
运行容器
,顾名思义就是整套微前端
的运行时
,以 「Docker 容器
」的形式部署。我们尽量复用 K8S 提供的基础设施(比如 PVC、配置映射、Sidecar 等) 来实现。
运行容器的主要结构:
运行容器
主要包含两大部分:
Nginx
:毫无疑问,Nginx 是静态资源伺服
的最佳能手,同时它作为内部服务反向代理
。transpiler
(我们称为转换器
): 这是一个「搬运工」,主要负责配置的收集、代码转换。并将转换后的静态资源交给 nginx
伺服。下面会详细介绍它的能力。
答案是”约定“。
运行容器约定了以下目录:
/data/
/source/ # 源目录
/__public__/ # 公共资源, 外部可以直接访问,不需要 __public__ 前缀
/__config__/ # 配置目录
config.yml
any-sub-dir/
my-config.yml
/__entry__/ # 基座目录
js/
index.html
/__apps__/ # 子应用目录
wkb/
dsp/
dmp/
js/
mapp.json
index.html
/__i18n__/ # 语言包目录
zh.tr
en.tr
any-sub-dir/
zh.tr
en.tr
/__theme__/ # 主题目录
config.yml
element-ui.css
element-plus.css
fonts/
i18n/
zh.tr
en.tr
/public/ # nginx 伺服目录
目录结构解析:
/data/source
。没错,transpiler
就是转译
和搬运这里的静态资源。/data/public
。 transpiler
就是将资源转译后搬运到这里,nginx
对外伺服这个目录。转译?
transpiler
可以认为就是一个模板引擎,它会替换代码里面的动态变量。
再来看 /data/source
:
__entry__
: 基座编译之后的代码就部署这里。
__apps__
: 子应用编译之后的代码就部署这里,子应用之间, 按照唯一的 name
区分目录。
__i18n__
: 扩展语言包,文件按照 <language>.tr
命名, 子目录的 .tr 文件也会被扫描到。
__config__
: 配置目录。配置文件使用 .yml
或 .yaml
命名,也可以放在子目录下。
__theme__
: 主题包目录。可以手动维护,也可以使用 npmTheme
配置项, 让 transpiler
从 npm 拉取。
__public__
: 公共资源目录。这些资源可以直接访问,而不需要 __public__
前缀。举个例子:
__theme__/
index.css *# -> 访问链接 example.com/__theme__/index.css*
__public__/
hello.html *# -> 访问链接 example.com/hello.html*
那 transpiler
的工作过程应该比较清晰了:
__apps__
下的子应用
。开发者也可以在子应用目录下使用 mapp.json
显式定义子应用描述信息。扫描后的子应用信息将放在 microApps
变量下。__config__
下的配置文件。解析出配置信息。__i18n__
下的 .tr
, 解析结果放在 i18n
变量下。__theme__
目录。__theme__
主题包也支持携带配置文件、语言包,所以这些信息也会合并到配置信息中。另外 CSS 文件、JavaScript 文件将被收集到 theme
变量中。扫描完毕之后,transpiler
拿着配置信息进行模板转译
,将 /data/source
下的静态资源转换被拷贝到 /data/public
目录下。
来看个实际的模板例子:
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="description" content="[%= description %]" />
<meta name="keywords" content="[%= keywords %]" />
<link rel="icon" href="[%= assets.IMG_BAY_FAVICON || entryPath + '/favicon.png' %]" />
<meta name="version" content="[%= version %]" />
<meta name="update-time" content="[%= `${year}-${month}-${date}` %]" />
<title>[%= title %]</title>
<!--! [%- theme.stylesheets.map(i => `<link rel="stylesheet" href="${i + '?' + hash }" />`).join('\n') %] -->
<!--! [%- theme.scripts.map(i => `<script async="true" src="${i + '?' + hash}"></script>`).join('\n') %] -->
<!--! [% if (microApps.length) { %] -->
<!--! [%-
`<script>
// 微应用注入
(window.__MAPPS__ = (window.__MAPPS__ || [])).push(${microApps.map(i => JSON.stringify(i)).join(', ')});
</script>`
%] -->
<!--! [% } %]-->
<!--! [%- `<script>
// 静态资源注入
(window.__MAPP_ASSETS__ = (window.__MAPP_ASSETS__ || [])).push(${JSON.stringify(assets)});
// 全局共享的语言包
window.__I18N_BUNDLES__ = ${JSON.stringify(i18n)};
</script>` %] -->
<script
defer="defer"
src="[%= cdnDomain ? '//' + cdnDomain : '' %][%= removeTrailingSlash(base) %]/__entry__/js/chunk-vendors.582ba02c.js?[%= hash %]"
></script>
<script
defer="defer"
src="[%= cdnDomain ? '//' + cdnDomain : '' %][%= removeTrailingSlash(base) %]/__entry__/js/app.01bd68bb.js?[%= hash %]"
></script>
<link
href="[%= cdnDomain ? '//' + cdnDomain : '' %][%= removeTrailingSlash(base) %]/__entry__/css/app.d835cada.css?[%= hash %]"
rel="stylesheet"
/>
</head>
<body>
<noscript
><strong
>We're sorry but [%= title %] doesn't work properly without JavaScript enabled. Please
enable it to continue.
</strong></noscript
>
<div id="app"></div>
</body>
</html>
上面是基座的 index.html
模板。transpiler
基于 ejs 模板引擎,会解析替换文本文件中 [% 模板 %]
语法。
还有很多用法值得去挖掘。 比如:
原理就是这么简单,只要子应用部署到 __apps__
目录下,我们就可以监听到,收集到必要的信息后,对 source
目录下的静态文件进行转译,输出到 public
目录下,最终由 Nginx 负责将文件传递给浏览器。
那么子应用具体如何部署和运维呢?
子应用构建、生成和发布容器的过程这里就不展开说了,可以自行搜索 Docker 的相关教程,我们这里主要简单介绍一下在 K8S 平台如何部署和运维。
将基座和子应用聚合在一起,我们需要用到 PVC
(PersistentVolumeClaim, 即持久化卷), 你可以认为 PVC 就是一个「网络硬盘」,而每个子应用、基座都是独立运行的「主机」(Pod
或 容器 , Kubernetes 中可部署的最小、最基本对象), 这个 PVC 可以被每个子应用共享访问,只要按照约定将子应用的静态文件拷贝到 PVC 对应位置就行了。如下图所示:
至于子应用和运行容器在 K8S 下如何组织,可以非常灵活,取决于需求和环境。笔者实践过以下几种方式:
Init Sidecar
(初始化边车)。这种部署方式比较简单,缺点就是任意一个应用需要更新,整个 Pod 都要重启,包括运行容器。
示例图:
开发者可以根据自己的运行环境选择不同的组织方式。
首先简单的配置可以通过环境变量
来实现,因为在 K8S 中,配置环境变量相对简单很多:
对于稍微复杂的配置,可以使用配置映射
(Config-Maps), 配置映射的每个键值对就相当于一个文件,我们可以挂载到容器的任何位置上:
定义配置映射:
挂载配置映射:
配置映射可以挂载到任意的路径或文件上,它还有一个更赞的能力是:我们可以直接修改配置映射,这些变动会同步到容器内,从而实现实时变更。
小结。我们尽量复用了 K8S 本身的能力,这些能力足以实现较为复杂功能,避免重复造轮子。
限于篇幅很多细节无法展开,这里点到为止:
EventBus
,没有提供其他应用通信的手段。对我们来说,微前端只不过是多页应用的延续。 设计良好的应用,不应该耦合其他应用。就算是一些共享状态,也可以从后端读取。运行容器
有动态替换变量的能力,因此应该避免在代码中硬编码配置信息,比如域名信息、企业文案、服务器链接。而是预留模板, 在部署时通过运行容器来配置。我们整套方案并没有‘自造’复杂的技术,而是基于已有的工具整合起来的能力。这也是笔者一直坚持的观念,简单至上。
这个方案未来会如何迭代呢?
本文大概介绍了我们落地和治理微前端应用的大概思路。这套体系中主要包含了三个主要部件:
集中式
的微前端方案,基座是整个微前端的核心,负责管理子应用,并为子应用的开发提供必要的支撑因为文章篇幅原因,这里面很多细节无法展开。感兴趣的可以移步我们公开的文档(暂未开源)。