微前端实践到落地

1,231 阅读34分钟

从项目出发


image.png

项目迭代就像搭积木 (组件或者物料堆)

某一个积木拼错了 (功能出现bug)

增加一个新功能 (最小的成本 增加功能 ) (高内聚,低耦合)

其他人也想要使用这个部件 (可测试性) (如a 物料 使用到 b 物料里)

其他人的部件怎么添加到我的这个积木里面

要搬家了,这个大家伙怎么带过去 (增量迭代,渐进式开发)

高内聚,低耦合

在设计各个子应用及主应用的过程中,需要遵循高内聚、低耦合的原则。

高内聚,即模块内的关系,一个软件模块只由相关性很强的代码组成。
设计的过程就是识别、度量模块内的联系,再将相关的行为聚集在一起,把不相关的行为放在别处。如果想要修改模块中某一部分的行为,只需要修改一处代码即可,而不需要修改多处。在实践的过程中,主要基于单一职责和关注点分离两个原则来实现。
低耦合,即模块间的关系。对于微架构系统来说,在服务之间、应用之间如果实现了松耦合,那么修改一个服务和应用就不需要修改另一个服务和应用。除了基座应用,每个应用都不应该关心协作应用的相关信息。
这两个词说起来很简单,但是真正在实现的过程中却相当麻烦。应用之间往往存在一定的依赖关系,要解耦这些依赖,就需要将数据独立出来,并使用通信的方式来传递状态。

在实际项目上遇到两个实际的场景

场景一:

如果我们的项目需要开发某个新的功能,而这个功能另一个项目已经开发好,我们想直接复用时。PS:我们需要的只是别人项目的这个功能页面的**内容部分,**不需要别人项目的顶部导航和菜单。

往往都会想到是 一个比较笨的办法就是直接把别人项目这个页面的代码拷贝过来,但是万一别人不是 vue开发的,或者说 vue版本、 UI
库等不同,以及别人的页面加载之前操作(路由拦截,鉴权等)我们都需要拷贝过来,更重要的问题是,别人代码有更新,我们如何做到同步更新。

长远来看,代码拷贝不太可行,问题的根本就是,我们需要做到让他们的代码运行在他们自己的环境之上,而我们对他们的页面仅仅是“引用”。这个环境包括各种插件( vue、 vuex、 vue-router等),也包括加载前的逻辑(读 cookie,鉴权,路由拦截等)。私有 npm
可以共享组件,但是依然存在技术栈不同/UI库不同等问题。

场景二:

2、巨无霸项目的自由拆分组合

  • 代码越来越多,打包越来越慢,部署升级麻烦,一些插件的升级和公共组件的修改需要考虑的更多,很容易牵一发而动全身
  • 项目太大,参与人员越多,代码规范比较难管理,代码冲突也频繁。
  • 产品功能齐全,但是客户往往只需要其中的部分功能。剥离不需要的代码后,需要独立制定版本,独立维护,增加人力成本。


举个栗子,你们的产品有几百个页面,功能齐全且强大,客户只需要其中的部分页面,而且需要你们提供源码,这时候把所有代码都给出去肯定是不可能的,只能挑出来客户需要,这部分代码需要另外制定版本维护,就很浪费。

什么是【微前端】


背景:

  • 前端应用越来越复杂


导致:

  1. 人力成本压力


线上出现问题,怎么快速定位问题
    

  1. 维护成本高
  2. 迭代成本高
  3. 需求变更影响范围大
  4. 持续化投入产出比不足


所以:

  • 面对这种情况 【后端】是如何处理的? (后端微服务)


解决:

  • 重新洗牌 (先拆后合)-> 重构


优点:

  1. 隔离(服务、IDC) IDC:机房
  2. 弹性/扩展性 (如:扩容)
  3. 增强稳定性
  4. 降低成本 (人力、上线、回归、需求)
  5. ...


主要事项:

  1. 测试
  2. 部署
  3. 服务拆分标准
  4. 过于 分散/密集

解读微前端


那什么是微前端?微前端主要是借鉴后端微服务的概念。简单地说,就是将一个巨无霸(Monolith)的前端工程拆分成一个一个的小工程。别小看这些小工程,它们也是“麻雀虽小,五脏俱全”,完全具备独立的开发、运行能力。整个系统就将由这些小工程协同合作,实现所有页面的展示与交互。




可以跟微服务这么对比着去理解:

微服务微前端
一个微服务就是由一组接口构成,接口地址一般是 URL。当微服务收到一个接口的请求时,会进行路由找到相应的逻辑,输出响应内容。一个微前端则是由一组页面构成,页面地址也是 URL。当微前端收到一个页面 URL 的请求时,会进行路由找到相应的组件,渲染页面内容。
后端微服务会有一个网关,作为单一入口接收所有的客户端接口请求,根据接口 URL 与服务的匹配关系,路由到对应的服务。微前端则会有一个加载器,作为单一入口接收所有页面 URL 的访问,根据页面 URL 与微前端的匹配关系,选择加载对应的微前端,由该微前端进行进行路由响应 URL。


这里要注意跟 iframe 实现页面嵌入机制的区别。微前端没有用到 iframe,它很纯粹地利用 JavaScript、MVVM 等技术来实现页面加载。后面我们将介绍相关的技术实现。

微前端本质是是一种项目架构方案,是为了解决前端项目太过庞大,导致项目管理维护难、团队协作乱、升级迭代困难、技术栈不统一等等问题

微前端特点


1、应用自治

微前端架构,是多个应用组件的统一应用,这些应用可以交由多个团队来开发。要遵循统一的接口规范或者框架,以便于系统集成到一起,因此相互之间是不存在依赖关         系的。我们可以在适当的时候,替换其中任意一个前端应用,而整体不受影响。这也意味着,我们可以使用各式各样的前端框架,而不会互相影响。

2、单一职责

与微服务类似的是,微前端架构理应满足单一职责的原则。然而,微前端架构要实现单一职责,并非那么容易。前端面向最终用户,前端需要保证用户体验的连续性。一旦在业务上关联密切,如B页面依赖A页面,A页面又在一定的程度上依赖B页面,拆分开来就没有那么容易。但是如果业务关联少,如一些关于“我们的联系方式”等的页面,使用不多,没有多少难度。因此,一旦面临用户体验的挑战,就要考虑选择其他方式。

3、技术栈无关

在后端微服务的架构中,技术栈无关是一个相当重要的特性。后端可以选用合适的语言和框架来开发最合适的服务,服务之间使用API进行通信即可。但是对于微前端架构来说,虽然拥有一系列的JavaScript语言,但是前端框架是有限的,即使在某个微前端架构里实现了框架无关,也并不是那么重要。框架之间的差距并不大,一个框架能做的事情,另一个框架也能做,这一点便不如后端。使用Java解决不了的人工智能部分,可以交给Python;如果觉得Java烦琐,可以使用Scala,如图所示
image.png
对大部分公司和团队来说,技术无关只是一个无关痛痒的话术。如果一家公司的几个创始人使用了Java,那么极有可能在未来的选型上继续使用Java。对于前端框架来说也是相似的,如果我们选定Vue,除非出现新的框架来解决vue框架的问题,否则大概率继续使用原有的框架——毕竟已经拥有大量成熟的基础设施。

微前端缺点


凡事都有两面性,没有任何一个技术方案是完美的,在解决某些问题的情况下,总会有不同的弊端出现,我们要做的就是在不同的方案面前,去衡量哪一种方案是解决我们当前产品最好的办法

有效的负载变大

微前端 的概念,使我们的每一个功能,成为了一个独立的 应用 ,这样会导致我们的依赖项出现大量的重复,对于用户来说,是需要做多次无意义的资源下载,这样其实是不友好的,但是针对该问题,目前有一种方案就是重复的依赖构建独立的资源包进行下载,这样好像是可以避免掉重复性资源的加载问题,但随之而来的两个不可避免的问题就出现了:

  • a页面 可能不需要 b页面 的相应资源,但是 c页面 需要,我们又不能针对这种依赖关系做详细的依赖切分,这样其实对于我们整体的工作而言,是一个很重的任务量,每一个项目都不止一个依赖包,我们不可能花很多的时间去细分每一个依赖包针对当前功能的依赖关系,这是一个非常沉重的工作量
  • a页面 可能使用的是 react 16.7 之前的版本,ui框架 antd 使用的也是 3.x 的,这里面是没有 hooks 的;b页面 使用的是 react 16.7 之后的版本,这时候支持 hooks ,但是 antd 还没有做更新,只能只用 3.x 版本;c页面 使用的也是 react 16.7 以后的版本,但是 antd 更新 4.x ,它支持 hooks;这里可能会有人问,为什么不直接使用 react 16.7版本, antd 4.x 不就行了,何必这么复杂?我们的需求不是一下子完成的,框架也是需要不断的优化实现不管开发效率还是用户体验方面的提升的,版本的控制就是我们需要面对的最大的一个问题,我们不能在同一个公共依赖包里面去做所有版本的兼容问题,这是个不现实的事情

