447 lines
15 KiB
TypeScript
447 lines
15 KiB
TypeScript
/**
|
|
* @author Kuitos
|
|
* @since 2020-04-01
|
|
*/
|
|
|
|
import { importEntry } from 'import-html-entry';
|
|
import { concat, forEach, mergeWith } from 'lodash';
|
|
import type { LifeCycles, ParcelConfigObject } from 'single-spa';
|
|
import getAddOns from './addons';
|
|
import { QiankunError } from './error';
|
|
import { getMicroAppStateActions } from './globalState';
|
|
import type {
|
|
FrameworkConfiguration,
|
|
FrameworkLifeCycles,
|
|
HTMLContentRender,
|
|
LifeCycleFn,
|
|
LoadableApp,
|
|
ObjectType,
|
|
} from './interfaces';
|
|
import { createSandboxContainer, css } from './sandbox';
|
|
import {
|
|
Deferred,
|
|
getContainer,
|
|
getDefaultTplWrapper,
|
|
getWrapperId,
|
|
isEnableScopedCSS,
|
|
performanceMark,
|
|
performanceMeasure,
|
|
performanceGetEntriesByName,
|
|
toArray,
|
|
validateExportLifecycle,
|
|
} from './utils';
|
|
|
|
function assertElementExist(element: Element | null | undefined, msg?: string) {
|
|
if (!element) {
|
|
if (msg) {
|
|
throw new QiankunError(msg);
|
|
}
|
|
|
|
throw new QiankunError('element not existed!');
|
|
}
|
|
}
|
|
|
|
function execHooksChain<T extends ObjectType>(
|
|
hooks: Array<LifeCycleFn<T>>,
|
|
app: LoadableApp<T>,
|
|
global = window,
|
|
): Promise<any> {
|
|
if (hooks.length) {
|
|
return hooks.reduce((chain, hook) => chain.then(() => hook(app, global)), Promise.resolve());
|
|
}
|
|
|
|
return Promise.resolve();
|
|
}
|
|
|
|
async function validateSingularMode<T extends ObjectType>(
|
|
validate: FrameworkConfiguration['singular'],
|
|
app: LoadableApp<T>,
|
|
): Promise<boolean> {
|
|
return typeof validate === 'function' ? validate(app) : !!validate;
|
|
}
|
|
|
|
// @ts-ignore
|
|
const supportShadowDOM = document.head.attachShadow || document.head.createShadowRoot;
|
|
|
|
function createElement(
|
|
appContent: string,
|
|
strictStyleIsolation: boolean,
|
|
scopedCSS: boolean,
|
|
appName: 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, appName);
|
|
}
|
|
|
|
const styleNodes = appElement.querySelectorAll('style') || [];
|
|
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
|
|
css.process(appElement!, stylesheetElement, appName);
|
|
});
|
|
}
|
|
|
|
return appElement;
|
|
}
|
|
|
|
/** generate app wrapper dom getter */
|
|
function getAppWrapperGetter(
|
|
appName: string,
|
|
appInstanceId: string,
|
|
useLegacyRender: boolean,
|
|
strictStyleIsolation: boolean,
|
|
scopedCSS: boolean,
|
|
elementGetter: () => HTMLElement | null,
|
|
) {
|
|
return () => {
|
|
if (useLegacyRender) {
|
|
if (strictStyleIsolation) throw new QiankunError('strictStyleIsolation can not be used with legacy render!');
|
|
if (scopedCSS) throw new QiankunError('experimentalStyleIsolation can not be used with legacy render!');
|
|
|
|
const appWrapper = document.getElementById(getWrapperId(appInstanceId));
|
|
assertElementExist(appWrapper, `Wrapper element for ${appName} with instance ${appInstanceId} is not existed!`);
|
|
return appWrapper!;
|
|
}
|
|
|
|
const element = elementGetter();
|
|
assertElementExist(element, `Wrapper element for ${appName} with instance ${appInstanceId} is not existed!`);
|
|
|
|
if (strictStyleIsolation && supportShadowDOM) {
|
|
return element!.shadowRoot!;
|
|
}
|
|
|
|
return element!;
|
|
};
|
|
}
|
|
|
|
const rawAppendChild = HTMLElement.prototype.appendChild;
|
|
const rawRemoveChild = HTMLElement.prototype.removeChild;
|
|
type ElementRender = (
|
|
props: { element: HTMLElement | null; loading: boolean; container?: string | HTMLElement },
|
|
phase: 'loading' | 'mounting' | 'mounted' | 'unmounted',
|
|
) => any;
|
|
|
|
/**
|
|
* Get the render function
|
|
* If the legacy render function is provide, used as it, otherwise we will insert the app element to target container by qiankun
|
|
* @param appName
|
|
* @param appContent
|
|
* @param legacyRender
|
|
*/
|
|
function getRender(appName: string, appContent: string, legacyRender?: HTMLContentRender) {
|
|
const render: ElementRender = ({ element, loading, container }, phase) => {
|
|
if (legacyRender) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn(
|
|
'[qiankun] Custom rendering function is deprecated, 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 ${appName} ${phase}!`;
|
|
|
|
case 'mounted':
|
|
return `Target container with ${container} not existed after ${appName} ${phase}!`;
|
|
|
|
default:
|
|
return `Target container with ${container} not existed while ${appName} 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;
|
|
}
|
|
|
|
function getLifecyclesFromExports(
|
|
scriptExports: LifeCycles<any>,
|
|
appName: string,
|
|
global: WindowProxy,
|
|
globalLatestSetProp?: PropertyKey | null,
|
|
) {
|
|
if (validateExportLifecycle(scriptExports)) {
|
|
return scriptExports;
|
|
}
|
|
|
|
// fallback to sandbox latest set property if it had
|
|
if (globalLatestSetProp) {
|
|
const lifecycles = (<any>global)[globalLatestSetProp];
|
|
if (validateExportLifecycle(lifecycles)) {
|
|
return lifecycles;
|
|
}
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn(
|
|
`[qiankun] lifecycle not found from ${appName} entry exports, fallback to get from window['${appName}']`,
|
|
);
|
|
}
|
|
|
|
// fallback to global variable who named with ${appName} while module exports not found
|
|
const globalVariableExports = (global as any)[appName];
|
|
|
|
if (validateExportLifecycle(globalVariableExports)) {
|
|
return globalVariableExports;
|
|
}
|
|
|
|
throw new QiankunError(`You need to export lifecycle functions in ${appName} entry`);
|
|
}
|
|
|
|
let prevAppUnmountedDeferred: Deferred<void>;
|
|
|
|
export type ParcelConfigObjectGetter = (remountContainer?: string | HTMLElement) => ParcelConfigObject;
|
|
|
|
export async function loadApp<T extends ObjectType>(
|
|
app: LoadableApp<T>,
|
|
configuration: FrameworkConfiguration = {},
|
|
lifeCycles?: FrameworkLifeCycles<T>,
|
|
): Promise<ParcelConfigObjectGetter> {
|
|
const { entry, name: appName } = app;
|
|
const appInstanceId = `${appName}_${+new Date()}_${Math.floor(Math.random() * 1000)}`;
|
|
|
|
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 } = await importEntry(entry, importEntryOpts);
|
|
|
|
// 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, appName)(template);
|
|
|
|
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
|
|
const scopedCSS = isEnableScopedCSS(sandbox);
|
|
let initialAppWrapperElement: HTMLElement | null = createElement(
|
|
appContent,
|
|
strictStyleIsolation,
|
|
scopedCSS,
|
|
appName,
|
|
);
|
|
|
|
const initialContainer = 'container' in app ? app.container : undefined;
|
|
const legacyRender = 'render' in app ? app.render : undefined;
|
|
|
|
const render = getRender(appName, appContent, legacyRender);
|
|
|
|
// 第一次加载设置应用可见区域 dom 结构
|
|
// 确保每次应用加载前容器 dom 结构已经设置完毕
|
|
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');
|
|
|
|
const initialAppWrapperGetter = getAppWrapperGetter(
|
|
appName,
|
|
appInstanceId,
|
|
!!legacyRender,
|
|
strictStyleIsolation,
|
|
scopedCSS,
|
|
() => initialAppWrapperElement,
|
|
);
|
|
|
|
let global = globalContext;
|
|
let mountSandbox = () => Promise.resolve();
|
|
let unmountSandbox = () => Promise.resolve();
|
|
const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
|
|
let sandboxContainer;
|
|
if (sandbox) {
|
|
sandboxContainer = createSandboxContainer(
|
|
appName,
|
|
// 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;
|
|
}
|
|
|
|
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);
|
|
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(
|
|
appName,
|
|
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, appName);
|
|
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;
|
|
}
|