开篇废话
滴,立冬卡❄️!
冬季伊始特奉上文章一篇,揭秘 qiankun 框架原理,以此温暖倔友们追求技术的心 🔥🔥🔥🤣。
single-spa
qiankun 实际上是对 single-spa 的二次封装;所以要了解 qiankun 的原理,还得先弄明白 single-spa 是如何工作的。
我们先来简单看一下 single-spa 的使用方式:
注册微应用
首先,需要使用 registerApplication
方法来注册一个微应用:
js
import * as singleSpa from 'single-spa';
// 微应用的名称
const name = 'app1';
// 加载微应用的方法,需要是个返回 Promise 的加载函数
const app = () => import('./app1.js');
// 这个微应用的路由匹配规则
const activeWhen = location => location.pathname.startsWith('/app1');
// 调用 registerApplication 方法,将对应参数传入,注册微应用
singleSpa.registerApplication({ name, app, activeWhen });
在注册微应用的过程中,必须传入以下几个参数:
- 微应用的名称;
- 微应用的加载方法;
- 微应用的路由匹配规则;
实现微应用生命周期
接下来,我们着手来实现一下我们刚刚注册的微应用 app1 的生命周期函数:
js
//app1.js
let domEl;
// app1 的初始化方法,创建一个 div 元素,并为其添加属性和内容
export function bootstrap(props) {
return Promise.resolve().then(() => {
domEl = document.createElement('div');
domEl.innerHTML = '我是app1的内容';
domEl.id = 'app1';
});
}
// 挂载方法,用于将初始化时创建的 dom 元素挂载到页面指定节点上
export function mount(props) {
return Promise.resolve().then(() => {
// 在这里通常使用框架将ui组件挂载到dom。
document.querySelector('#app').appendChild(domEl);
});
}
// 卸载方法,用于将页面上指定的 dom 元素移除。
export function unmount(props) {
return Promise.resolve().then(() => {
// 在这里通常是通知框架把ui组件从dom中卸载。
const target = document.querySelector('#app1')
target.remove()
})
}
在上面的代码中,我们为 app1 实现了几个生命周期函数,分别是:
- 初始化 (bootstrap):初始化方法,用于创建一个 div 元素,并为其添加属性和内容;
- 挂载 (mount):挂载方法,用于将初始化时创建的 dom 元素挂载到页面指定节点上
- 卸载 (unmount):卸载方法,用于将页面上指定的 dom 元素移除。
这些生命周期函数通过 export
向外暴露,single-spa 会在适当的时候去调用它们。
启动微应用
最后,我们通过 single-spa 的 start
方法来激活这个微应用:
js
import * as singleSpa from 'single-spa';
singleSpa.start()
一切准备就绪!
现在当我们访问 /app1
这个地址时,single-spa 会自动去做路由匹配,发现能够命中我们注册微应用时预先定义好的路由规则;
这时候,single-spa 就会通过我们在注册微应用 传入的 loadApp
方法获取到对应的资源;
最后 依次调用我们导出的 bootstrap
、mount
这些方法 ,将我们的 dom 节点顺利挂载到页面上。
简易版 single-spa
了解了 single-spa 的基本使用方式,下面我们来试着自己动手实现一个简易版的 single-spa。
实现 registerApplication 方法
首先是 registerApplication
方法,它的实现逻辑很简单,如下:
js
let apps = [];
function registerApplication(appName, appOrLoadApp, activeWhen, customProps) {
apps.push({
name: appName,
loadApp: appOrLoadApp,
activeWhen: activeWhen,
customProps: customProps,
status: 'NOT_LOADED'
});
}
single-spa 中支持注册多个微应用,所以我们用一个全局的 apps 数组来管理注册的应用;
当调用 registerApplication
方法时,只要将应用信息放这个数组就行了。
同时,我们给每个应用额外增加了一个 status
字段用来 标识这个应用的状态,方便我们对应用状态进行管理。
监听路由变化
我们在前面提到 ------ 当路由地址改变时, single-spa 会比较当前地址与路由匹配规则,从而来判断是否需要加载微应用;
这实际上是通过 监听 hashchange
和 popstate
事件来实现的:
js
function reroute() {
// Todo
}
// 监听路由的变化 调用对应的回调函数 reroute
window.addEventListener('hashchange', reroute);
window.addEventListener('popstate', reroute);
过滤需要激活的微应用
在对应的回调函数 reroute
中,我们遍历注册好的微应用,依次调用每个微应用的 activeWhen
方法并传入 window.location
对象,来判断是否能命中路由匹配规则;
如果能够命中,说明这是个 即将要激活的应用,我们将其保存在一个数组中:
js
function reroute() {
// 储存需要激活的应用
const activeApps = [];
apps.forEach(app => {
// 判断当前地址是否能命中微应用的路由匹配规则
if (app.activeWhen(window.location)) {
activeApps.push(app);
}
});
}
加载对应的微应用
通过上一步,我们已经知道了哪些是需要激活的微应用;
接下来就需要 获取这些微应用的资源,拿到它暴露出的生命周期钩子并按顺序调用它们,同时更新应用状态:
js
function reroute() {
const activeApps = [];
apps.forEach(app => {
if (app.activeWhen(window.location)) {
activeApps.push(app);
}
});
// +++ 新增代码 +++
// 遍历 activeApps 数组
activeApps.forEach(async app => {
// 判断应用状态 防止重复执行
if (app.status === 'NOT_LOADED') {
// 通过传入的 loadApp 方法拿到对应的资源
app.loadApp().then(result => {
// 修改应用状态
app.status = 'NOT_BOOTSTRAPPED';
// 获取暴露的生命周期狗子
app.bootstrap = result.bootstrap;
app.mount = result.mount;
app.unmount = result.unmount;
}).then(() => {
// 按顺序执行生命周期钩子 并修改应用状态
app.bootstrap().then(() => {
app.status = 'NOT_MOUNTED';
}).then(() => {
app.mount();
app.status = 'NOT_MOUNTED';
}).then(() => {
app.status = 'MOUNTED';
});
})
}
});
}
这样一来微应用 app1 对应的内容就被成功挂载到页面上了!
这时候,如果路由发生了改变与当前激活的微应用不再匹配,那么就需要 卸载对应的微应用。
对应的逻辑也很简单,直接上代码:
js
function reroute() {
// +++ 新增代码 +++
// 声明一个数组 用于储存失活的 app
const inactiveApps = [];
apps.forEach(app => {
if (app.activeWhen(window.location)) {
// ...省略应用激活逻辑...
} else {
// +++ 新增代码 +++
// 如果没有命中路由匹配规则,将其加入 inactiveApps 数组中
inactiveApps.push(app);
}
});
// ...省略应用激活逻辑...
// +++ 新增代码 +++
// 应用失活时的逻辑
inactiveApps.forEach(async app => {
if (app.status === 'MOUNTED') {
app.status = 'UNMOUNTING';
// 调用预先定义好的 unmount 方法卸载应用;
app.unmount().then(app => {
// 成功卸载后 修改应用的状态;
app.status = 'NOT_MOUNTED';
});
}
});
}
实现 start 方法
最后,我们来实现一下 start 方法;当调用这个方法时,只需要去调用 reroute 函数就可以啦:
js
export function start() {
reroute();
}
so easy~
完整代码
完整代码如下:
js
// my-single-spa
let apps = [];
// 注册微应用
export function registerApplication(options) {
const {
name,
app,
activeWhen
} = options
apps.push({
name: name,
loadApp: app,
activeWhen,
status: 'NOT_LOADED'
});
}
// 加载微应用
export function start() {
started = true;
reroute();
}
// 路由变化回调
export function reroute() {
const activeApps = [];
const inactiveApps = [];
apps.forEach(app => {
if (app.activeWhen(window.location.href)) {
activeApps.push(app);
} else {
inactiveApps.push(app);
}
});
activeApps.forEach(async app => {
if (app.status === 'NOT_LOADED') {
app.loadApp().then(result => {
app.status = 'NOT_BOOTSTRAPPED';
app.bootstrap = result.bootstrap;
app.mount = result.mount;
app.unmount = result.unmount;
}).then(() => {
app.bootstrap().then(() => {
app.status = 'NOT_MOUNTED';
}).then(() => {
app.mount();
app.status = 'NOT_MOUNTED';
}).then(() => {
app.status = 'MOUNTED';
});
})
}
});
inactiveApps.forEach(async app => {
if (app.status === 'MOUNTED') {
app.status = 'UNMOUNTING';
app.unmount().then(() => {
app.status = 'NOT_LOADED';
});
}
});
}
window.addEventListener('hashchange', reroute);
window.addEventListener('popstate', reroute);
singla-spa 痛点
至此,我们已经实现了一个简易的 single-spa。
但是,不知道各位小伙伴有没有发现,在使用 single-spa 的过程中有几个痛点:
-
首先,single-spa 加载的微应用时 需要以 JS 文件作为入口 ,这会把所有的资源文件打包在一个 bundle 中,造成最终输出的 bundle 十分庞大。
-
其次,对于每一个微应用,我们都需要手动实现它的生命周期,并暴露给 single-spa 使用 。
在上面的例子中,我们通过在生命周期钩子中操作原生 DOM 对象,从而实现应用的挂载、卸载等逻辑;而现在,大前端什么 Vue 、React 、Angular 等等各种框架五花八门,难不成针对每个框架都要调用相关的 API 封装一套对应的逻辑吗?
这可不能忍!😤😤😤
而 qiankun 针对上面提到的这些问题一一做出了处理,下面就来看看它是怎么做的吧。
qiankun
首先回想一下,当我们单独使用 Vue 、React 这些前端框架构建 SPA 时,它们最终都会打包生成一个 index.html
页面,用户操作时框架会通过执行各种脚本来动态地更新这个 index.html
页面。
如果我们能够拿到这个最终的 index.html
资源文件,并 把它作为微应用的入口 ;当微应用被 激活时,我们将这个 index.html
文件的内容挂载到页面指定的位置 ;当 微应用失活时,我们再操作 DOM 将对应的内容移除。
这样一来,管你微应用使用的是什么技术栈,老夫我直接操作 DOM 一把梭哈!
并且使用 HTML 作为入口文件还有一个好处:能够利用浏览器的并行加载能力 提高资源的加载速度,可谓是一举两得!
而 qiankun 底层正是通过 import-html-entry
这个库来实现 JS Entry 到 HTML Entry 的转变。
那么 import-html-entry
又是怎么运作的呢,我们接着往下看 ------
import-html-entry
假设在我们本地的 http://localhost:7104
地址上运行了一个项目,项目的 index.html
和 main.js
文件分别如下:
html
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script type="text/javascript" src="main.js"></script>
</body>
</html>
js
// main.js
console.log('我是 7104 端口')
export function main () {
console.log('我是 main 函数')
}
我们通过 import-html-entry
库的 importHTML
方法来加载 7104 端口这个项目的资源文件:
js
import importHTML from 'import-html-entry';
// 调用 importHTML 方法,传入需要加载的资源地址
importHTML('//localhost:7104').then(res => {
// 将获取到的资源结果输出
console.log(res);
});
这里输出的 res
是个对象,内容如下:
从图中可以看出,这个项目的 index.html
文件被字符串化放在了 template
字段中,并且对应的 main.js
脚本被一段文本注释代替了;
如果我们想要执行这些注释了的脚本,只需要 调用返回的这个对象上的 execScripts
方法;
同时,还能 通过 getExternalScripts
方法来获取对应脚本;以及 getExternalStyleSheets
方法来获取对应的样式表文件。
js
import importHTML from 'import-html-entry';
importHTML('//localhost:7104').then(res => {
const { execScripts, getExternalScripts, getExternalStyleSheets } = res;
// 执行脚本,控制台输出: '我是 7104 端口'
execScripts().then(exports => {
// 获取导出的内容,控制台输出: function main () {console.log('我是 main 函数')}
console.log(exports)
})
// 获取并执行外部脚本
// scripts 是个数组,数组中的每一项都是字符串化的脚本文件
getExternalScripts().then(scripts => {
// 执行脚本,控制台输出: '我是 7104 端口'
scripts.forEach(eval);
});
// 获取外部样式表
getExternalStyleSheets().then(styleSheets => {
// styleSheets 是个数组,数组中的每一项都是字符串化的样式文件
styleSheets.forEach(styleSheet => {});
});
});
import-html-entry
这个库的加入,让我们能够 获取到各个项目打包后的 index.html
资源,并且自由地控制各个脚本、样式文件的加载、执行时机。
沙盒
优化了微应用的加载,还有一个需要关注的问题 ------
微应用在加载的过程中,往往会声明全局的变量、设置监听器等等操作;这些副作用可能会导致 全局环境被污染,从而影响其他应用的执行。
因此,需要想办法给这些应用提供一个能够 隔离这些副作用的运行环境,保证它们能够稳定的运行彼此之间互不影响;
这就是我们常说的开启一个 沙盒 (sandbox) 。
隔离运行环境
一个最简单的隔离运行环境的方式,就是 复制 window 对象上的所有属性到一个新的对象中,然后使用 Proxy 来代理这个对象 。
我们通过几段代码来看看它的实现:
- 复制
window
对象:
js
// 复制 window 对象
function createFakeWindow(globalContext) {
// 声明一个新的变量 用于储存 window 对象上复制过来的属性
const fakeWindow = {};
// 遍历 window 对象上所有属性,过滤出哪些熟悉是可配置的
Object.getOwnPropertyNames(globalContext).filter(function (p) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
return !(descriptor === null || descriptor === void 0 ? void 0 : descriptor.configurable);
}).forEach(function (p) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
if (descriptor) {
// 将这些可配置的属性 设置到我们新的对象中
Object.defineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});
return fakeWindow;
}
const fakeWindow = createSimpleFakeWindow(window);
- 使用
proxy
劫持fakeWindow
:
js
class ProxySandbox {
private proxy
constructor() {
const rawWindow = window;
const fakeWindow = createFakeWindow(window)
// 使用 proxy 劫持
const proxy = new Proxy(fakeWindow, {
// 这里以 get 和 set 方法为例
get: (target: any, p: PropertyKey): any => {
// 对一些特殊的属性做处理 返回代理对象本身
if (p === 'window' || p === 'self') {
return proxy
}
return target[p] || rawWindow[p];
},
set: (target: any, p: PropertyKey, value: any): boolean => {
target[p] = value;
return true;
},
});
}
this.proxy = proxy
}
- 使用代理
window
对象:
js
// 使用示例
const sandbox = new ProxySandbox();
sandbox.a = 1; // 在沙箱环境中设置全局变量
console.log(sandbox.a); // 1
console.log(window.a); // undefined,全局环境未被污染
而在 qiankun 源码中主要采用的也是这种 Proxy 代理的方式来实现运行环境的隔离。
样式隔离
我们知道,在微应用中子应用的样式和主应用的样式最后是存在同一颗 DOM 树下的,这那就有可能会造成 样式冲突 的问题
因此,除了要开辟一个稳定的运行环境外,进行样式隔离同样是个重中之重;
我们来看看 qiankun 是怎么做的。
scoped css
如果开启了 experimentalStyleIsolation 选项,qiankun 会使用 scoped css 的方式来进行样式隔离。
具体做法就是: 给包裹子应用的最外层元素加上特定的属性;然后获取到子应用下的样式表内容,修改其中的 css 规则,给其加上对应的属性选择器:
html
<!-- 添加特定属性 data-qiankun="react15" -->
<div data-qiankun="react15"></div>
css
// 默认样式
.react15-icon {
height: 400px;
}
// 加上特定属性选择器前缀后
div[data-qiankun="react15"] .react15-icon {
height: 400px;
}
而这里的 react15 实际上就是我们在注册这个子应用时提供的 name 字段。
shadow dom
第二种方式是使用 shadow dom。
当配置为 { strictStyleIsolation: true }
时, qiankun 会为每个微应用的容器包裹上一个 shadow dom,如下图:
shadow dom 下的样式 只会应用于其内部的元素 ,不会泄漏到外部的文档或其他 shadow dom 中的元素;从而达到样式隔离的目的。
需要强调的是,shadow dom 的 DOM 访问方法、样式选择器等许多特性与普通 DOM 都有所差异,因此在使用时会有许多限制,需要谨慎开启!
总结
以上就是关于 qiankun 框架的全部介绍啦~
总的来说, qiankun 就是对 single-spa 的二次封装,它通过 import-html-entry
库将 JS Entry 转换为 HTML Entry,抹平了各大框架之间的差异。
此外,qiankun 还引入了沙盒机制,通过复制 window 对象并使用 Proxy 来隔离子应用的运行环境,防止全局环境被污染。
最后,qiankun 通过 scoped css 与 shadow dom 的方式来解决应用之间的样式冲突问题。