微前端(Micro-Frontends),受微服务模型的启发,那微服务是什么?微服务是一种用于构建分布式应用的架构模式,将其构建为服务集合。换句话说,微服务将后端划分为不同的服务。微前端具有与微服务类似的概念,背后想法是将应用视为由独立团队拥有的功能组合,每个团队都关注在自己项目而不受其他项目或模块影响。
微前端将庞大的前端应用拆分成多个独立灵活的小型应用,每个应用都可以独立开发、运行、部署。它是一个架构设计,并不是指某个框架或者工具,它是处理复杂前端应用程序的解决办法。
Architecture
当前趋势是构建一个功能丰富且强大的应用,也就是位于微服务架构之上的 single page app。随着时间的推移,通常由独立团队开发的前端层会增长并变得更难维护。这就是 Monolithic Frontends。
Monolithic Frontends
Monolith 由一个团队来管理一个完整的应用,共享数据库、后端和前端。Front & Back 将应用一分为二,也将团队划分为后端或前端。借助微服务,后端架构演变为更具可扩展性的架构,因为每个微服务都属于不同的工作团队。
虽然后端的划分是透明的,但是当试图将微服务集成到 monolithic frontend 时会出现问题,成为应用程序的瓶颈,monolithic 的缺点是:
-
太复杂,无法完全理解并快速正确地进行更改
-
前端代码的更改可能会影响整个网站
-
前端代码的任何修改都必须重新实现,增加了编译时间
因此出现 Vertical Organization,微前端将应用程序分成小的独立功能,每个功能由一个工作团队从后端到前端同时实现。
Vertical Organization
由于 Monolithic Frontends 存在的问题,Verticals 变得流行起来。前端发展迅速,采用 Monolithic,维护起来变得更加困难。
使用微前端,可以保证与后端微服务架构相同的可扩展性、灵活性和适应性。每个微前端还可以使用不同的框架进行开发。
Background
Coding.net,腾讯云旗下一站式 DevOps 研发管理平台,提供代码托管、项目协同、测试管理、持续集成、制品库、持续部署等系列工具产品;从需求提交到产品迭代,从代码开发到软件测试、部署,整套流程均可在 CODING 完成。
Coding 的功能非常多,所以项目也异常庞大,因此会遇到以下几个问题
- 开发效率慢,使用 webpack 开发时需要打包整个项目,但其实只需要开发个别界面或功能
- 功能发布难,因为整个项目是一体的,需要协调多个团队一起发布,如果遇到问题则需要将其他团队的代码一同回滚,即使其他团队的代码没有问题
- 样式或功能隔离难,在一些场景上样式出现污染问题,功能方面也同样
解决方案
要想解决上面的问题,就需要借助微前端。目前比较流行的微前端解决方案有以下几个
-
Server 路由转发:通过实现不同路径映射不同应用,如
home.com/test1
对应 app1,home.com/test2
对应 app2- 实现简单,快速,易配置,后端配置为主
- 切换应用时会触发浏览器刷新,无法跟随浏览器前进后退
- 界面刷新导致应用通信比较麻烦
-
iframe 嵌套:通过 iframe 来作为子应用入口,跟 Server 路由方式类似
- 通信可采用 postMessage 或者 contentWindow 方式
- 应用间自带沙箱,天然隔离,互不影响
-
Web Components:采用纯 Web Components 技术编写组件
- 应用间有隔离效果,互不影响
- 改造成本高和 Web Components 兼容性较差
-
路由分发:由父应用通过路由来管理子应用加载,启动,卸载,以及通信
- 用户体验可无感知切换
- 因为处于同运行时,需要解决子应用的样式冲突,变量对象污染,通信机制等问题
最后的选择是 路由分发
,它在社区上也有更多的解决方案,在体验和开发成本上也比较合适。
在路由分发这种做法上,最出名的框架应该是 single-spa。因此也选择该框架作为基础来去构建微前端。
Single-spa
Single SPA 借鉴现代框架组件生命周期,通过 url 路由来做应用切换。跟 React + React Router 的应用类似。
Single SPA 的生命周期包括四个阶段:bootstrap, mount, unmount,unload。下面简单介绍重要的几个生命周期函数和作用:
-
bootstrap:Single SPA 通过加载配置文件来确定需要加载的应用以及它们的依赖关系。会预加载应用代码,以便在需要时能够快速加载和渲染。
-
mount:将应用程序渲染到指定的 DOM 节点中。此阶段的钩子函数需要返回一个可用于卸载应用程序的函数。
-
unmount:清理在挂载应用时被创建的 DOM 元素、事件监听、内存、全局变量和消息订阅等
-
unload:移除已注册的应用,目的是让它在下一次需要挂载时重新初始化。
Implementation
完整的应用可以分为三大部份,Root App、Layout、Child App。但不绝对,Layout 部分也可以放到 Root App 里面,这个看业务等方面的考虑,就和 React 应用一样。
Root App 就像 React 应用中的 Root 部分,用来做各种初始化以及公用逻辑控制。Layout 则是界面公用的基础 UI 入口,一般是导航栏或者底部导航栏。Child App 则是每个大的模块应用,就类似于做了路由拆分后的每个 page。
除了上面的业务模块拆分外,代码构建也需要处理,当拆分项目后,子应用都需要自己进行打包和部署,虽然可以由子项目自行负责,但实现一个 platform-webpack
可以更好的控制一些打包上的逻辑。
Root App
作为整个应用的基座,在这里需要像 React 应用一样,实现一些初始化以及 Router 切换的逻辑。
-
实现 single spa 的路由切换加载
-
处理公用的 JS 实例,如 React、Router、Redux 等
-
处理公用的代码逻辑,例如错误上报等
公用 Package 实例直接挂载到 window,如 Child App 可以使用 window.store 获取 redux 的 store。而公用的代码逻辑部分则按原先的执行。
主要是路由切换部分,Root App 使用 CustomEvent 来通知 Child App 进行 mount 或者 unmount。
const UnmountEvent = new CustomEvent('app-unmount', {
detail: {
hazcheeseburger: true,
},
})
// 切换路由时通知 child app 去卸载
dispatchEvent(UnmountEvent)
路由切换,就需要一个 Router 的 Config 来匹配需要加载的 Child App,所以 Router 的 Config 部分也会在 Root App 这里处理。
这里有个点就是,Router Config 可以固定写在 Root App 中,但每个模块都是需要部署和发布的,这样在可能新开一个项目或者有调整的时候不方便,最好是动态的形式。
所以采取 Json,通过一个 config.json 来作为 Router Config。采用 match 来匹配 url,匹配的则加载应用的 html 或者 js,同时也支持从 Http 接口去读取前端配置。
[
{
"description": "", // 描述
"js": [], // 需要加载的入口
"match": "", // 正则匹配的 url
"name": "" // 在切换应用和注册应用时使用
}
]
Child App
Child App 最主要的是需要处理 Root App 中 dispatchEvent
的 mount 和 unmount。
- register: registerApplication 用于注册 Child App
- bootstrap: 可以做组件的设置,例如 antd 中的 getContainer
- mount:
ReactDOM.render(<Root store={store} history={window.microHistory} />, dom)
- unmount: 主要使用 ReactDOM.unmountComponentAtNode 来卸载当前的 Child App
其他的如分析埋点(ReactGA),Child App 有需要也是自己进行处理,除了这些核心逻辑,还有几个问题也在 Child App 中处理。
Style
由于多个项目已经分离,因此如果使用一些 Global 的样式,或者命名不规范容易导致样式污染。第三方的 UI 组件也容易出现不同团队加载不同版本的问题。
- 项目中的 classname 在打包时加上 hash,以及规范每个项目带上项目名前缀
- 后改为在 container 上加
data-frontend-module-container
, 如[data-frontend-module-container="git-app"] .btn
- 后改为在 container 上加
- 建设一个 UI 组件库,将需要公用的部分抽离,需要的地方自行 npm install 然后在项目中 import
Package
Package 主要是多版本问题,造成重复加载,容易导致代码体积变大。例如不同团队可能使用不同版本的 React,这样容易造成多次加载 React。还有一些逻辑上可能造成重复,例如错误监控和性能分析的埋点等。
- 将 React、Router、Redux 等基础 Package 提升到 Root App 中引入,Child App 统一使用 Root App 的实例,通过 window 来保存
- 将部分在 Root 上需要处理的逻辑上升到 Root App
Develop
当项目分离成微前端后,开发的内容或多或少都可能涉及多个子应用的交互,或者需要对线上 Bug 的定位。
这就需要借助 config.json,修改引用本地的 js 。例如使用 https://dev-micro-app/
作为入口,然后通过一个 params 来控制加载的 config.json。
local 的 config.json 由 webpack 来完成,主要是将当前子应用的 js 替代线上的。这样的结果就是当前子应用的代码是加载本地,而其他的代码都是使用 https://dev-micro-app/
。
实现思路如下:替换 config.json 的 route,将原本加载线上的配置改为 local。
config.json
local 生成一个 config.json,里面将当前的 Child App 配置进行处理。但有缺陷,需要在本地处理多个项目时不方便,所以主要 sha.json 的方式
当加载 https://dev-micro-app/app2?config=http://localhost:8080/static/config.json
使用 local 的配置,因此 app2 就是本地的代码。
http://localhost:8080/static/config.json
[
{
"description": "App1",
"js": ["http://localhost:8080/App1/index.html"],
"match": "App1",
"name": "App1"
},
{
"description": "App2",
"js": ["/micro-frontend/App2/index.html"],
"match": "App2",
"name": "App2"
}
]
// 这里使用 HTML 主要是用来加载 HTML 里面的 js,可以更动态的加载需要的 js
sha.json
local 生成一个 sha.json,sha = git commit
,里面的内容为 Child App 配置。
当加载 https://dev-micro-app/app2?buffet=qwerasdfzxcv
,根据 buffet
加载 json,里面包含了当前 Child App 的资源,而不使用 config.json。
// http://localhost:8080/static/qwerasdfzxcv.json
{
commitId: "qwerasdfzxcv",
css: ["index.css"],
js: ["index.js"] ,
name: "App1"
publicPath: ""
}
这里还有个问题,因为一个 Child App 可以 build 出来一个 config.json,但如果需要同时在 local 处理多个子应用要怎么处理?
还是在 url 上通过 params 来处理,local 上生成一个 (git commit - sha).json,例如 buffet=sha1,sha2,Child App 会加载对应的 sha.json,然后使用里面的配置。
Deploy
Child App 都由团队控制后,需要团队自己去控制部署和发布,将代码跑完 CI 后推送到服务器即可。deploy 时则默认使用 config.json,里面的 html 由 webpack 生成,Root App 根据 html 去加载资源。
deployd 也采用过 sha.json 的方式,提供 Api 和界面给团队去控制和修改 config.json,方便随时调整 name、match等。
不同的 Child App 在 deploy 资源后,通过调整 sha 就可以控制 Child App 的版本,后改为通过 HTML,为了方便发布
部署的话不可避免就要遇到缓存的问题,这里主要有两个部分,但是两个都是采取 url 中增加 timestamp 来处理。
- config.json:config.json 一般不会经常的改动, 所以使用
getFullYear() + getMonth() + getDay() + getHours()
- App 的 HTML:因为是入口,存在可能经常的发布或者 hotfix,所以也需要带上时间戳
Flow
Conclusion
微前端,并不是一个更先进的框架,而是一个架构,怎么实现的方式有很多,具体还需要根据项目等去处理。如路由配置用 json 还是 http 请求,local 路由配置和线上的合并,哪些业务模块提取成微前端的 Child App,哪些应该在 Root App。这些都是根据项目而调整。
微前端它主要给项目带来了以下几个帮助:
- 拆分项目,使得在开发和发布时更加方便高效
- 代码、样式的隔离,减少样式覆盖等问题
- Child App 可以自行控制打包、lib 升级等,而不用担心影响其他业务模块
- 可以更轻松在 dev/staging,甚至 prod 等环境去测试代码
上面都是微前端解决项目痛点的部分,可以说是它的优点所在,但它并不是没有缺点。
- Package 控制不便,继承 Root App 会导致 upgrade lib 更麻烦,使用 Child App 的则浪费 Root App 的实例,造成资源浪费
- 隔离容易导致代码累积,即使有公用 components,也需要建立良好的 lib 和 style,而越多公用 components 也导致维护成本增加
- 代码逻辑分隔多个项目,维护或者调整都会比较麻烦,例如需要
platform-webpack
等方便控制多个项目打包 - Child App 的通信,无法像以往一样通过 Root 节点的 Context 来处理,需要借助 storage、window、cookie 等
- 需要命名空间,例如缓存或者一些内容变量,都可能需要一些项目前缀来做区分
微前端就是需要在规范与自由做取舍,在 Package、utils、样式等方面,用 Root App 规范就不好升级和改动,任由 Child App 随意处理就容易带来性能等问题。
所以适不适合需要微前端是项目以及团队去讨论这些关键点来决定,但在 Coding 工作期间,相比于原始的做法,微前端的体验感觉更好些。