ShiningDan的博客

前端微服务

见内容

解决的问题

常见讨论

微前端的核心价值

观点:微前端的核心价值在于 “技术栈无关”

微前端的公司,基本上都是做 ToB 软件服务的,没有哪家 ToC 公司会有微前端的诉求,因为很少有 ToC 软件活得过 3 年以上的,如何给遗产项目续命,才是我们对微前端最开始的诉求。

微前端首先解决的,是如何解构巨石应用,解构之后还需要再重组,重组的过程中我们就会碰到各种 隔离性、依赖去重、通信、应用编排 等问题。

1
2
玉伯:
今天看各 BU 的业务问题,微前端的前提,还是得有主体应用,然后才有微组件或微应用,解决的是可控体系下的前端协同开发问题(含空间分离带来的协作和时间延续带来的升级维护)

「空间分离带来的协作问题」是在一个规模可观的应用的场景下会明显出现的问题,而「时间延续带来的升级维护」几乎是所有年龄超过 3 年的 web 应用都会存在的问题。

能力

我们希望,按照用户和使用场景将多个系统汇总成一个或者几个综合的系统.

将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。由此带来的变化是,这些前端应用可以独立运行、独立开发、独立部署。以及,它们应该可以在共享组件的同时进行并行开发——这些组件可以通过 NPM 或者 Git Tag、Git Submodule 来管理。

技术特点:

  1. 技术栈无关 主框架不限制接入应用的技术栈,子应用具备完全自主权
  2. 独立开发、独立部署 子应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
  3. 独立运行时 每个子应用之间状态隔离,运行时状态不共享

实现方案

将应用内的组件调用变成了更细粒度的应用间组件调用

  • 原有:将路由分发到应用的组件执行
  • 现有:路由来找到对应的应用,再由应用分发到对应的组件上

常见实现方案

  1. 使用 HTTP 服务器的路由来重定向多个应用
  2. 在不同的框架之上设计通讯、加载机制,诸如 Mooa 和 Single-SPA
  3. iFrame。使用 iFrame 及自定义消息传递机制
  4. 使用纯 Web Components 构建应用
  5. 结合 Web Components 构建

iframe

优点

比较容易实现

缺点

  1. 子项目需要改造,需要提供一组不带导航的功能
  2. iframe嵌入的显示区大小不容易控制,存在一定局限性
  3. URL的记录完全无效,页面刷新不能够被记忆,刷新会返回首页
  4. iframe功能之间的跳转是无效的
  5. iframe的样式显示、兼容性等都具有局限性

实现方式

在采用 iframe 的时候,我们需要做这么两件事:

  1. 设计管理应用机制
  2. 设计应用通讯机制

加载机制。在什么情况下,我们会去加载、卸载这些应用;在这个过程中,采用怎样的动画过渡,让用户看起来更加自然。

通过 iframeEl.contentWindow 去获取 iFrame 元素的 Window 对象是一个更简化的做法。随后,就需要定义一套通讯规范:事件名采用什么格式、什么时候开始监听事件等等。

纯 Web Components 技术构建

同一时刻可展示多个子应用。通常使用 Web Components 方案来做子应用封装,子应用更像是一个业务组件而不是应用。

暂时技术不成熟,兼容性不高

MPA + 路由分发

优点:

  1. 框架无关;2.
  2. 独立开发、部署、运行;
  3. 应用之间 100% 隔离。

缺点:

  1. 应用之间的彻底割裂导致复用困难。(比如,每个应用左侧和顶部都带有导航,那么, 当我要把该应用在其他系统中复用时,需要对该子应用的导航做较为复杂的改动) ;
  2. 每个独立的 SPA 应用加载时间较长,容易出现白屏,影响用户体验;
  3. 后续如果要做同屏多应用,不便于扩展。

基座式 SPA,主从应用设计

主应用捕获全局的路由事件,基于判断当前路由需要加载哪个子应用,然后 load 它。

路由这里有点不同,在类 Single-SPA 方案中,子应用在加载后,一般会由子应用去接管系统路由。而在基座式的方式中,子应用一般会把自己的路由注册到主应用中,并不接管系统路由。子应用更像是主应用的一个“路由模块”。

缺点:

首先基座就决定了它是框架强相关的,哪怕是基座的版本升级迭代,也会非常容易造成子应用 break change。

传统 SPA + 组件化(比如 Web Components) + 私有 npm 源

使用 npm 包的方案缺陷:需要不断地在库存模块的项目里调整代码,发布 npm,然后再用到库存模块的各个项目中,逐个更新依赖、构建、部署。

开源项目

Single-SPA

single-spa

同一时刻,只有一个子应用被展示,子应用具备一个完整的应用生命周期。通常基于 url 的变化来做子应用的切换。

