见内容
解决的问题
常见讨论
微前端的核心价值
观点:微前端的核心价值在于 “技术栈无关”
微前端的公司,基本上都是做 ToB 软件服务的,没有哪家 ToC 公司会有微前端的诉求,因为很少有 ToC 软件活得过 3 年以上的,如何给遗产项目续命,才是我们对微前端最开始的诉求。
微前端首先解决的,是如何解构巨石应用,解构之后还需要再重组,重组的过程中我们就会碰到各种 隔离性、依赖去重、通信、应用编排 等问题。
1 | 玉伯: |
「空间分离带来的协作问题」是在一个规模可观的应用的场景下会明显出现的问题,而「时间延续带来的升级维护」几乎是所有年龄超过 3 年的 web 应用都会存在的问题。
能力
我们希望,按照用户和使用场景将多个系统汇总成一个或者几个综合的系统.
将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。由此带来的变化是,这些前端应用可以独立运行、独立开发、独立部署。以及,它们应该可以在共享组件的同时进行并行开发——这些组件可以通过 NPM 或者 Git Tag、Git Submodule 来管理。
技术特点:
- 技术栈无关 主框架不限制接入应用的技术栈,子应用具备完全自主权
- 独立开发、独立部署 子应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
- 独立运行时 每个子应用之间状态隔离,运行时状态不共享
实现方案
将应用内的组件调用变成了更细粒度的应用间组件调用
- 原有:将路由分发到应用的组件执行
- 现有:路由来找到对应的应用,再由应用分发到对应的组件上
常见实现方案
- 使用 HTTP 服务器的路由来重定向多个应用
- 在不同的框架之上设计通讯、加载机制,诸如 Mooa 和 Single-SPA
- iFrame。使用 iFrame 及自定义消息传递机制
- 使用纯 Web Components 构建应用
- 结合 Web Components 构建
iframe
优点
比较容易实现
缺点
- 子项目需要改造,需要提供一组不带导航的功能
- iframe嵌入的显示区大小不容易控制,存在一定局限性
- URL的记录完全无效,页面刷新不能够被记忆,刷新会返回首页
- iframe功能之间的跳转是无效的
- iframe的样式显示、兼容性等都具有局限性
实现方式
在采用 iframe 的时候,我们需要做这么两件事:
- 设计管理应用机制
- 设计应用通讯机制
加载机制。在什么情况下,我们会去加载、卸载这些应用;在这个过程中,采用怎样的动画过渡,让用户看起来更加自然。
通过 iframeEl.contentWindow
去获取 iFrame 元素的 Window 对象是一个更简化的做法。随后,就需要定义一套通讯规范:事件名采用什么格式、什么时候开始监听事件等等。
纯 Web Components 技术构建
同一时刻可展示多个子应用。通常使用 Web Components 方案来做子应用封装,子应用更像是一个业务组件而不是应用。
暂时技术不成熟,兼容性不高
MPA + 路由分发
优点:
- 框架无关;2.
- 独立开发、部署、运行;
- 应用之间 100% 隔离。
缺点:
- 应用之间的彻底割裂导致复用困难。(比如,每个应用左侧和顶部都带有导航,那么, 当我要把该应用在其他系统中复用时,需要对该子应用的导航做较为复杂的改动) ;
- 每个独立的 SPA 应用加载时间较长,容易出现白屏,影响用户体验;
- 后续如果要做同屏多应用,不便于扩展。
基座式 SPA,主从应用设计
主应用捕获全局的路由事件,基于判断当前路由需要加载哪个子应用,然后 load 它。
路由这里有点不同,在类 Single-SPA 方案中,子应用在加载后,一般会由子应用去接管系统路由。而在基座式的方式中,子应用一般会把自己的路由注册到主应用中,并不接管系统路由。子应用更像是主应用的一个“路由模块”。
缺点:
首先基座就决定了它是框架强相关的,哪怕是基座的版本升级迭代,也会非常容易造成子应用 break change。
传统 SPA + 组件化(比如 Web Components) + 私有 npm 源
使用 npm 包的方案缺陷:需要不断地在库存模块的项目里调整代码,发布 npm,然后再用到库存模块的各个项目中,逐个更新依赖、构建、部署。
开源项目
Single-SPA
同一时刻,只有一个子应用被展示,子应用具备一个完整的应用生命周期。通常基于 url 的变化来做子应用的切换。
业内目前比较完善的开源框架,有以下几点能力:
- 在同一个页面中可以使用多种现有 Web 框架开发,如(React, AngularJS, Angular, Ember)
- 可以在现有的项目中,使用新的框架进行开发
- 加快加载速度,自动完成代码懒加载
single-spa
框架提供的是如何将不同的子应用集成到主应用中,并不关心子应用的代码如何拆分(一个仓库或多个仓库),如何构建、打包、发布等业务场景
主框架的定位则仅仅是:导航路由 + 资源加载框架。
概念
- Application(应用):可以理解为子应用,需要做到监听 URL 的变化,来在特定的 DOM 元素中 bootstrap(引导), mount(挂载), unmount(删除) 需要展示的元素
- single-spa-config:包含子应用的名字、代码、触发时机的声明
如何使用
如果搭建项目的时候,不是从头开始就使用 Single-SPA 的,则从已有的前端框架(Vue/Angular)迁移到 Single-SPA 需要一些适配成本
兼容性:Chrome, Firefox, Safari, IE11, and Edge.
single-spa-config
通过 single-spa-config
,来进行每个子应用的注册,以及 bootstrap
,mount
和 unmount
的触发。可以理解为,通过 single-spa-config
,来进行全局路由事件的广播。
子应用
子应用要做的事情,就是一个适配层。实现 Single-SPA 需要的函数,调用现有框架逻辑。
Single-SPA 需要的函数,类似于 Vue 的 created
、mounted
一样,要提供必选:bootstrap
,mount
和 unmount
;和可选:load
与 unload
除此以外,还支持 timeout。转场动画的支持比较简陋,可以参考 singlespa-transitions 这个仓库。
构建打包发布
single-spa
框架提供的是如何将不同的子应用集成到主应用中,并不关心子应用的代码如何拆分(一个仓库或多个仓库),如何构建、打包、发布等业务场景
下面是一些开源项目的实践:
公司实践
乾坤 - 蚂蚁金服
- MPA 方案的优点在于 部署简单、各应用之间硬隔离,天生具备技术栈无关、独立开发、独立部署的特性。缺点则也很明显,应用之间切换会造成浏览器重刷,由于产品域名之间相互跳转,流程体验上会存在断点。
- SPA 则天生具备体验上的优势,应用直接无刷新切换,能极大的保证多产品之间流程操作串联时的流程性。缺点则在于各应用技术栈之间是强耦合的。
这篇文章中讲解了一些技术栈选择的分析:
- 路由机制,什么时候交给子应用接管
- 通过构建时集成,还是运行时集成
- 运行时集成,使用 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 | import * as singleSpa from 'single-spa'; |
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 开始之前的阶段,确保应用对全局状态的污染全部清零。
对全局事件监听的劫持等,以确保应用在切出之后,对全局事件的监听能得到完整的卸载
网易严选
- 主应用负责应用加载与管理的同时来承载左侧和顶部导航栏。
- 不同的子应用,则展示在右侧区域。
运行时加载与路由策略
加载流程:
主应用是微前端框架的承载体,主要包含:
- 页面主体框架的渲染,比如一些通用的导航;
- 监听捕获全局的路由变化,加载 / 卸载子应用,active 标签等;
- 应用隔离、应用通信、数据共享等全局方法的载体。
子引用在被主应用启动后,会接管系统路由,与一个独立运行的应用没有本质区别。
子应用与子应用 JS 隔离
硬隔离:每个子应用加载之前,都进行一次 window reload
- 前端 snapshot + resume,快速恢复应用界面。当前已应用于生产环境。
- 主应用使用 SSR 局部直出,使页面在视觉效果上无刷新。
软隔离:应用加载之前做一次全局快照,在应用卸载之后,按快照恢复全局属性。
- 记住对全局变量的修改,解除应用时恢复原有值;
- 记住全局事件的修改,比如 window/document.addEventListener,卸载应用时 remove 事件;
- 记住 setTimeout 和 setIntervald 的修改,卸载应用时解除;
- 在加载子应用前创建一份 window snapshot ,卸载应用后按 snapshot 恢复全局方法(对象)和它们的原型链。
子应用与子应用 CSS 隔离
子应用加载时,标记该子应用所有的 link 和 style 文件。在子应用卸载后,同步卸载所有的 link 和 style 即可。
主应用与子应用隔离
都有一些思路,但是没有完美的解决方案
应用通信
封装了 Event 来进行跨应用的通信。Event 对象初始化后挂载在 Window 下,在全局以单例模式运行。
配置中心等相关配套设施
美团闪购
只讲和 Single-SPA 的实践区别吧
布局子系统
布局子系统是用来实现菜单和导航栏的Vue工程,本质上和一般的业务子系统没有区别。没太理解布局子系统,是不是指的主应用的侧边栏以及顶栏的布局?
同时发布布局的静态资源和NPM包。主系统通过NPM包的方式引入布局子系统,将它打包到项目中,避免线上运行时,额外加载布局子系统的资源,减小项目体积,加快渲染速度。
业务子系统 mounted
现有项目都是基于Vue技术栈开发,所以框架并不需要做到技术栈无关,只要满足Vue的项目即可。
所以 mounted
函数的实现方案,可以做到对业务代码的基本零侵入,原本子系统的初始化流程放到AppContainer对象的Mounted回调函数里即可。
构建完成之后生成一个包含子系统入口资源信息的配置文件。
1 | (callback) => callback({ |
配置文件是一个立即执行函数,主系统可以通过 JSONP的方式读取配置文件中的内容,解决了跨域的问题,子系统发布到任意CDN。
本地联调
只需要在开发时,模拟显示主系统的运行方式,去加载其他子系统的线上资源,之后就可以像调用后端API一样同各个子系统进行联调了。
全局状态
Bifrost的主系统会维护一个全局的Vuex Store,用于保存全局状态。
助Bifrost提供的 syncGlobalStore
函数来订阅全局Store。调用该函数后,任何全局状态的变化都会被同步到本地Store的Global命名空间下。
不太理解为什么有全局状态需要同步的场景。原作者也表示,在实际拆分子系统时,应该尽量避免这种情况发生。如果两个子系统之间需要频繁通信,那就应该考虑把他们划分到同一个子系统。
公共依赖
大家都是基于同样的组件库进行开发,如果不对公共依赖进行管理,项目会加载大量冗余代码。
采用的是Webpack External方式来解决。构建时,各个子系统会将公共依赖排除,主系统会打包一份包含所有这些公共依赖的DLL文件。子系统在运行时,直接从全局引用对应的依赖。
我们不会将Vue打到DLL文件中。因为在实际开发中,很多库都喜欢向Vue的原型链上挂载方法和属性。如果不同团队开发时挂载的内容恰好用到同一个字段,就会带来不可预知的影响。
模块复用
主系统采用Lerna的方式组织代码,各个子系统在开发时,可以通过软链直接引用到本地公共模块的代码,实现公共模块的复用。当公共模块发生更新,直接调用Lerna Publish就可以同时更新所有子系统package.json中依赖版本。
埋点及错误上报
可以借助主系统提供的一系列钩子函数实现针对子系统的埋点。
icestark - 阿里飞冰
路由监听
icestark 通过劫持 history.pushState 和 history.replaceState 两个 API,同时监听 popstate 事件,保证能够捕获到到所有路由变化。
子应用的 bundle 渲染到指定节点
icestark 为子应用提供了一个 getMountNode()
的 API 保证子应用能够渲染到指定的节点里。来屏蔽 getElementById
这种查找逻辑
从能力上来说可能没有太多区别,实现层上面可能有区别。
美团HR系统(比较早期的实践了)
HR系统最终线上运行的是一个单页应用,而项目开发中要求应用独立。把这个入口项目叫做“Portal项目”或“主项目”,业务应用叫做“子项目”
- “Portal项目”是比较特殊的,在开发阶段是一个容器,不包含任何业务,除了提供“子项目”注册、合并功能外,还可以提供一些系统级公共支持,例如: 用户登录机制 菜单权限获取 全局异常处理 全局数据打点
- “子项目”对外输出不需要入口HTML页面,只需要输出的资源文件即可
网络请求
看起来,在 Portal 项目中,同一封装 & 提供了唯一的网络请求库,请求访问的后端域名都是一个域名,根据不同的 Path 转发到不同的服务。不同的服务,在接入 HR 系统之前,需要先去 Nginx 上配置转发规则
应用注册机制
路由注册
“子项目”的路由应该由自己控制,而整个系统的导航是“Portal项目”提供
“Portal项目”从 window.app.routes
获取路由,“子项目”把自己需要注册的路由添加到 window.app.routes
中
1 | let app = window.app = window.app || {}; |
“子项目”间是彼此隔离,要避免样式污染,要做独立的数据流管理,我们用项目作用域的方式来解决这些问题
项目作用域控制
window.app
主要功能:
1 | let app = window.app || {}; |
define
定义项目的公共库,主要用来解决JS公共库的管理问题require
引用自己的定义的基础库,配合define来使用routes
用于存放全局的路由,子项目路由添加到window.app.routes,用于完成路由的注册init
注册入口,为子项目添加上namesapce标识,注册上子项目管理数据流的reducers
子项目注册的源码:
1 | import reducers from './redux/kaoqin-reducer'; |
- 把路由添加到
window.app
中 - 业务第一次功能被调用的时候执行
window.app.init(namespace,reducers)
,注册项目作用域和数据流的reducers
- 对业务功能的挂载节点包装一个根节点:
Component
挂载在className
为namespace-kaoqin
的div
下面
CSS作用域方面,使用webpack在构建阶段为业务的所有CSS都加上自己的作用域。然后在 init
函数中,在最外层 div
中添加该作用域 className
init
函数做了两件事:
- 挂载“子项目”的reducers,把“子项目”的数据流挂载了redux上
- “子项目”的弹出窗全部挂载在一个全局的div上,并为这个div添加对应的项目作用域,配合“子项目”构建的CSS,确保弹出框样式正确
公共库版本统一
“Portal项目”把公共库引入进来,重新定义,然后通过 window.app.require
的方式引用。
在编译“子项目”的时候,把引用公共库的代码从 require('react')
全部替换为 window.app.require('react')
构建发布
发布流程:
- 发布最新的静态资源文件
- 重新生成entry-xx.js和index.html(更新入口引用)
- 重启前端服务