跳到主要内容

qiankun

https://qiankun.umijs.org/zh

简介

乾坤是基于 single-spa 实现的微前端库,用来构建易拆分和解耦,可以独立开发和部署的前端个体应用架构系统。微前端具有如下特点:

1、与技术无关,不限制前端开发语言框架,只需要按照主框架方式接入即可。这样在中大型的业务开发中,可以把不同的相对独立的业务模块交给使用不同前端开发语言的团队去并行开发,降低了统一技术栈的技术门槛。

2、微应用支持独立开发和部署,具有相对独立的生命周期。传统项目开发中,如果项目耦合或者复杂度较大,通常一个业务模块和功能的上线会牵涉到其他模块的上线和发布。而使用微应用,模块可以解耦出来独立开发部署,减少了开发耦合带来的测试和部署成本。

3、独立运行时状态。微应用具有自己独立的状态和运行时,互相之间不会干扰。

4、功能升级和更新更加敏捷。对于技术框架的升级和业务的更新,传统框架会牵一发而动全身,但是通过微前端可以灵活并且渐进的升级功能和技术架构,尤其方便业务复杂场景。

总而言之,微前端相对于传统大而全的前端系统来说,实现了功能、业务的细颗粒物划分和解耦,单个微应用具有了独立灵敏的开发节奏,比较适合复杂业务场景。微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。

为什么不是 iframe

为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 "炫技" 或者刻意追求 "特立独行"。

如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

其中有的问题比较好解决(问题 1),有的问题我们可以睁一只眼闭一只眼(问题 4),但有的问题我们则很难解决(问题 3)甚至无法解决(问题 2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。

要求

微应用分为有 webpack 构建和无 webpack 构建项目,有 webpack 的微应用(主要是指 Vue、React、Angular)需要做的事情有:

  1. 新增 public-path.js 文件,用于修改运行时的 publicPath

注意:运行时的 publicPath 和构建时的 publicPath 是不同的,两者不能等价替代。

  1. 微应用建议使用 history 模式的路由,需要设置路由 base,值和它的 activeRule 是一样的。
  2. 在入口文件最顶部引入 public-path.js,修改并导出三个生命周期函数。(bootstrap、mount、unmount)
  3. 修改 webpack 打包,允许开发环境跨域和 umd 打包。

主要的修改就是以上四个,可能会根据项目的不同情况而改变。例如,你的项目是 index.html 和其他的所有文件分开部署的,说明你们已经将构建时的 publicPath 设置为了完整路径,则不用修改运行时的 publicPath (第一步操作可省)。

webpack 构建的微应用直接将 lifecycles 挂载到 window 上即可。

常用 API

主应用配置:

  • 注册微应用的基础配置信息。当浏览器 url 发生变化时,会自动检查每一个微应用注册的 activeRule 规则,符合规则的应用将会被自动激活。registerMicroApps(apps, lifeCycles?)
registerMicroApps(
[
{
name: 'app1',
entry: '//localhost:8080',
container: '#container',
activeRule: '/react',
props: {
name: 'kuitos',
},
},
],
{
beforeLoad: (app) => console.log('before load', app.name),
beforeMount: [(app) => console.log('before mount', app.name)],
},
);
  • 启动 qiankun:start(opts?)

  • 设置主应用启动后默认进入的微应用:setDefaultMountApp(appLink)

  • 第一个微应用 mount 后需要调用的方法,比如开启一些监控或者埋点脚本:runAfterFirstMounted(effect)

手动加载微应用:

  • 手动加载一个微应用,如果需要能支持主应用手动 update 微应用,需要微应用 entry 再多导出一个 update 钩子。loadMicroApp(app, configuration?)
  • 手动预加载指定的微应用静态资源。仅手动加载微应用场景需要,基于路由自动激活场景直接配置 prefetch 属性即可。prefetchApps(apps, importEntryOpts?)

全局钩子:

  • singleSpa.addErrorHandler(handleErr); singleSpa.removeErrorHandler(handleErr);

  • 添加全局的未捕获异常处理器:addGlobalUncaughtErrorHandler(handler)

  • 移除全局的未捕获异常处理器:removeGlobalUncaughtErrorHandler(handler)

  • 定义全局状态,并返回通信方法,建议在主应用使用,微应用通过 props 获取通信方法:initGlobalState(state)

源码简单解析:

入口文件index.ts

暴露上面提到的 API,其他如 setDefaultMountApp 来自effects.ts等,最终汇总到 index.ts 导出。

export { loadMicroApp, registerMicroApps, start } from "./apis";
export { initGlobalState } from "./globalState";
export { getCurrentRunningApp as __internalGetCurrentRunningApp } from "./sandbox";
export * from "./errorHandler";
export * from "./effects";
export * from "./interfaces";
export { prefetchImmediately as prefetchApps } from "./prefetch";

注册子应用

首先从最核心的 api registerMicroApps看,为了避免 app 多次注册,会首先通过 microApps 和当前注册的 apps 将重复注册的 app 给过滤掉,剩下的就是最新的增量注册 app(也就是未注册的 apps),然后将所有的 apps 结果保存到 microApps 中,并把本次未注册的 apps 每个都调用registerApplication注册一下。注意:registerApplication函数来自 single-spa.

export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>,
lifeCycles?: FrameworkLifeCycles<T>
) {
// Each app only needs to be registered once
const unregisteredApps = apps.filter(
(app) => !microApps.some((registeredApp) => registeredApp.name === app.name)
);

microApps = [...microApps, ...unregisteredApps];

unregisteredApps.forEach((app) => {
const { name, activeRule, loader = noop, props, ...appConfig } = app;

registerApplication({
name,
app: async () => {
loader(true);
await frameworkStartedDefer.promise;

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,
});
});
}

single-spa注册应用可以看到,registerApplication 有如下参数:

singleSpa.registerApplication({
name: 'myApp',
app: () => import('src/myApp/main.js'),
activeWhen: ['/myApp', (location) => location.pathname.startsWith('/some/other/path')],
customProps: {
some: 'value',
},
});

- name
必须是字符串。
- app
应用的定义,它可以是一个单spa生命周期的对象,加载函数或者与第二个参数相同。
- activeWhen
可以是激活函数,比如参数API、路径前缀或两者的数组。因为最常见的用例是使用`window.location` 将其URL前缀进行匹配,所以我们帮你实现了这个方法。
- customProps
可选的 customProps 属性提供传递给应用程序的 single-spa 生命周期函数的自定义props。自定义props可以是对象或返回对象的函数。使用应用程序名称和当前 window.location 作为参数调用自定义 prop 函数。

qiankun 中第 2 个参数 app 表示应用的定义,最终会 return 一个包含生命周期的对象。其中loader(true);,表示设置 loading 为 true,此时应用会显示加载中的状态。

  • loader - (loading: boolean) => void - 可选,loading 状态发生变化时会调用的方法。

await frameworkStartedDefer.promise;开启了一个延迟 promise,会等待在之后的start()函数调用中调用frameworkStartedDefer.resolve()来结束。

之后通过调用 loadApp 的立即执行函数,返回 registerApplication.app 需要的生命周期对象。loadApp()函数来自loader.ts文件,最后 return 的是一个Promise<ParcelConfigObjectGetter>,最终起始返回的是如下:

export type ParcelConfigObjectGetter = (
remountContainer?: string | HTMLElement
) => ParcelConfigObject;

type ParcelConfigObject<ExtraProps = CustomProps> = {
name?: string;
} & LifeCycles<ExtraProps>;

export type LifeCycles<ExtraProps = {}> = {
bootstrap: LifeCycleFn<ExtraProps> | Array<LifeCycleFn<ExtraProps>>;
mount: LifeCycleFn<ExtraProps> | Array<LifeCycleFn<ExtraProps>>;
unmount: LifeCycleFn<ExtraProps> | Array<LifeCycleFn<ExtraProps>>;
update?: LifeCycleFn<ExtraProps> | Array<LifeCycleFn<ExtraProps>>;
};

也就是

{
name: ...,
bootstrap:...,
mount:...,
unmount:...,
update:...
}

最终 return 的时候,可以看到 mount 字段返回的是一个数组,先设置 loader(true)显示全局 loading,然后开始执行 mount 中的各个触发函数,最后把 loading 关掉。

上面函数调用了await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)函数,现在在loader.ts中详细看一下它的实现。