业内目前比较完善的开源框架,有以下几点能力:

  1. 在同一个页面中可以使用多种现有 Web 框架开发,如(React, AngularJS, Angular, Ember)
  2. 可以在现有的项目中,使用新的框架进行开发
  3. 加快加载速度,自动完成代码懒加载

single-spa 框架提供的是如何将不同的子应用集成到主应用中,并不关心子应用的代码如何拆分(一个仓库或多个仓库),如何构建、打包、发布等业务场景

主框架的定位则仅仅是:导航路由 + 资源加载框架

概念

  1. Application(应用):可以理解为子应用,需要做到监听 URL 的变化,来在特定的 DOM 元素中 bootstrap(引导), mount(挂载), unmount(删除) 需要展示的元素
  2. single-spa-config:包含子应用的名字、代码、触发时机的声明

如何使用

如果搭建项目的时候,不是从头开始就使用 Single-SPA 的,则从已有的前端框架(Vue/Angular)迁移到 Single-SPA 需要一些适配成本

兼容性:Chrome, Firefox, Safari, IE11, and Edge.

single-spa-config

通过 single-spa-config,来进行每个子应用的注册,以及 bootstrapmountunmount 的触发。可以理解为,通过 single-spa-config,来进行全局路由事件的广播。

子应用

子应用要做的事情,就是一个适配层。实现 Single-SPA 需要的函数,调用现有框架逻辑。

Single-SPA 需要的函数,类似于 Vue 的 createdmounted 一样,要提供必选:bootstrapmountunmount;和可选:loadunload

除此以外,还支持 timeout。转场动画的支持比较简陋,可以参考 singlespa-transitions 这个仓库。

构建打包发布

single-spa 框架提供的是如何将不同的子应用集成到主应用中,并不关心子应用的代码如何拆分(一个仓库或多个仓库),如何构建、打包、发布等业务场景

下面是一些开源项目的实践:

Splitting up applications

公司实践

乾坤 - 蚂蚁金服

  1. MPA 方案的优点在于 部署简单、各应用之间硬隔离,天生具备技术栈无关、独立开发、独立部署的特性。缺点则也很明显,应用之间切换会造成浏览器重刷,由于产品域名之间相互跳转,流程体验上会存在断点
  2. SPA 则天生具备体验上的优势,应用直接无刷新切换,能极大的保证多产品之间流程操作串联时的流程性。缺点则在于各应用技术栈之间是强耦合的。

这篇文章中讲解了一些技术栈选择的分析:

  1. 路由机制,什么时候交给子应用接管
  2. 通过构建时集成,还是运行时集成
  3. 运行时集成,使用 JS Entry 还是 HTML Entry

JS Entry vs HTML Entry

single-spa 的 example 中的方式是 JS Entry,所有资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。这点存疑,感觉通过构建可以解决这个问题?现在的分 chunk 是可以减少包大小的。但是并行加载的确没有利用上(先加载 JS,才能加载 CSS)。是不是可以先打出一个特别小的 js,只用来在当前 div 中插入 entryJS 和 entryCSS?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import * as singleSpa from 'single-spa';

singleSpa.registerApplication('app-1', () =>
import ('../app1/app1.js'), pathPrefix('/app1'));
singleSpa.registerApplication('app-2', () =>
import ('../app2/app2.js'), pathPrefix('/app2'));

singleSpa.start();

function pathPrefix(prefix) {
return function(location) {
return location.pathname.startsWith(`${prefix}`);
}
}

HTML Entry 方案下,主框架注册子应用的方式则变成:

1
framework.registerApp('subApp1', { entry: '//abc.alipay.com/index.html'})

在某些场景下,我们也可以将 HTML Entry 的方案优化成 Config Entry,从而减少一次请求,如:

1
framework.registerApp('subApp1', { html: '', scripts: ['//abc.alipay.com/index.js'], css: ['//abc.alipay.com/index.css']})

模块导入

由于子应用通常又有集成部署、独立部署两种模式同时支持的需求,使得我们只能选择 umd 这种兼容性的模块格式打包我们的子应用

子应用与主框架之间约定好一个全局变量,把导出的钩子引用挂载到这个全局变量上,然后主应用从这里面取生命周期函数。

应用隔离

样式隔离

各个子应用之间不会出现样式互相干扰的问题。

解决方案其实很简单,我们只需要在应用切出/卸载后,同时卸载掉其样式表即可,原理是浏览器会对所有的样式表的插入、移除做整个 CSSOM 的重构,从而达到 插入、卸载 样式的目的。这样即能保证,在一个时间点里,只有一个应用的样式表是生效的。

JS 隔离

如何确保各个子应用之间的全局变量不会互相干扰,从而保证每个子应用之间的软隔离?

