SingleSpa及qiankun入门、源码分析及案例
SingleSpa及qiankun入门及案例一、简介1、微服务为了解决庞大的一整块后端服务带来的变更与扩展方面的限制,出现了微服务架构(Microservices):微服务是面向服务架构(SOA)的一种变体,把应用程序设计成一系列松耦合的细粒度服务,并通过轻量级的通信协议组织起来具体地,将应用构建成一组小型服务。这些服务都能够独立部署、独立扩展,每个服务都具有稳固的模块边界,甚至允许使用不同的编程
文章目录
SingleSpa及qiankun入门、源码分析及案例
一、简介
1、微服务
为了解决庞大的一整块后端服务带来的变更与扩展方面的限制,出现了微服务架构(Microservices):
微服务是面向服务架构(SOA)的一种变体,把应用程序设计成一系列松耦合的细粒度服务,并通过轻量级的通信协议组织起来
具体地,将应用构建成一组小型服务。这些服务都能够独立部署、独立扩展,每个服务都具有稳固的模块边界,甚至允许使用不同的编程语言来编写不同服务,也可以由不同的团队来管理
然而,越来越重的前端工程也面临同样的问题,自然地想到了将微服务思想应用(照搬)到前端,于是有了「微前端(micro-frontends)」的概念:
Micro frontends, An architectural style where independently deliverable frontend applications are composed into a greater whole.
即,一种由独立交付的多个前端应用组成整体的架构风格。具体的,将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的小块,而在用户看来仍然是内聚的单个产品:
Decomposing frontend monoliths into smaller, simpler chunks that can be developed, tested and deployed independently, while still appearing to customers as a single cohesive product.
2、什么是微前端
简单来讲,微前端的理念类似于微服务:
微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用来加载这些子应用。
微前端的核心在于拆, 拆完后再合!
将庞大的整体拆成可控的小块,并明确它们之间的依赖关系。关键优势在于:
- 代码库更小,更内聚、可维护性更高
- 松耦合、自治的团队可扩展性更好
- 渐进地升级、更新甚至重写部分前端功能成为了可能
可以跟微服务这么对比着去理解:
微服务 | 微前端 |
---|---|
一个微服务就是由一组接口构成,接口地址一般是 URL。当微服务收到一个接口的请求时,会进行路由找到相应的逻辑,输出响应内容。 | 一个微前端则是由一组页面构成,页面地址也是 URL。当微前端收到一个页面 URL 的请求时,会进行路由找到相应的组件,渲染页面内容。 |
后端微服务会有一个网关,作为单一入口接收所有的客户端接口请求,根据接口 URL 与服务的匹配关系,路由到对应的服务。 | 微前端则会有一个加载器,作为单一入口接收所有页面 URL 的访问,根据页面 URL 与微前端的匹配关系,选择加载对应的微前端,由该微前端进行进行路由响应 URL。 |
这里要注意跟 iframe 实现页面嵌入机制的区别。微前端没有用到 iframe,它很纯粹地利用 JavaScript、MVVM 等技术来实现页面加载。
3、微前端的优点
可以与时俱进,不断引入新技术/新框架
前端技术栈日新月异,推陈出新的速度绝对是冠绝群雄。如何在维护好遗留系统的前提下,不断引入新技术和新框架,提高开发效率、质量、用户体验,成为每个团队需要认真对待的问题。微前端可以很好的实现应用和服务的隔离,互相之间几乎没有影响,可以很好的支持团队引入新技术和新框架。
局部/增量升级
对于许多组织来说,追求增量升级就是他们迈向微前端的第一步。对他们来说,老式的大型单体前端要么是用老旧的技术栈打造的,要么就充斥着匆忙写成的代码,已经到了该重写整个前端的时候了。一次性重写整个系统风险很大,我们更倾向一点一点换掉老的应用,同时在不受单体架构拖累的前提下为客户不断提供新功能。
为了做到这一点,解决方案往往就是微前端架构了。一旦某个团队掌握了在几乎不影响旧世界的同时为生产环境引入新功能的诀窍,其他团队就会纷纷效仿。现有代码仍然需要继续维护下去,但在某些情况下还要继续添加新功能,现在总算有了解决方案。
到最后,我们就能更随心所欲地改动产品的各个部分,并逐渐升级我们的架构、依赖关系和用户体验。当主框架发生重大变化时每个微前端模块都可以按需升级,不需要整体下线或一次性升级所有内容。如果我们想要尝试新的技术或互动模式,也能在隔离度更好的环境下做试验。
代码简洁、解耦、更易维护
比起一整块的前端代码库,微前端架构下的代码库倾向于更小/简单、更容易开发
此外,更重要的是避免模块间不合理的隐式耦合造成的复杂度上升。通过界定清晰的应用边界来降低意外耦合的可能性,增加子应用间逻辑耦合的成本,促使开发者明确数据和事件在应用程序中的流向。
独立部署
就像微服务一样,微前端的一大优势就是可独立部署的能力。这种能力会缩减每次部署涉及的范围,从而降低了风险。不管你的前端代码是在哪里托管,怎样托管,各个微前端都应该有自己的持续交付管道;这些管道可以将微前端构建、测试并部署到生产环境中。我们在部署各个微前端时几乎不用考虑其他代码库或管道的状态;就算旧的单体架构采用了固定、手动的按季发布周期,或者隔壁的团队在他们的主分支里塞进了一个半成品或失败的功能,也不影响我们的工作。如果某个微前端已准备好投入生产,那么它就能顺利变为产品,且这一过程完全由开发和维护它的团队主导
组织更具扩展能力,其团队更加独立自治。
解藕代码库、分离发布周期还能带来一个高层次的好处,那就是大幅提升团队的独立性;一支独立的团队可以自主完成从产品构思到最终发布的完整流程,有足够的能力独立向客户交付价值,从而可以更快、更高效地工作。为了实现这一目标需要围绕垂直业务功能,而非技术功能来打造团队。一种简单的方法是根据最终用户将看到的内容来划分产品模块,让每个微前端都封装应用的某个页面,并分配给一个团队完整负责。相比围绕技术或“横向”问题(如样式、表单或验证)打造的团队相比,这种团队能有更高的凝聚力。
4、微前端的缺点
重复依赖
不同应用之间依赖的包存在很多重复,由于各应用独立开发、编译和发布,难免会存在重复依赖的情况。导致不同应用之间需要重复下载依赖,额外再增加了流量和服务端压力。
团队之间更加分裂
大幅提升的团队自治水平可能会让各个团队的工作愈加分裂。各团队只关注自己的业务或者平台功能,在面向用户的整体交付方面,会导致对用户需求和体现不敏感,和响应不及时。
操作/管理上的复杂性
在采用微前端之前,先要考虑几个问题:
- 现有的前端开发、测试、发布流程如何扩展支持很多个应用?
- 分散的,控制弱化的工具体系及开发实践是否可靠?
- 针对各式各样的前端代码库,如何建立质量标准?
总之,与之前不同的是,微前端将产生一堆小的东西,因此需要考虑是否具备采用这种方法所需的技术和组织成熟度
5、如何落地微前端
2018年 Single-SPA诞生了,single-spa是一个用于前端微服务化的JavaScript前端解决方案(本身没有处理样式隔离,js执行隔离)实现了路由劫持和应用加载;
2019年 qiankun 基于Single-SPA, 提供了更加开箱即用的 API (single-spa + sandbox + import-html-entry),它 做到了技术栈无关,并且接入简单(有多简单呢,像iframe一样简单)。
总结:子应用可以独立构建,运行时动态加载,主子应用完全解耦,并且技术栈无关,靠的是协议接入(这里提前强调一下:子应用必须导出 bootstrap、mount、unmount三个方法)。
这里先回答一下大家可能会有的疑问:
这不是iframe吗?
- 如果使用的是
iframe
,当iframe
中的子应用切换路由时用户刷新页面就尴尬了。
应用间如何通信?
- 基于URL来进行数据传递,但是这种传递消息的方式能力较弱;
- 基于
CustomEvent
实现通信; - 基于props主子应用间通信;
- 使用全局变量、
Redux
进行通信。
如何处理公共依赖?
CDN
- externalswebpack
联邦模块
6、示例
- 在线 Demo:https://demo.microfrontends.com/
- 源码地址:micro-frontends-demo/container
- 详细介绍:The example in detail
7、总结
类似于微服务之于后端,前端业务在发展到一定规模之后,也需要一种用来分解复杂度的架构模式,于是出现了微服务思想在前端领域的应用,即微前端。主要目的在于:
- 技术架构上进一步的扩展性(模块边界清晰、依赖明确)
- 团队组织上的自治权
- 开发流程上能独立开发、独立交付
最大的意义在于解锁了多技术栈并存的能力,尤其适用于渐进式重构中架构升级过渡期:
Suddenly we are not tightly coupled with one stack only, we can refactor legacy projects supporting the previous stack and a new one that slowly but steadily kicks into production environment without the need of a big bang releases (see strangler pattern).
允许低成本尝试新技术栈,甚至允许选用最合适的技术栈做不同的事情(类似于微服务中允许用不同的语言编写不同服务):
we can use different version of the same library or framework in production without affecting the entire application, we can try new frameworks or approaches seeing real performances in action, we can hire the best people from multiple communities and many other advantages.
二、SingleSpa
微前端的概念出现于2016年末,其将微服务的概念引入前端世界。用以解决在需求、人员、技术栈等因素不断更迭下前端工程演变成 巨石应用(Frontend Monolith) 而不可维护的问题。这类问题尤其常见于企业级Web项目中。
1、微前端的价值
为了解决上文提到的问题,微前端架构具备以下几个价值
- 技术栈无关:运行在微前端架构下的各个应用所使用的技术栈,完全自主。
- 独立开发、独立部署:各个应用有独立的开发环境及部署流程。
- 独立运行: 各个应用可以独立运行,也可以与其他应用一起运行。
借助微前端和微服务技术架构,敏捷的管理方式,开发团队可以更加专注于功能的渐进交付。
2、行业现状
目前多数场景集中在两种方案中:
MPA:多页面应用将页面部署在不同的URL下,其优点在于各应用技术栈无关,独立开发、独立部署且部署简单。但缺点也十分明显,由于浏览器页面的重刷,页面切换会出现明显的断点。
SPA:单页面应用的出现实现了页面的无刷新切换。缺点在于受制于技术,难以兼容不同框架的应用,导致必须重构的方式支持技术上的变更,大多数这种投入对业务没有帮助。
微前端架构很好的借鉴了SPA无刷新的特点,在SPA之上引入新的分层实现应用切换的功能:
3、SingleSpa概念
SingleSPA:SingleSPA是一套微前端框架,上图中的Switch Layer就是由它接管浏览器地址切换以达到切换应用的目的,也管理着各个应用从启动到销毁的生命周期,在生命周期变化过程中可以添加额外的功能(例如动画)。同时SingleSPA也为不同的SPA框架提供了插件,以整合现存的应用。
4、iframe和single-spa的优缺点
iframe的优缺点
缺点
- 页面加载问题: 影响主页面加载,阻塞
onload
事件,本身加载也很慢,页面缓存过多会导致电脑卡顿。(无法解决) - 布局问题:
iframe
必须给一个指定的高度,否则会塌陷。解决办法:子系统实时计算高度并通过postMessage
发送给主页面,主页面动态设置高度,修改子系统或者代理插入脚本。有些情况会出现多个滚动条,用户体验不佳。 - 弹窗及遮罩层问题:只能在
iframe
范围内垂直水平居中,没法在整个页面垂直水平居中。- 解决办法1:通过与框架页面消息同步解决,将弹窗消息发送给主页面,主页面来弹窗,对原项目改动大且影响原项目的使用。
- 解决办法2:修改弹窗的样式:隐藏遮罩层,修改弹窗的位置。修改的办法就是通过代理服务器插入css样式。
- 补充:
iframe
里面的内容无法实现占满屏幕的弹窗(非全屏),他只能在iframe范围内全屏,无法跳出iframe的限制在主页面全屏,不过这种情况也很少。
- 浏览器前进/后退问题:
iframe
和主页面共用一个浏览历史,iframe
会影响页面的前进后退,大部分时候正常,iframe
多次重定向则会导致浏览器的前进后退功能无法正常使用,不是全部页面都会出现,基本可以忽略。但是iframe
页面刷新会重置(比如说从列表页跳转到详情页,然后刷新,会返回到列表页),因为浏览器的地址栏没有变化。 iframe
的页面跳转到其他页面出问题,比如两个iframe
之间相互跳转,直接跳转会只在iframe
范围内跳转,所以必须通过主页面来进行跳转。不过iframe
跳转的情况很少- 不同源的系统之间的通讯需要通过
postMessage
,存在一定的安全性
优点
- 完全隔离了
css
和js
,避免了各个系统之间的样式和js
污染 - 可以在子系统完全不修改的情况下嵌入进来
single-spa的优缺点
缺点
css
和js
需要制定规范,进行隔离。否则容易造成全局污染,尤其是vue
的全局组件,全局钩子。- 需要子系统配合修改。但是不影响子系统独立开发部署,路由部分对子系统有一些改动,但是不影响功能。
优点
- 加载快,可以将所有系统共用的模块提取出来,实现按需加载,一次加载,其他的复用。
- 修改子系统的样式,不需要代理服务器,直接修改,因为同属于一个
document
。 - 用户体验好、快,内容的改变不需要重新加载整个页面,避免了不必要的跳转和重复渲染
http
请求少,服务器压力小。
single-spa和iframe对比
对比项 | single-spa | iframe | 补充 |
---|---|---|---|
加载速度 | single-spa可以将所有系统共用的vue/vuex/vue-router等文件提取出来,只加载一次,各系统复用,加载速度很快,但是必须保证文件版本统一 | iframe会占用主系统的http通道,影响主系统的加载,加载速度很慢 | 两者都可以通过http缓存提高一定的加载速度,但是对于vue这些通用文件没法做cdn,因为内部系统很可能无法访问外网 |
兼容性 | single-spa只适用于vue、react、angular编写的系统,对一些jq写的老系统无能为力 | iframe则可以嵌入任何页面 | |
技术难度 | single-spa需要一定的技术储备,有一些学习成本 | iframe门槛则很低,无需额外学习 | |
局限性 | single-spa可以嵌入任何部件 | iframe只能嵌入页面,当然了也可以把一个部件单独写成一个页面 | |
改造成本 | single-spa一定要对子系统进行改造,但是改造的内容并不多很多,半小时即可完成 | iframe可以不对原系统进行改造,但是必须借助代理服务器进行插入脚本和css,增加了代理服务器也增加了系统的不稳定性(两台服务器中的任何一台挂掉都会导致系统不可用),服务器也需要成本。如对原系统进行改造,则工作量和single-spa相当 | 项目的源文件丢失或者其他一些无法改动源文件的情况,只能使用iframe |
补充:
- 对于
SEO
,iframe
无法解决,但是single-spa
有办法解决(谷歌能支持单页应用的SEO
,百度则需要SSR
),但是内部系统,SEO的需求比较少。 iframe
存在安全隐患,两个iframe
页面互相引用则会导致无限嵌套bug
,会导致页面卡死,目前只能通过代理服务器检查iframe
页面内容来处理- 页面可以通过一些代码,不允许自己被
iframe
嵌入。这种情况下就只能选择其他的方案,淘宝京东腾讯等都曾设置过,代码如下:
if(window.top !== window.self){ window.top.location = window.location;}
5、SingleSpa实现原理
详细的可以参照这位大佬的博客
我们已经知道,single-spa
解决的是应用的加载与切换相关的问题,下面就来看完整的实现过程。
(1)路由问题
single-spa
是通过监听hashChange和popState这两个原生事件来检测路由变化的,它会根据路由的变化来加载对应的应用,相关的代码可以在single-spa
的 src/navigation/navigation-events.js
中找到
single-spa
解决路由问题的主要逻辑。主要是以下几步:
- 根据传入的参数
activeWhen
判断哪个应用需要加载,哪个应用需要卸载或清除,并将其push到对应的数组 - 如果应用已经启动,则进行应用加载或切换。针对应用的不同状态,直接执行应用自身暴露出的生命周期钩子函数即可。
- 如果应用未启动,则只去下载
appsToLoad
中的应用。
总的来看,当路由发生变化时,hashChange
或popState
会触发,这时single-spa
会监听到,并触发urlReroute
;接着它会调用reroute
,该函数正确设置各个应用的状态后,直接通过调用应用所暴露出的生命周期钩子函数即可。当某个应用被推送到appsToMount
后,它的mount
函数会被调用,该应用就会被挂载;而推送到appsToUnmount
中的应用则会调用其unmount
钩子进行卸载。
上面我们还提到,single-spa
除了监听hashChange
或popState
两个事件外,还劫持了原生的pushState
和 replaceState
两个方法,这是为什么呢?
这是因为像scroll-restorer这样的第三方组件可能会在页面滚动时,通过调用pushState
或replaceState
,将滚动位置记录在state中,而页面的url实际上没有变化。这种情况下,single-spa
理论上不应该去重新加载应用,但是由于这种行为会触发页面的hashChange
事件,所以根据上面的逻辑,single-spa
会发生意外重载。
为了解决这个问题,single-spa
允许开发者手动设置是否只对url值的变化监听,而不是只要发生hashChange
或popState
就去重新加载应用,我们可以像下面一样在启动single-spa
时添加urlRerouteOnly
参数:
singleSpa.start({
urlRerouteOnly: true,
});
这样除非url发生了变化,否则pushState
和popState
不会导致应用重载。
(2)应用入口
single-spa
采用的是协议入口,即只要实现了single-spa
的入口协议规范,它就是可加载的应用。single-spa
的规范要求应用入口必须暴露出以下三个生命周期钩子函数,且必须返回Promise,以保证single-spa
可以注册回调函数:
- bootstrap
- mount
- unmount
bootstrap用于应用引导,基座应用会在子应用挂载前调用它。举个应用场景,假如某个子应用要挂载到基座应用内id
为app
的节点上:
new Vue({
el: '#app',
...
})
但是基座应用中当前没有id
为app
的节点,我们就可以在子应用的bootstrap
钩子内手动创建这样一个节点并插入到基座应用,子应用就可以正常挂载了。所以它的作用就是做一些挂载前的准备工作。
mount用于应用挂载,就是一般应用中用于渲染的逻辑,即上述的new Vue
语句。我们通常会把它封装到一个函数里,在mount
钩子函数中调用。
unmount用于应用卸载,我们可以在这里调用实例的destroy
方法手动卸载应用,或清除某些内存占用等。
除了以上三个必须实现的钩子外,single-spa
还支持非必须的load
、unload
、update
等,分别用于加载、卸载和更新应用。
那么只使用single-spa
如何进行子应用加载呢?
(3)应用加载
实际上single-spa
并没有提供自己的解决方案,而是将它开放出来,由开发者提供。
我们看一下基于system.js
如何启动single-spa
:
<script type="systemjs-importmap">
{
"imports": {
"app1": "http://localhost:8080/app1.js",
"app2": "http://localhost:8081/app2.js",
"single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js"
}
}
</script>
... // system.js的相关依赖文件
<script>
(function(){
// 加载single-spa
System.import('single-spa').then((res)=>{
var singleSpa = res;
// 注册子应用
singleSpa.registerApplication('app1',
() => System.import('app1'),
location => location.hash.startsWith(`#/app1`);
);
singleSpa.registerApplication('app2',
() => System.import('app2'),
location => location.hash.startsWith(`#/app2`);
);
// 启动single-spa
singleSpa.start();
})
})()
</script>
我们在调用singleSpa.registerApplication
注册应用时提供的第二个参数就是加载这个子应用的方法。如果需要加载多个js,可以使用多个System.import
连续导入。single-spa
会调用这个函数,下载子应用代码并分别调用其bootstrap
和mount
方法进行引导和挂载。
从这里我们也可以看到single-spa
的弊端。首先我们必须手动实现应用加载逻辑,挨个罗列子应用需要加载的资源,这在大型项目里是十分困难的(特别是使用了文件名hash时);另外它只能以js文件为入口,无法直接以html为入口,这使得嵌入子应用变得很困难,也正因此,single-spa
不能直接加载jQuery应用。
single-spa
的start
方法也很简单:
export function start(opts) {
started = true;
if (opts && opts.urlRerouteOnly) {
setUrlRerouteOnly(opts.urlRerouteOnly);
}
if (isInBrowser) {
reroute();
}
}
先是设置started
状态,然后设置我们上面说到的urlRerouteOnly
属性,接着调用reroute
,开始首次加载子应用。加载完第一个应用后,single-spa
就时刻等待着hashChange
或popState
事件的触发,并执行应用的切换。
以上就是single-spa
的核心原理,从上面的介绍中不难看出,single-spa
只是负责把应用加载到一个页面中,至于应用能否协同工作,是很难保证的。而qiankun
所要解决的,就是协同工作的问题。
三、qiankun
1、qiankun简介
qiankun 是一个生产可用的微前端框架,它基于 single-spa,具备 js 沙箱、样式隔离、HTML Loader、预加载 等微前端系统所需的能力。qiankun 可以用于任意 js 框架,微应用接入像嵌入一个 iframe 系统一样简单。
微前端的概念借鉴自后端的微服务,主要是为了解决大型工程在变更、维护、扩展等方面的困难而提出的。目前主流的微前端方案包括以下几个:
- iframe
- 基座模式,主要基于路由分发,
qiankun
和single-spa
就是基于这种模式 - 组合式集成,即单独构建组件,按需加载,类似npm包的形式
- EMP,主要基于
Webpack5 Module Federation
- Web Components
iframe:是传统的微前端解决方案,基于iframe标签实现,技术难度低,隔离性和兼容性很好,但是性能和使用体验比较差,多用于集成第三方系统;
基座模式:主要基于路由分发,即由一个基座应用来监听路由,并按照路由规则来加载不同的应用,以实现应用间解耦;
组合式集成:把组件单独打包和发布,然后在构建或运行时组合;
EMP:基于Webpack5 Module Federation
,一种去中心化的微前端实现方案,它不仅能很好地隔离应用,还可以轻松实现应用间的资源共享和通信;
Web Components:是官方提出的组件化方案,它通过对组件进行更高程度的封装,来实现微前端,但是目前兼容性不够好,尚未普及。
总的来说,iframe主要用于简单并且性能要求不高的第三方系统;组合式集成目前主要用于前端组件化,而不是微前端;基座模式、EMP和Web Components是目前主流的微前端方案。
2、qiankun所基于的基座模式
qiankun
所基于的是基座模式。它的主要思路是将一个大型应用拆分成若干个更小、更简单,可以独立开发、测试和部署的子应用,然后由一个基座应用根据路由进行应用切换。
如果以前端组件的概念作类比,我们可以把每个被拆分出的子应用看作是一个应用级组件,每个应用级组件专门实现某个特定的业务功能(如商品管理、订单管理等)。这里实际上谈到了微前端拆分的原则:即以业务功能为基本单元。经过拆分后,整个系统的结构也发生了变化:
左侧是传统大型单页应用的前端架构,所有模块都在一个应用内,由应用本身负责路由管理,是应用分发路由的方式;而右侧是基座模式下的系统架构,各个子应用互不相关,单独运行在不同的服务上,由基座应用根据路由选择加载哪个应用到页面内,是路由分发应用的方式。这种方式使得各个模块的耦合性大大降低,而微前端需要解决的主要问题就是如何拆分和组织这些子应用。
为了让这些拆分出的子应用在一个单页面内协同工作,我们需要一个“管理者”应用,这就是我们上面说的基座应用,也叫主应用。基座应用一般是用户最终访问的应用,它会根据定义的规则,将不同的应用加载到页面内供用户使用。当然,这种架构下的每个子应用也具备单独访问的能力。
为了配合基座应用,子应用必须经过一些改造,向外暴露出相应的生命周期钩子,以便基座应用加载和卸载。实际上,一个典型的基于vue-router的Vue应用与这种架构存在着很大的相似性:
在典型的Vue应用中,各个组件当然都必须基于Vue编写;但是在微前端架构中,各个子应用可以基于不同的技术框架,这也是它最大的优势之一。这是因为各个子应用是独立编译和部署的,而基座应用是在运行时动态加载的子应用,由于在启动子应用时已经经历过编译阶段,所以基座应用加载的都是原生JavaScript代码,自然与子应用所用的技术框架无关(qiankun
甚至能加载jQuery编写的页面)。
概念性地讲,在微前端架构中,各个子应用将一些特定的业务功能封装在一个业务黑箱中,只对外暴露少量生命周期方法;基座应用根据路由地址变化,动态地加载对应的业务黑箱,并将其渲染到指定的占位DOM元素上。与Vue应用一样,微前端也可以一次加载多个业务黑箱,这称为多实例模式(类似于vue-router的命名视图)。
3、qiankun与single-spa实现原理
既然qiankun
是基于single-spa
的,那么我们就来看qiankun
和single-spa
在架构中分别扮演了什么角色。
一般来说,微前端需要解决的问题分为两大类:
- 应用的加载与切换
- 应用的隔离与通信
应用的加载与切换需要解决的问题包括:路由问题、应用入口、应用加载;应用的隔离与通信需要解决的问题包括:js隔离、css样式隔离、应用间通信。
single-spa
很好地解决了路由和应用入口两个问题,但并没有解决应用加载问题,而是将该问题暴露出来由使用者实现(一般可以用system.js
或原生script
标签来实现);qiankun
在此基础上封装了一个应用加载方案(即import-html-entry
),并给出了js隔离、css样式隔离和应用间通信三个问题的解决方案,同时提供了预加载功能。
借助single-spa
提供的能力,我们只能把不同的应用加载到一个页面内,但是很难保证这些应用不会互相干扰。而qiankun
为我们解决了这些后顾之忧,使得它成为一个更加完整的微前端运行时容器。
4、qiankun实现原理
(1)registerMicroApps和start函数
qiankun我们从两个函数去入手qiankun,registerMicroApps和start函数。
registerMicroApps
export function registerMicroApps(apps, lifeCycles) {
var _this = this;
// Each app only needs to be registered once
// apps是本文件定义的一个全局数组,装着你在qiankun中注册的子应用信息。
// 然后把app的元素存入unregisteredApps中。所以其实整句话的含义就是在app上找出那些没有被注册的应用。其实就是变量名称unregisteredApps的含义。
var unregisteredApps = apps.filter(function (app) {
return !microApps.some(function (registeredApp) {
// 那么这句话返回的就是false,取反就为true,
return registeredApp.name === app.name;
});
});
// 这里就把未注册的应用和已经注册的应用进行合并
microApps = [].concat(_toConsumableArray(microApps), _toConsumableArray(unregisteredApps));
unregisteredApps.forEach(function (app) {
// 解构出子应用的名字,激活的url匹配规规则,实际上activeRule就是用在single-spa的activeWhen,loader是一个空函数它是loadsh里面的东西,props传入子应用的值。
var name = app.name,
activeRule = app.activeRule,
_app$loader = app.loader,
loader = _app$loader === void 0 ? _noop : _app$loader,
props = app.props,
appConfig = __rest(app, ["name", "activeRule", "loader", "props"]);
// 这里调用的是single-spa构建应用的api
// name app activeRule props都是交给single-spa用的
registerApplication({
name: name,
// 这里可以看出我开始说的问题,qiankun帮主我们定制了一套加载子应用的方案。整个加载函数核心的逻辑就是loadApp
// 最后返回出一个经过处理的装载着生命周期函数的对象,和我上篇分析single-spa说到的加载函数的写法的理解是一致的
app: function app() {
return __awaiter(_this, void 0, void 0, /*#__PURE__*/_regeneratorRuntime.mark(function _callee3() {
var _this2 = this;
var _a, mount, otherMicroAppConfigs;
return _regeneratorRuntime.wrap(function _callee3$(_context3) {
while (1) {
switch (_context3.prev = _context3.next) {
case 0:
loader(true);
_context3.next = 3;
return frameworkStartedDefer.promise;
case 3:
_context3.next = 5;
return loadApp(Object.assign({
name: name,
props: props
}, appConfig), frameworkConfiguration, lifeCycles);
case 5:
_context3.t0 = _context3.sent;
_a = (0, _context3.t0)();
mount = _a.mount;
otherMicroAppConfigs = __rest(_a, ["mount"]);
return _context3.abrupt("return", Object.assign({
mount: [function () {
return __awaiter(_this2, void 0, void 0, /*#__PURE__*/_regeneratorRuntime.mark(function _callee() {
return _regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
return _context.abrupt("return", loader(true));
case 1:
case "end":
return _context.stop();
}
}
}, _callee);
}));
}].concat(_toConsumableArray(toArray(mount)), [function () {
return __awaiter(_this2, void 0, void 0, /*#__PURE__*/_regeneratorRuntime.mark(function _callee2() {
return _regeneratorRuntime.wrap(function _callee2$(_context2) {
while (1) {
switch (_context2.prev = _context2.next) {
case 0:
return _context2.abrupt("return", loader(false));
case 1:
case "end":
return _context2.stop();
}
}
}, _callee2);
}));
}])
}, otherMicroAppConfigs));
case 10:
case "end":
return _context3.stop();
}
}
}, _callee3);
}));
},
activeWhen: activeRule,
customProps: props
});
});
}
registerMicroApps其实只做了一件事情,根据用户传入的参数forEach遍历子应用注册数组,调用single-spa的registerApplication方法去注册子应用。
start函数
qiankun的start函数在single-spa的start函数的基础上增加了一些东西
export function start() {
var opts = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
frameworkConfiguration = Object.assign({
prefetch: true,
singular: true,
sandbox: true
}, opts);
var _frameworkConfigurati = frameworkConfiguration,
prefetch = _frameworkConfigurati.prefetch,
sandbox = _frameworkConfigurati.sandbox,
singular = _frameworkConfigurati.singular,
_frameworkConfigurati2 = _frameworkConfigurati.urlRerouteOnly,
urlRerouteOnly = _frameworkConfigurati2 === void 0 ? defaultUrlRerouteOnly : _frameworkConfigurati2,
importEntryOpts = __rest(frameworkConfiguration, ["prefetch", "sandbox", "singular", "urlRerouteOnly"]);
if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
frameworkConfiguration = autoDowngradeForLowVersionBrowser(frameworkConfiguration);
startSingleSpa({
urlRerouteOnly: urlRerouteOnly
});
started = true;
frameworkStartedDefer.resolve();
}
var autoDowngradeForLowVersionBrowser = function autoDowngradeForLowVersionBrowser(configuration) {
var sandbox = configuration.sandbox,
singular = configuration.singular;
// 检查当前环境是否支持proxy。因为后面沙箱环境中需要用到这个东西
if (sandbox) {
if (!window.Proxy) {
console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
if (singular === false) {
console.warn('[qiankun] Setting singular as false may cause unexpected behavior while your browser not support window.Proxy');
}
return Object.assign(Object.assign({}, configuration), {
sandbox: _typeof(sandbox) === 'object' ? Object.assign(Object.assign({}, sandbox), {
loose: true
}) : {
loose: true
}
});
}
}
return configuration;
};
总结:qiankun的start方法做了两件事情:
1.根据用户传入start的参数,判断预加载资源的时机。
2.执行single-spa的start方法启动应用。
(2)应用加载
在start启动应用之后不久,就会进入到加载函数。准备加载子应用。下面看看qiankun加载函数的源码。
app: function app() {
return __awaiter(_this, void 0, void 0, /*#__PURE__*/_regeneratorRuntime.mark(function _callee3() {
var _this2 = this;
var _a, mount, otherMicroAppConfigs;
return _regeneratorRuntime.wrap(function _callee3$(_context3) {
while (1) {
switch (_context3.prev = _context3.next) {
case 0:
loader(true);
_context3.next = 3;
return frameworkStartedDefer.promise;
case 3:
_context3.next = 5;
// 这里loadApp就是qiankun加载子应用的应对方案
return loadApp(Object.assign({
name: name,
props: props
}, appConfig), frameworkConfiguration, lifeCycles);
case 5:
_context3.t0 = _context3.sent;
_a = (0, _context3.t0)();
mount = _a.mount;
otherMicroAppConfigs = __rest(_a, ["mount"]);
return _context3.abrupt("return", Object.assign({
mount: [function () {
return __awaiter(_this2, void 0, void 0, /*#__PURE__*/_regeneratorRuntime.mark(function _callee() {
return _regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
return _context.abrupt("return", loader(true));
case 1:
case "end":
return _context.stop();
}
}
}, _callee);
}));
}].concat(_toConsumableArray(toArray(mount)), [function () {
return __awaiter(_this2, void 0, void 0, /*#__PURE__*/_regeneratorRuntime.mark(function _callee2() {
return _regeneratorRuntime.wrap(function _callee2$(_context2) {
while (1) {
switch (_context2.prev = _context2.next) {
case 0:
return _context2.abrupt("return", loader(false));
case 1:
case "end":
return _context2.stop();
}
}
}, _callee2);
}));
}])
}, otherMicroAppConfigs));
case 10:
case "end":
return _context3.stop();
}
}
}, _callee3);
}));
},
loadApp源码
export async function loadApp<T extends object>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
//从app参数中解构出子应用的入口entry,和子应用的名称。
const { entry, name: appName } = app;
//定义了子应用实例的id
const appInstanceId = `${appName}_${+new Date()}_${Math.floor(Math.random() * 1000)}`;
const markName = `[qiankun] App ${appInstanceId} Loading`;
if (process.env.NODE_ENV === 'development') {
//进行性能统计
performanceMark(markName);
}
const { singular = false, sandbox = true, excludeAssetFilter, ...importEntryOpts } = configuration;
//importEntry是import-html-entry库中的方法,这里就是qiankun对于加载子应用资源的策略
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
...省略
}
上面我们说到了,single-spa
提供的应用加载方案是开放式的。针对上面我们谈到的几个弊端,qiankun
进行了一次封装,给出了一个更完整的应用加载方案,qiankun
的作者将其封装成了npm插件import-html-entry
。
该方案的主要思路是允许以html文件为应用入口,然后通过一个html解析器从文件中提取js和css依赖,并通过fetch下载依赖,于是在qiankun
中你可以这样配置入口:
const MicroApps = [{
name: 'app1',
entry: 'http://localhost:8080',
container: '#app',
activeRule: '/app1'
}]
qiankun
会通过import-html-entry
请求http://localhost:8080
,得到对应的html文件,解析内部的所有script
和style
标签,依次下载和执行它们,这使得应用加载变得更易用。我们看一下这具体是怎么实现的。
importEntry源码
export function importEntry(entry, opts = {}) {
//第一个参数entry是你子应用的入口地址
//第二个参数{prefetch: true}
//defaultFetch是默认的资源请求方法,其实就是window.fecth。在qiankun的start函数中,可以允许你传入自定义的fetch方法去请求资源。
//defaultGetTemplate是一个函数,传入一个字符串,原封不动的返回出来
const { fetch = defaultFetch, getTemplate = defaultGetTemplate } = opts;
//getPublicPath是一个函数,用来解析用户entry,转变为正确的格式,因为用户可能写入口地址写得奇形怪状,框架把不同的写法统一一下。
const getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;
//没有写子应用加载入口直接报错
if (!entry) {
throw new SyntaxError('entry should not be empty!');
}
// html entry
if (typeof entry === 'string') {
//加载代码核心函数
return importHTML(entry, {
fetch,
getPublicPath,
getTemplate,
});
}
...省略
}
import-html-entry
暴露出的核心接口是importHTML
,用于加载html文件,它支持两个参数:
- url,要加载的文件地址,一般是服务中html的地址
- opts,配置参数
url不必多说。opts如果是一个函数,则会替换默认的fetch作为下载文件的方法,此时其返回值应当是Promise;如果是一个对象,那么它最多支持四个属性:fetch
、getPublicPath
、getDomain
、getTemplate
,用于替换默认的方法,这里暂不详述。
具体过程如下:
- 检查是否有缓存,如果有,直接从缓存中返回
- 如果没有,则通过fetch下载,并字符串化
- 调用
processTpl
进行一次模板解析,主要任务是扫描出外联脚本和外联样式,保存在scripts
和styles
中 - 调用
getEmbedHTML
,将外联样式下载下来,并替换到模板内,使其变成内部样式 - 返回一个对象,该对象包含处理后的模板,以及
getExternalScripts
、getExternalStyleSheets
、execScripts
等几个核心方法
export default function importHTML(url) {
var opts = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
var fetch = defaultFetch;
var autoDecodeResponse = false;
var getPublicPath = defaultGetPublicPath;
var getTemplate = defaultGetTemplate; // compatible with the legacy importHTML api
if (typeof opts === 'function') {
fetch = opts;
} else {
// fetch option is availble
if (opts.fetch) {
// fetch is a funciton
if (typeof opts.fetch === 'function') {
fetch = opts.fetch;
} else {
// configuration
fetch = opts.fetch.fn || defaultFetch;
autoDecodeResponse = !!opts.fetch.autoDecodeResponse;
}
}
getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;
getTemplate = opts.getTemplate || defaultGetTemplate;
}
// 上面部分的代码多为参数预处理,可忽略不看
// embedHTMCache是本文件开头定义的全局对象,用来缓存请求的资源的结果,下一次如果想要获取资源直接从缓存获取,不需要再次请求。
// 如果在缓存中找不到的话就去通过window.fetch去请求子应用的资源。但是这里需要注意,你从主应用中去请求子应用的资源是会存在跨域的。所以你在子应用中必须要进行跨域放行。配置下webpack的devServer的headers就可以
// 从这里可以看出来qiankun是如何获取子应用的资源的,默认是通过window.fetch去请求子应用的资源。而不是简单的注入srcipt标签,通过fetch去获得了子应用的html资源信息,然后通过response.text把信息转变为字符串的形式。
// 然后把得到的html字符串传入processTpl里面进行html的模板解析
// 如果已经加载过,则从缓存返回,否则fetch回来并保存到缓存中
return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url).then(function (response) {
return readResAsString(response, autoDecodeResponse);
}).then(function (html) {
// 获取处理后的url
var assetPublicPath = getPublicPath(url);
// 对html字符串进行初步处理
// processTpl这个拿到了子应用html的模板之后对微应用所有的资源引入做处理。
var _processTpl = processTpl(getTemplate(html), assetPublicPath),
template = _processTpl.template,
scripts = _processTpl.scripts,
entry = _processTpl.entry,
styles = _processTpl.styles;
// 先将外部样式处理成内联样式
// 然后返回几个核心的脚本及样式处理方法
return getEmbedHTML(template, styles, {
fetch: fetch
}).then(function (embedHTML) {
return {
// getEmbedHTML通过它的处理,就把外部引用的样式文件转变为了style标签,embedHTML就是处理后的html模板字符串
// embedHTML就是新生成style标签里面的内容
template: embedHTML,
assetPublicPath: assetPublicPath,
getExternalScripts: function getExternalScripts() {
return _getExternalScripts(scripts, fetch);
},
getExternalStyleSheets: function getExternalStyleSheets() {
return _getExternalStyleSheets(styles, fetch);
},
// 下面这个函数就是用来解析脚本的。从这里看来它并不是简单的插入script标签就完事了。而是
// 通过在代码内部去请求资源,然后再去运行了别人的脚本内容
execScripts: function execScripts(proxy, strictGlobal) {
var execScriptsHooks = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
if (!scripts.length) {
return Promise.resolve();
}
return _execScripts(entry, scripts, proxy, {
fetch: fetch,
strictGlobal: strictGlobal,
beforeExec: execScriptsHooks.beforeExec,
afterExec: execScriptsHooks.afterExec
});
}
};
});
}));
}
processTpl
主要基于正则表达式对模板字符串进行解析,这里不进行详述。我们来看getExternalScripts
、getExternalStyleSheets
、execScripts
这三个方法:
getExternalStyleSheets
getExternalStyleSheets: function getExternalStyleSheets() {
return _getExternalStyleSheets(styles, fetch);
},
// 所以直接看 _getExternalStyleSheets
function _getExternalStyleSheets(styles) {
var fetch = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : defaultFetch;
return Promise.all(styles.map(function (styleLink) {
if (isInlineCode(styleLink)) {
// if it is inline style
return getInlineCode(styleLink);
} else {
// external styles
return styleCache[styleLink] || (styleCache[styleLink] = fetch(styleLink).then(function (response) {
return response.text();
}));
}
}));
} // for prefetch
// 判断是否从<开始
var isInlineCode = function isInlineCode(code) {
return code.startsWith('<');
};
// 判断是否在<>之间
export function getInlineCode(match) {
var start = match.indexOf('>') + 1;
var end = match.lastIndexOf('<');
return match.substring(start, end);
}
遍历styles数组,如果是内联样式,则直接返回;否则判断缓存中是否存在,如果没有,则通过fetch去下载,并进行缓存。
getExternalScripts与上述过程类似。
getExternalScripts: function getExternalScripts() {
return _getExternalScripts(scripts, fetch);
},
function _getExternalScripts(scripts) {
var fetch = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : defaultFetch;
var errorCallback = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : function () {};
var fetchScript = function fetchScript(scriptUrl) {
return scriptCache[scriptUrl] || (scriptCache[scriptUrl] = fetch(scriptUrl).then(function (response) {
// usually browser treats 4xx and 5xx response of script loading as an error and will fire a script error event
// https://stackoverflow.com/questions/5625420/what-http-headers-responses-trigger-the-onerror-handler-on-a-script-tag/5625603
if (response.status >= 400) {
errorCallback();
throw new Error("".concat(scriptUrl, " load failed with status ").concat(response.status));
}
return response.text();
}));
};
execScripts是实现js隔离的核心方法。
通过调用importHTML方法,qiankun可以直接加载html文件,同时将外联样式处理成内部样式表,并且解析出JavaScript依赖。更重要的是,它获得了一个可以在隔离环境下执行应用脚本的方法execScripts。
(3)js隔离
上面我们说到,qiankun
通过import-html-entry
,可以对html入口进行解析,并获得一个可以执行脚本的方法execScripts
。qiankun
引入该接口后,首先为该应用生成一个window的代理对象,然后将代理对象作为参数传入接口,以保证应用内的js不会对全局window
造成影响。由于IE11不支持proxy,所以qiankun
通过快照策略来隔离js,缺点是无法支持多实例场景。
我们先来看基于proxy
的js隔离是如何实现的。
execScripts
execScripts: function execScripts(proxy, strictGlobal) {
var execScriptsHooks = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
if (!scripts.length) {
return Promise.resolve();
}
return _execScripts(entry, scripts, proxy, {
fetch: fetch,
strictGlobal: strictGlobal,
beforeExec: execScriptsHooks.beforeExec,
afterExec: execScriptsHooks.afterExec
});
}
function _execScripts(entry, scripts) {
... // 初始化参数
return _getExternalScripts(scripts, fetch, error).then(function (scriptsText) {
//scriptsText就是解析的到的js资源的字符串
// 在proxy对象下执行脚本的方法
var geval = function geval(scriptSrc, inlineScript) {
//第一个参数是解析js脚本的绝对路径 第二参数是解析js脚本的js字符串代码
var rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;
//这里这个code存放着执行脚本js代码的字符串
var code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal);
//这里就是正式执行js脚本,这里含有我们的子应用的js代码,但是被包裹在了一个立即执行函数的环境中。
(0, eval)(code);
afterExec(inlineScript, scriptSrc);
};
// 执行单个脚本的方法
function exec(scriptSrc, inlineScript, resolve) {
//第一个参数是解析js脚本的路径 第二参数是解析js脚本的js字符串代码
var markName = "Evaluating script ".concat(scriptSrc);
var measureName = "Evaluating Time Consuming: ".concat(scriptSrc);
if (process.env.NODE_ENV === 'development' && supportsUserTiming) {
performance.mark(markName);
}
if (scriptSrc === entry) {
noteGlobalProps(strictGlobal ? proxy : window);
try {
// bind window.proxy to change `this` reference in script
//这个geval会对的得到的js字符串代码做一下包装,这个包装就是改变它的window环境。
geval(scriptSrc, inlineScript);
var exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
//这里的resolve是从上层函数通过参数传递过来的,这里resolve相当于上层函数resolve返回给qiankun的调用await
resolve(exports);
} catch (e) {
// entry error must be thrown to make the promise settled
console.error("[import-html-entry]: error occurs while executing entry script ".concat(scriptSrc));
throw e;
}
} else {
if (typeof inlineScript === 'string') {
try {
// bind window.proxy to change `this` reference in script
geval(scriptSrc, inlineScript);
} catch (e) {
// consistent with browser behavior, any independent script evaluation error should not block the others
throwNonBlockingError(e, "[import-html-entry]: error occurs while executing normal script ".concat(scriptSrc));
}
} else {
// external script marked with async
inlineScript.async && (inlineScript === null || inlineScript === void 0 ? void 0 : inlineScript.content.then(function (downloadedScriptText) {
return geval(inlineScript.src, downloadedScriptText);
})["catch"](function (e) {
throwNonBlockingError(e, "[import-html-entry]: error occurs while executing async script ".concat(inlineScript.src));
}));
}
}
if (process.env.NODE_ENV === 'development' && supportsUserTiming) {
performance.measure(measureName, markName);
performance.clearMarks(markName);
performance.clearMeasures(measureName);
}
}
// 执行顺序排列函数,负责逐个执行脚本
function schedule(i, resolvePromise) {
if (i < scripts.length) {
var scriptSrc = scripts[i];
var inlineScript = scriptsText[i];
exec(scriptSrc, inlineScript, resolvePromise); // resolve the promise while the last script executed and entry not provided
if (!entry && i === scripts.length - 1) {
resolvePromise();
} else {
schedule(i + 1, resolvePromise);
}
}
}
// 返回执行顺序排列函数,执行脚本
//这个schedule的作用就是开始解析script脚本
return new Promise(function (resolve) {
return schedule(0, success || resolve);
});
});
}
// 这个函数的关键是定义了三个函数:geval、exec、schedule,其中实现js隔离的是geval函数内调用的getExecutableScript函数。我们看到,在调这个函数时,我们把外部传入的proxy作为参数传入了进去,而它返回的是一串新的脚本字符串,这段新的字符串内的window已经被proxy替代
function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
var sourceUrl = isInlineCode(scriptSrc) ? '' : "//# sourceURL=".concat(scriptSrc, "\n");
// 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
// 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
//这里直接把window.proxy对象改为了沙箱环境proxy,然后下面就传入的代码中,当作是别人的window环境。
var globalWindow = (0, eval)('window');
globalWindow.proxy = proxy; // TODO 通过 strictGlobal 方式切换切换 with 闭包,待 with 方式坑趟平后再合并
return strictGlobal ? ";(function(window, self, globalThis){with(window){;".concat(scriptText, "\n").concat(sourceUrl, "}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);") : ";(function(window, self, globalThis){;".concat(scriptText, "\n").concat(sourceUrl, "}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);");
} // for prefetch
核心代码就是由两个矩形框起来的部分,它把解析出的scriptText(即脚本字符串)用with(window){}包裹起来,然后把window.proxy作为函数的第一个参数传进来,所以with语法内的window实际上是window.proxy。
这样,当在执行这段代码时,所有类似var name = '张三’这样的语句添加的全局变量name,实际上是被挂载到了window.proxy上,而不是真正的全局window上。当应用被卸载时,对应的proxy会被清除,因此不会导致js污染。而当你配置webpack的打包类型为lib时,你得到的接口大概如下:
var vueApp = (function(){})();
如果你的应用内使用了Vue,那么这个Vue对象就会被挂载到window.proxy上。不过如果你在代码内直接写window.name = '张三’来生成全局变量,那么qiankun就无法隔离js污染了。
import-html-entry实现了上述能力后,qiankun要做的就很简单了,只需要在加载一个应用时为其初始化一个proxy传递进来即可:
在IE下,由于proxy
不被支持,并且没有可用的polyfill
,所以qiankun
退而求其次,采用快照策略实现js隔离。它的大致思路是,在加载应用前,将window
上的所有属性保存起来(即拍摄快照);等应用被卸载时,再恢复window
上的所有属性,这样也可以防止全局污染。但是当页面同时存在多个应用实例时,qiankun
无法将其隔离开,所以IE下的快照策略无法支持多实例模式。
总结一下:
-
框架其实是通过window.fetch去获取子应用的js代码。
-
拿到了子应用的js代码字符串之后,把它进行包装处理。把代码包裹在了一个立即执行函数中,通过参数的形式改变了它的window环境,变成了沙箱环境。
function(window, self) {
子应用js代码
}(window,proxy, window.proxy)
- 最后通过eval()去执行立即执行函数,正式去执行我们的子应用的js代码,去渲染出整个子应用。
(4)css隔离
目前qiankun
主要提供了两种样式隔离方案,一种是基于shadowDom
的;另一种则是实验性的,思路类似于Vue中的scoped
属性,给每个子应用的根节点添加一个特殊属性,用作对所有css选择器的约束。
开启样式隔离的语法如下:
registerMicroApps({
name: 'app1',
...
sandbox: {
strictStyleIsolation: true
// 实验性方案,scoped方式
// experimentalStyleIsolation: true
},
})
当启用strictStyleIsolation
时,qiankun
将采用shadowDom
的方式进行样式隔离,即为子应用的根节点创建一个shadow root
。最终整个应用的所有DOM将形成一棵shadow tree
。我们知道,shadowDom
的特点是,它内部所有节点的样式对树外面的节点无效,因此自然就实现了样式隔离。
但是这种方案是存在缺陷的。因为某些UI框架可能会生成一些弹出框直接挂载到document.body
下,此时由于脱离了shadow tree
,所以它的样式仍然会对全局造成污染。
此外qiankun
也在探索类似于scoped
属性的样式隔离方案,可以通过experimentalStyleIsolation
来开启。这种方案的策略是为子应用的根节点添加一个特定的随机属性,如:
<div
data-qiankun-asiw732sde
id="__qiankun_microapp_wrapper__"
data-name="module-app1"
>
然后为所有样式前面都加上这样的约束:
.app-main {
字体大小:14 px ;
}
// ->
div[data-qiankun-asiw732sde] .app-main {
字体大小:14 px ;
}
经过上述替换,这个样式就只能在当前子应用内生效了。虽然该方案已经提出很久了,但仍然是实验性的,因为它不支持@ keyframes,@ font-face,@ import,@ page(即不会被重写)。
(5)应用通信
一般来说,微前端中各个应用之前的通信应该是尽量少的,而这依赖于应用的合理拆分。反过来说,如果你发现两个应用间存在极其频繁的通信,那么一般是拆分不合理造成的,这时往往需要将它们合并成一个应用。
当然了,应用间存在少量的通信是难免的。qiankun
官方提供了一个简要的方案,思路是基于一个全局的globalState
对象。这个对象由基座应用负责创建,内部包含一组用于通信的变量,以及两个分别用于修改变量值和监听变量变化的方法:setGlobalState
和onGlobalStateChange
。
以下代码用于在基座应用中初始化它:
import { initGlobalState, MicroAppStateActions } from 'qiankun';
const initialState = {};
const actions: MicroAppStateActions = initGlobalState(initialState);
export default actions;
这里的actions
对象就是我们说的globalState
,即全局状态。基座应用可以在加载子应用时通过props
将actions
传递到子应用内,而子应用通过以下语句即可监听全局状态变化:
actions.onGlobalStateChange (globalState, oldGlobalState) {
...
}
同样的,子应用也可以修改全局状态:
actions.setGlobalState(...);
此外,基座应用和其他子应用也可以进行这两个操作,从而实现对全局状态的共享,这样各个应用之间就可以通信了。这种方案与Redux和Vuex都有相似之处,只是由于微前端中的通信问题较为简单,所以官方只提供了这样一个精简方案。关于其实现原理这里不再赘述,感兴趣的可以去看一下源码。
四、CSS隔离方案
子应用之间样式隔离:
Dynamic Stylesheet
动态样式表,当应用切换时移除掉老应用样式,再添加新应用样式,保证在一个时间点内只有一个应用的样式表生效
主应用和子应用之间的样式隔离:
- BEM(Block Element Modifier) 约定项目前缀
- CSS-Modules 打包时生成不冲突的选择器名
- Shadow DOM 真正意义上的隔离
- css-in-js
let shadowDom = document.getElementById('shadow').attachShadow({ mode: 'open' }); // open/close设置可否从外部获取
let pElement = document.createElement('p');
pElement.innerHTML = 'hello world';
let styleElement = document.createElement('style');
styleElement.textContent = `
p{color:red}
`
shadowDom.appendChild(pElement);
shadowDom.appendChild(styleElement)
shadow DOM 内部的元素始终不会影响到它的外部元素,可以实现真正意义上的隔离
五、JS沙箱机制
当运行子应用时应该跑在内部沙箱环境中
- 快照沙箱,当应用沙箱挂载或卸载时记录快照,在切换时依据快照恢复环境 (无法支持多实例)
- Proxy 代理沙箱,不影响全局环境
1.快照沙箱
- 1.激活时将当前window属性进行快照处理
- 2.失活时用快照中的内容和当前window属性比对
- 3.如果属性发生变化保存到modifyPropsMap中,并用快照还原window属性
- 4.再次激活时,再次进行快照,并用上次修改的结果还原window属性
class SnapshotSandbox {
constructor() {
this.proxy = window;
this.modifyPropsMap = {}; // 修改了哪些属性
this.active();
}
active() {
this.windowSnapshot = {}; // window对象的快照
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
// 将window上的属性进行拍照
this.windowSnapshot[prop] = window[prop];
}
}
Object.keys(this.modifyPropsMap).forEach(p => {
window[p] = this.modifyPropsMap[p];
});
}
inactive() {
for (const prop in window) { // diff 差异
if (window.hasOwnProperty(prop)) {
// 将上次拍照的结果和本次window属性做对比
if (window[prop] !== this.windowSnapshot[prop]) {
// 保存修改后的结果
this.modifyPropsMap[prop] = window[prop];
// 还原window
window[prop] = this.windowSnapshot[prop];
}
}
}
}
}
let sandbox = new SnapshotSandbox();
((window) => {
window.a = 1;
window.b = 2;
window.c = 3
console.log(a,b,c)
sandbox.inactive();
console.log(a,b,c)
})(sandbox.proxy);
快照沙箱只能针对单实例应用场景,如果是多个实例同时挂载的情况则无法解决,这时只能通过Proxy代理沙箱来实现
2.Proxy 代理沙箱
class ProxySandbox {
constructor() {
const rawWindow = window;
const fakeWindow = {}
const proxy = new Proxy(fakeWindow, {
set(target, p, value) {
target[p] = value;
return true
},
get(target, p) {
return target[p] || rawWindow[p];
}
});
this.proxy = proxy
}
}
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
((window) => {
window.a = 'hello';
console.log(window.a)
})(sandbox1.proxy);
((window) => {
window.a = 'world';
console.log(window.a)
})(sandbox2.proxy);
每个应用都创建一个proxy来代理window对象,好处是每个应用都是相对独立的,不需要直接更改全局的window属性。
六、从零实现微前端框架
一.初始化开发环境
初始化配置安装rollup
npm init -y
npm install rollup rollup-plugin-serve
import serve from 'rollup-plugin-serve'
export default {
input:'./src/single-spa.js',
output:{
file:'./lib/umd/single-spa.js',
format:"umd",
name:'singleSpa',
sourcemap:true
},
plugins:[
serve({
openPage:'/index.html',
contentBase:'',
port:3000
})
]
}
这里我们一切从简,只借助
rollup
模块化和打包的能力~,不进行过多的rollup
配置, 把精力放到编写微前端的核心逻辑上~~~
二.SignleSpa
的使用方式
singleSpa.registerApplication('app1',
async () => {
return {
bootstrap:async()=>{
console.log('应用启动');
},
mount:async()=>{
console.log('应用挂载');
},
unmount:async()=>{
console.log('应用卸载')
}
}
},
location => location.hash.startsWith('#/app1'),
{ store: { name: '1' } }
);
singleSpa.start();
- 参数分别是:
appName
: 当前注册应用的名字loadApp
: 加载函数(必须返回的是promise),返回的结果必须包含bootstrap
、mount
和unmount
做为接入协议activityWhen
: 满足条件时调用loadApp
方法customProps
:自定义属性可用于父子应用通信
根据使用方式编写源码
const apps = [];
export function registerApplication(appName,loadApp,activeWhen,customProps){
apps.push({
name:appName,
loadApp,
activeWhen,
customProps,
});
}
export function start(){
// todo...
}
export {registerApplication} from './applications/app.js';
export {start} from './start.js';
三.应用加载状态 - 生命周期
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"; // 运行出错
export function isActive(app) { // 当前app是否已经挂载
return app.status === MOUNTED;
}
export function shouldBeActive(app) { // 当前app是否应该激活
return app.activeWhen(window.location);
}
标注应用状态
import { NOT_LOADED } from './app.helpers';
apps.push({
name: appName,
loadApp,
activeWhen,
customProps,
status: NOT_LOADED // 默认应用为未加载
});
四.加载应用并启动
import {reroute} from '../navigation/reroute.js';
export function registerApplication(appName, loadApp, activeWhen, customProps) {
// ...
reroute(); // 这个是加载应用
}
import {reroute} from './navigation/reroute'
export let started = false;
export function start(){
started = true;
reroute(); // 这个是启动应用
}
reroute方法就是比较核心的一个方法啦~,当注册应用时reroute的功能是加载子应用,当调用start方法时是挂载应用。
五.reroute方法
这个方法是整个Single-SPA
中最核心的方法,当路由切换时也会执行该逻辑
1).获取对应状态的app
import {getAppChanges} from '../applications/apps';
export function reroute() {
const {
appsToLoad, // 获取要去加载的app
appsToMount, // 获取要被挂载的
appsToUnmount // 获取要被卸载的
} = getAppChanges();
}
export function getAppChanges(){
const appsToUnmount = [];
const appsToLoad = [];
const appsToMount = [];
apps.forEach(app => {
const appShouldBeActive = app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
switch (app.status) { // toLoad
case STATUS.NOT_LOADED:
case STATUS.LOADING_SOURCE_CODE:
if(appShouldBeActive){
appsToLoad.push(app);
}
break;
case STATUS.NOT_BOOTSTRAPPED: // toMount
case STATUS.NOT_MOUNTED:
if(appShouldBeActive){
appsToMount.push(app);
}
break
case STATUS.MOUNTED: // toUnmount
if(!appShouldBeActive){
appsToUnmount.push(app);
}
}
});
return {appsToUnmount,appsToLoad,appsToMount}
}
根据状态筛选对应的应用
2). 预加载应用
当用户没有调用start
方法时,我们默认会先进行应用的加载
if(started){
return performAppChanges();
}else{
return loadApps();
}
async function performAppChanges(){
// 启动逻辑
}
async function loadApps(){
// 预加载应用
}
import {toLoadPromise} from '../lifecycles/load';
async function loadApps(){
// 预加载应用
await Promise.all(appsToLoad.map(toLoadPromise));
}
import { LOADING_SOURCE_CODE, NOT_BOOTSTRAPPED } from "../applications/app.helpers";
function flattenFnArray(fns) { // 将函数通过then链连接起来
fns = Array.isArray(fns) ? fns : [fns];
return function(props) {
return fns.reduce((p, fn) => p.then(() => fn(props)), Promise.resolve());
}
}
export async function toLoadPromise(app) {
app.status = LOADING_SOURCE_CODE;
let { bootstrap, mount, unmount } = await app.loadApp(app.customProps); // 调用load函数拿到接入协议
app.status = NOT_BOOTSTRAPPED;
app.bootstrap = flattenFnArray(bootstrap);
app.mount = flattenFnArray(mount);
app.unmount = flattenFnArray(unmount);
return app;
}
用户load函数返回的
bootstrap
、mount
、unmount
可能是数组形式,我们将这些函数进行组合
3). app
运转逻辑
路由切换时卸载不需要的应用
import {toUnmountPromise} from '../lifecycles/unmount';
import {toUnloadPromise} from '../lifecycles/unload';
async function performAppChanges(){
// 卸载不需要的应用,挂载需要的应用
let unmountPromises = appsToUnmount.map(toUnmountPromise).map(unmountPromise=>unmountPromise.then(toUnloadPromise));
}
这里为了更加直观,我就采用最简单的方法来实现,调用钩子,并修改应用状态
import { UNMOUNTING, NOT_MOUNTED ,MOUNTED} from "../applications/app.helpers";
export async function toUnmountPromise(app){
if(app.status != MOUNTED){
return app;
}
app.status = UNMOUNTING;
await app.unmount(app);
app.status = NOT_MOUNTED;
return app;
}
import { NOT_LOADED, UNLOADING } from "../applications/app.helpers";
const appsToUnload = {};
export async function toUnloadPromise(app){
if(!appsToUnload[app.name]){
return app;
}
app.status = UNLOADING;
delete app.bootstrap;
delete app.mount;
delete app.unmount;
app.status = NOT_LOADED;
}
匹配到没有加载过的应用 (加载=> 启动 => 挂载)
const loadThenMountPromises = appsToLoad.map(async (app) => {
app = await toLoadPromise(app);
app = await toBootstrapPromise(app);
return toMountPromise(app);
});
这里需要注意一下,可能还有没加载完的应用这里不要进行重复加载
export async function toLoadPromise(app) {
if(app.loadPromise){
return app.loadPromise;
}
if (app.status !== NOT_LOADED) {
return app;
}
app.status = LOADING_SOURCE_CODE;
return (app.loadPromise = Promise.resolve().then(async ()=>{
let { bootstrap, mount, unmount } = await app.loadApp(app.customProps);
app.status = NOT_BOOTSTRAPPED;
app.bootstrap = flattenFnArray(bootstrap);
app.mount = flattenFnArray(mount);
app.unmount = flattenFnArray(unmount);
delete app.loadPromise;
return app;
}));
}
import { BOOTSTRAPPING, NOT_MOUNTED,NOT_BOOTSTRAPPED } from "../applications/app.helpers.js";
export async function toBootstrapPromise(app) {
if(app.status !== NOT_BOOTSTRAPPED){
return app;
}
app.status = BOOTSTRAPPING;
await app.bootstrap(app.customProps);
app.status = NOT_MOUNTED;
return app;
}
import { MOUNTED, MOUNTING,NOT_MOUNTED } from "../applications/app.helpers.js";
export async function toMountPromise(app) {
if (app.status !== NOT_MOUNTED) {
return app;
}
app.status = MOUNTING;
await app.mount();
app.status = MOUNTED;
return app;
}
已经加载过了的应用 (启动 => 挂载)
const mountPromises = appsToMount.map(async (app) => {
app = await toBootstrapPromise(app);
return toMountPromise(app);
});
await Promise.all(unmountPromises); // 等待先卸载完成
await Promise.all([...loadThenMountPromises,...mountPromises]);
六.路由劫持
import { reroute } from "./reroute.js";
export const routingEventsListeningTo = ["hashchange", "popstate"];
const capturedEventListeners = { // 存储hashchang和popstate注册的方法
hashchange: [],
popstate: []
}
function urlReroute() {
reroute([], arguments)
}
// 劫持路由变化
window.addEventListener('hashchange', urlReroute);
window.addEventListener('popstate', urlReroute);
// 重写addEventListener方法
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = function(eventName, fn) {
if (routingEventsListeningTo.indexOf(eventName) >= 0 && !capturedEventListeners[eventName].some(listener => listener == fn)) {
capturedEventListeners[eventName].push(fn);
return;
}
return originalAddEventListener.apply(this, arguments);
}
window.removeEventListener = function(eventName, listenerFn) {
if (routingEventsListeningTo.indexOf(eventName) >= 0) {
capturedEventListeners[eventName] = capturedEventListeners[
eventName
].filter((fn) => fn !== listenerFn);
return;
}
return originalRemoveEventListener.apply(this, arguments);
};
function patchedUpdateState(updateState, methodName) {
return function() {
const urlBefore = window.location.href;
const result = updateState.apply(this, arguments);
const urlAfter = window.location.href;
if (urlBefore !== urlAfter) {
urlReroute(new PopStateEvent('popstate', { state }));
}
return result;
}
}
// 重写pushState 和 repalceState方法
window.history.pushState = patchedUpdateState(window.history.pushState, 'pushState');
window.history.replaceState = patchedUpdateState(window.history.replaceState, 'replaceState');
// 在子应用加载完毕后调用此方法,执行拦截的逻辑(保证子应用加载完后执行)
export function callCapturedEventListeners(eventArguments) {
if (eventArguments) {
const eventType = eventArguments[0].type;
if (routingEventsListeningTo.indexOf(eventType) >= 0) {
capturedEventListeners[eventType].forEach((listener) => {
listener.apply(this, eventArguments);
});
}
}
}
为了保证应用加载逻辑最先被处理,我们对路由的一系列的方法进行重写,确保加载应用的逻辑最先被调用,其次手动派发事件
七.加载应用
await Promise.all(appsToLoad.map(toLoadPromise)); // 加载后触发路由方法
callCapturedEventListeners(eventArguments);
await Promise.all(unmountPromises); // 等待先卸载完成后触发路由方法
callCapturedEventListeners(eventArguments);
校验当前是否需要被激活,在进行启动和挂载
async function tryToBootstrapAndMount(app) {
if (shouldBeActive(app)) {
app = await toBootstrapPromise(app);
return toMountPromise(app);
}
return app;
}
八.批处理加载等待
export function reroute(pendings = [], eventArguments) {
if (appChangeUnderway) {
return new Promise((resolve, reject) => {
peopleWaitingOnAppChange.push({
resolve,
reject,
eventArguments
})
});
}
// ...
if (started) {
appChangeUnderway = true;
return performAppChanges();
}
async function performAppChanges() {
// ...
finishUpAndReturn(); // 完成后批量处理在队列中的任务
}
function finishUpAndReturn(){
appChangeUnderway = false;
if(peopleWaitingOnAppChange.length > 0){
const nextPendingPromises = peopleWaitingOnAppChange;
peopleWaitingOnAppChange = [];
reroute(nextPendingPromises)
}
}
}
这里的思路和
Vue.nextTick
一样,如果当前应用正在加载时,并且用户频繁切换路由。我们会将此时的reroute方法暂存起来,等待当前应用加载完毕后再次触发reroute渲染应用,从而节约性能!
最终别忘了,完成一轮应用加载时,需要手动触发用户注册的路由事件!
callAllEventListeners();
function callAllEventListeners() {
pendingPromises.forEach((pendingPromise) => {
callCapturedEventListeners(pendingPromise.eventArguments);
});
callCapturedEventListeners(eventArguments);
}
七、补充知识
1、ES6系列之Proxy
一、proxy概述
Proxy的兼容性
proxy
在目标对象的外层搭建了一层拦截,外界对目标对象的某些操作,必须通过这层拦截
var proxy = new Proxy(target, handler);
new Proxy()
表示生成一个Proxy
实例,target
参数表示所要拦截的目标对象,handler
参数也是一个对象,用来定制拦截行为
var target = {
name: 'poetries'
};
var logHandler = {
get: function(target, key) {
console.log(`${key} 被读取`);
return target[key];
},
set: function(target, key, value) {
console.log(`${key} 被设置为 ${value}`);
target[key] = value;
}
}
var targetWithLog = new Proxy(target, logHandler);
targetWithLog.name; // 控制台输出:name 被读取
targetWithLog.name = 'others'; // 控制台输出:name 被设置为 others
console.log(target.name); // 控制台输出: others
targetWithLog
读取属性的值时,实际上执行的是logHandler.get
:在控制台输出信息,并且读取被代理对象target
的属性。- 在
targetWithLog
设置属性值时,实际上执行的是logHandler.set
:在控制台输出信息,并且设置被代理对象target
的属性的值
// 由于拦截函数总是返回35,所以访问任何属性都得到35
var proxy = new Proxy({}, {
get: function(target, property) {
return 35;
}
});
proxy.time // 35
proxy.name // 35
proxy.title // 35
Proxy 实例也可以作为其他对象的原型对象
var proxy = new Proxy({}, {
get: function(target, property) {
return 35;
}
});
let obj = Object.create(proxy);
obj.time // 35
proxy
对象是obj
对象的原型,obj
对象本身并没有time
属性,所以根据原型链,会在proxy
对象上读取该属性,导致被拦截
Proxy的作用
对于代理模式
Proxy
的作用主要体现在三个方面
- 拦截和监视外部对对象的访问
- 降低函数或类的复杂度
- 在复杂操作前对操作进行校验或对所需资源进行管理
二、Proxy所能代理的范围–handler
实际上
handler
本身就是ES6
所新设计的一个对象.它的作用就是用来 自定义代理对象的各种可代理操作 。它本身一共有13
中方法,每种方法都可以代理一种操作.其13
种方法如下
// 在读取代理对象的原型时触发该操作,比如在执行 Object.getPrototypeOf(proxy) 时。
handler.getPrototypeOf()
// 在设置代理对象的原型时触发该操作,比如在执行 Object.setPrototypeOf(proxy, null) 时。
handler.setPrototypeOf()
// 在判断一个代理对象是否是可扩展时触发该操作,比如在执行 Object.isExtensible(proxy) 时。
handler.isExtensible()
// 在让一个代理对象不可扩展时触发该操作,比如在执行 Object.preventExtensions(proxy) 时。
handler.preventExtensions()
// 在获取代理对象某个属性的属性描述时触发该操作,比如在执行 Object.getOwnPropertyDescriptor(proxy, "foo") 时。
handler.getOwnPropertyDescriptor()
// 在定义代理对象某个属性时的属性描述时触发该操作,比如在执行 Object.defineProperty(proxy, "foo", {}) 时。
andler.defineProperty()
// 在判断代理对象是否拥有某个属性时触发该操作,比如在执行 "foo" in proxy 时。
handler.has()
// 在读取代理对象的某个属性时触发该操作,比如在执行 proxy.foo 时。
handler.get()
// 在给代理对象的某个属性赋值时触发该操作,比如在执行 proxy.foo = 1 时。
handler.set()
// 在删除代理对象的某个属性时触发该操作,比如在执行 delete proxy.foo 时。
handler.deleteProperty()
// 在获取代理对象的所有属性键时触发该操作,比如在执行 Object.getOwnPropertyNames(proxy) 时。
handler.ownKeys()
// 在调用一个目标对象为函数的代理对象时触发该操作,比如在执行 proxy() 时。
handler.apply()
// 在给一个目标对象为构造函数的代理对象构造实例时触发该操作,比如在执行new proxy() 时。
handler.construct()
三、Proxy场景
3.1 实现私有变量
var target = {
name: 'poetries',
_age: 22
}
var logHandler = {
get: function(target,key){
if(key.startsWith('_')){
console.log('私有变量age不能被访问')
return false
}
return target[key];
},
set: function(target, key, value) {
if(key.startsWith('_')){
console.log('私有变量age不能被修改')
return false
}
target[key] = value;
}
}
var targetWithLog = new Proxy(target, logHandler);
// 私有变量age不能被访问
targetWithLog.name;
// 私有变量age不能被修改
targetWithLog.name = 'others';
在下面的代码中,我们声明了一个私有的
apiKey
,便于api
这个对象内部的方法调用,但不希望从外部也能够访问api._apiKey
var api = {
_apiKey: '123abc456def',
/* mock methods that use this._apiKey */
getUsers: function(){},
getUser: function(userId){},
setUser: function(userId, config){}
};
// logs '123abc456def';
console.log("An apiKey we want to keep private", api._apiKey);
// get and mutate _apiKeys as desired
var apiKey = api._apiKey;
api._apiKey = '987654321';
很显然,约定俗成是没有束缚力的。使用
ES6 Proxy
我们就可以实现真实的私有变量了,下面针对不同的读取方式演示两个不同的私有化方法。第一种方法是使用set / get
拦截读写请求并返回undefined
:
let api = {
_apiKey: '123abc456def',
getUsers: function(){ },
getUser: function(userId){ },
setUser: function(userId, config){ }
};
const RESTRICTED = ['_apiKey'];
api = new Proxy(api, {
get(target, key, proxy) {
if(RESTRICTED.indexOf(key) > -1) {
throw Error(`${key} is restricted. Please see api documentation for further info.`);
}
return Reflect.get(target, key, proxy);
},
set(target, key, value, proxy) {
if(RESTRICTED.indexOf(key) > -1) {
throw Error(`${key} is restricted. Please see api documentation for further info.`);
}
return Reflect.get(target, key, value, proxy);
}
});
// 以下操作都会抛出错误
console.log(api._apiKey);
api._apiKey = '987654321';
第二种方法是使用
has
拦截in
操作
var api = {
_apiKey: '123abc456def',
getUsers: function(){ },
getUser: function(userId){ },
setUser: function(userId, config){ }
};
const RESTRICTED = ['_apiKey'];
api = new Proxy(api, {
has(target, key) {
return (RESTRICTED.indexOf(key) > -1) ?
false :
Reflect.has(target, key);
}
});
// these log false, and `for in` iterators will ignore _apiKey
console.log("_apiKey" in api);
for (var key in api) {
if (api.hasOwnProperty(key) && key === "_apiKey") {
console.log("This will never be logged because the proxy obscures _apiKey...")
}
}
3.2 抽离校验模块
让我们从一个简单的类型校验开始做起,这个示例演示了如何使用
Proxy
保障数据类型的准确性
let numericDataStore = {
count: 0,
amount: 1234,
total: 14
};
numericDataStore = new Proxy(numericDataStore, {
set(target, key, value, proxy) {
if (typeof value !== 'number') {
throw Error("Properties in numericDataStore can only be numbers");
}
return Reflect.set(target, key, value, proxy);
}
});
// 抛出错误,因为 "foo" 不是数值
numericDataStore.count = "foo";
// 赋值成功
numericDataStore.count = 333;
如果要直接为对象的所有属性开发一个校验器可能很快就会让代码结构变得臃肿,使用
Proxy
则可以将校验器从核心逻辑分离出来自成一体
function createValidator(target, validator) {
return new Proxy(target, {
_validator: validator,
set(target, key, value, proxy) {
if (target.hasOwnProperty(key)) {
let validator = this._validator[key];
if (!!validator(value)) {
return Reflect.set(target, key, value, proxy);
} else {
throw Error(`Cannot set ${key} to ${value}. Invalid.`);
}
} else {
throw Error(`${key} is not a valid property`)
}
}
});
}
const personValidators = {
name(val) {
return typeof val === 'string';
},
age(val) {
return typeof age === 'number' && val > 18;
}
}
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
return createValidator(this, personValidators);
}
}
const bill = new Person('Bill', 25);
// 以下操作都会报错
bill.name = 0;
bill.age = 'Bill';
bill.age = 15;
通过校验器和主逻辑的分离,你可以无限扩展
personValidators
校验器的内容,而不会对相关的类或函数造成直接破坏。更复杂一点,我们还可以使用Proxy
模拟类型检查,检查函数是否接收了类型和数量都正确的参数
let obj = {
pickyMethodOne: function(obj, str, num) { /* ... */ },
pickyMethodTwo: function(num, obj) { /*... */ }
};
const argTypes = {
pickyMethodOne: ["object", "string", "number"],
pickyMethodTwo: ["number", "object"]
};
obj = new Proxy(obj, {
get: function(target, key, proxy) {
var value = target[key];
return function(...args) {
var checkArgs = argChecker(key, args, argTypes[key]);
return Reflect.apply(value, target, args);
};
}
});
function argChecker(name, args, checkers) {
for (var idx = 0; idx < args.length; idx++) {
var arg = args[idx];
var type = checkers[idx];
if (!arg || typeof arg !== type) {
console.warn(`You are incorrectly implementing the signature of ${name}. Check param ${idx + 1}`);
}
}
}
obj.pickyMethodOne();
// > You are incorrectly implementing the signature of pickyMethodOne. Check param 1
// > You are incorrectly implementing the signature of pickyMethodOne. Check param 2
// > You are incorrectly implementing the signature of pickyMethodOne. Check param 3
obj.pickyMethodTwo("wopdopadoo", {});
// > You are incorrectly implementing the signature of pickyMethodTwo. Check param 1
// No warnings logged
obj.pickyMethodOne({}, "a little string", 123);
obj.pickyMethodOne(123, {});
3.3 访问日志
对于那些调用频繁、运行缓慢或占用执行环境资源较多的属性或接口,开发者会希望记录它们的使用情况或性能表现,这个时候就可以使用
Proxy
充当中间件的角色,轻而易举实现日志功能
let api = {
_apiKey: '123abc456def',
getUsers: function() { /* ... */ },
getUser: function(userId) { /* ... */ },
setUser: function(userId, config) { /* ... */ }
};
function logMethodAsync(timestamp, method) {
setTimeout(function() {
console.log(`${timestamp} - Logging ${method} request asynchronously.`);
}, 0)
}
api = new Proxy(api, {
get: function(target, key, proxy) {
var value = target[key];
return function(...arguments) {
logMethodAsync(new Date(), key);
return Reflect.apply(value, target, arguments);
};
}
});
api.getUsers();
3.4 预警和拦截
假设你不想让其他开发者删除
noDelete
属性,还想让调用oldMethod
的开发者了解到这个方法已经被废弃了,或者告诉开发者不要修改doNotChange
属性,那么就可以使用Proxy
来实现
let dataStore = {
noDelete: 1235,
oldMethod: function() {/*...*/ },
doNotChange: "tried and true"
};
const NODELETE = ['noDelete'];
const NOCHANGE = ['doNotChange'];
const DEPRECATED = ['oldMethod'];
dataStore = new Proxy(dataStore, {
set(target, key, value, proxy) {
if (NOCHANGE.includes(key)) {
throw Error(`Error! ${key} is immutable.`);
}
return Reflect.set(target, key, value, proxy);
},
deleteProperty(target, key) {
if (NODELETE.includes(key)) {
throw Error(`Error! ${key} cannot be deleted.`);
}
return Reflect.deleteProperty(target, key);
},
get(target, key, proxy) {
if (DEPRECATED.includes(key)) {
console.warn(`Warning! ${key} is deprecated.`);
}
var val = target[key];
return typeof val === 'function' ?
function(...args) {
Reflect.apply(target[key], target, args);
} :
val;
}
});
// these will throw errors or log warnings, respectively
dataStore.doNotChange = "foo";
delete dataStore.noDelete;
dataStore.oldMethod();
3.5 过滤操作
某些操作会非常占用资源,比如传输大文件,这个时候如果文件已经在分块发送了,就不需要在对新的请求作出相应(非绝对),这个时候就可以使用
Proxy
对当请求进行特征检测,并根据特征过滤出哪些是不需要响应的,哪些是需要响应的。下面的代码简单演示了过滤特征的方式,并不是完整代码,相信大家会理解其中的妙处
let obj = {
getGiantFile: function(fileId) {/*...*/ }
};
obj = new Proxy(obj, {
get(target, key, proxy) {
return function(...args) {
const id = args[0];
let isEnroute = checkEnroute(id);
let isDownloading = checkStatus(id);
let cached = getCached(id);
if (isEnroute || isDownloading) {
return false;
}
if (cached) {
return cached;
}
return Reflect.apply(target[key], target, args);
}
}
});
3.6 中断代理
Proxy
支持随时取消对target
的代理,这一操作常用于完全封闭对数据或接口的访问。在下面的示例中,我们使用了Proxy.revocable
方法创建了可撤销代理的代理对象:
let sensitiveData = { username: 'devbryce' };
const {sensitiveData, revokeAccess} = Proxy.revocable(sensitiveData, handler);
function handleSuspectedHack(){
revokeAccess();
}
// logs 'devbryce'
console.log(sensitiveData.username);
handleSuspectedHack();
// TypeError: Revoked
console.log(sensitiveData.username);
2、qiankun 常见问题集合
qiankun 常见问题集合(一)
1.Uncaught Error: application ‘reactApp’ died in status LOADING_SOURCE_CODE: [qiankun]: You need to export lifecycle functions in reactApp entry
at getLifecyclesFromExports (loader.js?6f14:221)
答: 此问题由于微应用中没有暴漏qiankuan的生命周期;需要在微应用工程中加入相关的生命周期函数;具体位置应为微应用中webpack的entry 值指向的js文件中添加即可;
注:如果你确定你写了这些生命周期函数,可以检查一下是否是因为你把方法名写错了
export async function bootstrap() {
console.log('react app bootstraped');
}
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount(props) {
ReactDOM.render(<App {...props}/>, props.container ? props.container.querySelector('#root') : document.getElementById('root'));
}
/**
* 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
export async function unmount(props) {
ReactDOM.unmountComponentAtNode(
props.container ? props.container.querySelector('#root') : document.getElementById('root'),
);
}
/**
* 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
*/
export async function update(props) {
console.log('update props', props);
}
2.Access to fetch at ‘http://localhost:3000/’ from origin ‘http://localhost:8000’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource. If an opaque response serves your needs, set the request’s mode to ‘no-cors’ to fetch the resource with CORS disabled.
答: 由于qiankun框架 解析微应用使用 import-html-entry
库通过fetch请求相关资源,所以需要微应用支持跨域访问;在webpack devServer
中加入以下代码即可
// 要添加的代码
config.headers = {
"Access-Control-Allow-Origin": '*'
}
// 大致配置
module.exports = {
webpack: (config) => {
config.output.library = 'reactApp';
config.output.libraryTarget = 'umd';
config.output.publicPath = 'http://localhost:20000/';
return config;
},
devServer: (configFunction) => {
return function (proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);
config.headers = {
"Access-Control-Allow-Origin": '*'
}
return config
}
}
}
3. 微应用打包之后 css 中的字体文件和图片加载 404?
答:目前官方关于资源文件加载推荐两种方式
- 所有图片等静态资源上传至 cdn,css 中直接引用 cdn 地址;
- 借助 webpack 的 url-loader 将字体文件和图片打包成 base64(适用于 字体文件和图片体积小的项目)
4. 如何判断微应用是否运行在主应用壳子中?
答:qiankun框架提供了window.__POWERED_BY_QIANKUN__
全局变量进行区分是否运行在qiankun框架容器中;
5. qiankun框架中微应用之间如何跳转?
答:qiankun框架提供两种跳转方式
- 通过
history.pushState()
方式进行跳转
<button onClick={() => {
window.history.pushState({
user: {
name: `张三${new Date().getTime()}`,
age: 18,
sex: '男'
}
}, '', '/app1')}
}>跳转第一个微应用</button>
2.将主应用的路由实例传递给子应用,子应用使用主应用实例进行跳转;
6.Application died in status NOT_MOUNTED: Target container with #container not existed while xxx loading!
- start函数调用时机不对
- 检查容器dom是否存在主应用的某个子路由下。
3、JS数组reduce()方法详解及高级技巧
1、语法
arr.reduce(callback,[initialValue])
reduce 为数组中的每一个元素依次执行回调函数,不包括数组中被删除或从未被赋值的元素,接受四个参数:初始值(或者上一次回调函数的返回值),当前元素值,当前索引,调用 reduce 的数组。
callback (执行数组中每个值的函数,包含四个参数)
1、previousValue (上一次调用回调返回的值,或者是提供的初始值(initialValue))
2、currentValue (数组中当前被处理的元素)
3、index (当前元素在数组中的索引)
4、array (调用 reduce 的数组)
initialValue (作为第一次调用 callback 的第一个参数。)
这样看有些复杂,可以简化
arr.reduce(function(prev,cur,index,arr){
...
}, init);
其中,
arr 表示原数组;
prev 表示上一次调用回调时的返回值,或者初始值 init;
cur 表示当前正在处理的数组元素;
index 表示当前正在处理的数组元素的索引,若提供 init 值,则索引为0,否则索引为1;
init 表示初始值。
2、实例解析 initialValue 参数
先看第一个例子:
var arr = [1, 2, 3, 4];
var sum = arr.reduce(function(prev, cur, index, arr) {
console.log(prev, cur, index);
return prev + cur;
})
console.log(arr, sum);
打印结果:
1 2 1
3 3 2
6 4 3
[1, 2, 3, 4] 10
这里可以看出,上面的例子index是从1开始的,第一次的prev的值是数组的第一个值。数组长度是4,但是reduce函数循环3次。
再看第二个例子:
var arr = [1, 2, 3, 4];
var sum = arr.reduce(function(prev, cur, index, arr) {
console.log(prev, cur, index);
return prev + cur;
},0) //注意这里设置了初始值
console.log(arr, sum);
打印结果:
0 1 0
1 2 1
3 3 2
6 4 3
[1, 2, 3, 4] 10
这个例子index是从0开始的,第一次的prev的值是我们设置的初始值0,数组长度是4,reduce函数循环4次。
结论:如果没有提供initialValue,reduce 会从索引1的地方开始执行 callback 方法,跳过第一个索引。如果提供initialValue,从索引0开始。
注意:如果这个数组为空,运用reduce是什么情况?
var arr = [];
var sum = arr.reduce(function(prev, cur, index, arr) {
console.log(prev, cur, index);
return prev + cur;
})
//报错,"TypeError: Reduce of empty array with no initial value"
但是要是我们设置了初始值就不会报错,如下:
var arr = [];
var sum = arr.reduce(function(prev, cur, index, arr) {
console.log(prev, cur, index);
return prev + cur;
},0)
console.log(arr, sum); // [] 0
所以一般来说我们提供初始值通常更安全
3、reduce的简单用法
当然最简单的就是我们常用的数组求和,求乘积了。
var arr = [1, 2, 3, 4];
var sum = arr.reduce((x,y)=>x+y)
var mul = arr.reduce((x,y)=>x*y)
console.log( sum ); //求和,10
console.log( mul ); //求乘积,24
4、reduce的高级用法
(1)计算数组中每个元素出现的次数
let names = ['Alice', 'Bob', 'Tiff', 'Bruce', 'Alice'];
let nameNum = names.reduce((pre,cur)=>{
if(cur in pre){
pre[cur]++
}else{
pre[cur] = 1
}
return pre
},{})
console.log(nameNum); //{Alice: 2, Bob: 1, Tiff: 1, Bruce: 1}
(2)数组去重
let arr = [1,2,3,4,4,1]
let newArr = arr.reduce((pre,cur)=>{
if(!pre.includes(cur)){
return pre.concat(cur)
}else{
return pre
}
},[])
console.log(newArr);// [1, 2, 3, 4]
(3)将二维数组转化为一维
let arr = [[0, 1], [2, 3], [4, 5]]
let newArr = arr.reduce((pre,cur)=>{
return pre.concat(cur)
},[])
console.log(newArr); // [0, 1, 2, 3, 4, 5]
(4)将多维数组转化为一维
let arr = [[0, 1], [2, 3], [4,[5,6,7]]]
const newArr = function(arr){
return arr.reduce((pre,cur)=>pre.concat(Array.isArray(cur)?newArr(cur):cur),[])
}
console.log(newArr(arr)); //[0, 1, 2, 3, 4, 5, 6, 7]
(5)对象里的属性求和
var result = [
{
subject: 'math',
score: 10
},
{
subject: 'chinese',
score: 20
},
{
subject: 'english',
score: 30
}
];
var sum = result.reduce(function(prev, cur) {
return cur.score + prev;
}, 0);
console.log(sum) //60
八、案例地址
码云地址: https://gitee.com/wu_yuxin/micro-front-end-learning
更多推荐
所有评论(0)