/** * @author Kuitos * @since 2020-04-01 */ import { importEntry } from 'import-html-entry'; import { concat, forEach, mergeWith } from 'lodash'; import { LifeCycles, ParcelConfigObject } from 'single-spa'; import getAddOns from './addons'; import { getMicroAppStateActions } from './globalState'; import { FrameworkConfiguration, FrameworkLifeCycles, HTMLContentRender, LifeCycleFn, LoadableApp } from './interfaces'; import { createSandbox, css } from './sandbox'; import { Deferred, getContainer, getDefaultTplWrapper, getWrapperId, isEnableScopedCSS, performanceMark, performanceMeasure, toArray, validateExportLifecycle, } from './utils'; function assertElementExist(element: Element | null | undefined, msg?: string) { if (!element) { if (msg) { throw new Error(msg); } throw new Error('[qiankun] element not existed!'); } } function execHooksChain( hooks: Array>, app: LoadableApp, global = window, ): Promise { if (hooks.length) { return hooks.reduce((chain, hook) => chain.then(() => hook(app, global)), Promise.resolve()); } return Promise.resolve(); } async function validateSingularMode( validate: FrameworkConfiguration['singular'], app: LoadableApp, ): Promise { return typeof validate === 'function' ? validate(app) : !!validate; } // @ts-ignore const supportShadowDOM = document.head.attachShadow || document.head.createShadowRoot; function createElement(appContent: string, strictStyleIsolation: boolean): 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; } } return appElement; } /** generate app wrapper dom getter */ function getAppWrapperGetter( appName: string, appInstanceId: string, useLegacyRender: boolean, strictStyleIsolation: boolean, enableScopedCSS: boolean, elementGetter: () => HTMLElement | null, ) { return () => { if (useLegacyRender) { if (strictStyleIsolation) throw new Error('[qiankun]: strictStyleIsolation can not be used with legacy render!'); if (enableScopedCSS) throw new Error('[qiankun]: experimentalStyleIsolation can not be used with legacy render!'); const appWrapper = document.getElementById(getWrapperId(appInstanceId)); assertElementExist( appWrapper, `[qiankun] Wrapper element for ${appName} with instance ${appInstanceId} is not existed!`, ); return appWrapper!; } const element = elementGetter(); assertElementExist( element, `[qiankun] Wrapper element for ${appName} with instance ${appInstanceId} is not existed!`, ); if (enableScopedCSS) { const attr = element!.getAttribute(css.QiankunCSSRewriteAttr); if (!attr) { element!.setAttribute(css.QiankunCSSRewriteAttr, appName); } } if (strictStyleIsolation) { return element!.shadowRoot!; } return element!; }; } const rawAppendChild = HTMLElement.prototype.appendChild; const rawRemoveChild = HTMLElement.prototype.removeChild; type ElementRender = ( props: { element: HTMLElement | null; loading: boolean; remountContainer?: 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 container * @param legacyRender */ function getRender( appName: string, appContent: string, container?: string | HTMLElement, legacyRender?: HTMLContentRender, ) { const render: ElementRender = ({ element, loading, remountContainer }, 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(remountContainer || 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 `[qiankun] Target container with ${container} not existed while ${appName} ${phase}!`; case 'mounted': return `[qiankun] Target container with ${container} not existed after ${appName} ${phase}!`; default: return `[qiankun] 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, appName: string, global: WindowProxy) { if (validateExportLifecycle(scriptExports)) { return scriptExports; } 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 Error(`[qiankun] You need to export lifecycle functions in ${appName} entry`); } let prevAppUnmountedDeferred: Deferred; export type ParcelConfigObjectGetter = (remountContainer?: string | HTMLElement) => ParcelConfigObject; export async function loadApp( app: LoadableApp, configuration: FrameworkConfiguration = {}, lifeCycles?: FrameworkLifeCycles, ): Promise { 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, ...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 strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation; const enableScopedCSS = isEnableScopedCSS(configuration); const appContent = getDefaultTplWrapper(appInstanceId, appName)(template); let appWrapperElement: HTMLElement | null = createElement(appContent, strictStyleIsolation); if (appWrapperElement && isEnableScopedCSS(configuration)) { const styleNodes = appWrapperElement.querySelectorAll('style') || []; forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => { css.process(appWrapperElement!, stylesheetElement, appName); }); } const container = 'container' in app ? app.container : undefined; const legacyRender = 'render' in app ? app.render : undefined; const render = getRender(appName, appContent, container, legacyRender); // 第一次加载设置应用可见区域 dom 结构 // 确保每次应用加载前容器 dom 结构已经设置完毕 render({ element: appWrapperElement, loading: true }, 'loading'); const appWrapperGetter = getAppWrapperGetter( appName, appInstanceId, !!legacyRender, strictStyleIsolation, enableScopedCSS, () => appWrapperElement, ); let global = window; let mountSandbox = () => Promise.resolve(); let unmountSandbox = () => Promise.resolve(); if (sandbox) { const sandboxInstance = createSandbox( appName, appWrapperGetter, Boolean(singular), enableScopedCSS, excludeAssetFilter, ); // 用沙箱的代理对象作为接下来使用的全局对象 global = sandboxInstance.proxy as typeof window; mountSandbox = sandboxInstance.mount; unmountSandbox = sandboxInstance.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, !singular); const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(scriptExports, appName, global); const { onGlobalStateChange, setGlobalState, offGlobalStateChange, }: Record = getMicroAppStateActions(appInstanceId); const parcelConfigGetter: ParcelConfigObjectGetter = remountContainer => { const parcelConfig: ParcelConfigObject = { name: appInstanceId, bootstrap, mount: [ async () => { if (process.env.NODE_ENV === 'development') { const marks = performance.getEntriesByName(markName, 'mark'); // mark length is zero means the app is remounting if (!marks.length) { performanceMark(markName); } } }, async () => { if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) { return prevAppUnmountedDeferred.promise; } return undefined; }, // 添加 mount hook, 确保每次应用加载前容器 dom 结构已经设置完毕 async () => { // element would be destroyed after unmounted, we need to recreate it if it not exist appWrapperElement = appWrapperElement || createElement(appContent, strictStyleIsolation); render({ element: appWrapperElement, loading: true, 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, 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(); } }, 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, remountContainer }, 'unmounted'); offGlobalStateChange(appInstanceId); // for gc appWrapperElement = null; }, async () => { if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) { prevAppUnmountedDeferred.resolve(); } }, ], }; if (typeof update === 'function') { parcelConfig.update = update; } return parcelConfig; }; return parcelConfigGetter; }