在应用的 bootstrap 及 mount 两个生命周期开始之前分别给全局状态打下快照,当应用切出/卸载时,将状态回滚至 bootstrap 开始之前的阶段,确保应用对全局状态的污染全部清零。

对全局事件监听的劫持等,以确保应用在切出之后,对全局事件的监听能得到完整的卸载

网易严选

网易严选企业级微前端解决方案与落地实践

  1. 主应用负责应用加载与管理的同时来承载左侧和顶部导航栏。
  2. 不同的子应用,则展示在右侧区域。

运行时加载与路由策略

加载流程:

主应用是微前端框架的承载体,主要包含:

  1. 页面主体框架的渲染,比如一些通用的导航;
  2. 监听捕获全局的路由变化,加载 / 卸载子应用,active 标签等;
  3. 应用隔离、应用通信、数据共享等全局方法的载体。

子引用在被主应用启动后,会接管系统路由,与一个独立运行的应用没有本质区别。

子应用与子应用 JS 隔离

硬隔离:每个子应用加载之前,都进行一次 window reload

  1. 前端 snapshot + resume,快速恢复应用界面。当前已应用于生产环境。
  2. 主应用使用 SSR 局部直出,使页面在视觉效果上无刷新。

软隔离:应用加载之前做一次全局快照,在应用卸载之后,按快照恢复全局属性。

  1. 记住对全局变量的修改,解除应用时恢复原有值;
  2. 记住全局事件的修改,比如 window/document.addEventListener,卸载应用时 remove 事件;
  3. 记住 setTimeout 和 setIntervald 的修改,卸载应用时解除;
  4. 在加载子应用前创建一份 window snapshot ,卸载应用后按 snapshot 恢复全局方法(对象)和它们的原型链。

子应用与子应用 CSS 隔离

子应用加载时,标记该子应用所有的 link 和 style 文件。在子应用卸载后,同步卸载所有的 link 和 style 即可。

主应用与子应用隔离

都有一些思路,但是没有完美的解决方案

应用通信

封装了 Event 来进行跨应用的通信。Event 对象初始化后挂载在 Window 下,在全局以单例模式运行。

配置中心等相关配套设施

美团闪购

Bifrost微前端框架及其在美团闪购中的实践

只讲和 Single-SPA 的实践区别吧

布局子系统

布局子系统是用来实现菜单和导航栏的Vue工程,本质上和一般的业务子系统没有区别。没太理解布局子系统,是不是指的主应用的侧边栏以及顶栏的布局?

同时发布布局的静态资源和NPM包。主系统通过NPM包的方式引入布局子系统,将它打包到项目中,避免线上运行时,额外加载布局子系统的资源,减小项目体积,加快渲染速度。

业务子系统 mounted

现有项目都是基于Vue技术栈开发,所以框架并不需要做到技术栈无关,只要满足Vue的项目即可。

所以 mounted 函数的实现方案,可以做到对业务代码的基本零侵入,原本子系统的初始化流程放到AppContainer对象的Mounted回调函数里即可。

构建完成之后生成一个包含子系统入口资源信息的配置文件。

1
2
3
4
5
6
7
8
(callback) => callback({      
scripts: [
'/js/chunk-vendors.dee65310.js','/js/home.b822227c.js'
],
styles: [
'/css/chunk-vendors.e7f4dbac.css','/css/home.285dac42.css'
]
}))(configLoadedCb.crm)

配置文件是一个立即执行函数,主系统可以通过 JSONP的方式读取配置文件中的内容,解决了跨域的问题,子系统发布到任意CDN。

本地联调

只需要在开发时,模拟显示主系统的运行方式,去加载其他子系统的线上资源,之后就可以像调用后端API一样同各个子系统进行联调了。

全局状态

Bifrost的主系统会维护一个全局的Vuex Store,用于保存全局状态。

助Bifrost提供的 syncGlobalStore 函数来订阅全局Store。调用该函数后,任何全局状态的变化都会被同步到本地Store的Global命名空间下。

不太理解为什么有全局状态需要同步的场景。原作者也表示,在实际拆分子系统时,应该尽量避免这种情况发生。如果两个子系统之间需要频繁通信,那就应该考虑把他们划分到同一个子系统。

公共依赖

大家都是基于同样的组件库进行开发,如果不对公共依赖进行管理,项目会加载大量冗余代码。

采用的是Webpack External方式来解决。构建时,各个子系统会将公共依赖排除,主系统会打包一份包含所有这些公共依赖的DLL文件。子系统在运行时,直接从全局引用对应的依赖。

我们不会将Vue打到DLL文件中。因为在实际开发中,很多库都喜欢向Vue的原型链上挂载方法和属性。如果不同团队开发时挂载的内容恰好用到同一个字段,就会带来不可预知的影响。

模块复用