如此来看,好像独立的资源加载好似是最好的办法,可以避免这样的问题,但是有效的负载变大存在的问题还是没有解决,其实独立的资源加载并不是错的,但是它也不是对的,只是我们一直在这个对和错之间,寻找一个边界,其实对和错是不难区分的,难区分的就是它们之间的这个边界问题,如果区分对错的边界,需要耗费我们区分对错更多的成本,那么其实当前的状态,可能就是最好的
当然不是鼓励大家不去更精细化的区分对和错的更深层次的边界,一个中小型企业,时间成本很重要,这种边界的探测对于公司的发展来说起不到什么比较有价值的作用,反而会耽误整体的发展;反之如果是一个专门研究这方面技术的,技术协会或者大型公司的技术团体,我还是很鼓励大家做更深层次的研究的,毕竟物尽其用,人尽其才

管理的复杂性

这一块其实内容就很好说明了,管理的复杂性指的很多方面,业务方面、和开发方面等:

  • 业务方面的划分,虽然说做到了更多的细节的把控,人员的目光和方向会更精确一点,但是带来的问题就是,业务的广泛性带来的成本也会很高,老人对业务扩展的创新性是否真的能直达用户内心、新人对业务方向的熟悉性是否能符合整个产品的发展方向,这些都是在不断的业务细分的场景下会出现的问题,有的时候细不一定是好,虽然说这样可以考虑到很多的点,但是很容易因为业务的细分导致大家的目光只盯在一个点上,这样其实对于一整个产品的发展并不友好,因为有可能产品和开发讨论了很久的一个对与错的细节问题,在用户看来,并不重要,尤其是对于业务在快速增长阶段的产品来说,这更是一个噩耗
  • 开发方面的划分,就是一个很明显的、繁琐的一个过程,微前端带给我们的好处上面已经说的很清楚了,但是根据每一个微应用去做划分的同时,带来的就是一个需要合理规划维护方式的技术难题:
    • 代码库的数量增加,是否有更好的办法去维护每一个版本的更新(CI是一个目前来说可以解决该问题毕竟)
    • 每一个微应用,可能都是一个单独的仓库,那么一整个团队应该如何管理这些微应用,是否需要一定的规范性去约束每一个项目,还是天马行空的发挥各自的想象
    • 假设有相同的功能点,我们有一套独立的微应用组件,那么这个开发工作应该如何划分,对于组件 api 的支持应该如何去制定


举个例子:七某云平台

本质上应该就是一个微前端应用,左侧的菜单就是各个子应用的入口,切换菜单的同时就是在切换子应用,而整个主容器就是一个portal门户(可能包含用户登录机制 、菜单权限获取 、 全局异常处理等)

iframe

iframe 作为一个非常 “古老” 的,人人都觉得普通的技术,却一直很管用。它能有效地将另一个网页/单页面应用嵌入到当前页面中,两个页面间的 CSS 和 JavaScript 是相互隔离的——除去 iframe 父子通信部分的代码,它们之间的代码是完全不相互干扰的。iframe 便相当于是创建了一个全新的独立的宿主环境,类似于沙箱隔离,它意味着前端应用之间可以相互独立运行。
iframe 确实挺好用的,但是也存在一些缺点:

  • 子项目需要改造,需要提供一组不带导航的功能
  • iframe 嵌入的显示区大小不容易控制,存在一定局限性
  • URL 的记录完全无效,页面刷新不能够被记忆,刷新会返回首页
  • iframe 功能之间的跳转是无效的
  • iframe 的样式显示、兼容性等都具有局限性
  • iframe 方式的应用之间通信和调试比较麻烦,存在不可控性风险
  • iframe 的优点是简单易用,缺点是用户体验差,应用之间通信困难。我们在微前端架构方案中,首选方案并不是 iframe。但是在接入第三方应用时,iframe 依旧是首选方案。


微前端场景分析

iframesingle-spa.js其他
缺点
1. 结构冗余 (嵌套处理)
1. 事件通讯繁琐
1. 只能处理视图相关服务
1. 操作反馈复杂
1. 其他更多的缺点

1. 只有app级别的隔离
1. 没有统一的服务规范
1. 使用了system.js
1. 对业务侵入性太强
目标:
吸收single-spa的优点
改正single-spa的缺点

为什么需要微前端?

思考


结合别人实际场景

在介绍具体的改造方式之前,我想跟大家先说明下我们当时面临的问题,以及改造后的对比,以便大家以此为对照,评判或决定使用。主要包括打包速度、页面加载速度、多人多地协作、SaaS 产品定制化、产品拆分这几个角度。

首先是打包速度。在 6 个月前,我们的 B 端工程那会儿还是一个 Monolith。当时已经有 20 多个依赖、60 多个公共组件、200 多个页面,对接 700 多个接口。我们使用了 Webpack 2,并启用 DLL Plugin、HappyPack 4。在我的个人主机上使用 4 线程编译,大概要 5 分钟。而如果不拆分,算下来现在我们已经有近 400 个页面,对接1000 多个接口。 这个时间意味着什么?它不仅会耽误我们开发人员的时间,还会影响整个团队的效率。上线时,在 Docker、CI 等环境下,耗时还会被延长。如果部署后出几个 Bug,要线上立即修复,那就不知道要熬到几点了。 在使用微前端改造后,目前我们已经有 26 个微前端工程,平均打包时间在 30-45 秒之间(注意,这里还没有应用 DLL + HappyPack)。 页面加载速度其实影响到并不是很大,因为经过 CDN、gzip 后,资源的大小还能接受。这里只是给大家看一些直观的数据变化。6 个月前,打包生成的 app.js 有 5MB(gzip 后 1MB),vendor.js 有 2MB(gzip 后 700KB),app.css 有 1.5MB(gzip 后 250KB)。这样首屏大概要传输 2MB 的内容。拆分后,目前首屏只需要传输 800KB 左右。(优化)

在协作上,我们在全国有三个地方的前端团队,这么多人在同一个工程里开发,遭遇代码冲突的概率会很频繁,而且冲突的影响面比较大。如果代码中出现问题,导致 CI 失败,所有其他人的代码提交与更新也都会被阻塞。使用微前端后,这样的风险就平摊到各个工程上去了。(协作)

再者就是定制化了。我们做的额是一款 toB 的产品,做成 SaaS 标准版产品大概是所有从业者的愿望。但整体市场环境与产品功能所限,经常要面临一些客户要求做本地化与定制化的要求。本地化就会有代码安全方面的考量,最好是不给客户源代码,最差则是只给客户购买功能的源代码。而定制化从易到难则可以分为独立新模块、改造现有模块、替换现有模块。(拆、合)

通过微前端技术,我们可以很容易达到本地化代码安全的下限——只给客户他所购买的模块的前端源码。定制化里最简单的独立新模块也变得简单:交付团队增加一个新的微前端工程即可,不需要揉进现有研发工程中,不占用研发团队资源。而定制化中的改造现有模块也可以比较好地实现:比如说某个标准版的页面中需要增加一个面板,则可以通过一个新的微前端工程,同样响应该页面的 URL(当然要控制好顺序),在页面的恰当位置插入一个新的 DOM 节点即可。

最后就是产品拆分方面的考量了。我们的产品比较大,有几块功能比较独立、有特色。如果说将来需要独立成一个子产品,有微前端拆分作为铺垫,腾挪组合也会变得更加容易些。


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

我们通过3W(what,why,how)的方式来讲解微前端

What?什么是微前端?


微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用来加载这些子应用。
微前端的核心在于, 拆完后在!

Why?为什么去使用他?

  • 不同团队间开发同一个应用技术栈不同怎么破?
  • 希望每个团队都可以独立开发,独立部署怎么破?
  • 项目中还需要老的应用代码怎么破?


我们是不是可以将一个应用划分成若干个子应用,将子应用打包成一个个的lib。当路径切换时加载不同的子应用。这样每个子应用都是独立的,技术栈也不用做限制了!从而解决了前端协同开发问题

How?怎样落地微前端?