loadApp 代码如下:

export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>
): Promise<ParcelConfigObjectGetter> {
const { entry, name: appName } = app;
const appInstanceId = genAppInstanceIdByName(appName);

const markName = `[qiankun] App ${appInstanceId} Loading`;
if (process.env.NODE_ENV === "development") {
performanceMark(markName);
}

const {
singular = false,
sandbox = true,
excludeAssetFilter,
globalContext = window,
...importEntryOpts
} = configuration;

// get the entry html content and script executor
const { template, execScripts, assetPublicPath, getExternalScripts } =
await importEntry(entry, importEntryOpts);
// trigger external scripts loading to make sure all assets are ready before execScripts calling
await getExternalScripts();

// as single-spa load and bootstrap new app parallel with other apps unmounting
// (see https://github.com/CanopyTax/single-spa/blob/master/src/navigation/reroute.js#L74)
// we need wait to load the app until all apps are finishing unmount in singular mode
if (await validateSingularMode(singular, app)) {
await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
}

const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);

const strictStyleIsolation =
typeof sandbox === "object" && !!sandbox.strictStyleIsolation;

if (process.env.NODE_ENV === "development" && strictStyleIsolation) {
console.warn(
"[qiankun] strictStyleIsolation configuration will be removed in 3.0, pls don't depend on it or use experimentalStyleIsolation instead!"
);
}

const scopedCSS = isEnableScopedCSS(sandbox);
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId
);

const initialContainer = "container" in app ? app.container : undefined;
const legacyRender = "render" in app ? app.render : undefined;

const render = getRender(appInstanceId, appContent, legacyRender);

// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
render(
{
element: initialAppWrapperElement,
loading: true,
container: initialContainer,
},
"loading"
);

const initialAppWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => initialAppWrapperElement
);

let global = globalContext;
let mountSandbox = () => Promise.resolve();
let unmountSandbox = () => Promise.resolve();
const useLooseSandbox = typeof sandbox === "object" && !!sandbox.loose;
// enable speedy mode by default
const speedySandbox =
typeof sandbox === "object" ? sandbox.speedy !== false : true;
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 {
beforeUnmount = [],
afterUnmount = [],
afterMount = [],
beforeMount = [],
beforeLoad = [],
} = mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) =>
concat(v1 ?? [], v2 ?? [])
);

await execHooksChain(toArray(beforeLoad), app, global);

// get the lifecycle hooks from module exports
const scriptExports: any = await execScripts(
global,
sandbox && !useLooseSandbox,
{
scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
}
);
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
sandboxContainer?.instance?.latestSetProp
);

const {
onGlobalStateChange,
setGlobalState,
offGlobalStateChange,
}: Record<string, CallableFunction> = getMicroAppStateActions(appInstanceId);

// FIXME temporary way
const syncAppWrapperElement2Sandbox = (element: HTMLElement | null) =>
(initialAppWrapperElement = element);

