diff --git a/packages/mapp-shared/index.d.ts b/packages/mapp-shared/index.d.ts index fd22099..0beb801 100644 --- a/packages/mapp-shared/index.d.ts +++ b/packages/mapp-shared/index.d.ts @@ -50,6 +50,17 @@ export interface MicroApp { */ independent?: boolean; + /** + * 可选,默认为 false。 + * + * 开启时将认为该应用为一个支持现代浏览器的应用 + * + * 因此将允许使用 module script 这些功能 + * + * 该功能只工作在开发模式下 并且不支持沙盒隔离 + */ + modern?: boolean; + /** * 路由模式,默认为 hash */ diff --git a/packages/mapp/package.json b/packages/mapp/package.json index 1a5f1d7..616def6 100644 --- a/packages/mapp/package.json +++ b/packages/mapp/package.json @@ -21,11 +21,13 @@ "author": "ivan-lee", "license": "MIT", "dependencies": { + "@qiankunjs/loader": "0.0.1-alpha.4", "@wakeadmin/h": "workspace:^0.3.0", "@wakeadmin/mapp-shared": "workspace:^0.1.1", "@wakeadmin/utils": "workspace:^0.1.5", "path-browserify": "^1.0.1", - "qiankun": "^2.10.8" + "qiankun": "^2.10.13", + "single-spa": "^5.9.5" }, "peerDependencies": { "vue": "^3.2.37", @@ -33,5 +35,11 @@ }, "jest": { "preset": "jest-preset-universe-vue" + }, + "resolutions": { + "single-spa": "^5.9.5" + }, + "devDependencies": { + "@types/semver": "^7.5.1" } } diff --git a/packages/mapp/src/main/Bay/AppContext.ts b/packages/mapp/src/main/Bay/AppContext.ts index c406d91..8284974 100644 --- a/packages/mapp/src/main/Bay/AppContext.ts +++ b/packages/mapp/src/main/Bay/AppContext.ts @@ -125,6 +125,8 @@ export class AppContext { afterMount: LifeCycleFn = async (app, global) => { this.triggerHooks('afterAppMount', this.getApp(app)); + this.currentMountingApp.value = undefined; + this.currentLoadingApp.value = undefined; }; beforeUnmount: LifeCycleFn = async (app, global) => { @@ -133,6 +135,7 @@ export class AppContext { afterUnmount: LifeCycleFn = async (app, global) => { this.triggerHooks('afterAppUnmount', this.getApp(app)); + this.currentApp.value = undefined; }; private getApp(app: LoadableApp) { @@ -142,4 +145,6 @@ export class AppContext { private triggerHooks>(name: Name, option: Option) { (this.bay as any).triggerHooks(name, option); } + + } diff --git a/packages/mapp/src/main/Bay/Bay.ts b/packages/mapp/src/main/Bay/Bay.ts index c85d560..3d89689 100644 --- a/packages/mapp/src/main/Bay/Bay.ts +++ b/packages/mapp/src/main/Bay/Bay.ts @@ -1,30 +1,31 @@ -import { createApp, App } from 'vue'; -import { createRouter, createWebHistory, Router } from 'vue-router'; import { EventEmitter, trimQueryAndHash } from '@wakeadmin/utils'; -import { registerMicroApps, start, RegistrableApp, prefetchApps, addGlobalUncaughtErrorHandler } from 'qiankun'; +import { RegistrableApp, addGlobalUncaughtErrorHandler, prefetchApps, registerMicroApps, start } from 'qiankun'; +import { App, createApp } from 'vue'; +import { Router, createRouter, createWebHistory } from 'vue-router'; import { BayHooks, BayOptions, IBay, - Parameter, INetworkInterceptorRegister, MicroApp, MicroAppStatus, + Parameter, } from '../../types'; +import { AJAXInterceptor, FetchInterceptor } from '../NetworkInterceptor'; +import { UniverseHistory } from '../UniverseHistory'; import { NoopPage } from '../components'; import { BayProviderContext, DEFAULT_ROOT } from '../constants'; -import { UniverseHistory } from '../UniverseHistory'; -import { AJAXInterceptor, FetchInterceptor } from '../NetworkInterceptor'; -import { groupAppsByIndependent, normalizeOptions } from './options'; -import { createRoutes, generateLandingUrl, Navigator } from './route'; import { AppContext } from './AppContext'; -import { normalizeUrl } from './utils'; -import { MicroAppNormalized } from './types'; -import { flushMountQueue } from './mount-delay'; +import { ParcelContext } from './ParcelContext'; import { AssetFilter } from './exclude-asset'; +import { flushMountQueue } from './mount-delay'; +import { groupAppsByIndependent, normalizeModernApps, normalizeOptions } from './options'; +import { Navigator, createRoutes, generateLandingUrl } from './route'; +import { MicroAppNormalized } from './types'; +import { normalizeUrl } from './utils'; export class Bay implements IBay { app: App; @@ -55,6 +56,8 @@ export class Bay implements IBay { nonIndependentApps: MicroAppNormalized[]; + private parcelContext: ParcelContext; + get location() { return this.history.location; } @@ -113,11 +116,15 @@ export class Bay implements IBay { this.independentApps = independentApps; this.nonIndependentApps = nonIndependentApps; + const modernApps = this.apps.filter(app => app.modern).map(app => normalizeModernApps(app)); + if (this.options.networkInterceptors?.length) { this.registerNetworkInterceptor(...this.options.networkInterceptors); } + this.navigator = new Navigator(this); this.appContext = new AppContext(this); + this.parcelContext = new ParcelContext(modernApps, this.appContext); if (options.excludeAssetFilter) { this.assetFilter.addFiler(options.excludeAssetFilter); @@ -130,6 +137,7 @@ export class Bay implements IBay { this.router = this.createRouter(); this.history = new UniverseHistory(l => { this.triggerHooks('locationChange', l); + this.parcelContext.mountOrUnmountAppIfNeed(); }); this.app.use(this.router); @@ -172,6 +180,8 @@ export class Bay implements IBay { }, }); + this.parcelContext.mountOrUnmountAppIfNeed(); + this.started = true; } @@ -288,7 +298,7 @@ export class Bay implements IBay { } private registerApps() { - const apps = this.apps; + const apps = this.apps.filter(app => !app.modern); this.triggerHooks('beforeAppsRegister', apps); diff --git a/packages/mapp/src/main/Bay/ParcelContext.ts b/packages/mapp/src/main/Bay/ParcelContext.ts new file mode 100644 index 0000000..50c2a99 --- /dev/null +++ b/packages/mapp/src/main/Bay/ParcelContext.ts @@ -0,0 +1,379 @@ +import { mountRootParcel, type ParcelConfig, type Parcel } from 'single-spa'; +import { AppContext } from './AppContext'; +import { MicroAppNormalized, ModernMicroAppNormalized } from './types'; + +import { loadEntry } from '@qiankunjs/loader'; +import { Noop } from '@wakeadmin/utils'; +import isFunction from 'lodash/isFunction'; +import { parcelUnmountDeferred, qiankunUnmountDeferred, Deferred } from './deferred'; + +const isDev = process.env.NODE_ENV === 'development'; + +const LOG_PREFIX = '[Single SPA Parcel] '; + +const PARCEL_INSTANCE_APP_NAME = '__$$app_name__'; + +const QIANKUN_CONTEXT_KEYS = ['__POWERED_BY_QIANKUN__']; + +/** + * document.head的快照 + * 用于处理添加到document.head里的元素 + * + * **只处理新增节点** + */ +class DocumentHeadSnapshot { + private snapshotRecordMap: Map = new Map(); + private currentAppName?: string; + private observer: MutationObserver; + + constructor() { + this.observer = new MutationObserver(records => { + if (!this.currentAppName) { + return; + } + const nodeRecord = this.snapshotRecordMap.get(this.currentAppName)![0]; + + if (!nodeRecord) { + return; + } + + for (const record of records) { + if (record.type === 'childList' && record.addedNodes) { + for (const node of record.addedNodes) { + if (node.nodeName.toLocaleLowerCase() === 'style') { + nodeRecord.push(node); + } + } + } + } + }); + + this.observer.observe(document.head, { childList: true }); + } + + /** + * 创建一个子应用快照列表 + * + * 如果存在 不做任何操作 + * @param name + */ + create(name: string) { + this.currentAppName = name; + if (this.snapshotRecordMap.has(name)) { + return; + } + this.snapshotRecordMap.set(name, []); + } + /** + * 创建一个快照点 + */ + capture() { + if (this.currentAppName) { + this.snapshotRecordMap.get(this.currentAppName)!.unshift([]); + } + } + + /** + * 恢复到上一个快照 + * @returns + */ + restore() { + if (!this.currentAppName) { + return; + } + const snapshot = this.snapshotRecordMap.get(this.currentAppName)![0] || []; + this.removeNodes(snapshot); + } + + /** + * 使用最新的快照进行还原 + */ + use() { + if (!this.currentAppName) { + return; + } + const snapshot = this.snapshotRecordMap.get(this.currentAppName)![0] || []; + this.appendNodes(snapshot); + } + + destroy() { + this.observer.disconnect(); + } + + private appendNodes(nodeList: Node[]) { + const parentElement = document.head; + nodeList.filter(node => node.parentElement !== parentElement).forEach(node => parentElement.appendChild(node)); + } + + private removeNodes(nodeList: Node[]) { + const parentElement = document.head; + nodeList.filter(node => node.parentElement === parentElement).forEach(node => (node as any as Element).remove()); + } +} + +export class ParcelContext { + private parcelInstance?: Parcel & { + [PARCEL_INSTANCE_APP_NAME]: string; + }; + + private global = window; + private documentHeadSnapshot = new DocumentHeadSnapshot(); + + private currentApp?: MicroAppNormalized; + + private isLoading: boolean = false; + + private mountDeferredWeakMap: WeakMap> = new WeakMap(); + + private parcelConfigCache: Map< + string, + { + config: ParcelConfig; + container: HTMLElement; + } + > = new Map(); + + constructor(private apps: ModernMicroAppNormalized[], private appContext: AppContext) {} + + mountOrUnmountAppIfNeed(container?: HTMLElement) { + const app = this.getMatchedApp(); + if (!app) { + return this.unmountAppIfNeed(); + } + return this.mountApp(app, container); + } + + private async mountApp(app: ModernMicroAppNormalized, container?: HTMLElement) { + isDev && console.log(`${LOG_PREFIX}匹配应用 `, app.name); + + if (this.currentApp?.name === app.name) { + return; + } + + if (this.parcelInstance) { + await this.unmountApp(); + } + + if ( + this.appContext.currentApp.value || + this.appContext.currentLoadingApp.value || + this.appContext.currentMountingApp.value + ) { + isDev && console.log(`${LOG_PREFIX}等待qiankun卸载子应用 -> `, this.appContext.currentApp.value?.name); + + await qiankunUnmountDeferred.promise; + + isDev && console.log(`${LOG_PREFIX}qiankun卸载子应用完成`); + } + + this.loadApp(app, container); + } + + private unmountAppIfNeed() { + if (this.parcelInstance) { + this.isLoading = false; + return this.unmountApp(); + } + + return Promise.resolve(); + } + + private getMatchedApp(location: Location = window.location): ModernMicroAppNormalized | undefined { + return this.apps.find(item => item.activeRuleWhen.some(fn => fn(location))); + } + + private unmountApp() { + if (this.parcelInstance) { + isDev && console.debug(`${LOG_PREFIX}卸载子应用 -> ${this.currentApp?.name}`); + + return this.parcelInstance.unmount().then(() => { + if (isDev) { + const deferred = this.mountDeferredWeakMap.get(this.parcelInstance!); + deferred?.reject( + `${LOG_PREFIX}子应用(${this.parcelInstance![PARCEL_INSTANCE_APP_NAME]})挂载失败 -> 当前子应用已被卸载` + ); + } + this.parcelInstance = undefined; + }); + } + + return Promise.resolve(); + } + + private async loadApp(app: ModernMicroAppNormalized, target?: HTMLElement): Promise { + if (this.isLoading && this.parcelInstance?.[PARCEL_INSTANCE_APP_NAME] === app.name) { + // eslint-disable-next-line @typescript-eslint/return-await + return this.mountDeferredWeakMap.get(this.parcelInstance)!.promise; + } + + this.isLoading = true; + + this.simulateQiankunContext(); + + const { config, container } = await this.load(app, target); + + const instance = mountRootParcel(config, { domElement: container }); + + // @ts-expect-error + instance[PARCEL_INSTANCE_APP_NAME] = app.name; + + this.mountDeferredWeakMap.set(instance, new Deferred()); + this.parcelInstance = instance as any; + } + + private async load( + app: ModernMicroAppNormalized, + target: HTMLElement | undefined + ): Promise<{ + container: HTMLElement; + config: ParcelConfig; + }> { + this.currentApp = app; + + this.documentHeadSnapshot.create(app.name); + this.documentHeadSnapshot.use(); + this.documentHeadSnapshot.capture(); + + if (this.parcelConfigCache.has(app.name)) { + const cache = this.parcelConfigCache.get(app.name)!; + + // 这里重新执行下 用于添加 entry 的 html 内容到挂载点上 + await loadEntry(app.entry, cache.container, { + fetch: window.fetch, + }); + + // 重置下bootstrap 防止多次调用 + (cache.config as any).bootstrap = () => Promise.resolve(); + return cache; + } + + isDev && console.debug(`${LOG_PREFIX}开始加载子应用 -> ${app.name}`); + + await app.loader!(true); + + const container = + target ?? + ((typeof app.container === 'string' ? document.querySelector(app.container) : app.container!) as HTMLElement); + + const loadedApp = Object.create(app, { + container: { + value: container, + }, + }); + + this.appContext.beforeLoad(loadedApp, this.global); + + await loadEntry(app.entry, container, { + fetch: window.fetch, + }); + + isDev && console.debug(`${LOG_PREFIX}加载子应用完成 -> ${app.name}`); + + // 这里重新判断下当前正在加载的子应用是否是最新的 + // --loadAppA-----------loadEntryA---- + // ----loadAppB----loadEntryB--------- + // 假设有两个并发如上 可以发现A会在B后面运行下面的代码 从而导致子应用不正确 + if (this.currentApp.name !== app.name) { + throw new Error( + `${LOG_PREFIX}子应用挂载失败: 当前子应用为 ${app.name}, 需要挂载的子应用为 ${this.currentApp.name}` + ); + } + + const { bootstrap, mount, unmount, update } = this.getAppLifeCycles(app.name); + + const config = { + name: app.name, + + bootstrap, + + mount: [ + () => { + isDev && console.debug(`${LOG_PREFIX}开始挂载子应用 -> ${app.name}`); + parcelUnmountDeferred.reset(); + return this.appContext.beforeMount(loadedApp, this.global); + }, + (props: Record) => + mount({ ...props, container }).catch((err: unknown) => + console.debug(`${LOG_PREFIX}子应用 (${app.name}) 挂载失败`, err) + ), + () => { + isDev && console.debug(`${LOG_PREFIX}挂载子应用完成 -> ${app.name}`); + this.isLoading = false; + return this.appContext.afterMount(loadedApp, this.global); + }, + () => { + this.mountDeferredWeakMap.get(this.parcelInstance!)?.resolve(); + return Promise.resolve(); + }, + ], + + unmount: [ + () => { + isDev && console.debug(`${LOG_PREFIX}开始卸载子应用 -> ${app.name}`); + return this.appContext.beforeUnmount(loadedApp, this.global); + }, + (props: Record) => + unmount({ ...props, container }).catch((err: unknown) => + console.debug(`${LOG_PREFIX}子应用 (${app.name}) 卸载失败`, err) + ), + () => { + isDev && console.debug(`${LOG_PREFIX}卸载子应用完成 -> ${app.name}`); + parcelUnmountDeferred.resolve(); + this.currentApp = undefined; + return this.appContext.afterUnmount(loadedApp, this.global); + }, + () => { + container.innerHTML = ''; + this.documentHeadSnapshot.restore(); + this.cancelSimulateQiankunContext(); + return Promise.resolve(); + }, + ], + + update: Noop, + }; + + if (isFunction(update)) { + config.update = update; + } + + this.parcelConfigCache.set(app.name, { + config, + container, + }); + return { config, container }; + } + + private getAppLifeCycles(appName: string) { + // @ts-expect-error + const obj = this.global[appName]; + + if (!obj) { + throw new Error(`${LOG_PREFIX}无法获取到子应用 (${appName}) 的生命周期函数`); + } + + const { bootstrap, mount, unmount, update } = obj; + + if (isFunction(bootstrap) && isFunction(mount) && isFunction(unmount)) { + return { bootstrap, mount, unmount, update }; + } + + throw new Error( + `${LOG_PREFIX}子应用 (${appName}) 的生命周期函数异常,请确保 bootstrap, mount, unmount 三个字段为函数` + ); + } + + private simulateQiankunContext() { + for (const key of QIANKUN_CONTEXT_KEYS) { + // @ts-expect-error + this.global[key] = true; + } + } + + private cancelSimulateQiankunContext() { + for (const key of QIANKUN_CONTEXT_KEYS) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (this.global as any)[key]; + } + } +} diff --git a/packages/mapp/src/main/Bay/deferred.ts b/packages/mapp/src/main/Bay/deferred.ts new file mode 100644 index 0000000..49c2e87 --- /dev/null +++ b/packages/mapp/src/main/Bay/deferred.ts @@ -0,0 +1,24 @@ +export class Deferred { + promise!: Promise; + + resolve!: (value: T | PromiseLike) => void; + + reject!: (reason?: unknown) => void; + + constructor() { + this.reset(); + } + + reset() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } +} + +export const parcelUnmountDeferred = new Deferred(); +parcelUnmountDeferred.resolve(); + +export const qiankunUnmountDeferred = new Deferred(); +qiankunUnmountDeferred.resolve(); diff --git a/packages/mapp/src/main/Bay/options.ts b/packages/mapp/src/main/Bay/options.ts index 4d548e8..705b8e7 100644 --- a/packages/mapp/src/main/Bay/options.ts +++ b/packages/mapp/src/main/Bay/options.ts @@ -1,13 +1,15 @@ /* eslint-disable no-lone-blocks */ import { removeTrailingSlash } from '@wakeadmin/utils'; import path from 'path-browserify'; +import { pathToActiveWhen } from 'single-spa'; import { BayOptions, MicroApp } from '../../types'; import { DEFAULT_ROOT_FOR_CHILD, MAX_WAIT_TIMES } from '../constants'; +import { parcelUnmountDeferred, qiankunUnmountDeferred } from './deferred'; import { pushMountQueue } from './mount-delay'; -import { MicroAppNormalized } from './types'; -import { normalizeUrl, trimBaseUrl } from './utils'; +import { MicroAppNormalized, ModernMicroAppNormalized } from './types'; +import { normalizeUrl, runPromiseChain, toArray, trimBaseUrl } from './utils'; function hasContainer(container: string | HTMLElement) { if (typeof container === 'string') { @@ -181,13 +183,38 @@ export function groupAppsByIndependent(apps: MicroAppNormalized[]) { * @returns */ export function normalizeOptions(options: BayOptions): BayOptions { - let { baseUrl = process.env.MAPP_BASE_URL ?? '/', apps, bayReady, ...others } = options; + let { baseUrl = process.env.MAPP_BASE_URL ?? '/', hooks = {}, apps, bayReady, ...others } = options; baseUrl = normalizeUrl(baseUrl); return { baseUrl, - apps: normalizeApps(baseUrl, apps, bayReady), + apps: normalizeApps( + baseUrl, + apps, + runPromiseChain([bayReady || (() => Promise.resolve()), () => parcelUnmountDeferred.promise]) + ), + hooks: { + ...hooks, + beforeAppMount: app => { + if (hooks.beforeAppMount) { + hooks.beforeAppMount(app); + } + qiankunUnmountDeferred.reset(); + }, + afterAppUnmount(app) { + if (hooks.afterAppUnmount) { + hooks.afterAppUnmount(app); + } + qiankunUnmountDeferred.resolve(); + }, + }, ...others, }; } + +export function normalizeModernApps(app: MicroAppNormalized): ModernMicroAppNormalized { + return Object.assign(app, { + activeRuleWhen: toArray(app.activeRule).map(rule => pathToActiveWhen(rule, true)), + }); +} diff --git a/packages/mapp/src/main/Bay/types.ts b/packages/mapp/src/main/Bay/types.ts index 2ebe272..04ae3f0 100644 --- a/packages/mapp/src/main/Bay/types.ts +++ b/packages/mapp/src/main/Bay/types.ts @@ -1,4 +1,5 @@ import { MicroApp } from '../../types'; +import { type ActivityFn } from 'single-spa'; export interface MicroAppNormalized extends MicroApp { /** @@ -8,3 +9,7 @@ export interface MicroAppNormalized extends MicroApp { loader?: (loading: boolean) => Promise; } + +export interface ModernMicroAppNormalized extends MicroAppNormalized { + activeRuleWhen: ActivityFn[]; +} diff --git a/packages/mapp/src/main/Bay/utils.ts b/packages/mapp/src/main/Bay/utils.ts index 363bde2..9099678 100644 --- a/packages/mapp/src/main/Bay/utils.ts +++ b/packages/mapp/src/main/Bay/utils.ts @@ -17,3 +17,14 @@ export function normalizeUrl(url: string) { export function trimBaseUrl(baseUrl: string, path: string) { return normalizeUrl(normalizeUrl(path).replace(normalizeUrl(baseUrl), '')); } + +export function toArray(input: T): T extends any[] ? T : T[] { + // @ts-expect-error + return Array.isArray(input) ? input : [input]; +} + +export function runPromiseChain(this: any, chain: any[]): () => Promise { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const that = this; + return (...args) => chain.reduce((pre, cur) => pre.then(() => cur.apply(that, args)), Promise.resolve()); +}