热知识~,乾坤是基于 single-spa 实现的微前端框架,而 single-spa 用到 SystemJS 作为主要的模块加载工具
在进一步剖析乾坤源码之前,我们先来了解下 SystemJS 和 single-spa 的工作原理
SystemJS 和 single-spa
- SystemJS: 允许在浏览器环境中动态加载微应用的模块,处理模块的导入导出
- single-spa: 提供了微前端的核心架构和生命周期管理,确保各个微应用能够独立运行和协作。 通过路由劫持机制实现子应用的动态加载,并利用 SystemJS 作为模块加载器来管理各个子应用的导入与导出。为了确保子应用能够与主应用无缝集成,子应用需要遵循特定的接入协议,即暴露固定的生命周期方法:
bootstrap、mount和unmount
SystemJS
是一个可运行于浏览器端的模块加载器,让我们可以在浏览器中使用 ES6 import/export 语法
我们可以通过 systemjs-importmap 指定依赖库的地址,也可以在 script标签里 System.import('./index.js') 直接导入某个模块,具体语法可以参考下面代码
注意🙌,模块导入是一个异步过程,返回的是一个 Promise 对象,可以配合 then 来使用
<body>
<h3>主应用,也叫基座,用来加载子应用的 webpack importMap</h3>
<div id="root"></div>
<!-- 可以在浏览器使用 ES6 的 import/export 语法, 通过 systemjs-importmap 指定依赖库的地址 -->
<script type="systemjs-importmap">
{
"imports": {
"react-dom": "https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js",
"react": "https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js"
}
}
</script>
<script src="https://cdn.bootcdn.net/ajax/libs/systemjs/6.10.1/system.min.js"></script>
<script>
// 直接加载子应用, 导入打包后的包来进行加载, 采用是 system规范
System.import('./index.js')
</script>
</body>
SystemJS 和 single-spa 没有任何关系,只是它的 in-browser import/export 和 single-spa 倡导的 in-browser run time 相符合,所以 single-spa 将其作为主要的导入导出工具 (并不是必须的!!!)
甚至在一些现代浏览器中,我们可以借助 importmap 实现 _import axios from 'axios'_ 导入功能💯
<script type="importmap">
{
"imports": {
"axios": "https://cdn.jsdelivr.net/npm/axios@0.20.0/dist/axios.min.js"
}
}
</script>
<script type="module">
import axios from 'axios'
</script>
但在低版本浏览器中,我们就需要借助于一些 "Polyfill" 来实现 import/export 了。SystemJS 就是解决这个问题的
我们也可以用 Webpack 动态引入,甚至可能比 SystemJS 更好用💯
import(/* webpackChunkName: "index" */ './index.js').then(moduleA => {
moduleA.doSomething();
});
single-spa
single-spa 通过路由劫持实现应用的加载(采用SystemJS),提供应用间公共组件加载以及公共业务逻辑处理。子应用需要遵循特定的接入协议,即暴露固定的生命周期钩子(bootstrap、mount、unmount)💯
无沙箱机制,需要实现自己的JS沙箱以及CSS沙箱
index.html(主应用)
负责声明资源路径
<script type="systemjs-importmap">
{
"imports": {
"@burc/root-config": "//localhost:9000/burc-root-config.js",
"@burc/react":"//localhost:3000/react.js",
"@burc/vue":"//localhost:4000/js/app.js"
}
}
</script>
main.js(主应用)
负责注册子应用和启动主应用 Application
import {
registerApplication,
start
} from "single-spa";
registerApplication({
name: "@burc/react", // 不重名即可
app: () =>
System.import('@burc/react'),
activeWhen: (location) => location.pathname.startsWith('/react'),
});
registerApplication({
name: "@burc/vue", // 不重名即可
app: () =>
System.import('@burc/vue'),
activeWhen: (location) => location.pathname.startsWith('/vue'),
});
start({
urlRerouteOnly: true,
});
sing-spa 只做了两件事: 一是提供生命周期概念,负责调度子应用的生命周期;二是劫持 url 变化事件,url 变化时匹配对应子应用,执行生命周期流程
Root Config: 指主应用的 index.html + main.js。HTML 负责声明资源路径,JS 负责注册子应用和启动主应用
Application: 子应用要暴露 bootstrap,mount,unmount 三个生命周期(接入协议)
registerMicroApps(注册子应用)
用于注册子应用的基础配置信息。当浏览器 url 发生变化时,会自动检查每一个微应用注册的 activeRule 规则,符合规则的应用将会被自动激活
详细参数可查看乾坤官网 - registerMicroApps(apps, lifeCycles?), 其基本语法如下:
import { registerMicroApps, start } from 'qiankun';
registerMicroApps(
[
{
name: 'reactApp', // 微应用的名称,微应用之间必须确保唯一
entry: '//localhost:40000', // 微应用的入口
activeRule: '/react', // 微应用的激活规则,当路径以 /react 为前缀时启动
container: '#container', // 微应用的容器节点的选择器或者 Element 实例
loader, // loading 状态发生变化时会调用的方法
props: { userInfo:{ name: 'burc', password: 'xxxxxx'} }, // 主应用需要传递给微应用的数据
},
{
name: 'vueApp',
entry: '//localhost:20000', // 默认react启动的入口是10000端口
activeRule: '/vue', // 当路径是 /react的时候启动
container: '#container', // 应用挂载的位置
loader,
props: { userInfo:{ name: 'burc', password: 'xxxxxx'}},
},
],
{
beforeLoad() { },
beforeMount() { },
afterMount() { },
beforeUnmount() { },
afterUnmount() { },
},
)
start()
registerMicroApps 注册子应用,乾坤源码对应 qiankun/blob/master/src/apis.ts
name: 微应用之间必须确保唯一,标识,用于区分不同的微应用
import { registerApplication, start as startSingleSpa } from 'single-spa';
export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>, // 本次要注册的应用
lifeCycles?: FrameworkLifeCycles<T>, // 自己编写的生命周期
) {
// 拿到没有被注册过的应用,name 属性就是用来区分不同的应用的
const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));
// 最新要注册的应用
microApps = [...microApps, ...unregisteredApps];
// 循环注册未注册的应用
unregisteredApps.forEach((app) => {
// appConfig 应用的配置
const { name, activeRule, loader = noop, props, ...appConfig } = app;
// 注册应用的逻辑采用的是 single-spa 的 registerApplication (路由劫持也是在 single-spa 内部实现)
registerApplication({
name,
app: async () => {
loader(true);
await frameworkStartedDefer.promise; // 等待调用 start 方法,frameworkStartedDefer.resolve()
// loadApp方法返回的是一个函数 (loadApp())(), 沙箱的处理
const { mount, ...otherMicroAppConfigs } = (
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)();
return {
// 返回的是应用的接入协议
mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
...otherMicroAppConfigs,
};
},
activeWhen: activeRule,
customProps: props,
});
// 1. 目前不会执行app方法,会等待路径匹配后执行app方法
// 2. 执行app方法时,也会等待调用start方法, await frameworkStartedDefer.promise
});
}
注册应用的底层逻辑采用的是 single-spa 的 registerApplication 方法
监听路由变化,匹配对应的子应用 (single-spa内部执行),路径匹配成功后执行 app 方法,然后等待调用下文的 start 方法,再去加载子应用 loadApp()
下文的 start 方法:
import { start as startSingleSpa } from 'single-spa';
export function start(opts: FrameworkConfiguration = {}) {
...
frameworkStartedDefer.resolve(); // 调用成功的promise
}
loadApp(加载子应用)
乾坤源码对应 qiankun/blob/master/src/loader.ts
- 通过 importEntry 加载解析子应用的入口HTML文件,获取 template模版 以及 js脚本执行器 execScripts 等
- 对 template 模板进行处理,
<qiankun-head-head>替换<head>,添加data-name、data-version等属性 - 创建 css沙箱,实现 影子dom沙箱 和 作用域css沙箱
- 创建 js沙箱,这里存在兼容性的降级操作,多应用代理沙箱 -> 单应用代理沙箱 -> 快照沙箱
- 在 js沙箱环境中执行脚本执行器 execScripts(),这里用 js沙箱的代理对象 代替了 全局对象window
importEntry(加载HTML)
通过 importEntry 加载解析子应用的入口HTML文件,获取解析后的html文件、并且拿到js脚本的执行器、和额外的js脚本
import { importEntry } from 'import-html-entry'
// 获取解析后的html文件、并且拿到js脚本的执行器、和额外的js脚本
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
乾坤相比于 single-spa 有两大特色,一个是实现了 JS 和 CSS沙箱机制;
另一个就是使用 import-html-entry 实现了 HTML entry,而 single-spa 只能是 JS entry 的形式来加载子应用
- JS Entry。 通常将子应用的所有资源打包成一个入口文件,在 single-spa 的很多样例中就使用了这种方式
- HTML Entry。 子应用构建输出的是一个 HTML 文件,主应用通过加载这个 HTML 文件完成子应用的加载
import-html-entry到底干了些什么?
[import-html-entry,它可以从指定的 URL 加载解析 HTML 文件,返回值如下:
- template: 是注释掉了 js脚本,并将外部css样式转化为内部css样式之后的 html
- assetPublicPath: 静态资源的公共路径
- execScripts: Promise<> ,执行js脚本的函数(包括内部脚本和外部脚本)
- **getExternalScripts:**Promise<> Scripts URL from template,返回 html 文件的所有js脚本
- **getExternalStyleSheets:**Promise<> StyleSheets URL from template,返回 html 文件的外部css样式表
template模版,是注释掉了 js脚本,并将外部css样式转化为内部css样式之后的 html
getExternalScripts,Promise<>,返回 html 文件的所有js脚本
getExternalStyleSheets,Promise<>,返回 html 文件的外部css样式表
getDefaultTplWrapper(处理template)
对 template 模板进行处理,<qiankun-head-head> 替换 <head>,添加 data-name、data-version 等属性
// 对 template 模板进行处理
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
export function getDefaultTplWrapper(name: string, sandboxOpts: FrameworkConfiguration['sandbox']) {
return (tpl: string) => {
let tplWithSimulatedHead: string;
if (tpl.indexOf('<head>') !== -1) {
// We need to mock a head placeholder as native head element will be erased by browser in micro app
tplWithSimulatedHead = tpl
.replace('<head>', `<${qiankunHeadTagName}>`)
.replace('</head>', `</${qiankunHeadTagName}>`);
} else {
// Some template might not be a standard html document, thus we need to add a simulated head tag for them
tplWithSimulatedHead = `<${qiankunHeadTagName}></${qiankunHeadTagName}>${tpl}`;
}
return `<div id="${getWrapperId(
name,
)}" data-name="${name}" data-version="${version}" data-sandbox-cfg=${JSON.stringify(
sandboxOpts,
)}>${tplWithSimulatedHead}</div>`;
};
}
结果如下:
createElement(css沙箱)
创建一个 css沙箱,实现 影子dom沙箱 和 作用域css沙箱
- 影子dom沙箱,就是给微应用的容器包裹上一个 shadow dom 节点
- 作用域css沙箱,就是拿到所有的
<style>标签,对里面的 css 增加css前缀。**对于<link>外链 而言,已经提前通过 import-html-entry 将<link>链 转换成了 内嵌style
这两种 css 沙箱是如何实现的?可以搭配柏成之前写的 qiankun 的 CSS 沙箱隔离机制,配合食用~
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appInstanceId: string,
): HTMLElement {
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
// appContent always wrapped with a singular div
// 对于严格样式隔离 就是增加影子dom
const appElement = containerElement.firstChild as HTMLElement;
if (strictStyleIsolation) {
if (!supportShadowDOM) {
console.warn(
'[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
);
} else {
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// createShadowRoot was proposed in initial spec, which has then been deprecated
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
// 作用域css,就是拿到所有的style标签,对里面的css 增加css前缀
//(对于link外链而言,已经提前通过 import-html-entry 将 link外链 转换成 内嵌style)
if (scopedCSS) {
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
}
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
}
return appElement;
}
createSandboxContainer(js沙箱)
创建一个 js沙箱,然后在沙箱环境中执行脚本执行器 execScripts(),注意!这个用沙箱的代理对象 替换了 全局window
const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose; // 快照沙箱
// enable speedy mode by default
const speedySandbox = typeof sandbox === 'object' ? sandbox.speedy !== false : true; //proxy
let sandboxContainer;
if (sandbox) {
// 创建一个沙箱
sandboxContainer = createSandboxContainer(
appInstanceId,
// FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
initialAppWrapperGetter,
scopedCSS,
useLooseSandbox,
excludeAssetFilter,
global,
speedySandbox,
);
// 用沙箱的代理对象作为接下来使用的全局对象
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}
// 根据指定的沙箱环境执行脚本
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
});
这里存在兼容性的降级操作,多应用代理沙箱 -> 单应用代理沙箱 -> 快照沙箱
这三种 js 沙箱是如何实现的?可以移步柏成之前写的 qiankun 的 JS 沙箱隔离机制,或者 乾坤源码
export function createSandboxContainer(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
scopedCSS: boolean,
useLooseSandbox?: boolean,
excludeAssetFilter?: (url: string) => boolean,
globalContext?: typeof window,
speedySandBox?: boolean,
) {
let sandbox: SandBox;
if (window.Proxy) {
sandbox = useLooseSandbox
? new LegacySandbox(appName, globalContext) // 单应用代理沙箱
: new ProxySandbox(appName, globalContext, { speedy: !!speedySandBox }); // 多应用代理沙箱
} else {
sandbox = new SnapshotSandbox(appName); // 快照沙箱
}
return {
instance: sandbox,
}
}
start(启动)
乾坤源码对应 qiankun/blob/master/src/apis.ts
start API 详细参数可查看乾坤官网 - start(opts?)
- 如果支持预加载,则开始调用预加载的策略
doPrefetchStrategy - 对 js沙箱来做降级处理,老旧浏览器不支持proxy
autoDowngradeForLowVersionBrowser - 启动 single-spa 的 start 方法,然后 single-spa 开始监听路由变化,并根据当前路由加载和挂载相应的微应用
import { start as startSingleSpa } from 'single-spa';
export function start(opts: FrameworkConfiguration = {}) {
// 在start参数中,增加了 prefetch(预加载)、singular(单例模式)、(sandbox)沙箱
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
const {
prefetch,
sandbox,
singular,
urlRerouteOnly = defaultUrlRerouteOnly,
...importEntryOpts
} = frameworkConfiguration;
// 如果支持预加载,则开始调用预加载的策略
if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
// 对 js沙箱来做降级处理,老旧浏览器不支持proxy
frameworkConfiguration = autoDowngradeForLowVersionBrowser(frameworkConfiguration);
startSingleSpa({ urlRerouteOnly }); // 就是 single-spa 的 start方法
started = true;
frameworkStartedDefer.resolve(); // 调用成功的promise
}
doPrefetchStrategy(预加载)
乾坤源码对应 qiankun/blob/master/src/prefetch.ts
start API 详细参数可查看乾坤官网 - start(opts?)
- prefetch -
boolean | 'all' | string[] | (( apps: RegistrableApp[] ) => { criticalAppNames: string[]; minorAppsName: string[] })- 可选,是否开启预加载,默认为true。 - 配置为
true则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源 - 配置为
'all'则主应用start后即开始预加载所有微应用静态资源
export function doPrefetchStrategy(
apps: AppMetadata[],
prefetchStrategy: PrefetchStrategy,
importEntryOpts?: ImportEntryOpts,
) {
switch (prefetchStrategy) {
case true:
// 等待第一个应用加载完毕后 加载其他应用
prefetchAfterFirstMounted(apps, importEntryOpts);
break;
case 'all':
prefetchImmediately(apps, importEntryOpts);
break;
default:
break;
}
}
prefetchAfterFirstMounted
在第一个微应用 mount 完成后开始预加载其他微应用的静态资源
当第一个微应用 mount 完成后,single-spa中 内部会派发 dispatchEvent('single-spa:first-mount'),我们可以使用 window.addEventListener('single-spa:first-mount') 监听此事件
然后遍历所有未加载的子应用 app,调用 prefetch 依次去加载静态资源
function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
// single-spa中 默认内部 dispatchEvent('single-spa:first-mount')
window.addEventListener('single-spa:first-mount', function listener() {
// 获取到所有未加载的app
const notLoadedApps = apps.filter((app) => getAppStatus(app.name) === NOT_LOADED);
if (process.env.NODE_ENV === 'development') {
const mountedApps = getMountedApps();
console.log(`[qiankun] prefetch starting after ${mountedApps} mounted...`, notLoadedApps);
}
// 遍历所有未加载的app依次去加载
notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));
// 加载完毕后移除监听
window.removeEventListener('single-spa:first-mount', listener);
});
}
prefetchImmediately
主应用 start 后,即开始预加载所有微应用静态资源
遍历所有的子应用 app,调用 prefetch 依次去预加载静态资源
export function prefetchImmediately(apps: AppMetadata[], opts?: ImportEntryOpts): void {
if (process.env.NODE_ENV === 'development') {
console.log('[qiankun] prefetch starting for apps...', apps);
}
apps.forEach(({ entry }) => prefetch(entry, opts));
}
prefetch(预加载事件)
通用的预加载方法,预先加载子应用的静态资源,以减少实际访问时的加载时间
预加载用到了一个浏览器 API - requestIdleCallback,在此方法中插入一个函数,这个函数将在浏览器主线程空闲时期被调用,不会阻塞关键渲染路径的任务
预加载使用了 import-html-entry 来加载解析子应用的入口 HTML 文件,这里拿到了子应用的 全部js脚本(直接在 script 中编写的内部js、通过 script 引入的外部js)和 外部css样式(通过 link 标签引入的样式表)
对于工程化项目来讲,这里我可以理解为是预加载了子应用的 全部js脚本 和 全部css样式(仅排除了入口html文件中直接在 style 标签中编写的样式)
import { importEntry } from 'import-html-entry';
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
if (!navigator.onLine || isSlowNetwork) {
// 如果慢网的情况或者无网的情况 结束
return;
}
requestIdleCallback(async () => {
// 预加载HTML入口文件,用 import-html-entry 替代了 systemjs
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
requestIdleCallback(getExternalStyleSheets); // 仅获取外部的样式表
requestIdleCallback(getExternalScripts); // 获取内部和外部的js脚本
});
}
autoDowngradeForLowVersionBrowser(js沙箱自动降级)
LegacySandbox(单应用代理沙箱)和 ProxySandbox(多应用代理沙箱)是基于 proxy 实现的,具体是如何实现的?可参考 qiankun 的 JS 沙箱隔离机制
部分老旧浏览器不支持 proxy,则自动降级为 SnapshotSandbox(快照沙箱),这里只是修改了frameworkConfiguration配置项,增加了loose: true快照沙箱标识
js沙箱是如何被创建的?可以看上一章 loadApp 中的 createSandboxContainer 方法,乾坤源码对应 qiankun/blob/master/src/loader.ts(loadApp 中的 createSandboxContainer方法)
const autoDowngradeForLowVersionBrowser = (configuration: FrameworkConfiguration): FrameworkConfiguration => {
const { sandbox, singular } = configuration;
if (sandbox) {
if (!window.Proxy) {
// 不支持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 { ...configuration, sandbox: typeof sandbox === 'object' ? { ...sandbox, loose: true } : { loose: true } };
}
}
return configuration;
};