2018年 Single-SPA诞生了, single-spa是一个用于前端微服务化的JavaScript前端解决方案 (本身没有处理样式隔离,js执行隔离) 实现了路由劫持和应用加载
2019年 qiankun基于Single-SPA, 提供了更加开箱即用的 APIsingle-spa + sandbox + import-html-entry) 做到了,技术栈无关、并且接入简单(像iframe一样简单)

总结:子应用可以独立构建,运行时动态加载,主子应用完全解耦,技术栈无关,靠的是协议接入(子应用必须导出 bootstrap、mount、unmount方法)

这里先回答大家肯定会问的问题:

这不是iframe吗?

  • 如果使用iframeiframe中的子应用切换路由时用户刷新页面就尴尬了。

应用通信:

  • 基于URL来进行数据传递,但是传递消息能力弱
  • 基于CustomEvent实现通信
  • 基于props主子应用间通信
  • 使用全局变量、Redux进行通信

公共依赖:

  • CDN - externals
  • webpack联邦模块

实施微前端的六种方式


自己在主导新技术落地,需要考虑:
无论是对于从头开发的微前端应用,还是正在迁移的微前端应用,这种架构的演进都需要一个实施时间。出于以下目的,我们需要快速“发布”MRV版本(最小可发布版本)的微前端架构应用:
◎ 架构在项目中的可行性验证。
◎ 向领导和团队证明架构的可能性。
◎ 增强团队对于新技术的信心。

将那个最小的、成本最低的应用迁移到微前端架构中。

《前端架构从入门到微前端》一书中,将微前端的实施分为六种:

1、路由分发

路由分发式微前端,即通过路由将不同的业务分发到不同的、独立前端应用上。其通常可以通过 HTTP 服务器的反向代理来实现,又或者是应用框架自带的路由来解决。如图:

2、前端微服务化

前端微服务化,是微服务架构在前端的实施,每个前端应用都是完全独立(技术栈、开发、部署、构建独立)、自主运行的,最后通过模块化的方式组合出完成的应用。
采用这种方式意味着,一个页面上可以同时存在两个以上的前端应用在运行。如图:

目前主流的框架有 Single-SPAqiankunMooa,后两者都是基于 Single-SPA 的封装。

用基座化的方式来加载其他应用。基座化方式可以支持加载不同的前端框架,以及在基座工程上绑定业务逻辑。

3、微应用

微应用化是指在开发时应用都是以单一、微小应用的形式存在的,而在运行时,则是通过构建系统合并这些应用,并组合成一个新的应用。
微应用化大都是以软件工程的方式来完成前端应用的聚合,因此又可以称之为组合式集成
微应用化只能使用唯一的一种前端框架。
如图:

4、组件化微前端:微件化

微件化(Widget)是一段可以直接嵌入应用上运行的代码,它由开发人员预先编译好,在加载时不需要再做任何修改或编译。微前端下的微件化是指,每个业务团第编写自己的业务代码,并将编译好的代码部署到指定的服务器上,运行时只需要加载指定的代码即可。
如图:

5、iframe

iFrame 作为一个非常古老的,人人都觉得普通的技术,却一直很管用。

HTML 内联框架元素 <iframe> 表示嵌套的正在浏览的上下文,能有效地将另一个 HTML 页面嵌入到当前页面中。

iframe 可以创建一个全新的独立的宿主环境,这意味着我们的前端应用之间可以相互独立运行。采用 iframe 有几个重要的前提:

  • 网站不需要 SEO 支持
  • 拥有相应的应用管理机制。

在很多业务场景下,难免会遇到一些难以解决的问题,那么可以引入 iframe 来解决。

6、Web Components

Web Components 是一套不同的技术,允许开发者创建可重用的定制元素(它们的功能封装在代码之外)并且在您 Web 应用中使用它们。
在真正的项目上使用 Web Components 技术,离现在还有一些距离,结合 Web Components 来构建前端应用,是一种面向未来演进的架构。或者说在未来可以采用这种方式来构建应用。
如图:

在真实的业务场景中,往往是上面提到六种方式中的几种的结合使用,或者是某种方式的变种。下面看我遇到的真实场景。

看原型 ->

微前端的业务划分方式


与微服务类似,要划分不同的前端边界不是一件容易的事。就当前而言,以下几种方式是常见的划分微前端的方式:

◎ 按照业务拆分。
◎ 按照权限拆分。
◎ 按照变更的频率拆分。
◎ 按照组织结构拆分。
◎ 跟随后端微服务划分。

who use qiankun


蚂蚁金服 tech.antfin.com/
ZStack——国内领先的私有云计算公司 www.zstack.io/product/zst…
百应 www.byai.com/

带你手写微前端框架

在了解其他微前端框架前,可以先了解微前端底层。

可以借鉴 www.yuque.com/web-develop…
通讯:插件与插件 不能直接通讯,通过系统核心,发布订阅
拆分:按功能模块拆分
公共物料:可配置物料

微前端的核心为app,微前端的场景主要是:将应用拆分为多个app加载,或将多个不同的应用当成app组合在一起加载。


将应用拆分为多个app加载,或将多个不同的应用当成app组合在一起加载 ,

第一种:可以按功能模块拆分,每个功能是个应用,不限你选择的技术框架
第二种:多个不同项目整合成一个项目,类似 企业的信息门户

image.png

location 拦截
状态
生命周期 ,
falatten 超时

vue-single-spa

基于vuejs的微前端demo项目。内含 vuejs,react,angular 三个子项目
gitee.com/Janlaywss/v…

微前端的 8 个问题

  1. 我们如何实现在一个页面里渲染多种技术栈?
  2. 不同技术栈的独立模块之间如何通讯?
  3. 如何通过路由渲染到正确的模块?
  4. 在不同技术栈之间的路由该如何正确触发?
  5. 项目代码别切割之后,通过何种方式合并到一起?
  6. 我们的每一个模块项目如何打包?
  7. 前端微服务化后我们该如何编写我们的代码?
  8. 独立团队之间该如何协作?

qiankun 实战



export const NOT_LOADED = "NOT_LOADED"; // 没有加载过
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 加载原代码
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 没有启动
export const BOOTSTRAPPING = "BOOTSTRAPPING"; // 启动中
export const NOT_MOUNTED = "NOT_MOUNTED"; // 没有挂载
export const MOUNTING = "MOUNTING"; // 挂载中
export const MOUNTED = "MOUNTED"; // 挂载完毕
export const UPDATING = "UPDATING"; // 更新中
export const UNMOUNTING = "UNMOUNTING"; // 卸载中
export const UNLOADING = "UNLOADING"; // 没有加载中
export const LOAD_ERROR = "LOAD_ERROR"; // 加载失败
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN"; // 运行出错
  "scripts": {
    "install": "npm-run-all --serial install:*",
    "install:main": "cd main && npm i",
    "install:sub-vue": "cd sub-vue && npm i",
    "install:sub-react": "cd sub-react && npm i",
    "start": "npm-run-all --parallel start:*",
    "start:sub-react": "cd sub-react && npm start",
    "start:sub-vue": "cd sub-vue && npm start",
    "start:main": "cd main && npm start",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

npm-run-all 提供了多种运行多个命令的方式,常用的有以下几个:

--parallel: 并行运行多个命令,例如:npm-run-all --parallel lint build
--serial: 多个命令按排列顺序执行,例如:npm-run-all --serial clean lint build:**
--continue-on-error: 是否忽略错误,添加此参数 npm-run-all 会自动退出出错的命令,继续运行正常的
--race: 添加此参数之后,只要有一个命令运行出错,那么 npm-run-all 就会结束掉全部的命令

在切换到react 子应用的时候报错:

Uncaught Error: application 'sub-react' died in status LOADING_SOURCE_CODE: [qiankun] You need to export lifecycle functions in sub-react entry

项目架构


参考官方的examples代码,项目根目录下有基座main和其他子应用sub-vuesub-react,搭建后的初始目录结构如下:

├── common     //公共模块
├── main       // 基座
├── sub-react  // react子应用
└── sub-vue    // vue子应用

基座是用vue搭建,子应用有reactvue

基座配置

基座main采用是的Vue-Cli3搭建的,它只负责导航的渲染和登录态的下发,为子应用提供一个挂载的容器div,基座应该保持简洁(qiankun官方demo甚至直接使用原生html搭建),不应该做涉及业务的操作。
qiankun这个库只需要在基座引入,在main.js中注册子应用,为了方便管理,我们将子应用的配置都放在:main/src/micro-app.js下。

const microApps = [
  {
    name: 'sub-vue',
    entry: '//localhost:7777/',
    activeRule: '/sub-vue',
    container: '#subapp-viewport', // 子应用挂载的div
    props: {
      routerBase: '/sub-vue' // 下发路由给子应用,子应用根据该值去定义qiankun环境下的路由
    }
  },
  {
    name: 'sub-react',
    entry: '//localhost:7788/',
    activeRule: '/sub-react',
    container: '#subapp-viewport', // 子应用挂载的div
    props: {
      routerBase: '/sub-react'
    }
  }
]
export default microApps

然后在src/main.js中引入

import Vue from 'vue';
import App from './App.vue';
import { registerMicroApps, start } from 'qiankun';
import microApps from './micro-app';
Vue.config.productionTip = false;
new Vue({
  render: h => h(App),
}).$mount('#app');
registerMicroApps(microApps, {
  beforeLoad: app => {
    console.log('before load app.name====>>>>>', app.name)
  },
  beforeMount: [
    app => {
      console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name);
    },
  ],
  afterMount: [
    app => {
      console.log('[LifeCycle] after mount %c%s', 'color: green;', app.name);
    }
  ],
  afterUnmount: [
    app => {
      console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name);
    },
  ],
});
start();

