前言
在上一篇文章了解了qiankun的整體運行。下面繼續看:
1.qiankun如何根據entry字段去加載子應用的資源。
2.qiankun提供的沙箱隔離。
正文
(1) loadApp
在上一篇中說到single-spa的app配置需要開發者自己處理加載子應用的邏輯,在qiankun的registerMicroApps
中,封裝了loadApp
方法。
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,
});
});
}
然后進入到src/loader.ts
中查看loadApp
都做了什么:
首先使用importEntry
將entry傳入,獲取到 template, execScripts, assetPublicPath。importEntry這個包的作用就是,給它一個站點鏈接,它請求到站點的整個html,然后解析出html的各個內容:dom、script、css等,然后根據需要去加載。下面是其全部返回的輸出:
這里也許會有疑問是:為什么像上一篇中直接使用動態加載script技術一樣,直接將整個html append到domcument加載出來,不是很省事情嗎?
這是因為qiankun要做沙箱隔離,所以先自己解析出資源,處理后再加載到頁面。
對于dom和style,qiankun會對其進行一些包裝,然后使用getRender
下的render
方法,將內容append到容器中:
if (element) {
rawAppendChild.call(containerElement, element);
}
最終容器內容如下:
(2) execScripts 與沙箱隔離
上圖可以看到script標簽都被注釋掉了,下面要使用execScripts去執行JS。在importEntry中查看execScripts:
其可以傳入一個沙箱,讓JS都在這個沙箱中執行。回到qiankun的loadApp方法中:
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
const { entry, name: appName } = app;
const {
singular = false,
sandbox = true,
excludeAssetFilter,
globalContext = window,
...importEntryOpts
} = configuration;
... ...
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
let global = globalContext;
const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
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,
);
// 用沙箱的代理對象作為接下來使用的全局對象
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}
... ...
// get the lifecycle hooks from module exports
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox);
從上面可以看到,global一開始默認為window環境,后判斷如果sandbox
為true,使用createSandboxContainer
的sandboxContainer.instance.proxy
來替換。在src/sandbox/index.ts
下查看createSandboxContainer
:
import LegacySandbox from './legacy/sandbox';
import ProxySandbox from './proxySandbox';
import SnapshotSandbox from './snapshotSandbox';
export function createSandboxContainer(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
scopedCSS: boolean,
useLooseSandbox?: boolean,
excludeAssetFilter?: (url: string) => boolean,
globalContext?: typeof window,
) {
let sandbox: SandBox;
if (window.Proxy) {
sandbox = useLooseSandbox ? new LegacySandbox(appName, globalContext) : new ProxySandbox(appName, globalContext);
} else {
sandbox = new SnapshotSandbox(appName);
}
... ...
}
可以看到qiankun有三種JS隔離機制,分別是 SnapshotSandbox、LegacySandbox和ProxySandbox。
三個沙箱的原理,都比較簡單:
SnapshotSandbox:快照沙箱。就是在加載子應用時淺拷貝一份window,名為windowSnapshot。在卸載子應用時,再使用windowSnapshot將window復原。下面是自己的簡單實現:
LegacySandbox:
LegacySandbox和快照沙箱差不多,不同的是,其使用Proxy劫持set操作,記錄那些被更改的window屬性。這樣在后續的狀態還原時候就不再需要遍歷window的所有屬性來進行對比,提升了程序運行的性能。但是它最終還是去修改了window上的屬性,所以這種機制仍然污染了window的狀態。
ProxySandbox:看了LegacySandbox會有疑問,既然都用了代理了,修改代理對象就好了,為什么經過代理后還去修改window啊:
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
this.registerRunningApp(name, proxy);
// 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;
}
... ...
}
},
});
上面代碼中fakeWindow
初始為一個對象,通過劫持set操作,將value保存到fakeWindow
即可,不用去修改window,所以也不用復原操作。
(3) execScripts原理
終于弄明白傳入execScripts的沙箱是個啥玩意了,下面回到import-html-entry
的execScripts中:
主要看到
getExecutableScript
和evalCode
,我們有了沙箱后,如何讓JS代碼在沙箱環境執行呢?
看到getExecutableScript
,它將傳入的scriptText
進行了一層自執行函數包裹,自執行函數接收代理對象,然后函數參數名為window。這樣子,scriptText
中對window的訪問,實際都是訪問到代理對象!
得到最終的code后,調用evalCode
:
可以看到,
evalCode
就是簡單的調用eval,執行我們的JS代碼,由此實現應用的JS Bundle加載!
總結
這一篇主要講的是qiankun的loadApps,如何根據entry字段去加載子應用的資源、以及提供的沙箱來執行JS。大概流程就是這樣。
另外有個疑問是,無論是快照沙箱還是代理沙箱,只能監聽到window上第一層的key值,對于更深層的對象,如果被修改了那還是會被污染的。