const parcelConfigGetter: ParcelConfigObjectGetter = (
remountContainer = initialContainer
) => {
let appWrapperElement: HTMLElement | null;
let appWrapperGetter: ReturnType<typeof getAppWrapperGetter>;

const parcelConfig: ParcelConfigObject = {
name: appInstanceId,
bootstrap,
mount: [
async () => {
if (process.env.NODE_ENV === "development") {
const marks = performanceGetEntriesByName(markName, "mark");
// mark length is zero means the app is remounting
if (marks && !marks.length) {
performanceMark(markName);
}
}
},
async () => {
if (
(await validateSingularMode(singular, app)) &&
prevAppUnmountedDeferred
) {
return prevAppUnmountedDeferred.promise;
}

return undefined;
},
// initial wrapper element before app mount/remount
async () => {
appWrapperElement = initialAppWrapperElement;
appWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => appWrapperElement
);
},
// 添加 mount hook, 确保每次应用加载前容器 dom 结构已经设置完毕
async () => {
const useNewContainer = remountContainer !== initialContainer;
if (useNewContainer || !appWrapperElement) {
// element will be destroyed after unmounted, we need to recreate it if it not exist
// or we try to remount into a new container
appWrapperElement = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId
);
syncAppWrapperElement2Sandbox(appWrapperElement);
}

render(
{
element: appWrapperElement,
loading: true,
container: remountContainer,
},
"mounting"
);
},
mountSandbox,
// exec the chain after rendering to keep the behavior with beforeLoad
async () => execHooksChain(toArray(beforeMount), app, global),
async (props) =>
mount({
...props,
container: appWrapperGetter(),
setGlobalState,
onGlobalStateChange,
}),
// finish loading after app mounted
async () =>
render(
{
element: appWrapperElement,
loading: false,
container: remountContainer,
},
"mounted"
),
async () => execHooksChain(toArray(afterMount), app, global),
// initialize the unmount defer after app mounted and resolve the defer after it unmounted
async () => {
if (await validateSingularMode(singular, app)) {
prevAppUnmountedDeferred = new Deferred<void>();
}
},
async () => {
if (process.env.NODE_ENV === "development") {
const measureName = `[qiankun] App ${appInstanceId} Loading Consuming`;
performanceMeasure(measureName, markName);
}
},
],
unmount: [
async () => execHooksChain(toArray(beforeUnmount), app, global),
async (props) => unmount({ ...props, container: appWrapperGetter() }),
unmountSandbox,
async () => execHooksChain(toArray(afterUnmount), app, global),
async () => {
render(
{ element: null, loading: false, container: remountContainer },
"unmounted"
);
offGlobalStateChange(appInstanceId);
// for gc
appWrapperElement = null;
syncAppWrapperElement2Sandbox(appWrapperElement);
},
async () => {
if (
(await validateSingularMode(singular, app)) &&
prevAppUnmountedDeferred
) {
prevAppUnmountedDeferred.resolve();
}
},
],
};

if (typeof update === "function") {
parcelConfig.update = update;
}

return parcelConfig;
};

return parcelConfigGetter;
}

首先听过 genAppInstanceIdByName 函数生成了一个当前要注册的 app 的实例 id,起始就是一个 appName 的字符串,如果有多个相同名称的 name,会返回${appName}_${globalAppInstanceMap[appName]}的一个 id,也就是 appName 后面跟上一个计数器数字。

之后,通过 app 参数中配置的 entry 和 configuration 中配置的 importEntryOpts,调用await importEntry(entry, importEntryOpts)方法获取要加在的 entry 文件的模板字符串。这里的 importEntry 就是来自这个核心的第三方库import-html-entry

import-html-entry这个库的作用起始也很简单,就是根据给得地址,通过 fetch 方法获取入口文件字符串模板,经过一些解析处理后,将 template、scripts、styles 等解析出来,然后返回回来。具体可以看:https://github.com/kuitos/import-html-entry/blob/master/src/index.js#L299,

要注意的是,qiankun 的 entry 配置如下:

entry - string | { scripts?: string[]; styles?: string[]; html?: string } - 必选,微应用的入口。

配置为字符串时,表示微应用的访问地址,例如 https://qiankun.umijs.org/guide/。
配置为对象时,html 的值是微应用的 html 内容字符串,而不是微应用的访问地址。微应用的 publicPath 将会被设置为 /。

entry 既可以是 string,也可是对象,这个参数就是在 import-html-entry 中使用的,使用如下:

export function importEntry(entry, opts = {}) {
const {
fetch = defaultFetch,
getTemplate = defaultGetTemplate,
postProcessTemplate,
} = opts;
const getPublicPath =
opts.getPublicPath || opts.getDomain || defaultGetPublicPath;

if (!entry) {
throw new SyntaxError("entry should not be empty!");
}

// html entry
if (typeof entry === "string") {
return importHTML(entry, {
fetch,
getPublicPath,
getTemplate,
postProcessTemplate,
});
}

// config entry
if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) {
const { scripts = [], styles = [], html = "" } = entry;
const getHTMLWithStylePlaceholder = (tpl) =>
styles.reduceRight(
(html, styleSrc) => `${genLinkReplaceSymbol(styleSrc)}${html}`,
tpl
);
const getHTMLWithScriptPlaceholder = (tpl) =>
scripts.reduce(
(html, scriptSrc) => `${html}${genScriptReplaceSymbol(scriptSrc)}`,
tpl
);

return getEmbedHTML(
getTemplate(
getHTMLWithScriptPlaceholder(getHTMLWithStylePlaceholder(html))
),
styles,
{ fetch }
).then((embedHTML) => ({
template: embedHTML,
assetPublicPath: getPublicPath(entry),
getExternalScripts: () => getExternalScripts(scripts, fetch),
getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
execScripts: (proxy, strictGlobal, opts = {}) => {
if (!scripts.length) {
return Promise.resolve();
}
return execScripts(scripts[scripts.length - 1], scripts, proxy, {
fetch,
strictGlobal,
...opts,
});
},
}));
} else {
throw new SyntaxError("entry scripts or styles should be array!");
}
}

字符串的话直接调用 importHTML,如果是对象的话,将 scripts 和 styles 拼到 html 上再去解析。

通过调用 importEntry 方法, 收到返回的入口 html 文件字符模板,以及需要执行的脚本。

然后调用getExternalScripts(),触发外部脚本加载以确保所有资产都准备就绪。

接下来会判断如果是单节点模式singular=true,会等待其他 app 都卸载后继续执行。

之后,会掉用getDefaultTplWrapper方法,并从返回值函数中传入 html 的字符串模板,将页面渲染到如下节点中:

也就是将模板文件用一个 div 包裹。

之后,会使用到 samdbox 参数。在回顾一下 sandbox 参数的可选参数

sandbox - boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean } - 可选,是否开启沙箱,默认为 true

默认情况下沙箱可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离。当配置为 { strictStyleIsolation: true } 时表示开启严格的样式隔离模式。这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响。

除此以外,qiankun 还提供了一个实验性的样式隔离特性,当 experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围,因此改写后的代码会表达类似为如下结构:

// 假设应用名是 react16
.app-main {
font-size: 14px;
}

div[data-qiankun-react16] .app-main {
font-size: 14px;
}

通过 html 模板、strictStyleIsolation 配置来调用 createElement 函数,生成初始化之后的 app html 元素。

其中,createElement 逻辑如下:

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
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;
}
}

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;
}