App.vue中,需要声明micro-app.js配置的子应用挂载div(注意id一定要一致),以及基座布局相关的,大概这样:

<template>
  <div id="layout-wrapper">
    <div class="layout-header">头部导航</div>
    <div id="subapp-viewport"></div>
  </div>
</template>

这样,基座就算配置完成了。项目启动后,子应用将会挂载到<div id="subapp-viewport"></div>中。

当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑,所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。

官方建议:子应用建议使用 history 模式的路由,需要设置路由 base,值和它的 activeRule 是一样的。


### 子应用配置 #### 一、vue子应用 用Vue-cli在项目根目录新建一个`sub-vue`的子应用,子应用的名称最好与父应用在`src/micro-app.js`中配置的名称一致(这样可以直接使用`package.json`中的`name`作为output)。
  1. 新增vue.config.js,devServer的端口改为与主应用配置的一致,且加上跨域headersoutput配置。
// package.json的name需注意与主应用一致
const { packageName } = require("../package.json");

module.exports = {
  publicPath: "/subapp/sub-vue",
  transpileDependencies: ["common"],
  chainWebpack: config => config.resolve.symlinks(false),
  configureWebpack: {
    output: {
      // 把子应用打包成 umd 库格式
      library: `${packageName}-[name]`,
      libraryTarget: "umd", // 把微应用打包成 umd 库格式
      jsonpFunction: `webpackJsonp_${packageName}`
    }
  },
  lintOnSave: process.env.NODE_ENV === "development",
  devServer: {
    port: process.env.VUE_APP_PORT,
    headers: {
      "Access-Control-Allow-Origin": "*"
    },
    overlay: {
      warnings: false,
      errors: true
    }
  }
};

  1. 新增src/public-path.js
(function() {
  if (window.__POWERED_BY_QIANKUN__) {
    if (process.env.NODE_ENV === 'development') {
      // eslint-disable-next-line no-undef
      __webpack_public_path__ = `//localhost:${process.env.VUE_APP_PORT}/`;
      return;
    }
    // eslint-disable-next-line no-undef
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
  }
})();
  1. src/router/index.js改为只暴露routes,new Router改到main.js中声明。
  2. 改造main.js,引入上面的public-path.js,改写render,添加生命周期函数等,最终如下:
import './public-path' // 注意需要引入public-path
import Vue from 'vue'
import App from './App.vue'
import routes from './router'
import store from './store'
import VueRouter from 'vue-router'
Vue.config.productionTip = false
let instance = null
function render (props = {}) {
  const { container, routerBase } = props
  const router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? routerBase : process.env.BASE_URL,
    mode: 'history',
    routes
  })
  instance = new Vue({
    router,
    store,
    render: (h) => h(App)
  }).$mount(container ? container.querySelector('#app') : '#app')
}
if (!window.__POWERED_BY_QIANKUN__) {
  render()
}
export async function bootstrap () {
  console.log('[vue] vue app bootstraped')
}
export async function mount (props) {
  console.log('[vue] props from main framework', props)
  render(props)
}
export async function unmount () {
  instance.$destroy()
  instance.$el.innerHTML = ''
  instance = null
}

至此,基础版本的vue子应用配置好了,如果routervuex不需用到,可以去掉。

二、react子应用

  1. 通过npx create-react-app sub-react新建一个react应用。
  2. 新增.env文件添加PORT变量,端口号与父应用配置的保持一致。
  3. 为了不eject所有webpack配置,我们用react-app-rewired方案复写webpack就可以了。
  • 首先npm install react-app-rewired --save-dev
  • 新建sub-react/config-overrides.js
const {
  packageName
} = require('./package.json');

module.exports = {
  webpack: function override(config, env) {
    // 解决主应用接入后会挂掉的问题:https://github.com/umijs/qiankun/issues/340
    config.entry = config.entry.filter(
      (e) => !e.includes('webpackHotDevClient')
    );

    config.output.library = `${packageName}-[packageName]`;
    config.output.libraryTarget = 'umd';
    config.output.jsonpFunction = `webpackJsonp_${packageName}`;
    return config;
  },
  devServer: (configFunction) => {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      config.open = false;
      config.hot = false;
      config.headers = {
        'Access-Control-Allow-Origin': '*',
      };
      // Return your customised Webpack Development Server config.
      return config;
    };
  },
};
  1. 新增src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
  1. 改造index.js,引入public-path.js,添加生命周期函数等。
import './public-path'
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
function render() {
  ReactDOM.render(
    <App />,
    document.getElementById('root')
  );
}
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}
/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log('react app bootstraped');
}
/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  console.log(props);
  render();
}
/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount() {
  ReactDOM.unmountComponentAtNode(document.getElementById('root'));
}
/**
 * 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
 */
export async function update(props) {
  console.log('update props', props);
}
serviceWorker.unregister();

至此,基础版本的react子应用配置好了。

进阶


全局状态管理

qiankun通过 initGlobalState, onGlobalStateChange, setGlobalState 实现主应用的全局状态管理,然后默认会通过props将通信方法传递给子应用。先看下官方的示例用法:qiankun.umijs.org/zh/api#init…
主应用:

// main/src/main.js
import { initGlobalState } from 'qiankun';

// 初始化 state
const initialState = {
  user: {} // 用户信息
};

const actions = initGlobalState(initialState);
actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();

子应用:

js沙箱


js 沙箱的原理是子项目加载之前,对 window 对象做一个快照,子项目卸载时恢复这个快照,如图:

那么如何监测 window 对象的变化呢,直接将 window 对象进行一下深拷贝,然后深度对比各个属性显然可行性不高,qiankun框架采用的是ES6新特性,proxy代理方法。
但是 proxy 是不兼容 IE11 的,为了兼容,低版本 IE 采用了 diff 方法:浅拷贝 window 对象,然后对比每一个属性。

**在多应用场景下,每个微应用的沙箱都是相互隔离的,也就是说每个微应用对全局的影响都会局限在微应用自己的作用域内。**比如 A 应用在 window 上新增了个属性 test,这个属性只能在 A 应用自己的作用域通过 window.test 获取到,主应用或者其他微应用都无法拿到这个变量。

但是注意,页面上不能同时显示多个依赖于路由的微应用,因为浏览器只有一个 url,如果有多个依赖路由的微应用同时被激活,那么大概率会导致其中一个 404。

兼容 IE11 的沙箱能力

在 qiankun issue 区域呼声最高的就是 IE 的兼容,有不少小伙伴都期待 qiankun 能够在 IE 下使用。
qiankun 1.x 在 IE 使用的主要阻碍就是 qiankun 的沙箱使用了 ES6 的 Proxy,而这无法通过 ployfill 等方式弥补。这导致 IE 下的 qiankun 用户无法开启 qiankun 的沙箱功能,导致 js 隔离、样式隔离这些能力都无法启用。
为此,我们实现了一个 IE 特供的快照沙箱,用于这些不支持 Proxy 的浏览器;这不需要用户手动开启,在代理沙箱不支持的环境中,我们会自动降级到快照沙箱。

注意,由于快照沙箱不能做到互相之间的完全独立,所以 IE 等环境下我们不支持多应用场景, > singlur 会被强制设为 true。

基于 shadow DOM 的样式隔离

样式隔离也是微前端面临的一个重要问题,在 qiankun@1.x 中,我们支持了微应用之间的样式隔离(仅沙箱开启时生效),这尚存一些问题:

  1. 主子应用之间的样式隔离依赖手动配置插件处理
  2. 多应用场景下微应用之间的样式隔离亟待处理

