Bobolo
  • Home
  • Me

Micro Frontend

Coding.net 的 Micro Frontend

12 Min Read

Thu Dec 23 2021

Written By Bobolo

微前端(Micro-Frontends),受微服务模型的启发,那微服务是什么?微服务是一种用于构建分布式应用的架构模式,将其构建为服务集合。换句话说,微服务将后端划分为不同的服务。微前端具有与微服务类似的概念,背后想法是将应用视为由独立团队拥有的功能组合,每个团队都关注在自己项目而不受其他项目或模块影响。

微前端将庞大的前端应用拆分成多个独立灵活的小型应用,每个应用都可以独立开发、运行、部署。它是一个架构设计,并不是指某个框架或者工具,它是处理复杂前端应用程序的解决办法。

Architecture

当前趋势是构建一个功能丰富且强大的应用,也就是位于微服务架构之上的 single page app。随着时间的推移,通常由独立团队开发的前端层会增长并变得更难维护。这就是 Monolithic Frontends。

Monolithic Frontends

Monolithic Frontends

Monolith 由一个团队来管理一个完整的应用,共享数据库、后端和前端。Front & Back 将应用一分为二,也将团队划分为后端或前端。借助微服务,后端架构演变为更具可扩展性的架构,因为每个微服务都属于不同的工作团队。

虽然后端的划分是透明的,但是当试图将微服务集成到 monolithic frontend 时会出现问题,成为应用程序的瓶颈,monolithic 的缺点是:

  • 太复杂,无法完全理解并快速正确地进行更改

  • 前端代码的更改可能会影响整个网站

  • 前端代码的任何修改都必须重新实现,增加了编译时间

因此出现 Vertical Organization,微前端将应用程序分成小的独立功能,每个功能由一个工作团队从后端到前端同时实现。

Vertical Organization

Organisation in Verticals

由于 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:移除已注册的应用,目的是让它在下一次需要挂载时重新初始化。

Render

Implementation

完整的应用可以分为三大部份,Root App、Layout、Child App。但不绝对,Layout 部分也可以放到 Root App 里面,这个看业务等方面的考虑,就和 React 应用一样。

Root App 就像 React 应用中的 Root 部分,用来做各种初始化以及公用逻辑控制。Layout 则是界面公用的基础 UI 入口,一般是导航栏或者底部导航栏。Child App 则是每个大的模块应用,就类似于做了路由拆分后的每个 page。

Render

除了上面的业务模块拆分外,代码构建也需要处理,当拆分项目后,子应用都需要自己进行打包和部署,虽然可以由子项目自行负责,但实现一个 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
  • 建设一个 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,方便随时调整 namematch等。

不同的 Child App 在 deploy 资源后,通过调整 sha 就可以控制 Child App 的版本,后改为通过 HTML,为了方便发布

部署的话不可避免就要遇到缓存的问题,这里主要有两个部分,但是两个都是采取 url 中增加 timestamp 来处理。

  • config.json:config.json 一般不会经常的改动, 所以使用 getFullYear() + getMonth() + getDay() + getHours()
  • App 的 HTML:因为是入口,存在可能经常的发布或者 hotfix,所以也需要带上时间戳

Flow

Render

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 工作期间,相比于原始的做法,微前端的体验感觉更好些。

Reference

Powered by Bobolo

Copyright © Bobolo Blog 2021