逻辑也很简单,就是如果使用 strictStyleIsolation 开启了严格的样式隔离模式,会将原本的 html 内容外面包裹一个 shadow dom。

shadow dom 的原理可以参考使用 shadow DOM

之后,通过包裹完成的 appContent 和 appInstanceId 创建渲染器。

function getRender(appInstanceId: string, appContent: string, legacyRender?: HTMLContentRender) {
const render: ElementRender = ({ element, loading, container }, phase) => {
if (legacyRender) {
if (process.env.NODE_ENV === 'development') {
console.error(
'[qiankun] Custom rendering function is deprecated and will be removed in 3.0, you can use the container element setting instead!',
);
}

return legacyRender({ loading, appContent: element ? appContent : '' });
}

const containerElement = getContainer(container!);

// The container might have be removed after micro app unmounted.
// Such as the micro app unmount lifecycle called by a react componentWillUnmount lifecycle, after micro app unmounted, the react component might also be removed
if (phase !== 'unmounted') {
const errorMsg = (() => {
switch (phase) {
case 'loading':
case 'mounting':
return `Target container with ${container} not existed while ${appInstanceId} ${phase}!`;

case 'mounted':
return `Target container with ${container} not existed after ${appInstanceId} ${phase}!`;

default:
return `Target container with ${container} not existed while ${appInstanceId} rendering!`;
}
})();
assertElementExist(containerElement, errorMsg);
}

if (containerElement && !containerElement.contains(element)) {
// clear the container
while (containerElement!.firstChild) {
rawRemoveChild.call(containerElement, containerElement!.firstChild);
}

// append the element to container if it exist
if (element) {
rawAppendChild.call(containerElement, element);
}
}

return undefined;
};

return render;
}

此外有一个legacyRender参数,在 loadApp 函数中也能看到,如果提供了 render 函数,就是用提供的 render,否则使用 qiankun 提供的 render 函数。

这个 render 函数的作用就是把提供的 container 下面所有的子节点清除,并把需要渲染的 element 放到 container 下面。这个 element 就是上面包裹完成的微应用的页面内容元素。

之后调用 getAppWrapperGetter 获取 appContent 的包裹节点,也就是上面 getDefaultTplWrapper 中返回的最外层用 div 包裹的元素。

getAppWrapperGetter 内部根据不同条件返回不同的 wrapper 元素。

之后就是创建沙箱的环节,调用 createSandboxContainer 函数。来自src/sandbox/index.ts文件夹。

沙箱

http://zoo.zhengcaiyun.cn/blog/article/qiankun

我们在使用微前端框架的时候,经常听到 js 沙箱这个词,那究竟什么是 js 沙箱,js 沙箱又是来做什么的。

在计算机安全中,沙箱(Sandbox)是一种用于隔离正在运行程序的安全机制,通常用于执行未经测试或不受信任的程序或代码,它会为待执行的程序创建一个独立的执行环境,内部程序的执行不会影响到外部程序的运行。而 js 沙箱也是来源与这个概念。

在前端中最直观的副作用/危害就是污染、篡改全局 window 状态。首先我们先来看一个场景,我们在 A 微应用中定义了一个全局变量 city,有很多业务是基于 city 变量展开的。但是突然有一天微应用 B 也因为业务需求定义了一个全局变量 city,这时候在 A,B 微应用互相切换的时候,会导致基于 city 的代码逻辑互相影响。这时我们首先想到的是在定义的时候可以互相沟通一下避免这种重复的情况,或者每个微应用定义全局变量时可以加一个自己独有的前缀。但是在微应用数量增多或者团队人员增多的时候,这个问题就会越发凸显,因为前面的提出的解决方案严重依赖沟通和对编码规则的彻底执行,这样就总会出现遗漏的状况。这时我们就要产出一种方案,达到即使两个微应用定义了相同的全局变量也不会互相影响的效果,其中一种解决方案就是 js 沙箱隔离。

那 js 沙箱的原理是什么,又是如何来解决上面的问题的。其实原理很简单,就是在不同的微应用中记录在当前微应用中定义以及改变了哪些全局变量,并且在切换微应用的时候恢复和删除之前的修改,这样就可以做到互不影响了。

目前在乾坤的代码中一共有三种沙箱实现方案。这三种方案也是随着技术的成熟和微前端的逐步发展而不断进化出来的,我们可以在这三种方案的实现源码中体会出微前端的发展历程。

SnapshotSandbox 沙箱快照方案

sanpshot 沙箱是基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器

在乾坤中,所有子应用都有加载(active)和卸载(inactive)两个周期函数。

SnapshotSandbox.jpg

active(加载函数)

  1. 循环 window,把子应用加载前的 window 进行复制暂存,用于卸载时恢复初始 window。
  2. 恢复之前的变更。上次子应用运行时改变的 window 变量会再存下来,再次加载时会恢复之前的 window 变更。
  3. 修改 sandboxRunning 标识,标识子应用运行中。
active() {
// 记录当前快照
this.windowSnapshot = {} as Window;
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});

// 恢复之前的变更
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});

this.sandboxRunning = true;
}

inactive(卸载函数)

  1. 循环 window 与之前的暂存 window 做对比,记录变更。
  2. 恢复子应用加载前的 window 状态。
  3. 修改 sandboxRunning 标识,标识子应用已卸载。
 inactive() {
this.modifyPropsMap = {};

iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录变更,恢复环境
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});

if (process.env.NODE_ENV === 'development') {
console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));
}

this.sandboxRunning = false;
}

总结

优点:实现简单易懂,代码兼容性好。 不足:每次激活,卸载都要遍历 window,性能较差。只能支持加载一个子应用。

legacySandbox 沙箱快照方案

legacySandbox.jpg

constructor

创建变量 fakeWindow(虚拟的 window),并代理 fakeWindow,在每次更改 fakeWindow 时,记录下更改记录,并存放在子应用的内存变量内。

内存变量有:

addedPropsMapInSandbox : 沙箱期间新增的全局变量, 用于卸载子应用时删除此变量 modifiedPropsOriginalValueMapInSandbox :沙箱期间更新的全局变量,用于卸载时删除修改。

currentUpdatedPropsValueMap : 所有的更改记录(新增和修改的),用于下次再加载自用时时恢复 window。

通过这三个变量就是记录先子应用以及原来环境的变化,qiankun 也能以此作为恢复环境的依据。