为此,我们引入了一个新的选项, sandbox: { strictStyleIsolation?: boolean }
在该选项开启的情况下,我们会以 Shadow DOM 的形式嵌入微应用,以此来做到应用样式的真正隔离:

import { loadMicroApp } from 'qiankun'
loadMicroApp({xxx}, { sandbox: { strictStyleIsolation: true } });

Shadow DOM 可以做到样式之间的真正隔离(而不是依赖分配前缀等约定式隔离),其形式如下:
图片来自 > MDN 在开启 strictStyleIsolation 时,我们会将微应用插入到 qiankun 创建好的 Shadow Tree 中,微应用的样式(包括动态插入的样式)都会被挂载到这个 Shadow Host 节点下,因此微应用的样式只会作用在 Shadow Tree 内部,这样就做到了样式隔离。
但是开启 Shadow DOM 也会引发一些别的问题:
一个典型的问题是,一些组件可能会越过 Shadow Boundary 到外部 Document Tree 插入节点,而这部分节点的样式就会丢失;比如 antd 的 Modal 就会渲染节点至 ducument.body ,引发样式丢失;针对刚才的 antd 场景你可以通过他们提供的 ConfigProvider.getPopupContainer API 来指定在 Shadow Tree 内部的节点为挂载节点,但另外一些其他的组件库,或者你的一些代码也会遇到同样的问题,需要你额外留心。
此外 Shadow DOM 场景下还会有一些额外的事件处理、边界处理等问题,后续我们会逐步更新官方文档指导用户更顺利的开启 Shadow DOM。
所以请根据实际情况来选择是否开启基于 shadow DOM 的样式隔离,并做好相应的检查和处理。

通讯

全局状态管理


这两段代码不难理解,父子应用通过onGlobalStateChange这个方法进行通信,这其实是一个发布-订阅的设计模式。
ok,官方的示例用法很简单也完全够用,纯JavaScript的语法,不涉及任何的vue或react的东西,开发者可自由定制。

如果我们直接使用官方的这个示例,那么数据会比较松散且调用复杂,所有子应用都得声明onGlobalStateChange对状态进行监听,再通过setGlobalState进行更新数据。
因此,我们很有必要对数据状态做进一步的封装设计因此者这里,我们很有必要

  • 主应用要保持简洁简单,对子应用来说,主应用下发的数据就是一个很纯粹的object,以便更好地支持不同框架的子应用,因此主应用不需用到vuex
  • vue子应用要做到能继承父应用下发的数据,又支持独立运行。


子应用在mount声明周期可以获取到最新的主应用下发的数据,然后将这份数据注册到一个名为global的vuex module中,子应用通过global module的action动作进行数据的更新,更新的同时自动同步回父应用。

因此,对子应用来说,它不用知道自己是一个qiankun子应用还是一个独立应用,它只是有一个名为global的module,它可通过action更新数据,且不再需要关心是否要同步到父应用(同步的动作会封装在方法内部,调用者不需关心),这也是为后面支持子应用独立启动开发做准备

  • react子应用同理。

主应用的状态封装

主应用维护一个initialState的初始数据,它是一个object类型,会下发给子应用。

// main/src/store.js
import { initGlobalState } from 'qiankun';
import Vue from 'vue'
//父应用的初始state
// Vue.observable是为了让initialState变成可响应:https://cn.vuejs.org/v2/api/#Vue-observable。
let initialState = Vue.observable({
  user: {},
});
const actions = initGlobalState(initialState);
actions.onGlobalStateChange((newState, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log('main change', JSON.stringify(newState), JSON.stringify(prev));
  for (let key in newState) {
    initialState[key] = newState[key]
  }
});
// 定义一个获取state的方法下发到子应用
actions.getGlobalState = (key) => {
  // 有key,表示取globalState下的某个子级对象
  // 无key,表示取全部
  return key ? initialState[key] : initialState
}
export default actions;

这里有两个注意的地方:

- getGlobalState方法,这个是有争议的,大家在github上有讨论:github.com/umijs/qiank…

一方面,作者认为getGlobalState不是必须的,onGlobalStateChange其实已经够用。
另一方面,和其他提pr的同学觉得有必要提供一个getGlobalState的api,理由是get方法更方便使用,子应用有需求是不需一直监听stateChange事件,它只需要在首次mount时通过getGlobalState初始化一次即可。在这里,先坚持己见让父应用下发一个getGlobalState的方法。
由于官方还不支持getGlobalState,所以需要显示地在注册子应用时通过props去下发该方法:

import store from './store';
const microApps = [
  {
    name: 'sub-vue',
    entry: '//localhost:7777/',
    activeRule: '/sub-vue',
  },
  {
    name: 'sub-react',
    entry: '//localhost:7788/',
    activeRule: '/sub-react',
  }
]
const apps = microApps.map(item => {
  return {
    ...item,
    container: '#subapp-viewport', // 子应用挂载的div
    props: {
      routerBase: item.activeRule, // 下发基础路由
      getGlobalState: store.getGlobalState // 下发getGlobalState方法
    },
  }
})
export default microApps

vue子应用的状态封装

前面说了,子应用在mount时会将父应用下发的state,注册为一个叫global的vuex module,为了方便复用我们封装一下:

// common/store/global-register.js
/**
 * 
 * @param {vuex实例} store 
 * @param {qiankun下发的props} props 
 */
function registerGlobalModule(store, props = {}) {
  if (!store || !store.hasModule) {
    return;
  }
  // 获取初始化的state
  const initState = props.getGlobalState && props.getGlobalState() || {
    menu: [],
    user: {}
  };
  // 将父应用的数据存储到子应用中,命名空间固定为global
  if (!store.hasModule('global')) {
    const globalModule = {
      namespaced: true,
      state: initState,
      actions: {
        // 子应用改变state并通知父应用
        setGlobalState({ commit }, payload) {
          commit('setGlobalState', payload);
          commit('emitGlobalState', payload);
        },
        // 初始化,只用于mount时同步父应用的数据
        initGlobalState({ commit }, payload) {
          commit('setGlobalState', payload);
        },
      },
      mutations: {
        setGlobalState(state, payload) {
          // eslint-disable-next-line
          state = Object.assign(state, payload);
        },
        // 通知父应用
        emitGlobalState(state) {
          if (props.setGlobalState) {
            props.setGlobalState(state);
          }
        },
      },
    };
    store.registerModule('global', globalModule);
  } else {
    // 每次mount时,都同步一次父应用数据
    store.dispatch('global/initGlobalState', initState);
  }
};
export default registerGlobalModule;

main.js中添加global-module的使用:

import globalRegister from './store/global-register'
export async function mount(props) {
  console.log('[vue] props from main framework', props)
  globalRegister(store, props)
  render(props)
}

可以看到,该vuex模块在子应用mount时,会调用initGlobalState将父应用下发的state初始化一遍,同时提供了setGlobalState方法供外部调用,内部自动通知同步到父应用。子应用在vue页面使用时如下:

export default {
  computed: {
    ...mapState('global', {
      user: state => state.user, // 获取父应用的user信息
    }),
  },
  methods: {
    ...mapActions('global', ['setGlobalState']),
    update () {
    	this.setGlobalState('user', { name: '张三' })
    }
  },
};

这样就达到了一个效果:子应用不用知道qiankun的存在,它只知道有这么一个global module可以存储信息,父子之间的通信都封装在方法本身了,它只关心本身的信息存储就可以了。

ps: 该方案也是有缺点的,由于子应用是在mount时才会同步父应用下发的state的。因此,它只适合每次只mount一个子应用的架构(不适合多个子应用共存);若父应用数据有变化而子应用又没触发mount,则父应用最新的数据无法同步回子应用。想要做到多子应用共存且父动态传子,子应用还是需要用到qiankun提供的onGlobalStateChange的api监听才行,有更好方案的同学可以分享讨论一下。该方案刚好符合当前的项目需求,因此够用了,请同学们根据自己的业务需求来封装。

子应用切换Loading处理


所有url 跳转 都是先卸载在加载

子应用首次加载时相当于新加载一个项目,还是比较慢的,因此loading是不得不加上的。
官方的例子中有做了loading的处理,但是需要额外引入import Vue from 'vue/dist/vue.esm',这会增加主应用的打包体积(对比发现大概增加了100KB)。一个loading增加了100K,显然代价有点无法接受,所以需要考虑一种更优一点的办法。

我们的主应用是用vue搭建的,而且qiankun提供了loader方法可以获取到子应用的加载状态,所以自然而然地可以想到:**在main.js中子应用加载时,将loading 的状态传给Vue实例,让Vue实例响应式地显示loading。**接下来先选一个loading组件:

  • 如果主应用使用了ElementUI或其他框架,可以直接使用UI库提供的loading组件。
  • 如果主应用为了保持简单没有引入UI库,可以考虑自己写一个loading组件,或者找个小巧的loading库,如这里要用到的NProgress
