qiankun-fit/src/loader.ts
2021-09-15 20:53:17 +08:00

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