我们在single-spa 技术分析 基本实现了一个微前端框架需要具备的各种功能,但是又实现的不够彻底,遗留了很多问题需要解决。虽然官方提供了很多样例和最佳实践,但是总显得过于单薄,总给人一种“问题解决了,但是又没有完全解决”的感觉。
qiankun 在 single-spa 的基础上做了二次开发,完善了很多功能,算是一个比较完备的微前端框架了。今天我们来聊一聊 qiankun 的技术原理。
在本系列的开头,我们提到微前端的核心问题其实就是解决如何加载子应用以及如果做好子应用间的隔离问题。所以,我们从这两点来看 qiankun 的实现。
如何加载子应用
single-spa 通过 js entry 的形式来加载子应用。而 qiankun 采用了 html entry 的形式。这两种方式的优缺点我们在理解微前端技术原理中已经做过分析,这里不再赘述,我们看看 qiankun 是如何实现 html entry 的。
qiankun 提供了一个 API registerMicroApps
来注册子应用,其内部调用 single-spa 提供的 registerApplication
方法。在调用 registerApplication
之前,会调用内部的 loadApp
方法来加载子应用的资源,初始化子应用的配置。
通过阅读 loadApp
的代码,我们发现,qiankun 通过 import-html-entry 这个包来加载子应用。import-html-entry 的作用就是通过解析子应用的入口 html 文件,来获取子应用的 html 模板、css 样式和入口 JS 导出的生命周期函数。
import-html-entry
import-html-entry
是这样工作的,假设我们有如下 html entry 文件:
test
我们使用 import-html-entry
来解析这个 html 文件:
import importHTML from 'import-html-entry';
importHTML('./subApp/index.html')
.then(res => {
console.log(res.template);
res.execScripts().then(exports => {
const mobx = exports;
const { observable } = mobx;
observable({
name: 'kuitos'
})
})
});
importHTML
的返回值有如下几个属性:
- template 处理后的 HTML 模板
- assetPublicPath 静态资源的公共路径
- getExternalScripts 获取所有外部脚本的函数,返回脚本路径
- getExternalStyleSheets 获取所有外部样式的函数,返回样式文件的路径
- execScripts 执行脚本的函数
在 importHTML
的返回值中,除了几个工具类的方法,最重要的就是 template
和 execScripts
了。
importHTML('./subApp/index.html')
的整个执行过程代码比较长,我们只讲一下大概的执行原理,感兴趣的同学可以自行查看importHTML 的源码。
importHTML
首先会通过 fetch
函数请求具体的 html 内容,然后在 processTpl
函数 中通过一系列复杂的正则匹配,解析出 html 中的样式文件和 js 文件。
importHTML
函数返回值为 { template, scripts, entry, styles }
,分别是 html 模板,html 中的 js 文件(包含内嵌的代码和通过链接加载的代码),子应用的入口文件,html 中的样式文件(同样是包含内嵌的代码和通过链接加载的代码)。
之后通过 getEmbedHTML
函数 将所有使用外部链接加载的样式全部转化成内嵌到 html 中的样式。getEmbedHTML
返回的 html 就是 importHTML
函数最终返回的 template
内容。
现在,我们看看 execScripts
是怎么实现的。
execScripts
内部会调用 getExternalScripts
加载所有 js 代码的文本内容,然后通过 eval("code")
的形式执行加载的代码。
注意,execScripts
的函数签名是这样的 (sandbox?: object, strictGlobal?: boolean, execScriptsHooks?: ExecScriptsHooks): Promise
。允许我们传入一个沙箱对象,如果子应用按照微前端的规范打包,那么会在全局对象上设置 mount
、unmount
这几个生命周期函数属性。execScripts
在执行 eval("code")
的时候,会巧妙的把我们指定的沙箱最为全局对象包装到 "code"
中,子应用能够运行在沙盒环境中。
在执行完 eval("code")
以后,就可以从沙盒对象上获取子应用导出的生命周期函数了。
loadApp
现在我们把视线拉回 loadApp
中,loadApp
在获取到 template
、execScripts
这些信息以后,会基于 template
生成 render
函数用于渲染子应用的页面。之后会根据需要生成沙盒,并将沙盒对象传给 execScripts
来获取子应用导出的声明周期函数。
之后,在子应用生命周期函数的基础上,构建新的生命周期函数,再调用 single-spa 的 API 启动子应用。
在这些新的生命周期函数中,会在不同时机负责启动沙盒、渲染子应用、清理沙盒等事务。
隔离
在完成子应用的加载以后,作为一个微前端框架,要解决好子应用的隔离问题,主要要解决 JS 隔离和样式隔离这两方面的问题。
JS 隔离
qiankun 为根据浏览器的能力创建两种沙箱,在老旧浏览器中会创建快照模式 的浏览器中创建 VM 模式的沙箱 ProxySandbox
。
篇幅限制,我们只看 ProxySandbox
的实现,在其构造函数中,我们可以看到具体的逻辑:首先会根据用户指定的全局对象(默认是 window)创建一个 fakeWindow
,之后在这个 fakeWindow
上创建一个 proxy 对象,在子应用中,这个 proxy 对象就是全局变量 window
。
constructor(name: string, globalContext = window) {
const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext);
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {},
get: (target: FakeWindow, p: PropertyKey): any => {},
has(target: FakeWindow, p: string | number | symbol): boolean {},
getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {},
ownKeys(target: FakeWindow): ArrayLike {},
defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {},
deleteProperty: (target: FakeWindow, p: string | number | symbol): boolean => {},
getPrototypeOf() {
return Reflect.getPrototypeOf(globalContext);
},
});
this.proxy = proxy;
}
其实 qiankun 中的沙箱分两个类型:
- app 环境沙箱
app 环境沙箱是指应用初始化过之后,应用会在什么样的上下文环境运行。每个应用的环境沙箱只会初始化一次,因为子应用只会触发一次bootstrap
。子应用在切换时,实际上切换的是 app 环境沙箱。 - render 沙箱
子应用在 app mount 开始前生成好的的沙箱。每次子应用切换过后,render 沙箱都会重现初始化。
上面说的 ProxySandbox
其实是 render 沙箱。至于 app 环境沙箱,qiankun 目前只针对在应用 bootstrap
时动态创建样式链接、脚本链接等副作用打了补丁,保证子应用切换时这些副作用互不干扰。
之所以设计两层沙箱,是为了保证每个子应用切换回来之后,还能运行在应用 bootstrap 之后的环境下。
样式隔离
qiankun 提供了多种样式隔离方式,隔离效果最好的是 shadow dom,但是由于其存在诸多限制,qiankun 官方在将来的版本中将会弃用,转而推行 experimentalStyleIsolation
方案。
我们可以通过下面这段代码看到 experimentalStyleIsolation
方案的基本原理。
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
css.process
的核心逻辑,就是给读取到的子应用的样式添加带有子应用信息的前缀。效果如下:
/* 假设应用名是 react16 */
.app-main {
font-size: 14px;
}
div[data-qiankun-react16] .app-main {
font-size: 14px;
}
通过上面的隔离方法,基本可以保证子应用间的样式互不影响。
小结
qiankun 在 single-spa 的基础上根据实际的生产实践开发了很多有用的功能,大大降低了微前端的使用成本。
本文仅仅针对如何加载子应用和如何做好子应用间的隔离这两个问题,介绍了 qiankun 的实现。其实,在隔离这个问题上,qiankun 也仅仅是根据实际中会遇到的情况做了必要的隔离措施,并没有像 iframe 那样实现完全的隔离。我们可以说 qiankun 实现的隔离有缺陷,也可以说是 qiankun 在实际的业务需求和完全隔离的实现成本之间做的取舍。
常见面试知识点、技术方案分析、教程,都可以扫码关注公众号“众里千寻”获取,或者来这里 https://everfind.github.io/po... 。