/**
* 基于 Proxy 实现的沙箱
* TODO: 为了兼容性 singular 模式下依旧使用该沙箱,等新沙箱稳定之后再切换
*/
export default class LegacySandbox implements SandBox {
/** 沙箱期间新增的全局变量 */
private addedPropsMapInSandbox = new Map<PropertyKey, any>();

/** 沙箱期间更新的全局变量 */
private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();

/** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();

name: string;

proxy: WindowProxy;

globalContext: typeof window;

type: SandBoxType;

sandboxRunning = true;

latestSetProp: PropertyKey | null = null;

private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
if (value === undefined && toDelete) {
// eslint-disable-next-line no-param-reassign
delete (this.globalContext as any)[prop];
} else if (
isPropConfigurable(this.globalContext, prop) &&
typeof prop !== "symbol"
) {
Object.defineProperty(this.globalContext, prop, {
writable: true,
configurable: true,
});
// eslint-disable-next-line no-param-reassign
(this.globalContext as any)[prop] = value;
}
}

active() {
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) =>
this.setWindowProp(p, v)
);
}

this.sandboxRunning = true;
}

inactive() {
if (process.env.NODE_ENV === "development") {
console.info(
`[qiankun:sandbox] ${this.name} modified global properties restore...`,
[
...this.addedPropsMapInSandbox.keys(),
...this.modifiedPropsOriginalValueMapInSandbox.keys(),
]
);
}

// renderSandboxSnapshot = snapshot(currentUpdatedPropsValueMapForSnapshot);
// restore global props to initial snapshot
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) =>
this.setWindowProp(p, v)
);
this.addedPropsMapInSandbox.forEach((_, p) =>
this.setWindowProp(p, undefined, true)
);

this.sandboxRunning = false;
}

constructor(name: string, globalContext = window) {
this.name = name;
this.globalContext = globalContext;
this.type = SandBoxType.LegacyProxy;
const {
addedPropsMapInSandbox,
modifiedPropsOriginalValueMapInSandbox,
currentUpdatedPropsValueMap,
} = this;

const rawWindow = globalContext;
const fakeWindow = Object.create(null) as Window;

const setTrap = (
p: PropertyKey,
value: any,
originalValue: any,
sync2Window = true
) => {
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
// 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}

currentUpdatedPropsValueMap.set(p, value);

if (sync2Window) {
// 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
(rawWindow as any)[p] = value;
}

this.latestSetProp = p;

return true;
}

if (process.env.NODE_ENV === "development") {
console.warn(
`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`
);
}

// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
};

const proxy = new Proxy(fakeWindow, {
set: (_: Window, p: PropertyKey, value: any): boolean => {
const originalValue = (rawWindow as any)[p];
return setTrap(p, value, originalValue, true);
},

get(_: Window, p: PropertyKey): any {
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window
// or use window.top to check if an iframe context
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === "top" || p === "parent" || p === "window" || p === "self") {
return proxy;
}

const value = (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},

// trap in operator
// see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
has(_: Window, p: string | number | symbol): boolean {
return p in rawWindow;
},

getOwnPropertyDescriptor(
_: Window,
p: PropertyKey
): PropertyDescriptor | undefined {
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
// A property cannot be reported as non-configurable, if it does not exists as an own property of the target object
if (descriptor && !descriptor.configurable) {
descriptor.configurable = true;
}
return descriptor;
},

defineProperty(
_: Window,
p: string | symbol,
attributes: PropertyDescriptor
): boolean {
const originalValue = (rawWindow as any)[p];
const done = Reflect.defineProperty(rawWindow, p, attributes);
const value = (rawWindow as any)[p];
setTrap(p, value, originalValue, false);

return done;
},
});

this.proxy = proxy;
}

patchDocument(): void {}
}

active(加载函数)

  1. 恢复子工程上次运行时修改的全局变量。
  2. 更改标识,标记当前子应用运行中。
   active() {
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
}

this.sandboxRunning = true;
}

inactive(卸载函数)

  1. 恢复应用加载前的全局变量。
  2. 删除沙箱本次运行中新增的全局变量。
  3. 更改标识,标记当前子应用已卸载。
inactive() {
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}

总结

优点:相比第一种,采用代理的方式修改 window, 不用再遍历 window, 性能得到提升。 不足:兼容性不如第一种,只能支持加载一个子应用。

proxySandbox 沙箱快照方案

proxySandbox.jpg

constructor

与 legacySandbox 方案一样,创建变量 fakeWindow(虚拟的 window ),并代理 fakeWindow。

每个子应用在创建时都会分配一个空的 fakeWindow 变量。每当设置全局变量时,都会改变 fakeWindow 的值,同时判断如果 fakeWindows 上没有当前设置的值才会更改 window。取值时,先判断当前的 fakeWindow 里是否有要取的值,如果有,则直接返回,没有在从 window 上获取;

/**
* 基于 Proxy 实现的沙箱
*/
export default class ProxySandbox implements SandBox {
/** window 值变更记录 */
private updatedValueSet = new Set<PropertyKey>();

name: string;

type: SandBoxType;

proxy: WindowProxy;

sandboxRunning = true;

private document = document;

latestSetProp: PropertyKey | null = null;

active() {
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}

inactive() {
if (process.env.NODE_ENV === "development") {
console.info(
`[qiankun:sandbox] ${this.name} modified global properties restore...`,
[...this.updatedValueSet.keys()]
);
}

if (inTest || --activeSandboxCount === 0) {
// reset the global value to the prev value
Object.keys(this.globalWhitelistPrevDescriptor).forEach((p) => {
const descriptor = this.globalWhitelistPrevDescriptor[p];
if (descriptor) {
Object.defineProperty(this.globalContext, p, descriptor);
} else {
// @ts-ignore
delete this.globalContext[p];
}
});
}

this.sandboxRunning = false;
}

// the descriptor of global variables in whitelist before it been modified
globalWhitelistPrevDescriptor: {
[p in (typeof globalVariableWhiteList)[number]]:
| PropertyDescriptor
| undefined;
} = {};
globalContext: typeof window;

constructor(
name: string,
globalContext = window,
opts?: { speedy: boolean }
) {
this.name = name;
this.globalContext = globalContext;
this.type = SandBoxType.Proxy;
const { updatedValueSet } = this;
const { speedy } = opts || {};

const { fakeWindow, propertiesWithGetter } = createFakeWindow(
globalContext,
!!speedy
);

const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
const hasOwnProperty = (key: PropertyKey) =>
fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key);

const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
this.registerRunningApp(name, proxy);
// We must keep 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, set } = descriptor!;
// only writable property can be overwritten
// here we ignored accessor descriptor of globalContext as it makes no sense to trigger its logic(which might make sandbox escaping instead)
// we force to set value by data descriptor
if (writable || set) {
Object.defineProperty(target, p, {
configurable,
enumerable,
writable: true,
value,
});
}
} else {
target[p] = value;
}

// sync the property to globalContext
if (
typeof p === "string" &&
globalVariableWhiteList.indexOf(p) !== -1
) {
this.globalWhitelistPrevDescriptor[p] =
Object.getOwnPropertyDescriptor(globalContext, p);
// @ts-ignore
globalContext[p] = value;
}

updatedValueSet.add(p);

this.latestSetProp = p;

return true;
}