npm install --save nprogress

接下来是想办法如何把loading状态传给主应用的App.vue。经过试验发现,new Vue方法返回的vue实例可以通过instance.$children[0]来改变App.vue的数据,所以改造一下main.js

// 引入nprogress的css
import 'nprogress/nprogress.css'
import microApps from './micro-app';
// 获取实例
const instance = new Vue({
  render: h => h(App),
}).$mount('#app');
// 定义loader方法,loading改变时,将变量赋值给App.vue的data中的isLoading
function loader(loading) {
  if (instance && instance.$children) {
    // instance.$children[0] 是App.vue,此时直接改动App.vue的isLoading
    instance.$children[0].isLoading = loading
  }
}
// 给子应用配置加上loader方法
let apps = microApps.map(item => {
  return {
    ...item,
    loader
  }
})
registerMicroApps(apps);
start();

PS: qiankun的registerMicroApps方法也监听到子应用的beforeLoad、afterMount等生命周期,因此也可以使用这些方法记录loading状态,但更好的用法肯定是通过loader参数传递。

改造主应用的App.vue,通过watch监听isLoading

<template>
  <div id="layout-wrapper">
    <div class="layout-header">头部导航</div>
    <div id="subapp-viewport"></div>
  </div>
</template>
<script>
import NProgress from 'nprogress'
export default {
  name: 'App',
  data () {
    return {
      isLoading: true
    }
  },
  watch: {
    isLoading (val) {
      if (val) {
        NProgress.start()
      } else {
        this.$nextTick(() => {
          NProgress.done()
        })
      }
    }
  },
  components: {},
  created () {
    NProgress.start()
  }
}
</script>

至此,loading效果就实现了。虽然instance.$children[0].isLoading的操作看起来比较骚,但确实比官方的提供的例子成本小很多(体积增加几乎为0),若有更好的办法,欢迎大家分享。

抽取公共代码

不可避免,有些方法或工具类是所有子应用都需要用到的,每个子应用都copy一份肯定是不好维护的,所以抽取公共代码到一处是必要的一步。
根目录下新建一个common文件夹用于存放公共代码,如上面的多个vue子应用都可以共用的global-register.js,或者是可复用的request.jssdk之类的工具函数等。
公共代码抽取后,其他的应用如何使用呢? 可以让common发布为一个npm私包,npm私包有以下几种组织形式:

  • npm指向本地file地址:npm install file:../common。直接在根目录新建一个common目录,然后npm直接依赖文件路径。
  • npm指向私有git仓库: npm install git+ssh://xxx-common.git
  • 发布到npm私服。

demo因为是基座和子应用都集合在一个git仓库上,所以采用了第一种方式,但实际应用时是发布到npm私服,因为后面我们会拆分基座和子应用为独立的子仓库,支持独立开发,后文会讲到。
需要注意的是,由于common是不经过babel和pollfy的,所以引用者需要在webpack打包时显性指定该模块需要编译,如vue子应用的vue.config.js需要加上这句:

module.exports = {
  transpileDependencies: ['common'],
}

子应用支持独立开发

微前端一个很重要的概念是拆分,是分治的思想,把所有的业务拆分为一个个独立可运行的模块。
从开发者的角度看,整个系统可能有N个子应用,如果启动整个系统可能会很慢很卡,而产品的某个需求可能只涉及到其中一个子应用,因此开发时只需启动涉及到的子应用即可,独立启动专注开发,因此是很有必要支持子应用的独立开发的。如果要支持,主要会遇到以下几个问题:

  • 子应用的登录态怎么维护?
  • 基座不启动时,怎么获取到基座下发的数据和能力?

项目间组件共享

组件共享,优先推荐 npm 包方式,但是如果没有部署私有 npm ,项目又涉及隐私不能放到 GitHub,或者是带数据的业务组件共享,可以考虑下面的几种方式。

父子项目间的组件共享

因为主项目会先加载,然后才会加载子项目,所以一般是子项目复用主项目的组件。
做法也很简单,主项目加载时,将组件挂载到 window 上,子项目直接注册即可。
主项目入口文件:

import HelloWorld from '@/components/HelloWorld.vue'
window.commonComponent = { HelloWorld };

子项目直接使用:

components: { 
  HelloWorld: window.__POWERED_BY_QIANKUN__ ? window.commonComponent.HelloWorld :
     import('@/components/HelloWorld.vue'))
}

