乾坤源码解析
Single-Spa 痛点
single-spa就做了两件事 1. 加载微应用(加载方法还是用户自己提供的)2. 维护微应用状态(初始化、挂载、卸载)
主要的问题
1、对微应用的侵入性太强
○ 微应用路由改造,添加一个特定的前缀
○ 微应用入口改造,挂载点变更和生命周期函数导出
○ 打包工具配置更改
2、样式隔离问题
3、JS 隔离
4、资源预加载
5、应用间通信
第一个和第四个就不好解决了,这是 JS Entry 方式带来的问题
Qiankun由来
qiankun 基于 single-spa 做了二次封装,很好的解决了上面提到的几个问题
● HTML Entry
qiankun 通过 HTML Entry 的方式来解决 JS Entry 带来的问题,让你接入微应用像使用 iframe 一样简单。
● 样式隔离
qiankun 实现了两种样式隔离
○ 严格的样式隔离模式,为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响
○ 实验性的方式,通过动态改写 css 选择器来实现,可以理解为 css scoped 的方式
● 运行时沙箱
qiankun 的运行时沙箱分为 JS 沙箱和 样式沙箱
JS 沙箱 为每个微应用生成单独的 window proxy 对象,配合 HTML Entry 提供的 JS 脚本执行器 (execScripts) 来实现 JS 隔离;
样式沙箱 通过重写 DOM 操作方法,来劫持动态样式和 JS 脚本的添加,让样式和脚本添加到正确的地方,即主应用的插入到主应用模版内,微应用的插入到微应用模版,并且为劫持的动态样式做了 scoped css 的处理,为劫持的脚本做了 JS 隔离的处理
● 资源预加载
qiankun 实现预加载的思路有两种,
一种是当主应用执行 start 方法启动 qiankun 以后立即去预加载微应用的静态资源
另一种是在第一个微应用挂载以后预加载其它微应用的静态资源,这个是利用 single-spa 提供的single-spa:first-mount事件来实现的
● 应用间通信
qiankun 通过发布订阅模式来实现应用间通信,状态由框架来统一维护,每个应用在初始化时由框架生成一套通信方法,应用通过这些方法来更改全局状态和注册回调函数,全局状态发生改变时触发各个应用注册的回调函数执行,将新旧状态传递到所有应用
要了解乾坤原理 首先需要了解底层两个依赖逻辑原理
一个是single-spa
一个是html entry(由 import-html-entry库)实现
然后再看qiankun的源码部分,相对容易理解一些
qiankun源码
示例
qiankun提供了完整的项目示例 https://github.com/liyongning/qiankun
- git clone https://github.com/liyongning/qiankun.git
- yarn examples:install
- yarn examples:start localhost:7099
qiankun提供的demo中 提供了两种主应用的实现方式(基于路由配置的 registerMicroApps 和 手动加载微应用的 loadMicroApp),五种微应用的接入示例
Q&A
qiankun样式隔离如何做的?
乾坤主要通过样式沙箱 通过增强 createElement 方法,负责创建元素并劫持 script、link、style 三个标签的创建动作;简单来说就是属于主应用的插入主应用,属于微应用的插入到对应的微应用中,方便微应用卸载的时候一起删除;
样式沙箱还额外做了两件事:
● 在卸载之前为动态添加样式做缓存
● 在微应用重新挂载时再插入到微应用内
将 proxy 对象传递给 execScripts 函数,将其设置为微应用的执行上下文
支持两种方式实现样式隔离 分别是shadow dom 和 scoped css
shadow dom为例1
2registerMicoApp时 配置strictStyleIsolation 和sandbox选项,内部核心代码在createElement实现,通过shadow dom实现
主要步骤为 通过document.createElement('div')创建包裹容器原始dom,innerHTML赋值为appContent即编译后html模板,取出firstChild作为appElement,保存其innerHTML内容后清空innerHTML,检测支持attachShadow使用appElement.attachShadow({ mode: 'open' })作为shadow;否则使用 appElement.createShadowRoot()作为shadow; 最后把原始innerHTML赋值给 shadow.innerHTML
scoped css为例1
2
3取出appInstanceId(初始化时被赋值),取出所有style节点,遍历尽心scope处理
生成scope prefix ${tag}[${QiankunCSSRewriteAttr}="${appName}"] 拿到样式表sheet cssRules
通过rewrite方法传递css和prefix, 通过给textContent赋值全新的css
核心代码https://github.com/umijs/qiankun/blob/master/src/sandbox/patchers/css.ts
loadAPP作用
loadApp是乾坤的核心 主要干了以下几件事
1、通过 HTML Entry 的方式远程加载微应用,得到微应用的 html 模版(首屏内容)、JS 脚本执行器、静态经资源路径
2、样式隔离,shadow DOM 或者 scoped css 两种方式
3、渲染微应用
4、运行时沙箱,JS 沙箱、样式沙箱
5、合并沙箱传递出来的 生命周期方法、用户传递的生命周期方法、框架内置的生命周期方法,将这些生命周期方法统一整理,导出一个生命周期对象,供 single-spa 的 registerApplication 方法使用,这个对象就相当于使用 single-spa 时你的微应用导出的那些生命周期方法,只不过 qiankun额外填了一些生命周期方法,做了一些事情
6、给微应用注册通信方法并返回通信方法,然后会将通信方法通过 props 注入到微应用
loadApp内部是如何渲染微应用的
- 拿到配置中的 container 和 render
- 通过配置信息 拿到内部需要调用包装后的 render (getRender)
- 调用render
运行时JS沙箱机制作用
JS 沙箱,通过 proxy 代理 window 对象,记录 window 对象上属性的增删改查
单例模式
直接代理了原生 window 对象,记录原生 window 对象的增删改查,当 window 对象激活时恢复 window 对象到上次即将失活时的状态,失活时恢复 window 对象到初始初始状态
多例模式
代理了一个全新的对象,这个对象是复制的 window 对象的一部分不可配置属性,所有的更改都是基于这个 fakeWindow 对象,从而保证多个实例之间属性互不影响
将这个 proxy 作为微应用的全局对象,所有的操作都在这个 proxy 对象上,这就是 JS 沙箱的原理
作用是保证每一个微应用运行在一个干净的环境中(JS 执行上下文独立、应用间不会发生样式污染)
单例沙箱和多例沙箱的具体实现
单例沙箱 基于 Proxy 实现的单例模式下的沙箱,直接操作原生 window 对象,并记录 window 对象的增删改查,在每次微应用切换时初始化 window 对象;激活时:将 window 对象恢复到上次即将失活时的状态;失活时:将 window 对象恢复为初始状态
多例沙箱 通过 proxy 代理 fakeWindow 对象,所有的更改都是基于 fakeWindow,这点和单例不一样,从而保证每个 ProxySandbox 实例之间属性互不影响
Single-SPA
systemjs
Dynamic ES module loader动态模块加载器,动态加载我们每个依赖的编译后的脚本文件。也正是因为system.js存在,你不会在代码中看到大量script脚本插入的痕迹。single-spa-react / single-spa-vue
bootstrap,mount,unmount 支持下一步single-spa进行注册registerApplicationsingle-spa
registerApplication注册,将所有的子模块加载到全局变量app数据中,并保存各种状态,用于后边的各种装载和卸载。本质上single-spa就是一个维护应用的状态机
关键的几步是
● 初始:默认劫持浏览器事件,等注册应用完成后执行
● registerApplication注册应用,触发reroute
● start初始化第一次执行,触发reroute
● 根据不同情况,实现了加载、卸载、更改组件生命周期状态、并延迟执行执行浏览器事件reroute
reroute执行时机:
● registerApplication初始化注册应用
● start第一次执行
● 浏览器更新路由hashchange/popstate - urlReroute(navigation event
如何监听路由的变化从而响应的
start的时候 执行了下面的代码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72/**
* 监听路由变化
*/
if (isInBrowser) {
// We will trigger an app change for any routing events,监听hashchange和popstate事件
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
// Monkeypatch addEventListener so that we can ensure correct timing
/**
* 扩展原生的addEventListener和removeEventListener方法
* 每次注册事件和事件处理函数都会将事件和处理函数保存下来,当然移除时也会做删除
* */
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = function (eventName, fn) {
if (typeof fn === "function") {
if (
// eventName只能是hashchange或popstate && 对应事件的fn注册函数没有注册
routingEventsListeningTo.indexOf(eventName) >= 0 &&
!find(capturedEventListeners[eventName], (listener) => listener === fn)
) {
// 注册(保存)eventName 事件的处理函数
capturedEventListeners[eventName].push(fn);
return;
}
}
// 原生方法
return originalAddEventListener.apply(this, arguments);
};
window.removeEventListener = function (eventName, listenerFn) {
if (typeof listenerFn === "function") {
// 从captureEventListeners数组中移除eventName事件指定的事件处理函数
if (routingEventsListeningTo.indexOf(eventName) >= 0) {
capturedEventListeners[eventName] = capturedEventListeners[
eventName
].filter((fn) => fn !== listenerFn);
return;
}
}
return originalRemoveEventListener.apply(this, arguments);
};
// 增强pushstate和replacestate
window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
window.history.replaceState,
"replaceState"
);
if (window.singleSpaNavigate) {
console.warn(
formatErrorMessage(
41,
__DEV__ &&
"single-spa has been loaded twice on the page. This can result in unexpected behavior."
)
);
} else {
/* For convenience in `onclick` attributes, we expose a global function for navigating to
* whatever an <a> tag's href is.
* singleSpa暴露出来的一个全局方法,用户也可以基于它去判断子应用是运行在基座应用上还是独立运行
*/
window.singleSpaNavigate = navigateToUrl;
}
}
HTML Entry
为什么HTML Entry?
JS Entry 改造时对微应用的侵入行太强,而且和主应用的耦合性太强。
single-spa 采用 JS Entry 的方式接入微应用。微应用改造一般分为三步:
● 微应用路由改造,添加一个特定的前缀
● 微应用入口改造,挂载点变更和生命周期函数导出
● 打包工具配置更改
侵入型强其实说的就是第三点,更改打包工具的配置,使用 single-spa 接入微应用需要将微应用整个打包成一个 JS 文件,发布到静态资源服务器,然后在主应用中配置该 JS 文件的地址告诉 single-spa 去这个地址加载微应用。
将整个微应用打包成一个 JS 文件,常见的打包优化基本上都没了,比如:按需加载、首屏资源加载优化、css 独立打包等优化措施。
qiankun 框架为了解决 JS Entry 的问题,于是采用了 HTML Entry 的方式,让用户接入微应用就像使用 iframe 一样简单。
概述
HTML Entry 是由 import-html-entry 库实现的,通过 http 请求加载指定地址的首屏内容即 html 页面,然后解析这个 html 模版得到 template, scripts , entry, styles
- template: 经过处理的脚本,link、script 标签都被注释掉了
- scripts: 脚本的http地址 或者 { async: true, src: xx } 或者 代码块
- styles: 样式的http地址
- entry: 入口脚本的地址,要不是标有 entry 的 script 的 src,要不就是最后一个 script 标签的 src
向外暴露一个 Promise 对象
应用
HTML Entry 最终会返回一个 Promise 对象,qiankun 就用了这个对象中的 template、assetPublicPath 和 execScripts 三项,将 template 通过 DOM 操作添加到主应用中,执行 execScripts 方法得到微应用导出的生命周期方法,并且还顺便解决了 JS 全局污染的问题,因为执行 execScripts 方法的时候可以通过 proxy 参数指定 JS 的执行上下文。