if (process.env.NODE_ENV === "development") {
console.warn(
`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`
);
}

// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
},

get: (target: FakeWindow, p: PropertyKey): any => {
this.registerRunningApp(name, proxy);

if (p === Symbol.unscopables) return unscopables;
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === "window" || p === "self") {
return proxy;
}

// hijack globalWindow accessing with globalThis keyword
if (p === "globalThis" || (inTest && p === mockGlobalThis)) {
return proxy;
}

if (
p === "top" ||
p === "parent" ||
(inTest && (p === mockTop || p === mockSafariTop))
) {
// if your master app in an iframe context, allow these props escape the sandbox
if (globalContext === globalContext.parent) {
return proxy;
}
return (globalContext as any)[p];
}

// proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnProperty
if (p === "hasOwnProperty") {
return hasOwnProperty;
}

if (p === "document") {
return this.document;
}

if (p === "eval") {
return eval;
}

const actualTarget = propertiesWithGetter.has(p)
? globalContext
: p in target
? target
: globalContext;
const value = actualTarget[p];

// frozen value should return directly, see https://github.com/umijs/qiankun/issues/2015
if (isPropertyFrozen(actualTarget, p)) {
return value;
}

/* Some dom api must be bound to native window, otherwise it would cause exception like 'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
See this code:
const proxy = new Proxy(window, {});
const proxyFetch = fetch.bind(proxy);
proxyFetch('https://qiankun.com');
*/
const boundTarget = useNativeWindowForBindingsProps.get(p)
? nativeGlobal
: globalContext;
return getTargetValue(boundTarget, value);
},

// trap in operator
// see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
has(target: FakeWindow, p: string | number | symbol): boolean {
// property in cachedGlobalObjects must return true to avoid escape from get trap
return p in cachedGlobalObjects || p in target || p in globalContext;
},

getOwnPropertyDescriptor(
target: FakeWindow,
p: string | number | symbol
): PropertyDescriptor | undefined {
/*
as the descriptor of top/self/window/mockTop in raw window are configurable but not in proxy target, we need to get it from target to avoid TypeError
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
> A property cannot be reported as non-configurable, if it does not existed as an own property of the target object or if it exists as a configurable own property of the target object.
*/
if (target.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(target, p);
descriptorTargetMap.set(p, "target");
return descriptor;
}

if (globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
descriptorTargetMap.set(p, "globalContext");
// A property cannot be reported as non-configurable, if it does not exist as an own property of the target object
if (descriptor && !descriptor.configurable) {
descriptor.configurable = true;
}
return descriptor;
}

return undefined;
},

// trap to support iterator with sandbox
ownKeys(target: FakeWindow): ArrayLike<string | symbol> {
return uniq(
Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target))
);
},

defineProperty: (
target: Window,
p: PropertyKey,
attributes: PropertyDescriptor
): boolean => {
const from = descriptorTargetMap.get(p);
/*
Descriptor must be defined to native window while it comes from native window via Object.getOwnPropertyDescriptor(window, p),
otherwise it would cause a TypeError with illegal invocation.
*/
switch (from) {
case "globalContext":
return Reflect.defineProperty(globalContext, p, attributes);
default:
return Reflect.defineProperty(target, p, attributes);
}
},

deleteProperty: (
target: FakeWindow,
p: string | number | symbol
): boolean => {
this.registerRunningApp(name, proxy);
if (target.hasOwnProperty(p)) {
// @ts-ignore
delete target[p];
updatedValueSet.delete(p);

return true;
}

return true;
},

// makes sure `window instanceof Window` returns truthy in micro app
getPrototypeOf() {
return Reflect.getPrototypeOf(globalContext);
},
});

this.proxy = proxy;

activeSandboxCount++;
}

public patchDocument(doc: Document) {
this.document = doc;
}

private registerRunningApp(name: string, proxy: Window) {
if (this.sandboxRunning) {
const currentRunningApp = getCurrentRunningApp();
if (!currentRunningApp || currentRunningApp.name !== name) {
setCurrentRunningApp({ name, window: proxy });
}
// FIXME if you have any other good ideas
// remove the mark in next tick, thus we can identify whether it in micro app or not
// this approach is just a workaround, it could not cover all complex cases, such as the micro app runs in the same task context with master in some case
nextTask(clearCurrentRunningApp);
}
}
}

active(加载函数)

active() {
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}

inactive(卸载函数)

 inactive() {
this.sandboxRunning = false;
}

总结

优点:相比第二种,不用再加载和卸载时恢复全局变量,性能得到进一步提升。并且支持加载多个子应用。 不足:兼容性不如第一种。

qiankun 如何调用

文章到这里有个问题,就是除了第一种方案之外的其他两种方案如何设置全局变量。如果看代码,我要设置设置一个 window.city = "杭州",要用 LegacySandbox.proxy.city = “杭州”,这明显不符合大家的书写习惯啊。但是大家都知道,在乾坤的子应用中直接用 window.xxx 设置我们需要的变量。其实这里的实现是通过 import-html-entry 包来实现的,它支持执行页级 js 脚本以及拉取上述 html 中所有的外联 js 并支持执行。