子项目间的组件共享(弱依赖

什么是弱依赖呢?就是子项目本身自己也有这个组件,当别的子项目已经加载过了,他就复用别人的组件,如果别的子项目未加载,就使用自己的这个组件。
适用场景就是避免组件的重复加载,这个组件可能并不是全局的,只是某个页面使用。做法分三步:

  1. 由于子项目之间的全局变量不共享,主项目提供一个全局变量,用来存放组件,通过 props 传给需要共享组件的子项目。
  2. 子项目拿到这个变量挂载到 window
export async function mount(props) {
  window.commonComponent = props.data.commonComponent;
  render(props.data);
}
  1. 子项目中的共享组件写成异步组件
components: {
   HelloWorld: () => {
      if(!window.commonComponent){
        // 独立运行时
        window.commonComponent = {};
      }
      const HelloWorld = window.commonComponent.HelloWorld;
      return HelloWorld || (window.commonComponent.HelloWorld =
             import('@/components/HelloWorld.vue'));
   
}

这里有个 bug :来回切换时,共享组件的样式不加载。感觉这个 bug 和“子项目跳转到主项目页面,主项目的样式不加载”是同一个 bug,暂时没有很好的解决方法。
另一个子项目也这样写就行,只要某个子项目加载过一次,另一个项目就可以直接复用,不用重复加载。

子项目间的组件共享(强依赖)

与弱依赖不同的是,子项目本身没有这个组件,所以另一个子项目一定先加载,然后他才能拿到这个组件。
那么如何保证子项目间的加载顺序呢?解决方案:在子项目使用这个组件前,手动加载另一个子项目,保证一定能拿到这组件(在有权限的情况下,无权限会加载失败)

qiankun 也可以使用 loadMicroApp 来手动加载子项目。基本步骤如下:
1、同样,由于子项目之间的全局变量不共享,主项目提供一个全局变量,用来存放组件,通过 props 传给使用组件的子项目,同时还要将 loadMicroApp 函数传过去

// main/src/micro-app.js

// 处理组件共享
const commonComponents = {};

const apps = microApps.map(item => {
  return {
    ...item,
    container: "#subapp-viewport", // 子应用挂载的div
    props: {
      routerBase: item.activeRule, // 下发基础路由
      getGlobalState: store.getGlobalState, // 下发getGlobalState方法
      data: { commonComponents } // 下发组件
    }
  };
});

// main/src/main.js
// 共享组件必须开启多实例
start({ singular: false });

sub-vue 有一个tag组件

// Tag.vue
<template lang="html">
  <div>
    <h1>{{ msg }}</h1>
  </div>
</template>

<script>
export default {
  name: "Tag",
  props: {
    msg: String,
  },
};
</script>

// sub-vue main.js

export async function mount(props) {
  console.log("[vue] props from main framework", props);
  // 挂载组件
  if(props.data.commonComponents){
    props.data.commonComponents.Tag = Tag
  }
  render(props);
}

在sub-vue-hash 使用

子项目将加载函数和公共变量挂载到全局

// main.js
export async function mount(props) {
  console.log("[vue] props from main framework", props);
  // 获取全局组件
  window.loadMicroApp = props.data.loadMicroApp;
  window.commonComponents = props.data.commonComponents;
  render(props);
}

子项目在需要使用组件的页面,手动加载提供组件的子项目,等它加载完成,就可以拿到组件。

这里需要注意的是:由于提供组件的子项目是通过 loadMicroApp 加载的,所以存放组件的公共变量必须由 loadMicroApp 传递过去。另外:加载子项目需要提供容器,在 APP.vue 提供一个隐藏的容器就行。

由于组件强依赖,子项目这里就没法独立运行,这里注册组件有两个选择:

  • 一是使用异步组件:
<Tag msg="我是来自 sub-vue 组件Tag" />
  
<script>
export default {
  components: {
    Tag: async () => {
      if (window.commonComponents.Tag) return window.commonComponents.Tag;

      const app = window.loadMicroApp({
        name: "sub-vue",
        entry: "http://localhost:7777",
        container: "#subapp-viewport",
        props: { data: { commonComponents: window.commonComponents } },
      });

      await app.mountPromise;
      // app.unmount(); 不能卸载,卸载时会去掉样式的
      return window.commonComponents.Tag;
    },
  },
};
</script>
  • 二是动态注册,先使用 v-if 来隐藏标签:
<Tag msg="我是来自 sub-vue 组件Tag" v-if="loadingEnd"/>
  
<script>
export default {
  async created() {
    const app = window.loadMicroApp({
      name: 'app-vue-history',
      entry: 'http://localhost:2222', 
      container: '#appContainer2',
      props: { data: { commonComponent: window.commonComponent } }
    })
    await app.mountPromise;
    Vue.component('Tag', window.commonComponent.Tag)
    this.loadingEnd = true;
  }
};
</script>

复用公共依赖(方案)


子项目要想复用公共依赖,配置 webpack 的 externals 是必须的,而配置了这个之后,子项目独立运行时,这些依赖的来源有且仅有 index.html 中的外链 script 标签。

有两种情况:

  • 子项目之间的依赖“复用”

这个很好办,你只需要保证依赖的 url 一致即可。比如说子项目A 使用了 vue,子项目B 也使用了同版本的 vue,如果两个项目使用了同一份 CND 文件,加载时会先从缓存读取:

const fetchScript = scriptUrl => scriptCache[scriptUrl] ||
	(scriptCache[scriptUrl] = fetch(scriptUrl).then(response => response.text()));
复制代码
  • 子项目复用主项目的依赖

只需要给子项目 index.html 中公共依赖的 scriptlink 标签加上 ignore 属性(这是自定义的属性,非标准属性)。
有了这个属性,qiankun 便不会再去加载这个 js/css,而子项目独立运行,这些 js/css 仍能被加载,如此,便实现了“子项目复用主项目的依赖”。

<link ignore rel="stylesheet" href="//cnd.com/antd.css">
<script ignore src="//cnd.com/antd.js"></script>

修改主项目和子项目的依赖名称

主应用和子应用复用的依赖改个名称,这样就不会影响其他不复用依赖的子应用。具体改的有:

  1. 修改子应用和主应用的 externals 配置,修改依赖的名称,不使用 Vue
// # main.js
module.exports = {
  configureWebpack: {
    externals: {
      vue: "Vue",
      "vue-router": "VueRouter",
    }
  },
};
  1. 在主应用导入外链 vue.js 之后,将名称改成 Vue2
<script src="https://unpkg.com/vue@2.5.16/dist/vue.runtime.min.js"></script>

<script>
window.Vue2 = winow.Vue;
window.Vue = undefined;
</script>

通过 props 传递依赖

上面的兼容性问题,可以考虑 主项目通过props把依赖传给子项目,不配置 externals 来解决。
主项目注册时,将依赖传递给子项目(省略了一些不必要的代码):

import VueRouter from 'vue-router'
registerMicroApps([
  {
    name: 'app-vue-hash', 
    entry: 'http://localhost:1111', 
    container: '#appContainer', 
    activeRule: '/app-vue-hash', 
    props: { data : { VueRouter } }
  },
]);

子项目配置 externals 并且外链依赖加上ignore 属性:

function render(parent = {}) {
  if(!instance){
    // 当它独立运行时,使用自己的外链依赖 window.VueRoute
    const VueRouter = parent.VueRouter || window.VueRouter; 
    Vue.use(VueRouter);
    router = new VueRouter({
      routes,
    });
    instance = new Vue({
      router,
      store,
      render: h => h(App),
    }).$mount('#appVueHash');
  }
}
export async function mount(props) {
  render(props.data);
}

加载子项目之前处理下全局变量

假设 app-vue-hash 子项目复用主项目依赖,app-vue-history子项目不复用主项目依赖。
在主项目中注册子项目时新增如下代码解决:

registerMicroApps(apps,
{
  beforeLoad(app){
    if(app.name === 'app-vue-hash'){
      // 如果直接在 app-vue-hash 子项目刷新页面,此时 window.Vue2 是 undefined
      // 所以先判断下 window.Vue2 是否存在
      if(window.Vue2){
        window.Vue = window.Vue2; 
        window.Vue2 = undefined;
      }
    }else if(app.name === 'app-vue-history'){
      window.Vue2 = window.Vue; 
      window.Vue = undefined
    }
  },
});

子应用支持独立开发

但如果基座不启动,只是子应用独立启动,子应用就没法通过props获取到所需的用户信息了。因此,解决办法只能是父子应用都得实现一套相同的登录逻辑。为了可复用,可以把登录逻辑封装在common中,然后在子应用独立运行的逻辑中添加登录相关的逻辑。

// sub-vue/src/main.js
import { store as commonStore } from 'common'
import store from './store'
if (!window.__POWERED_BY_QIANKUN__) {
  // 这里是子应用独立运行的环境,实现子应用的登录逻辑
  
  // 独立运行时,也注册一个名为global的store module
  commonStore.globalRegister(store)
  // 模拟登录后,存储用户信息到global module
  const userInfo = { name: '我是独立运行时名字叫张三' } // 假设登录后取到的用户信息
  store.commit('global/setGlobalState', { user: userInfo })
  
  render()
}
// ...
export async function mount (props) {
  console.log('[vue] props from main framework', props)
  commonStore.globalRegister(store, props)
  render(props)
}
// ...

!window.__POWERED_BY_QIANKUN__表示子应用处于非qiankun内的环境,即独立运行时。此时我们依然要注册一个名为global的vuex module,子应用内部同样可以从global module中获取用户的信息,从而做到抹平qiankun和独立运行时的环境差异。

PS:我们前面写的global-register.js写得很巧妙,能够同时支持两种环境,因此上面可以通过commonStore.globalRegister直接引用。

子应用独立仓库

随着项目发展,子应用可能会越来越多,如果子应用和基座都集合在同一个git仓库,就会越来越臃肿。
若项目有CI/CD,只修改了某个子应用的代码,但代码提交会同时触发所有子应用构建,牵一发动全身,是不合理的。
同时,如果某些业务的子应用的开发是跨部门跨团队的,代码仓库如何分权限管理又是一个问题。
基于以上问题,我们不得不考虑将各个应用迁移到独立的git仓库。由于我们独立仓库了,项目可能不会再放到同一个目录下,因此前面通过npm i file:../common方式安装的common就不适用了,所以最好还是发布到公司的npm私服或采用git地址形式。

qiankun-example 为了更好展示,仍将所有应用都放在同一个git仓库下,仅供学习。

子应用独立仓库后聚合管理

子应用独立git仓库后,可以做到独立启动独立开发了,
这时候又会遇到问题:开发环境都是独立的,无法一览整个应用的全貌
虽然开发时专注于某个子应用时更好,但总有需要整个项目跑起来的时候,比如当多个子应用需要互相依赖跳转时,所以还是要有一个整个项目对所有子应用git仓库的聚合管理才行,该聚合仓库要求做到能够一键install所有的依赖(包括子应用),一键启动整个项目。

这里主要考虑了三种方案:

  1. 使用git submodule
  2. 使用git subtree
  3. 单纯地将所有子仓库放到聚合目录下并.gitignore掉。
  4. 使用lerna管理。
  5. git submodulegit subtree都是很好的子仓库管理方案,但缺点是每次子应用变更后,聚合库还得同步一次变更。

考虑到并不是所有人都会使用该聚合仓库,子仓库独立开发时往往不会主动同步到聚合库,使用聚合库的同学就得经常做同步的操作,比较耗时耗力,不算特别完美。
所以第三种方案比较符合目前团队的情况。聚合库相当于是一个空目录,在该目录下clone所有子仓库,并gitignore,子仓库的代码提交都在各自的仓库目录下进行操作,这样聚合库可以避免做同步的操作。
由于ignore了所有子仓库,聚合库clone下来后,仍是一个空目录,此时我们可以写个脚本scripts/clone-all.sh,把所有子仓库的clone命令都写上:

# 子仓库一
git clone git@xxx1.git
# 子仓库二
git clone git@xxx2.git

然后在聚合库也初始化一个package.json,scripts加上:

"scripts": {
    "clone:all": "bash ./scripts/clone-all.sh",
 }

这样,git clone聚合库下来后,再npm run clone:all就可以做到一键clone所有子仓库了。
前面说到聚合库要能够做到一键install和一键启动整个项目,我们参考qiankun的examples,使用npm-run-all来做这个事情。

  1. 聚合库安装npm i npm-run-all -D
  2. 聚合库的package.json增加install和start命令:
"scripts": {
    ...
    "install": "npm-run-all --serial install:*",
    "install:main": "cd main && npm i",
    "install:sub-vue": "cd sub-vue && npm i",
    "install:sub-react": "cd sub-react && npm i",
    "start": "npm-run-all --parallel start:*",
    "start:sub-react": "cd sub-react && npm start",
    "start:sub-vue": "cd sub-vue && npm start",
    "start:main": "cd main && npm start"
  },
复制代码

npm-run-all--serial表示有顺序地一个个执行,--parallel表示同时并行地运行。

配好以上,一键安装npm i,一键启动npm start

vscode eslint配置

如果使用vscode,且使用了eslint的插件做自动修复,由于项目处于非根目录,eslint没法生效,所以还需要指定eslint的工作目录:

// .vscode/settings.json
{
  "eslint.workingDirectories": [
    "./main",
    "./sub-vue",
    "./sub-react",
    "./common"
  ],
  "eslint.enable": true,
  "editor.formatOnSave": false,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "search.useIgnoreFiles": false,
  "search.exclude": {
    "**/dist": true
  },
}

子应用互相跳转

除了点击页面顶部的菜单切换子应用,我们的需求也要求子应用内部跳其他子应用,这会涉及到顶部菜单active状态的展示问题:sub-vue切换到sub-react,此时顶部菜单需要将sub-react改为激活状态。有两种方案:

  • 子应用跳转动作向上抛给父应用,由父应用做真正的跳转,从而父应用知道要改变激活状态,有点子组件$emit事件给父组件的意思。
  • 父应用监听history.pushState事件,当发现路由换了,父应用从而知道要不要改变激活状态。


由于qiankun暂时没有封装子应用向父应用抛出事件的api,如iframe的postMessage,所以方案一有些难度,不过可以将激活状态放到状态管理中,子应用通过改变vuex中的值让父应用同步就行,做法可行但不太好,维护状态在状态管理中有点复杂了。
所以我们这里选方案二,子应用跳转是通过history.pushState(null, '/sub-react', '/sub-react')的,因此父应用在mounted时想办法监听到history.pushState就可以了。由于history.popstate只能监听back/forward/go却不能监听history.pushState,所以需要额外全局复写一下history.pushState事件。

// main/src/App.vue
export default {
  methods: {
    bindCurrent () {
      const path = window.location.pathname
      if (this.microApps.findIndex(item => item.activeRule === path) >= 0) {
        this.current = path
      }
    },
    listenRouterChange () {
      const _wr = function (type) {
        const orig = history[type]
        return function () {
          const rv = orig.apply(this, arguments)
          const e = new Event(type)
          e.arguments = arguments
          window.dispatchEvent(e)
          return rv
        }
      }
      history.pushState = _wr('pushState')
      window.addEventListener('pushState', this.bindCurrent)
      window.addEventListener('popstate', this.bindCurrent)
      this.$once('hook:beforeDestroy', () => {
        window.removeEventListener('pushState', this.bindCurrent)
        window.removeEventListener('popstate', this.bindCurrent)
      })
    }
  },
  mounted () {
    this.listenRouterChange()
  }
}


_wr函数其实是封装了一个可拦截history方法的函数,调用_wr('pushState')即表示监听拦截了history对象的pushState方法,在方法的内部做了一些操作。这个操作是:上面说了在原生浏览器里面是没法直接监听pushState事件的,但浏览器支持我们注册自定义的事件,new Event就是注册了一个名为pushState自定义事件,并dispatchEvent去触发这个事件(这里可以联想到vuex的dispatch一个commit,一样的道理),而通过window.addEventListener是可以捕捉到dispatchEvent触发的事件的。 所以在这里,虽然父子应用没法直接通信,但它们是处于同一个window的环境下的。父应用通过addEventListener监听了事件,子应用调用history.pushState时又会触发dispatchEvent事件,因此父应用就可以监听到子应用的这个动作了,再做对应的操作。

假设场景分析


场景1

假设 一个微前端应用,那登录是做在主应用里面好,还是单独做个应用的,且 把侧边栏 和 头部做在主应用里面,子应用 有些需要用到 有些 不需要用到,那这样要如何处理呢

场景2

假设如果同一个页面中,加载多个不同的微应用,该如何处理?

项目部署

考虑到主应用和子应用共用域名时可能会存在路由冲突的问题,
子应用可能会源源不断地添加进来,因此我们将子应用都放在xx.com/subapp/这个二级目录下,根路径/留给主应用。
步骤如下:

  1. 主应用main和所有子应用都打包出一份html,css,js,static,分目录上传到服务器,子应用统一放到subapp目录下,最终如:
  2. 配置nginx,预期是xx.com根路径指向主应用,xx.com/subapp指向子应用,子应用的配置只需写一份,以后新增子应用也不需要改nginx配置,以下应该是微应用部署的最简洁的一份nginx配置了。
server {
    listen       80;
    server_name qiankun.fengxianqi.com;
    location / {
        root   /data/web/qiankun/main;  # 主应用所在的目录
        index index.html;
        try_files $uri $uri/ /index.html;
    }
    location /subapp {
	    alias /data/web/qiankun/subapp;
        try_files $uri $uri/ /index.html;
    }
}
复制代码

nginx -s reload后就可以了。

单独访问子应用:

  • subapp/sub-vue, 注意观察vuex数据的变化。
  • subapp/sub-react


项目代码 github.com/zhangbinzhb…

登录 一般是单独做个应用,因为子应用独立运行时也需要登录的,跳转过去,登录完成跳转回来


> 把是否展示侧边栏和头部的数据配在子项目的路由数据里面(meta里面),然后子项目路由切换,通过props把子项目的路由数据传回主项目,主项目拿到数据再隐藏/展示
> 使用 loadMicroApp 加载微应用就可以了,给每个应用不同的容器,loadMicroApp 不需要设置activeRule,与路由无关。在主项目的某个路由页面的 mounted 周期里面,使用 loadMicroApp 直接加载多个微应用,这样这些微应用就能同时展示,也就是多个微应用同时运行。

微前端是为什么而生?解决了什么问题?它的技术核心点是什么?

为什么需要微前端?
1、解决遗留系统,采用微前端方案最重要的原因。过去那些使用Backbone.js、Angular.js、Vue.js等框架所编写的单页面应用,已经在线上稳定地运行了,也没有新的功能。对于这样的应用来说,我们也没有理由浪费时间和精力重写旧的应用。而这些应用是使用旧的、不再使用的技术栈编写的,由于框架本身已经不更新(不增加新功能或者不再维护)。既然应用可以使用,就不花太多的力气重写,而是直接整合到新的应用中去。
不重写原有系统,同时抽出人力来开发新的业务,这对业务人员来说,是一个相当有吸引力的特性,而且对技术人员来说,也是一件相当不错的事情。人生苦短,请尽量不重写。
2、多个产品聚合成一个大的产品
3、大公司需要折腾

解决了什么问题?
微前端的实现可以看成对前端应用的拆分。拆分应用的目的并不只是为了在架构上好看,它还可以提升开发效率。比如10万行的代码拆解成10个项目,每个项目1万行代码,要独立维护每个项目就会容易得多。实现应用的自治,即实现应用的独立开发和独立部署,就可以在某种程度上实现微前端架构的目的。

技术核心

不受技术框架限制的,任意 js 框架均可使用。

请教一个细节的问题,例如一个大后台管理项目,里面的侧边菜单栏是一个公共组件,如果要把这个大项目拆分成10个前端项目,那这个侧边菜单栏组件应该怎么处理?

我这边想法是准备侧边菜单写出一个子微应用来管理, 但是现在都是 侧边栏写在 主应用里,也就是公共的。侧边栏 和 头部做在主应用里面,子应用 有些需要用到 有些 不需要用到这考虑,把是否展示侧边栏和头部的数据配在子项目的路由数据里面(meta里面),然后子项目路由切换,通过props把子项目的路由数据传回主项目,主项目拿到数据再隐藏/展示

什么情况下,才会去采用微前端解决方案呢?我就写一个SPA的管理系统有必要用吗?

先看你项目,是否是很多模块的,像我们项目 20 多个依赖、60 多个公共组件、200 多个页面, 是否多人团队开发,在多协作开发,多人在同一个工程里开发,遭遇代码冲突的概率会很频繁,而且冲突的影响面比较大。如果代码中出现问题,导致 CI 失败,所有其他人的代码提交与更新也都会被阻塞。使用微前端后,这样的风险就平摊到各个工程上去了。是否考虑定制化,定制化从易到难则可以分为独立新模块、改造现有模块、替换现有模块。或者产品比较大,有几块功能比较独立、有特色。如果说将来需要独立成一个子产品。