主系统采用Lerna的方式组织代码,各个子系统在开发时,可以通过软链直接引用到本地公共模块的代码,实现公共模块的复用。当公共模块发生更新,直接调用Lerna Publish就可以同时更新所有子系统package.json中依赖版本。

埋点及错误上报

可以借助主系统提供的一系列钩子函数实现针对子系统的埋点。

icestark - 阿里飞冰

面向大型工作台的微前端解决方案 icestark

路由监听

icestark 通过劫持 history.pushState 和 history.replaceState 两个 API,同时监听 popstate 事件,保证能够捕获到到所有路由变化。

子应用的 bundle 渲染到指定节点

icestark 为子应用提供了一个 getMountNode() 的 API 保证子应用能够渲染到指定的节点里。来屏蔽 getElementById 这种查找逻辑

从能力上来说可能没有太多区别,实现层上面可能有区别。

美团HR系统(比较早期的实践了)

参考:用微前端的方式搭建类单页应用

HR系统最终线上运行的是一个单页应用,而项目开发中要求应用独立。把这个入口项目叫做“Portal项目”或“主项目”,业务应用叫做“子项目”

  1. “Portal项目”是比较特殊的,在开发阶段是一个容器,不包含任何业务,除了提供“子项目”注册、合并功能外,还可以提供一些系统级公共支持,例如: 用户登录机制 菜单权限获取 全局异常处理 全局数据打点
  2. “子项目”对外输出不需要入口HTML页面,只需要输出的资源文件即可

网络请求

看起来,在 Portal 项目中,同一封装 & 提供了唯一的网络请求库,请求访问的后端域名都是一个域名,根据不同的 Path 转发到不同的服务。不同的服务,在接入 HR 系统之前,需要先去 Nginx 上配置转发规则

应用注册机制

路由注册

“子项目”的路由应该由自己控制,而整个系统的导航是“Portal项目”提供

“Portal项目”从 window.app.routes 获取路由,“子项目”把自己需要注册的路由添加到 window.app.routes

1
2
3
4
5
6
7
let app = window.app = window.app || {}; 
app.routes = (app.routes || []).concat([
{
code:'attendance-record',
path: '/attendance-record',
component: wrapper(() => async(require('./nodes/attendance-record'), 'kaoqin')),
}]);

“子项目”间是彼此隔离,要避免样式污染,要做独立的数据流管理,我们用项目作用域的方式来解决这些问题

项目作用域控制

window.app 主要功能:

1
2
3
4
5
6
7
let app = window.app || {};
app = {
require:function(request){...},
define:function(name,context,index){...},
routes:[...],
init:function(namespace,reducers){...}
};
  1. define 定义项目的公共库,主要用来解决JS公共库的管理问题
  2. require 引用自己的定义的基础库,配合define来使用
  3. routes 用于存放全局的路由,子项目路由添加到window.app.routes,用于完成路由的注册
  4. init 注册入口,为子项目添加上namesapce标识,注册上子项目管理数据流的reducers

子项目注册的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import reducers from './redux/kaoqin-reducer';
let app = window.app = window.app || {};
app.routes = (app.routes || []).concat([
{
code:'attendance-record',
path: '/attendance-record',
component: wrapper(() => async(require('./nodes/attendance-record'), 'kaoqin')),
// ... 其他路由
}]);

function wrapper(loadComponent) {
let React = null;
let Component = null;
let Wrapped = props => (
<div className="namespace-kaoqin">
<Component {...props} />
</div>
);
return async () => {
await window.app.init('namespace-kaoqin',reducers);
React = require('react');
Component = await loadComponent();
return Wrapped;
};
}
  1. 把路由添加到 window.app
  2. 业务第一次功能被调用的时候执行 window.app.init(namespace,reducers),注册项目作用域和数据流的 reducers
  3. 对业务功能的挂载节点包装一个根节点:Component 挂载在 classNamenamespace-kaoqindiv 下面

CSS作用域方面,使用webpack在构建阶段为业务的所有CSS都加上自己的作用域。然后在 init 函数中,在最外层 div 中添加该作用域 className

init 函数做了两件事:

  1. 挂载“子项目”的reducers,把“子项目”的数据流挂载了redux上
  2. “子项目”的弹出窗全部挂载在一个全局的div上,并为这个div添加对应的项目作用域,配合“子项目”构建的CSS,确保弹出框样式正确

公共库版本统一

“Portal项目”把公共库引入进来,重新定义,然后通过 window.app.require 的方式引用。

在编译“子项目”的时候,把引用公共库的代码从 require('react') 全部替换为 window.app.require('react')

构建发布

发布流程:

  1. 发布最新的静态资源文件
  2. 重新生成entry-xx.js和index.html(更新入口引用)
  3. 重启前端服务

参考