function fn(window, self, globalThis) {
// 你的 JavaScript code
}
const bindedFn = fn.bind(window.proxy);
// 将子应用中的window.proxy指向window
bindedFn(window.proxy, window.proxy, window.proxy);

因此,当我们在 JS 文件里有 window.city = "杭州" 时,实际上会变成:

function fn(window, self, globalThis) {
window.city = "杭州";
}
const bindedFn = fn.bind(window.proxy);
bindedFn(window.proxy, window.proxy, window.proxy);

那么此时,window.city 的 window 就不是全局 window 而是 fn 的入参 window 了。又因为我们把 window.proxy 作为入参传入,所以 window.city 实际上为 window.proxy.city = "杭州"。

生命周期

创建完沙箱之后,就会创建 qiankun 的生命周期,包括 beforeLoad、beforeMount、afterMount、beforeUnmount、afterUnmount

首先执行beforeLoad的生命周期 hook。

然后从之前 import-html-entry 中返回的结果中调用其中的 execScripts 函数,返回子应用的生命周期。子应用中的生命周期包括 bootstrap, mount, unmount, update 等。

然后为子应用注册一个全局依赖 state 变化的监听 onGlobalStateChange、更新 store 的函数 setGlobalState、以及卸载监听的函数 offGlobalStateChange。

最后返回一个包含配置的包裹函数 parcelConfigGetter,其中就是上面提到的包含 name、bootstrap、mount 和 unmount 的对象。

注意,mount 是个数组,会依次

1、执行 prevAppUnmountedDeferred 上的 promise

2、重新初始化包裹元素 appWrapperElement 和最外层的包裹元素 appWrapperGetter

3、根据配置创建包裹完成的子应用内容,确保每次应用加载前容器 dom 结构已经设置完毕,并触发 render 函数构建 dom 树(此时还没有挂在到页面上,属于 mounting 阶段)。

4、执行沙箱的 mount 函数(dom 都创建完了,此时执行沙箱的 mount 刚刚好,可以拦截 windows)

5、执行beforeMount的生命周期 hook

6、调用子应用暴露出的 mount 函数,此时子应用会去挂在到页面上。

7、调用 render 函数,将 loading 关掉,并且更改此时为 mounted 状态,也就是挂载完成。

8、执行afterMount的生命周期 hook

9、最后重置一下 prevAppUnmountedDeferred

至此 mount 执行完成。

unmount 也是个数组,回依次执行:

1、执行beforeUnmount的生命周期 hook

2、调用子应用暴露出的 unmount 函数,此时子应用会调用卸载函数。

3、执行沙箱的 unmount,恢复之前的全局状态,并暂存当前子应用的全局状态。

4、执行afterUnmount的生命周期 hook

5、调用 render 函数,全局状态变为 unmounted,卸载掉当前子应用的全局 stata 监听函数,并且初始化 appWrapperElement 为 null,等待垃圾回收

6、最后,prevAppUnmountedDeferred 调用 resolve,表示下载完成。

至此 unmount 完成。

启动应用

上面已经完成了子应用的注册,并且将所有子应用的生命周期、沙箱和渲染等逻辑封装到了 app 里面。当执行 start()函数时,代码如下:

export function start(opts: FrameworkConfiguration = {}) {
frameworkConfiguration = {
prefetch: true,
singular: true,
sandbox: true,
...opts,
};
const {
prefetch,
urlRerouteOnly = defaultUrlRerouteOnly,
...importEntryOpts
} = frameworkConfiguration;

if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}

frameworkConfiguration = autoDowngradeForLowVersionBrowser(
frameworkConfiguration
);

startSingleSpa({ urlRerouteOnly });
started = true;

frameworkStartedDefer.resolve();
}

其中,当 prefetch 配置时,会执行 doPrefetchStrategy 函数。这个函数会根据配置的预加载策略却加载不同的应用。

首先检测 prefetch 配置为 string[] 则会在第一个微应用 mounted 后开始加载数组内的微应用资源

if (Array.isArray(prefetchStrategy)) {
prefetchAfterFirstMounted(
appsName2Apps(prefetchStrategy as string[]),
importEntryOpts
);
}

其次如果 prefetch 是函数,则完全自定义应用的资源加载时机 (首屏应用及次屏应用)。

(async () => {
// critical rendering apps would be prefetch as earlier as possible
const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(
apps
);
prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
})();

最后其他选项根据策略分别判断

switch (prefetchStrategy) {
case true:
prefetchAfterFirstMounted(apps, importEntryOpts);
break;

case "all":
prefetchImmediately(apps, importEntryOpts);
break;

default:
break;
}

然后调用 single-spa 的 start 函数,开始启动微应用。

设置主应用启动后默认进入的微应用 setDefaultMountApp

export function setDefaultMountApp(defaultAppLink: string) {
// can not use addEventListener once option for ie support
window.addEventListener("single-spa:no-app-change", function listener() {
const mountedApps = getMountedApps();
if (!mountedApps.length) {
navigateToUrl(defaultAppLink);
}

window.removeEventListener("single-spa:no-app-change", listener);
});
}

很简单,监听 app 加载完成后,直接导航到这个 link,再移除这个监听函数。

第一个微应用 mount 后需要调用的方法 runAfterFirstMounted

export function runAfterFirstMounted(effect: () => void) {
// can not use addEventListener once option for ie support
window.addEventListener("single-spa:first-mount", function listener() {
if (process.env.NODE_ENV === "development") {
console.timeEnd(firstMountLogLabel);
}

effect();

window.removeEventListener("single-spa:first-mount", listener);
});
}

也很简单,就是监听 single-spa 的第一个 app mount 后,触发该 effect,再移除监听。

手动加载微应用 loadMicroApp

