不懂qiankun原理?这篇文章五张图片带你迅速通晓
前言
这是一篇以通俗易懂的方式剖析qiankun
运行原理的文章。通过这篇文章,读者们可以知道:
single-spa
的使用入门qiankun
是如何基于single-spa
的基础上运行的qiankun
内置的js
沙箱的原理以及其作用
single-spa
入门
qiankun
是基于single-spa
实现的,因此我们有必要了解single-spa
,它也是一个微前端框架,该框架的使用方式如下所示:
import * as singleSpa from "single-spa";
// 通过registerApplication注册单个子应用
// 当url为/app1时,加载app1子应用,加载方式为请求'/app1'路径获取资源
singleSpa.registerApplication({
name: "app1",
app1: () => import("/app1"),
activeWhen: "/app1",
});
// 当url为/app2时,加载app2子应用,加载方式为请求'/app2'路径获取资源
singleSpa.registerApplication({
name: "app2",
app2: () => import("/app2"),
activeWhen: "/app2",
});
// 在调用start()之前。如果url匹配已注册的子应用的activeWhen规则,则只会加载子应用资源,但不会初始化,挂载或卸载子应用
// 之所以拆分出start,是为了更好地控制整个微前端的执行流程。举个例子,如果当前路由匹配已注册的子应用的路由规则,但子应用初始化时需要主应用正在请求的用户信息,那我们可以等主应用获取到用户信息中再执行start
singleSpa.start();
而被加载的子应用中,其加载路径对应的资源必须是一个js
文件,js
文件中必须通过export
导出三个函数:bootstrap
、mount
、unmount
,如下所示,这三个函数会作为生命周期钩子函数在singleSpa
获取子应用资源后被依次执行。
let domEl;
export function bootstrap(props) {
return Promise.resolve().then(() => {
domEl = document.createElement("div");
domEl.id = "app1";
document.body.appendChild(domEl);
});
}
export function mount(props) {
return Promise.resolve().then(() => {
// 在这里通常使用框架将ui组件挂载到dom。请参阅https://single-spa.js.org/docs/ecosystem.html。
domEl.textContent = "App 1 is mounted!";
});
}
export function unmount(props) {
return Promise.resolve().then(() => {
// 在这里通常是通知框架把ui组件从dom中卸载。参见https://single-spa.js.org/docs/ecosystem.html
domEl.textContent = "";
});
}
当我们的子应用使用了vue
或者react
时,我们可以分别使用single-spa-vue
和single-spa-react
这两个库去生成上述三个函数,如下所示:
React
Vue2
Vue3
对于上述代码中值得一提的两点:
-
上面图中的
props.domElement
是主应用中用于挂载子应用的DOM
元素,通常称为应用基座。通常我们会在主应用中把应用基座传给子应用,而不是在子应用中通过css selector
规则写死主应用中应用基座的位置,如下所示:singleSpa.registerApplication({ name: "vue-app1", app1: () => import("/vue-app1"), activeWhen: "/vue-app1", // 在customProps属性中的domElement字段传入应用基座,customProps在生命周期钩子函数执行时会被作为参数传入 customProps: { domElement: document.querySelectot("#micro-app"), }, });
-
single-spa-vue
中会在应用基座里创建一个子元素div.single-spa-container
作为vue
实例挂载的元素,主要原因是vue
挂载会完全替换掉挂载元素,例如:<!-- 如果在开发代码中这么编写 --> <html> <head> <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script> </head> <body> <!-- 挂载元素 --> <div id="app"></div> </body> <script type="text/javascript"> new Vue({ el: "#app", template: "<div>Hello Vue</div>", }); </script> </html> <!-- 最终运行后的页面代码为 --> <html> <head> <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script> </head> <body> <!-- 注意此处的挂载元素被完全替换 --> <div>Hello Vue</div> </body> </html>
但如果在
react
中就不会出现这种情况,因为react
是把渲染内容作为子元素挂载到挂载元素里。而vue
这种在微前端应用中会替换掉整个应用基座,这样子导致的后果是:在切换成别的页面时,执行子应用的unmount
周期函数销毁vue
实例,此时不会恢复应用基座的DOM
,然后再次切换进入该子应用页面时,会因为找不到应用基座而报错。因此
single-spa-vue
采取的做法是在应用基座里新建一个子元素div.single-spa-container
作为vue
实例挂载的元素。这样子就避免了应用基座被取替的情况。
single-spa
提供了一个成熟的微前端实现方式:监听url
以加载、挂载、卸载子应用。而qiankun
则是基于single-spa
上实现了更多功能。
站在single-spa
的肩膀上,qiankun
做了什么?
qiankun
与single-spa
最大的区别是:single-spa
提供的注册子应用 APIregisterApplication
中,请求的子应用资源类型是js
。而qiankun
提供的注册子应用 APIregisterMicroApps
中,请求的子应用资源是html
文件,即子应用打包后的入口页面,如下所示。
registerMicroApps([
{
name: "app1",
// 子应用app1的入口页面网址
entry: "//localhost:8080",
container: "#container",
activeRule: "/react",
},
]);
而registerMicroApps
内部调用了single-spa
的registerApplication
。下面用伪代码的形式展示是如何调用的:
// 伪代码形式展示registerMicroApps内部细节
registerApplication({
name,
app: async () => {
// 1. 加载和解析`html`资源,获取页面、样式资源链接、脚本资源链接三种数据
// 2. 对外部样式资源(css)进行加载处理,生成`render`函数
// 3. 生成 `js`沙箱
// 4. 加载外部脚本资源(js),且进行包装、执行
// 5. 从入口文件里获取生命周期钩子函数:`bootstrap`、`mount`、`unmount`,然后当作registerApplication的app形参中的返回数据,如下所示
return {
bootstrap,
mount:[
// `render`函数用于把处理后的html页面插入到应用基座上
render()
mount()
],
unmount: ,
};
},
activeWhen: activeRule,
customProps: props,
});
下面会一步一步对上述过程中的每一步进行分析:
1. 加载和解析html
资源
qiankun
会调用import-html-entry
这个第三方库去加载和处理html
资源。import-html-entry
会在获取html
页面后会通过正则表达式解析并提取其中的link
元素和script
元素。
假设存在子应用的html
是以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<title>React App</title>
<link href="/react-app/static/css/main.css" rel="stylesheet" />
<style type="text/css">
.contaier {
width: auto;
}
</style>
</head>
<body>
<div id="root"></div>
<script>
function(){}
</script>
<script defer="defer" src="/react-app/static/js/chunk1.js"></script>
<script defer="defer" src="/react-app/static/js/main.js"></script>
</body>
</html>
经过解析后会返回template(html页面)
、scripts(script外链或脚本内容列表)
、entry(js入口文件链接)
、styles(links外链列表)
四个值,他们的值分别如下所示:
// 对 内联脚本 和 资源类型为javascript 的script取替成<!-- xxx replaced by import-html-entry -->标志位
// 对资源类型为stylesheet的link取替成<!-- xxx replaced by import-html-entry -->标志位
template = `
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<title>React App</title>
<!-- link http://xxx/react-app/static/css/main.css replaced by import-html-entry -->
<style type="text/css">
.contaier {
width: auto;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- inline scripts replaced by import-html-entry -->
<!-- script http://xxx/react-app/static/js/chunk1.js replaced by import-html-entry -->
<!-- script http://xxx/react-app/static/js/main.js replaced by import-html-entry -->
</body>
</html>
`;
// 集成所有 内联脚本 和 资源类型为javascript的script外链
scripts = [
"\x3Cscript>\n function(){}\n \x3C/script>",
"http://xxx/react-app/static/js/chunk1.js",
"http://xxx/react-app/static/js/main.js",
];
// 如果存在带entry的script元素,则entry为script元素的外链或内联脚本。否则为scripts最后的元素
entry = "http://xxxx/react-app/static/js/main.js";
// 集成所有 资源类型为stylesheet的link外链
styles = ["http://xxx/react-app/static/css/main.css"];
运行流程图:
2. 对外部样式资源(css)进行加载处理,生成render
函数
对上一步骤中获取的styles
,也就是样式资源外链进行请求,拿到css
代码,然后生成内联样式<style>xxx</style>
插入且取替步骤一中template
对应的标志位上,假如"http://xxx/react-app/static/css/main.css"
链接对应样式资源的css
代码如下所示:
.toolbar {
width: 100%;
}
则插入后的template
代码如下所示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<title>React App</title>
<style>
/* http://xxx/react-app/static/css/main.css */
.toolbar {
width: 100%;
}
</style>
<style type="text/css">
.contaier {
width: auto;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- inline scripts replaced by import-html-entry -->
<!-- script http://xxx/react-app/static/js/main.js replaced by import-html-entry -->
</body>
</html>
完成这一步后会生成render
函数,render
函数用于把上述的template
插入到应用基座上。render
函数会在single-spa
里的mount
生命周期中执行,即放入到registerApplication
的app
参数的返回的mount
属性里。
流程图:
3. 生成 js
沙箱
qiankun
会根据用户设置的sandbox
属性来生成 js
沙箱,js
沙箱 用于隔离应用间的window
环境,防止子应用在执行代码期间影响了其他应用设置在window
上的属性。qiankun
内置的沙箱有三种:ProxySandbox
、LegacySandbox
、SnapshotSandbox
,使用场景如下所示:
ProxySandbox
:sandbox
没有设置或设置为true
,且浏览器环境支持ES6-Proxy
类LegacySandbox
:sandbox
设置为对象,且浏览器环境支持ES6-Proxy
类SnapshotSandbox
:浏览器环境不支持ES6-Proxy
类
为了让大家更好理解沙箱的原理,这里放出简化版的ProxySandbox
源码:
export default class ProxySandbox implements SandBox {
// 用于记录更改过的window属性
private updatedValueSet = new Set<PropertyKey>();
name: string;
// 用于存放全局对象Window
globalContext: typeof window;
// 用于记录最后被修改的属性
latestSetProp: string | number | symbol | null = null;
constructor(name: string, globalContext = window) {
this.name = name;
this.globalContext = globalContext;
// createFakeWindow会创建一个纯对象,然后把全局对象Window所有属性及其值复制到该纯对象上
const { fakeWindow } = createFakeWindow(globalContext);
// 创建以fakeWindow作为目标对象的Proxy实例
this.proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
// We must kept its description while the property existed in globalContext before
if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
const { writable, configurable, enumerable } = descriptor!;
if (writable) {
Object.defineProperty(target, p, {
configurable,
enumerable,
writable,
value,
});
}
} else {
// @ts-ignore
target[p] = value;
}
// 白名单制度,如果修改的属性在白名单里,则会把值同步到全局对象Window上
if (variableWhiteList.indexOf(p) !== -1) {
// @ts-ignore
globalContext[p] = value;
}
updatedValueSet.add(p);
this.latestSetProp = p;
return true;
},
get: (target: FakeWindow, p: PropertyKey): any => {
return ? (globalContext as any)[p]
: p in target
? (target as any)[p]
: (globalContext as any)[p];
},
})
}
ProxyWindow
里创建了一个带有全局对象Window
所有属性及其值fakeWindow
,然后再创建了以fakeWindow
为目标对象的Proxy
实例。当我们在子应用中通过window['a']=1
新增或修改属性时,会触发Proxy
实例的handler.set
方法执行,此时他会做以下操作:
- 修改
fakeWindow
的"a"
属性为 1 - 如果是修改
System
等一些在白名单属性里的值,则会先把Window
中的['xx']的目前值备份一下,然后在把新的值覆盖到Window
的属性中(当子应用销毁时,会把备份的值重置到原本属性中)。但大多数属性包括"a"
都不在白名单属性中,因此是不会修改到Window
的同名属性里的。
我们再来一段代码来理解一下沙箱的效果:
// 1. 主应用加载时定义window.app
window.app = "masterapp";
// 2. 子应用读取window.app的值
console.log(window.app); // 显示'master-app'
// 3. 子应用A更改window.app的值,然后在子应用A中读取是'micro-a',但如果在主应用中读取依旧是'master-app'
window.app = "micro-a";
console.log(window.app); // 'micro-a'
// 3. 切换到子应用B且读取window.app的值时,此时子应用A已销毁,window.app会从'micro-a'撤回为'masterapp'
console.log(window.user); // 'master-app'
总的来说,qiankun
设置这种js
沙箱是为了隔离子应用和主应用的window
。但目前存在一个缺点:这种沙箱只能隔离Window
的一级属性。因为Proxy
只会捕获到一级属性的增删改,不能捕获到二级以上属性的变动,我们可以通过下图的控制台操作得出此结论:
4. 加载外部脚本资源(js),且进行包装、执行
开始对scripts
列表中的外链进行资源请求,获取到的js
代码,假设上述template
里的"http://xxx/react-app/static/js/chunk1.js"
的代码为:
function fn1() {
window["micro-app"] = "dsn";
}
fn1();
然后对所有 请求获取的js
代码 和 内联脚本 都进行包装以修改其作用域上的window
,那上面的js
代码做例子,包装后的代码如下所示:
;(function(window, self, globalThis){
with(window){
function fn1() {
window["micro-app"] = "dsn";
}
fn1();
}
//# sourceURL=http://xxx/react-app/static/js/chunk1.js
).bind(window.proxy)(window.proxy, window.proxy, window.proxy);
其中window.proxy
是上一步中生成的js
沙箱,通过bind
传入到匿名函数的作用域里,然后再通过with
语句把window
的指向从全局对象Window
切换到该js
沙箱。从而让子应用的js
代码执行过程中,调用的window
其实是一个目标对象为fakeWindow
的Proxy
实例,从而完成了应用间window
的隔离。
最终上面包装后的代码会用eval
执行。
流程图:
5. 从入口文件里获取生命周期钩子函数:bootstrap
、mount
、unmount
,然后交给single-spa
去调用执行
在qiankun
的官方教程中,告诉我们webpack
设置中要加上以下配置:
const packageName = require("./package.json").name;
// 官方实例,仅适用于webpack4
module.exports = {
output: {
library: `${packageName}-[name]`,
libraryTarget: "umd",
jsonpFunction: `webpackJsonp_${packageName}`,
},
};
// 个人实践出的webpack5配置
module.exports = {
output: {
library: {
// [name]代表入口文件名称
name: `${packageName}-[name]`,
type: "umd", //
},
chunkLoadingGlobal: `webpackJsonp_${packageName}`,
},
};
我们来说一下library
,它用于在执行环境中插入一个全局对象,该全局对象会包含入口文件中通过export
导出的所有方法,例如如果我在名为react-ts-app
子应用里打印window["react-ts-app-main"]
,则控制台会输出以下:
注意这个插入过程是对子应用的window
进行的,也就是js
沙箱中的proxy
,因此如果在控制台里直接打印window["react-ts-app-main"]
是不会输出以上结果的。
因此我们在入口文件在上一步骤中包装且eval
执行后,qiankun
会通过等同于Object.keys(window).pop()
获取到最新插入的属性"react-ts-app-main"
,然后通过window["react-ts-app-main"]
拿到这些生命周期钩子函数。最终这些生命周期钩子函数用作single-spa
的registerApplication
的app
的返回数据里。从而完成了从解析html
到把入口文件中的生命周期钩子函数交给single-spa
调用的整个过程。
关于library
的更多详细用法可看webpack#outputlibraryname。
流程图:
至此,子应用主要的加载过程已经描述完毕。
整个流程图如下所示:
当然其中还有很多其他细节方面的逻辑还没说详细,如:
-
在步骤 5中会把
beforeLoad
和beforeMount
等周期函数的执行逻辑安插在registerApplication
的app
返回的声明周期函数中。 -
在步骤 2中会把
template
的<head></head>
换成<qiankun-head></qiankun-head>
。然后再用一个div
标签包裹着template
,如下所示:<div id="__qiankun_microapp_wrapper_for_${appName}" data-name="${appName}" data-version="${version}" > ${template} </div>
然后会再次创建一个
div
元素appElement
,通过appElement.innerHTML = template
把对于上述字符串转化为DOM
元素。接下来中会创建一个会根据
sandbox
里的设置分开做处理:- 若
strictStyleIsolation
为true
,则通过把appElement.attachShadow({ mode: 'open' })
上述appElement
转为shadowDOM
。 - 若
experimentalStyleIsolation
为true
,则把步骤二中通过links
外链请求获取到的所有css
代码都加上div[data-qiankun="${appName}"]
的前缀,于此同时也给appElement
加上属性data-qiankun="${appName}"
。
- 若
-
在步骤 5中会把
GlobalState
的初始化、卸载等逻辑安插在registerApplication
的app
返回的声明周期函数中。
如果想查看更多可直接看qiankun
的registerMicroApps
源码。
我们为什么需要js
沙箱
js
沙箱为我们隔离了应用之间的window
,虽然说只是隔离了一级属性,但也已经非常给力。开发者们会疑惑 🤔:怎么给力了?我写开发代码是尽量不会往window
添加或修改属性的。况且如果有这个需要,只要约定好每个应用使用的属性名不一样就行,不会互相影响呀?
的确我们在开发中通过代码规则约束能避免window
污染,但我们的第三方依赖库就不一定能做到这点。如果有些应用都是用了同一个依赖库,而这个依赖库都往window
里添加属性了,那如果没有js
沙箱隔离就会导致应用之间彼此影响。例如我们常用的svg-sprite-loader
和font-awesome
,他们都会把自身的svgComplier
挂载到window
上,如下所示:
如果应用都使用了这两个泛用的第三方库且没有隔离window
,必然会造成影响。
后记
这篇文章写到这里就结束了,如果觉得有用可以点赞收藏,如果有疑问可以直接在评论留言,欢迎交流 👏👏👏。
转载自:https://juejin.cn/post/7202246519080304697