export function loadMicroApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration?: FrameworkConfiguration & { autoStart?: boolean },
lifeCycles?: FrameworkLifeCycles<T>
): MicroApp {
const { props, name } = app;

const container = "container" in app ? app.container : undefined;
// Must compute the container xpath at beginning to keep it consist around app running
// If we compute it every time, the container dom structure most probably been changed and result in a different xpath value
const containerXPath = getContainerXPath(container);
const appContainerXPathKey = `${name}-${containerXPath}`;

let microApp: MicroApp;
const wrapParcelConfigForRemount = (
config: ParcelConfigObject
): ParcelConfigObject => {
let microAppConfig = config;
if (container) {
if (containerXPath) {
const containerMicroApps =
containerMicroAppsMap.get(appContainerXPathKey);
if (containerMicroApps?.length) {
const mount = [
async () => {
// While there are multiple micro apps mounted on the same container, we must wait until the prev instances all had unmounted
// Otherwise it will lead some concurrent issues
const prevLoadMicroApps = containerMicroApps.slice(
0,
containerMicroApps.indexOf(microApp)
);
const prevLoadMicroAppsWhichNotBroken = prevLoadMicroApps.filter(
(v) =>
v.getStatus() !== "LOAD_ERROR" &&
v.getStatus() !== "SKIP_BECAUSE_BROKEN"
);
await Promise.all(
prevLoadMicroAppsWhichNotBroken.map((v) => v.unmountPromise)
);
},
...toArray(microAppConfig.mount),
];

microAppConfig = {
...config,
mount,
};
}
}
}

return {
...microAppConfig,
// empty bootstrap hook which should not run twice while it calling from cached micro app
bootstrap: () => Promise.resolve(),
};
};

/**
* using name + container xpath as the micro app instance id,
* it means if you rendering a micro app to a dom which have been rendered before,
* the micro app would not load and evaluate its lifecycles again
*/
const memorizedLoadingFn = async (): Promise<ParcelConfigObject> => {
const userConfiguration = autoDowngradeForLowVersionBrowser(
configuration ?? { ...frameworkConfiguration, singular: false }
);
const { $$cacheLifecycleByAppName } = userConfiguration;

if (container) {
// using appName as cache for internal experimental scenario
if ($$cacheLifecycleByAppName) {
const parcelConfigGetterPromise = appConfigPromiseGetterMap.get(name);
if (parcelConfigGetterPromise)
return wrapParcelConfigForRemount(
(await parcelConfigGetterPromise)(container)
);
}

if (containerXPath) {
const parcelConfigGetterPromise =
appConfigPromiseGetterMap.get(appContainerXPathKey);
if (parcelConfigGetterPromise)
return wrapParcelConfigForRemount(
(await parcelConfigGetterPromise)(container)
);
}
}

const parcelConfigObjectGetterPromise = loadApp(
app,
userConfiguration,
lifeCycles
);

if (container) {
if ($$cacheLifecycleByAppName) {
appConfigPromiseGetterMap.set(name, parcelConfigObjectGetterPromise);
} else if (containerXPath)
appConfigPromiseGetterMap.set(
appContainerXPathKey,
parcelConfigObjectGetterPromise
);
}

return (await parcelConfigObjectGetterPromise)(container);
};

if (!started && configuration?.autoStart !== false) {
// We need to invoke start method of single-spa as the popstate event should be dispatched while the main app calling pushState/replaceState automatically,
// but in single-spa it will check the start status before it dispatch popstate
// see https://github.com/single-spa/single-spa/blob/f28b5963be1484583a072c8145ac0b5a28d91235/src/navigation/navigation-events.js#L101
// ref https://github.com/umijs/qiankun/pull/1071
startSingleSpa({
urlRerouteOnly:
frameworkConfiguration.urlRerouteOnly ?? defaultUrlRerouteOnly,
});
}

microApp = mountRootParcel(memorizedLoadingFn, {
domElement: document.createElement("div"),
...props,
});

if (container) {
if (containerXPath) {
// Store the microApps which they mounted on the same container
const microAppsRef =
containerMicroAppsMap.get(appContainerXPathKey) || [];
microAppsRef.push(microApp);
containerMicroAppsMap.set(appContainerXPathKey, microAppsRef);

const cleanup = () => {
const index = microAppsRef.indexOf(microApp);
microAppsRef.splice(index, 1);
// @ts-ignore
microApp = null;
};

// gc after unmount
microApp.unmountPromise.then(cleanup).catch(cleanup);
}
}

return microApp;
}

手动加载 app 核心就是调用 single-spa 的 mountRootParcel,mountRootParcel 将会创建并挂载一个 single-spa parcel,注意:Parcel 不会自动卸载。卸载需要手动触发。

上面使用到了 memorizedLoadingFn 函数,它会根据name + container xpath作为微应用的实例 id,判断一下如果已经之前 render 过,就不会重新执行,否则会执行上面提到过的 loadApp 函数,返回一个 ParcelConfigObjectGetter(和上面一样)。

手动预加载指定的微应用静态资源 prefetchApps

src/prefetch.ts

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 assets, do nothing while in mobile network
* @param entry
* @param opts
*/
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
if (!navigator.onLine || isSlowNetwork) {
// Don't prefetch if in a slow network or offline
return;
}

requestIdleCallback(async () => {
const { getExternalScripts, getExternalStyleSheets } = await importEntry(
entry,
opts
);
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
}

预加载指定的微应用就是在浏览器空闲时执行 importEntry 暴露出来的脚本和样式文件。

总结

至此,乾坤核心原理大致也能看出来了。它依赖了两个核心库:single-spa 和 import-html-entry。其中 single-spa 是一个用于前端微服务的 javascript 框架,qiankun 属于在其上的完善。而 import-html-entry 是 qiankun 团队抽离出来的根据入口地址对子应用进行解析功能。

在主应用中通过注册子应用的入口,调用 import-html-entry 通过 fetch 获取子应用的 html 并通过 eval 进行解析,然后为每个子应用暴露出生命周期,脚本执行数组、样式执行数组等参数。在执行 js 资源时通过 eval,会将 window 绑定到一个 Proxy 对象上,以防污染全局变量,并方便对脚本的 window 相关操作做劫持处理,达到子应用之间的脚本隔离。 如果开启沙箱,qiankun 还会将当前渲染的子应用挂在到 shadowdom 中。最后通过路由变化触发不同的子应用渲染以及卸载等。

3 种沙箱可以保证样式隔离,snapshot 沙箱是遍历当前 windows 对象,保存到一个空对象上作为假的 window。而 legacy 沙箱是把当前的 windows 做一层代理,生成一个 fakewindow。proxy 沙箱是为每一个子应用的生成一个实例 proxy,优先作用到自己的 fakewindows 上,没有的属性才会透传到 windows。

当然,乾坤也有很多缺陷,比如依赖共享、主应用和子应用联调困难等问题。

img