diff --git a/README.md b/README.md index 98dfa06..52fa844 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ npm i @worktile/planet --save ## Demo -![ngx-planet-micro-front-end.gif](https://github.com/worktile/ngx-planet/blob/master/src/assets/ngx-planet-micro-front-end.gif?raw=true) +![ngx-planet-micro-front-end.gif](https://github.com/worktile/ngx-planet/blob/master/examples/portal/src/assets/ngx-planet-micro-front-end.gif?raw=true) ## Usage diff --git a/examples/portal/src/app/app.component.ts b/examples/portal/src/app/app.component.ts index 59ea017..485ef82 100644 --- a/examples/portal/src/app/app.component.ts +++ b/examples/portal/src/app/app.component.ts @@ -51,7 +51,7 @@ export class AppComponent implements OnInit { routerPathPrefix: /\/app1|app4/, // '/app1', selector: 'app1-root-container', resourcePathPrefix: 'app1/static/', - preload: true, + preload: false, loadSerial: true, // prettier-ignore scripts: [ @@ -71,7 +71,7 @@ export class AppComponent implements OnInit { hostClass: appHostContainerClass, routerPathPrefix: '/app2', selector: 'app2-root-container', - preload: true, + preload: false, // prettier-ignore scripts: [ '/app2/static/main.js' diff --git a/examples/portal/src/tslint.json b/examples/portal/src/tslint.json index ca6ba8c..fd6454a 100644 --- a/examples/portal/src/tslint.json +++ b/examples/portal/src/tslint.json @@ -1,5 +1,5 @@ { - "extends": "../tslint.json", + "extends": "../../../tslint.json", "rules": { "directive-selector": [true, "attribute", "app", "camelCase"], "component-selector": [true, "element", "app", "kebab-case"] diff --git a/package.json b/package.json index 0607aee..2efc0fa 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build:app2": "ng build app2", "build": "ng build planet", "test": "ng test planet", - "lint": "ng lint", + "lint": "ng lint planet", "e2e": "ng e2e", "pub-only": "cd dist/planet && npm publish --access=public", "pub": "npm run build && npm run pub-only" diff --git a/packages/planet/package.json b/packages/planet/package.json index f1646ad..95f74c0 100644 --- a/packages/planet/package.json +++ b/packages/planet/package.json @@ -1,5 +1,5 @@ { - "name": "@worktile/planet", + "name": "@worktile/ngx-planet", "version": "0.0.11", "private": false, "peerDependencies": { diff --git a/packages/planet/src/application/planet-application-loader.spec.ts b/packages/planet/src/application/planet-application-loader.spec.ts new file mode 100644 index 0000000..e276dc9 --- /dev/null +++ b/packages/planet/src/application/planet-application-loader.spec.ts @@ -0,0 +1,134 @@ +import { Subject } from 'rxjs'; +import { RouterModule } from '@angular/router'; +import { TestBed, fakeAsync, flush, tick } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { PlanetApplicationLoader, ApplicationStatus } from './planet-application-loader'; +import { AssetsLoader } from '../assets-loader'; + +import { SwitchModes } from '../planet.class'; +import { PlanetApplicationService } from './planet-application.service'; +import { CommonModule } from '@angular/common'; +import { NgZone } from '@angular/core'; +import { PlanetApplicationRef } from './planet-application-ref'; + +const app1 = { + name: 'app1', + host: '.host-selector', + selector: 'app1-root-container', + routerPathPrefix: '/app1', + hostClass: 'app1-host', + preload: false, + switchMode: SwitchModes.coexist, + resourcePathPrefix: '/static/app1', + styles: ['styles/main.css'], + scripts: ['vendor.js', 'main.js'], + loadSerial: false, + manifest: '', + extra: { + appName: '应用1' + } +}; + +const app2 = { + name: 'app2', + host: '.host-selector', + selector: 'app2-root-container', + routerPathPrefix: '/app2', + hostClass: 'app2-host', + preload: false, + switchMode: SwitchModes.coexist, + resourcePathPrefix: '/static/app2', + styles: ['styles/main.css'], + scripts: ['vendor.js', 'main.js'], + loadSerial: false, + extra: { + appName: '应用2' + } +}; + +function mockApplicationRef(appName: string) { + const planetAppRef = new PlanetApplicationRef(appName, null); + (window as any).planet.apps[appName] = planetAppRef; + return planetAppRef; +} + +describe('PlanetApplicationLoader', () => { + let planetApplicationLoader: PlanetApplicationLoader; + let planetApplicationService: PlanetApplicationService; + let assetsLoader: AssetsLoader; + let ngZone: NgZone; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterModule.forRoot([])] + }); + planetApplicationLoader = TestBed.get(PlanetApplicationLoader); + planetApplicationService = TestBed.get(PlanetApplicationService); + assetsLoader = TestBed.get(AssetsLoader); + ngZone = TestBed.get(NgZone); + + planetApplicationService.register(app1); + planetApplicationService.register(app2); + }); + + afterEach(() => { + (window as any).planet.apps = {}; + }); + + it(`should load and bootstrap app`, fakeAsync(() => { + const loadScriptsAndStyles$ = new Subject(); + const assetsLoaderSpy = spyOn(assetsLoader, 'loadScriptsAndStyles'); + assetsLoaderSpy.and.returnValue(loadScriptsAndStyles$); + + const planetAppRef = mockApplicationRef(app1.name); + const bootstrapSpy = spyOn(planetAppRef, 'bootstrap'); + + const appStatusChangeSpy = jasmine.createSpy('app status change spy'); + planetApplicationLoader.appStatusChange.subscribe(appStatusChangeSpy); + expect(appStatusChangeSpy).not.toHaveBeenCalled(); + + planetApplicationLoader.reroute({ url: '/app1/dashboard' }); + + expect(appStatusChangeSpy).toHaveBeenCalled(); + expect(appStatusChangeSpy).toHaveBeenCalledWith({ app: app1, status: ApplicationStatus.assetsLoading }); + + loadScriptsAndStyles$.next(); + loadScriptsAndStyles$.complete(); + + expect(appStatusChangeSpy).toHaveBeenCalledTimes(2); + expect(appStatusChangeSpy).toHaveBeenCalledWith({ app: app1, status: ApplicationStatus.assetsLoaded }); + + expect(bootstrapSpy).not.toHaveBeenCalled(); + ngZone.onStable.next(); + expect(bootstrapSpy).toHaveBeenCalled(); + + expect(appStatusChangeSpy).toHaveBeenCalledTimes(4); + expect(appStatusChangeSpy).toHaveBeenCalledWith({ app: app1, status: ApplicationStatus.bootstrapped }); + + tick(); + })); + + // it(`should cancel load app1 which not returned and start load app2`, fakeAsync(() => { + // const loadScriptsAndStylesApp1$ = new Subject(); + // const loadScriptsAndStylesApp2$ = new Subject(); + // const assetsLoaderSpy = spyOn(assetsLoader, 'loadScriptsAndStyles'); + // assetsLoaderSpy.and.returnValues(loadScriptsAndStylesApp1$, loadScriptsAndStylesApp2$); + // const appStatusChangeSpy = jasmine.createSpy('app status change spy'); + // planetApplicationLoader.appStatusChange.subscribe(appStatusChangeSpy); + // expect(appStatusChangeSpy).not.toHaveBeenCalled(); + // planetApplicationLoader.reroute({ url: '/app1' }); + // expect(appStatusChangeSpy).toHaveBeenCalled(); + // expect(appStatusChangeSpy).toHaveBeenCalledWith({ app: app1, status: ApplicationStatus.assetsLoading }); + // planetApplicationLoader.reroute({ url: '/app2' }); + // expect(appStatusChangeSpy).toHaveBeenCalledTimes(2); + // expect(appStatusChangeSpy).toHaveBeenCalledWith({ app: app2, status: ApplicationStatus.assetsLoading }); + // loadScriptsAndStylesApp1$.next(); + // loadScriptsAndStylesApp1$.complete(); + // expect(appStatusChangeSpy).toHaveBeenCalledTimes(2); + // loadScriptsAndStylesApp2$.next(); + // loadScriptsAndStylesApp1$.complete(); + // expect(appStatusChangeSpy).toHaveBeenCalledTimes(3); + // expect(appStatusChangeSpy).toHaveBeenCalledWith({ app: app2, status: ApplicationStatus.bootstrapped }); + // tick(); + // })); +}); diff --git a/packages/planet/src/application/planet-application-loader.ts b/packages/planet/src/application/planet-application-loader.ts new file mode 100644 index 0000000..e6ba33c --- /dev/null +++ b/packages/planet/src/application/planet-application-loader.ts @@ -0,0 +1,334 @@ +import { Injectable, NgZone, ApplicationRef, Injector } from '@angular/core'; +import { of, Observable, Subject, forkJoin } from 'rxjs'; +import { AssetsLoader, AssetsLoadResult } from '../assets-loader'; +import { PlanetApplication, PlanetRouterEvent, SwitchModes, PlanetOptions } from '../planet.class'; +import { switchMap, finalize, share, map, tap, delay, take } from 'rxjs/operators'; +import { getScriptsAndStylesFullPaths, getHTMLElement, coerceArray } from '../helpers'; +import { PlanetApplicationRef, getPlanetApplicationRef, globalPlanet } from './planet-application-ref'; +import { PlanetPortalApplication } from './portal-application'; +import { PlanetApplicationService } from './planet-application.service'; +import { GlobalEventDispatcher } from '../global-event-dispatcher'; +import { Router } from '@angular/router'; + +export enum ApplicationStatus { + assetsLoading = 1, + assetsLoaded = 2, + bootstrapping = 3, + bootstrapped = 4 +} + +@Injectable({ + providedIn: 'root' +}) +export class PlanetApplicationLoader { + private options: PlanetOptions; + + private inProgressAppLoads = new Map>(); + + private appsStatus = new Map(); + + private portalApp = new PlanetPortalApplication(); + + private routeChange$ = new Subject(); + + private appStatusChange$ = new Subject<{ app: PlanetApplication; status: ApplicationStatus }>(); + + get appStatusChange(): Observable<{ app: PlanetApplication; status: ApplicationStatus }> { + return this.appStatusChange$.asObservable(); + } + + private firstLoad = true; + + public loadingDone = false; + + constructor( + private assetsLoader: AssetsLoader, + private planetApplicationService: PlanetApplicationService, + private ngZone: NgZone, + router: Router, + injector: Injector, + private applicationRef: ApplicationRef + ) { + this.options = { + switchMode: SwitchModes.default, + errorHandler: (error: Error) => { + console.error(error); + } + }; + this.portalApp.ngZone = ngZone; + this.portalApp.applicationRef = applicationRef; + this.portalApp.router = router; + this.portalApp.injector = injector; + this.portalApp.globalEventDispatcher = injector.get(GlobalEventDispatcher); + globalPlanet.portalApplication = this.portalApp; + this.setupRouteChange(); + } + + private setAppStatus(app: PlanetApplication, status: ApplicationStatus) { + this.appStatusChange$.next({ + app: app, + status: status + }); + this.appsStatus.set(app, status); + } + + private switchModeIsCoexist(app: PlanetApplication) { + if (app && app.switchMode) { + return app.switchMode === SwitchModes.coexist; + } else { + return this.options.switchMode === SwitchModes.coexist; + } + } + + private errorHandler(error: Error) { + this.options.errorHandler(error); + this.applicationRef.tick(); + } + + private setupRouteChange() { + this.routeChange$ + .pipe( + switchMap(event => { + this.loadingDone = false; + const shouldLoadApps = this.planetApplicationService.getAppsByMatchedUrl(event.url); + const shouldUnloadApps = this.getUnloadApps(shouldLoadApps); + this.unloadApps(shouldUnloadApps, event); + if (shouldLoadApps && shouldLoadApps.length > 0) { + return of(shouldLoadApps).pipe( + switchMap(apps => { + const loadApps$ = apps.map(app => { + const appStatus = this.appsStatus.get(app); + if (!appStatus) { + return this.startLoadAppAssets(app); + } else { + return of(app); + } + }); + return forkJoin(loadApps$); + }), + map(apps => { + const shouldBootstrapApps = []; + const shouldShowApps = []; + apps.forEach(app => { + const appStatus = this.appsStatus.get(app); + if (appStatus === ApplicationStatus.bootstrapped) { + shouldShowApps.push(app); + } else { + shouldBootstrapApps.push(app); + } + }); + + // 切换到应用后会有闪烁现象,所以使用 onStable 后启动应用 + this.ngZone.onStable.pipe(take(1)).subscribe(() => { + this.ngZone.runOutsideAngular(() => { + shouldShowApps.forEach(app => { + this.showApp(app); + const appRef = getPlanetApplicationRef(app.name); + appRef.onRouteChange(event); + }); + + shouldBootstrapApps.forEach(app => { + this.bootstrapApp(app); + }); + }); + }); + + return apps; + }) + ); + } else { + return of([]); + } + }) + ) + .subscribe({ + next: apps => { + this.loadingDone = true; + if (this.firstLoad) { + this.preloadApps(apps); + this.firstLoad = false; + } + }, + error: error => { + this.errorHandler(error); + } + }); + } + + private startLoadAppAssets(app: PlanetApplication) { + if (this.inProgressAppLoads.get(app.name)) { + return this.inProgressAppLoads.get(app.name); + } else { + const loadApp$ = this.loadAppAssets(app).pipe( + tap(() => { + this.inProgressAppLoads.delete(app.name); + this.setAppStatus(app, ApplicationStatus.assetsLoaded); + }), + map(() => { + return app; + }), + share() + ); + this.inProgressAppLoads.set(app.name, loadApp$); + this.setAppStatus(app, ApplicationStatus.assetsLoading); + return loadApp$; + } + } + + private loadAppAssets(app: PlanetApplication): Observable<[AssetsLoadResult[], AssetsLoadResult[]]> { + if (app.manifest) { + return this.assetsLoader.loadManifest(`${app.manifest}?t=${new Date().getTime()}`).pipe( + switchMap(manifestResult => { + const { scripts, styles } = getScriptsAndStylesFullPaths(app, manifestResult); + return this.assetsLoader.loadScriptsAndStyles(scripts, styles, app.loadSerial); + }) + ); + } else { + const { scripts, styles } = getScriptsAndStylesFullPaths(app); + return this.assetsLoader.loadScriptsAndStyles(scripts, styles, app.loadSerial); + } + } + + private hideApp(planetApp: PlanetApplication) { + const appRootElement = document.querySelector(planetApp.selector); + if (appRootElement) { + appRootElement.setAttribute('style', 'display:none;'); + } + } + + private showApp(planetApp: PlanetApplication) { + const appRootElement = document.querySelector(planetApp.selector); + if (appRootElement) { + appRootElement.setAttribute('style', ''); + } + } + + private destroyApp(planetApp: PlanetApplication) { + const appRef = getPlanetApplicationRef(planetApp.name); + if (appRef) { + appRef.destroy(); + } + const container = getHTMLElement(planetApp.host); + const appRootElement = container.querySelector(planetApp.selector); + if (appRootElement) { + container.removeChild(appRootElement); + } + } + + private bootstrapApp( + app: PlanetApplication, + defaultStatus: 'hidden' | 'display' = 'display' + ): PlanetApplicationRef { + this.setAppStatus(app, ApplicationStatus.bootstrapping); + const appRef = getPlanetApplicationRef(app.name); + if (appRef && appRef.bootstrap) { + const container = getHTMLElement(app.host); + if (container) { + let appRootElement = container.querySelector(app.selector); + if (!appRootElement) { + appRootElement = document.createElement(app.selector); + if (defaultStatus === 'hidden') { + appRootElement.setAttribute('style', 'display:none;'); + } + if (app.hostClass) { + appRootElement.classList.add(...coerceArray(app.hostClass)); + } + container.appendChild(appRootElement); + } + } + appRef.bootstrap(this.portalApp); + this.setAppStatus(app, ApplicationStatus.bootstrapped); + return appRef; + } + return null; + } + + private getUnloadApps(activeApps: PlanetApplication[]) { + const unloadApps: PlanetApplication[] = []; + this.appsStatus.forEach((value, app) => { + if (value === ApplicationStatus.bootstrapped && !activeApps.find(item => item.name === app.name)) { + unloadApps.push(app); + } + }); + return unloadApps; + } + + private unloadApps(shouldUnloadApps: PlanetApplication[], event: PlanetRouterEvent) { + const hideApps: PlanetApplication[] = []; + const destroyApps: PlanetApplication[] = []; + shouldUnloadApps.forEach(app => { + if (this.switchModeIsCoexist(app)) { + hideApps.push(app); + this.hideApp(app); + this.setAppStatus(app, ApplicationStatus.bootstrapped); + } else { + destroyApps.push(app); + // 销毁之前先隐藏,否则会出现闪烁,因为 destroy 是延迟执行的 + // 如果销毁不延迟执行,会出现切换到主应用的时候会有视图卡顿现象 + this.hideApp(app); + this.setAppStatus(app, ApplicationStatus.assetsLoaded); + } + }); + + // 从其他应用切换到主应用的时候会有视图卡顿现象,所以先等主应用渲染完毕后再加载其他应用 + // 此处尝试使用 this.ngZone.onStable.pipe(take(1)) 应用之间的切换会出现闪烁 + setTimeout(() => { + hideApps.forEach(app => { + const appRef = getPlanetApplicationRef(app.name); + if (appRef) { + appRef.onRouteChange(event); + } + }); + destroyApps.forEach(app => { + this.destroyApp(app); + }); + }); + } + + private preloadApps(activeApps?: PlanetApplication[]) { + setTimeout(() => { + const toPreloadApps = this.planetApplicationService.getAppsToPreload( + activeApps ? activeApps.map(item => item.name) : null + ); + const loadApps$ = toPreloadApps.map(preloadApp => { + return this.preload(preloadApp); + }); + forkJoin(loadApps$).subscribe({ + error: this.errorHandler + }); + }); + } + + setOptions(options: Partial) { + this.options = { + ...this.options, + ...options + }; + } + + /** + * reset route by current router + */ + reroute(event: PlanetRouterEvent) { + this.routeChange$.next(event); + } + + /** + * Preload planet application + * @param app app + */ + preload(app: PlanetApplication): Observable { + const status = this.appsStatus.get(app); + if (!status || status === ApplicationStatus.assetsLoading) { + return this.startLoadAppAssets(app).pipe( + map(() => { + this.ngZone.runOutsideAngular(() => { + this.bootstrapApp(app, 'hidden'); + }); + return getPlanetApplicationRef(app.name); + }) + ); + } + return of(getPlanetApplicationRef(app.name)); + } +} diff --git a/packages/planet/src/application/planet-application-ref.spec.ts b/packages/planet/src/application/planet-application-ref.spec.ts new file mode 100644 index 0000000..6d2923a --- /dev/null +++ b/packages/planet/src/application/planet-application-ref.spec.ts @@ -0,0 +1,49 @@ +import { defineApplication, getPlanetApplicationRef } from './planet-application-ref'; +import { PlanetPortalApplication } from './portal-application'; +describe('PlanetApplicationRef', () => { + afterEach(() => { + // delete all apps + Object.keys(window['planet'].apps).forEach(appName => { + delete window['planet'].apps[appName]; + }); + }); + + describe('defineApplication', () => { + it('should define application success', () => { + defineApplication('app1', (portalApp?: PlanetPortalApplication) => { + return new Promise(() => {}); + }); + expect(window['planet'].apps['app1']).toBeTruthy(); + }); + + it('should throw error when define application has exist', () => { + defineApplication('app1', (portalApp?: PlanetPortalApplication) => { + return new Promise(() => {}); + }); + expect(() => { + defineApplication('app1', (portalApp?: PlanetPortalApplication) => { + return new Promise(() => {}); + }); + }).toThrowError('app1 application has exist.'); + }); + }); + + describe('getPlanetApplicationRef', () => { + it('should get planet application ref success', () => { + defineApplication('app1', (portalApp?: PlanetPortalApplication) => { + return new Promise(() => {}); + }); + const planetAppRef = getPlanetApplicationRef('app1'); + expect(planetAppRef).toBeTruthy(); + expect(planetAppRef).toBe(window['planet'].apps['app1']); + }); + + it('should not get planet appRef which has not exist', () => { + defineApplication('app1', (portalApp?: PlanetPortalApplication) => { + return new Promise(() => {}); + }); + const planetAppRef = getPlanetApplicationRef('app2'); + expect(planetAppRef).toBeFalsy(); + }); + }); +}); diff --git a/packages/planet/src/application/planet-application-ref.ts b/packages/planet/src/application/planet-application-ref.ts index 704b26d..106f1db 100644 --- a/packages/planet/src/application/planet-application-ref.ts +++ b/packages/planet/src/application/planet-application-ref.ts @@ -1,4 +1,4 @@ -import { PlanetRouterEvent } from '../planet.class'; +import { PlanetRouterEvent, PlanetApplication } from '../planet.class'; import { PlanetPortalApplication } from './portal-application'; import { NgModuleRef, NgZone, ApplicationRef } from '@angular/core'; import { Router } from '@angular/router'; @@ -37,7 +37,7 @@ export class PlanetApplicationRef { this.appModuleBootstrap = appModuleBootstrap; } - bootstrap(app: PlanetPortalApplication): Promise { + async bootstrap(app: PlanetPortalApplication): Promise { if (!this.appModuleBootstrap) { throw new Error(`${this.name} app is not define`); } @@ -85,4 +85,17 @@ export function defineApplication(name: string, bootstrapModule: BootstrapAppMod window.planet.apps[name] = appRef; } +export function getPlanetApplicationRef(appName: string): PlanetApplicationRef { + const planet = (window as any).planet; + if (planet && planet.apps && planet.apps[appName]) { + return planet.apps[appName]; + } else { + return null; + } +} + +export function setPortalApplicationData(data: T) { + globalPlanet.portalApplication.data = data; +} + export { globalPlanet }; diff --git a/packages/planet/src/application/planet-application.service.spec.ts b/packages/planet/src/application/planet-application.service.spec.ts new file mode 100644 index 0000000..3424254 --- /dev/null +++ b/packages/planet/src/application/planet-application.service.spec.ts @@ -0,0 +1,125 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { PlanetApplicationService } from './planet-application.service'; +import { SwitchModes } from '../planet.class'; + +describe('PlanetApplicationService', () => { + let planetApplicationService: PlanetApplicationService; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [] + }); + planetApplicationService = TestBed.get(PlanetApplicationService); + }); + + const app1 = { + name: 'app1', + host: '.host-selector', + selector: 'app1-root-container', + routerPathPrefix: '/app1', + hostClass: 'app1-host', + preload: false, + switchMode: SwitchModes.coexist, + resourcePathPrefix: '/static/app1', + styles: ['styles/main.css'], + scripts: ['vendor.js', 'main.js'], + loadSerial: false, + manifest: '/static/app/manifest.json', + extra: { + appName: '应用1' + } + }; + + const app2 = { + name: 'app2', + host: '.host-selector', + selector: 'app2-root-container', + routerPathPrefix: '/app2', + hostClass: 'app2-host', + preload: true, + switchMode: SwitchModes.coexist, + resourcePathPrefix: '/static/app2', + styles: ['styles/main.css'], + scripts: ['vendor.js', 'main.js'], + loadSerial: false, + manifest: '/static/app/manifest.json', + extra: { + appName: '应用2' + } + }; + + describe('register', () => { + it('should register signal app1 success', () => { + planetApplicationService.register(app1); + expect(planetApplicationService.getApps()).toEqual([app1]); + }); + + it('should throw error when register exist app1 ', () => { + planetApplicationService.register(app1); + expect(() => { + planetApplicationService.register(app1); + }).toThrowError('app1 has be registered.'); + }); + + it('should register multiple apps contains app1 and app2 success', () => { + planetApplicationService.register([app1, app2]); + expect(planetApplicationService.getApps()).toEqual([app1, app2]); + }); + }); + + describe('unregister', () => { + it('should unregister app1 success', () => { + planetApplicationService.register(app1); + expect(planetApplicationService.getApps()).toEqual([app1]); + planetApplicationService.unregister(app1.name); + expect(planetApplicationService.getApps()).toEqual([]); + }); + }); + + describe('getAppByMatchedUrl', () => { + it('should get matched app by url app1/dashboard', () => { + planetApplicationService.register(app1); + planetApplicationService.register(app2); + const app = planetApplicationService.getAppByMatchedUrl('/app1/dashboard'); + expect(app).toBe(app1); + }); + + it('should get matched app which rule is RegExp("(a\\wp3)|app4") by url app3/dashboard', () => { + planetApplicationService.register(app1); + planetApplicationService.register(app2); + const app3 = { + ...app1, + name: 'app3', + routerPathPrefix: new RegExp('(a\\wp3)|app4') + }; + planetApplicationService.register(app3); + const app = planetApplicationService.getAppByMatchedUrl('/app3/dashboard'); + expect(app).toBeTruthy('app is not found'); + expect(app).toBe(app3); + }); + + it('should not get matched app by url /__app1/dashboard', () => { + planetApplicationService.register(app1); + planetApplicationService.register(app2); + const app = planetApplicationService.getAppByMatchedUrl('/__app1/dashboard'); + expect(app).toBeFalsy(); + }); + }); + + describe('getAppsToPreload', () => { + it('should get correct preload apps', () => { + planetApplicationService.register(app1); + planetApplicationService.register(app2); + const appsToPreload = planetApplicationService.getAppsToPreload(); + expect(appsToPreload).toEqual([app2]); + }); + + it('should get correct preload apps exclude app names', () => { + planetApplicationService.register(app1); + planetApplicationService.register(app2); + const appsToPreload = planetApplicationService.getAppsToPreload(['app2']); + expect(appsToPreload).toEqual([]); + }); + }); +}); diff --git a/packages/planet/src/application/planet-application.service.ts b/packages/planet/src/application/planet-application.service.ts index 9a78892..eae1076 100644 --- a/packages/planet/src/application/planet-application.service.ts +++ b/packages/planet/src/application/planet-application.service.ts @@ -1,9 +1,10 @@ import { PlanetApplication } from '../planet.class'; import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { shareReplay, map } from 'rxjs/operators'; -import { coerceArray } from '../helpers'; -import { Observable } from 'rxjs'; +import { shareReplay, map, switchMap } from 'rxjs/operators'; +import { coerceArray, getScriptsAndStylesFullPaths } from '../helpers'; +import { Observable, of } from 'rxjs'; +import { AssetsLoadResult, AssetsLoader } from '../assets-loader'; interface InternalPlanetApplication extends PlanetApplication { loaded?: boolean; @@ -19,7 +20,7 @@ export class PlanetApplicationService { private currentApps: InternalPlanetApplication[] = []; - constructor(private http: HttpClient) {} + constructor(private http: HttpClient, private assetsLoader: AssetsLoader) {} register(appOrApps: PlanetApplication | PlanetApplication[]) { const apps = coerceArray(appOrApps); @@ -80,4 +81,25 @@ export class PlanetApplicationService { } }); } + + getApps() { + return this.apps; + } + + loadApp(app: InternalPlanetApplication): Observable<[AssetsLoadResult[], AssetsLoadResult[]]> { + if (app.loaded) { + return of(null); + } + if (app.manifest) { + return this.assetsLoader.loadManifest(`${app.manifest}?t=${new Date().getTime()}`).pipe( + switchMap(manifestResult => { + const { scripts, styles } = getScriptsAndStylesFullPaths(app, manifestResult); + return this.assetsLoader.loadScriptsAndStyles(scripts, styles, app.loadSerial); + }) + ); + } else { + const { scripts, styles } = getScriptsAndStylesFullPaths(app); + return this.assetsLoader.loadScriptsAndStyles(scripts, styles, app.loadSerial); + } + } } diff --git a/packages/planet/src/helpers.spec.ts b/packages/planet/src/helpers.spec.ts index 9226231..ba7b3b5 100644 --- a/packages/planet/src/helpers.spec.ts +++ b/packages/planet/src/helpers.spec.ts @@ -1,4 +1,12 @@ -import { hashCode, isEmpty, coerceArray, getHTMLElement, getResourceFileName } from './helpers'; +import { + hashCode, + isEmpty, + coerceArray, + getHTMLElement, + getResourceFileName, + buildResourceFilePath, + getScriptsAndStylesFullPaths +} from './helpers'; describe('helpers', () => { describe('hashCode', () => { @@ -130,4 +138,73 @@ describe('helpers', () => { expect(result).toBe('file.js'); }); }); + + describe('buildResourceFilePath', () => { + it('should get correct path input "main.js"', () => { + const result = buildResourceFilePath('main.js', { 'main.js': 'main.h2d3f2232.js' }); + expect(result).toBe('main.h2d3f2232.js'); + }); + + it('should get correct path when input "main.js" which has not exist in manifest', () => { + const result = buildResourceFilePath('main.js', {}); + expect(result).toBe('main.js'); + }); + + it('should get correct path input "assets/scripts/main.js"', () => { + const result = buildResourceFilePath('assets/scripts/main.js', { 'main.js': 'main.h2d3f2232.js' }); + expect(result).toBe('assets/scripts/main.h2d3f2232.js'); + }); + }); + + describe('getScriptsAndStylesFullPaths', () => { + const app = { + name: 'app1', + host: '.host-selector', + selector: 'app1-root-container', + routerPathPrefix: '/app1', + hostClass: 'app1-host', + preload: false, + // resourcePathPrefix: '/static/app1/', + styles: ['styles/main.css'], + scripts: ['vendor.js', 'main.js'], + extra: { + appName: '应用1' + } + }; + + it('should get correct full path without resourcePathPrefix', () => { + const result = getScriptsAndStylesFullPaths({ ...app }); + expect(result).toEqual({ + scripts: ['vendor.js', 'main.js'], + styles: ['styles/main.css'] + }); + }); + + it('should get correct full path with resourcePathPrefix', () => { + const result = getScriptsAndStylesFullPaths({ ...app, resourcePathPrefix: '/static/app1/' }); + expect(result).toEqual({ + scripts: ['/static/app1/vendor.js', '/static/app1/main.js'], + styles: ['/static/app1/styles/main.css'] + }); + }); + + it('should get correct full path with manifest', () => { + const manifest = { + 'vendor.js': `vendor.${randomString()}.js`, + 'main.js': `main.${randomString()}.js`, + 'main.css': `main.${randomString()}.css` + }; + const result = getScriptsAndStylesFullPaths({ ...app, resourcePathPrefix: '/static/app1/' }, manifest); + expect(result).toEqual({ + scripts: [`/static/app1/${manifest['vendor.js']}`, `/static/app1/${manifest['main.js']}`], + styles: [`/static/app1/styles/${manifest['main.css']}`] + }); + }); + }); }); + +function randomString() { + return Math.random() + .toString(36) + .slice(-8); +} diff --git a/packages/planet/src/helpers.ts b/packages/planet/src/helpers.ts index 4811532..c261d61 100644 --- a/packages/planet/src/helpers.ts +++ b/packages/planet/src/helpers.ts @@ -1,3 +1,5 @@ +import { PlanetApplication } from './planet.class'; + export function hashCode(str: string): number { let hash = 0; let chr: number; @@ -32,6 +34,12 @@ export function isEmpty(value: any): boolean { } } +/** + * Get file name from path + * 1. "main.js" => "main.js" + * 2. "assets/scripts/main.js" => "main.js" + * @param path path + */ export function getResourceFileName(path: string) { const lastSlashIndex = path.lastIndexOf('/'); if (lastSlashIndex >= 0) { @@ -40,3 +48,51 @@ export function getResourceFileName(path: string) { return path; } } + +/** + * Build resource path by manifest + * if manifest is { "main.js": "main.h2sh23abee.js"} + * 1. "main.js" => "main.h2sh23abee.js" + * 2. "assets/scripts/main.js" =>"assets/scripts/main.h2sh23abee.js" + * @param resourceFilePath Resource File Path + * @param manifestResult manifest + */ +export function buildResourceFilePath(resourceFilePath: string, manifestResult: { [key: string]: string }) { + const fileName = getResourceFileName(resourceFilePath); + if (manifestResult[fileName]) { + return resourceFilePath.replace(fileName, manifestResult[fileName]); + } else { + return resourceFilePath; + } +} + +/** + * Get static resource full path + * @param app PlanetApplication + * @param manifestResult manifest + */ +export function getScriptsAndStylesFullPaths(app: PlanetApplication, manifestResult?: { [key: string]: string }) { + let scripts = app.scripts || []; + let styles = app.styles || []; + // combine resource path by manifest + if (manifestResult) { + scripts = scripts.map(script => { + return buildResourceFilePath(script, manifestResult); + }); + styles = styles.map(style => { + return buildResourceFilePath(style, manifestResult); + }); + } + if (app.resourcePathPrefix) { + scripts = scripts.map(script => { + return `${app.resourcePathPrefix}${script}`; + }); + styles = styles.map(style => { + return `${app.resourcePathPrefix}${style}`; + }); + } + return { + scripts: scripts, + styles: styles + }; +} diff --git a/packages/planet/src/planet.class.ts b/packages/planet/src/planet.class.ts index cef92e4..4c7e4a1 100644 --- a/packages/planet/src/planet.class.ts +++ b/packages/planet/src/planet.class.ts @@ -3,7 +3,6 @@ import { Router } from '@angular/router'; import { PlanetPortalApplication } from './application/portal-application'; export interface PlanetOptions { - preload?: boolean; switchMode?: SwitchModes; errorHandler: (error: Error) => void; } diff --git a/packages/planet/src/planet.ts b/packages/planet/src/planet.ts index 73b9dc6..225a700 100644 --- a/packages/planet/src/planet.ts +++ b/packages/planet/src/planet.ts @@ -1,138 +1,30 @@ -import { Injectable, NgZone, ApplicationRef, Injector } from '@angular/core'; +import { Injectable } from '@angular/core'; import { NavigationEnd, RouterEvent, Router } from '@angular/router'; -import { AssetsLoader, AssetsLoadResult } from './assets-loader'; -import { GlobalEventDispatcher } from './global-event-dispatcher'; -import { getHTMLElement, coerceArray, getResourceFileName } from './helpers'; -import { of, Observable, BehaviorSubject, Subject, Observer } from 'rxjs'; -import { tap, map, switchMap } from 'rxjs/operators'; -import { SwitchModes, PlanetRouterEvent, PlanetOptions, PlanetApplication } from './planet.class'; +import { PlanetOptions, PlanetApplication } from './planet.class'; import { PlanetApplicationService } from './application/planet-application.service'; -import { PlanetPortalApplication } from './application/portal-application'; -import { PlanetApplicationRef, globalPlanet } from './application/planet-application-ref'; - -interface InternalPlanetApplication extends PlanetApplication { - loaded?: boolean; -} +import { setPortalApplicationData } from './application/planet-application-ref'; +import { PlanetApplicationLoader, ApplicationStatus } from './application/planet-application-loader'; @Injectable({ providedIn: 'root' }) export class Planet { - private options: PlanetOptions; - - private currentApp: InternalPlanetApplication; - - private portalApp = new PlanetPortalApplication(); - - private firstLoad = true; - - public loadingDone = true; - - private setLoadingDoneInNgZone(loadingDone: boolean) { - this.ngZone.run(() => { - this.loadingDone = loadingDone; - }); - } - - private switchModeIsCoexist(app?: InternalPlanetApplication) { - if (app && app.switchMode) { - return app.switchMode === SwitchModes.coexist; - } else { - return this.options.switchMode === SwitchModes.coexist; - } - } - - private getPlanetApplicationRef(app: InternalPlanetApplication): PlanetApplicationRef { - const planet = (window as any).planet; - if (planet && planet.apps && planet.apps[app.name]) { - return planet.apps[app.name]; - } else { - return null; - } - } - - private hideApplication(planetApp: InternalPlanetApplication) { - const appRootElement = document.querySelector(planetApp.selector); - if (appRootElement) { - appRootElement.setAttribute('style', 'display:none;'); - } - } - - private showApplication(planetApp: InternalPlanetApplication) { - const appRootElement = document.querySelector(planetApp.selector); - if (appRootElement) { - appRootElement.setAttribute('style', ''); - } - } - - private buildResourceFilePath(resourceFilePath: string, manifestResult: { [key: string]: string }) { - const fileName = getResourceFileName(resourceFilePath); - if (manifestResult[fileName]) { - return resourceFilePath.replace(fileName, manifestResult[fileName]); - } else { - return resourceFilePath; - } - } - - private getScriptsAndStylesFullPaths(app: InternalPlanetApplication, manifestResult?: { [key: string]: string }) { - let scripts = app.scripts || []; - let styles = app.styles || []; - // combine resource path by manifest - if (manifestResult) { - scripts = scripts.map(script => { - return this.buildResourceFilePath(script, manifestResult); - }); - styles = styles.map(style => { - return this.buildResourceFilePath(style, manifestResult); - }); - } - if (app.resourcePathPrefix) { - scripts = scripts.map(script => { - return `${app.resourcePathPrefix}${script}`; - }); - styles = styles.map(style => { - return `${app.resourcePathPrefix}${style}`; - }); - } - return { - scripts: scripts, - styles: styles - }; + public get loadingDone() { + return this.planetApplicationLoader.loadingDone; } constructor( - private assetsLoader: AssetsLoader, - private ngZone: NgZone, + private planetApplicationLoader: PlanetApplicationLoader, private router: Router, - globalEventDispatcher: GlobalEventDispatcher, - injector: Injector, - private applicationRef: ApplicationRef, private planetApplicationService: PlanetApplicationService - ) { - this.portalApp.ngZone = ngZone; - this.portalApp.applicationRef = applicationRef; - this.portalApp.router = router; - this.portalApp.injector = injector; - this.portalApp.globalEventDispatcher = globalEventDispatcher; - globalPlanet.portalApplication = this.portalApp; - this.options = { - switchMode: SwitchModes.default, - preload: true, - errorHandler: (error: Error) => { - console.error(error); - } - }; - } + ) {} setOptions(options: Partial) { - this.options = { - ...this.options, - ...options - }; + this.planetApplicationLoader.setOptions(options); } setPortalAppData(data: T) { - this.portalApp.data = data; + setPortalApplicationData(data); } registerApp(app: PlanetApplication) { @@ -143,162 +35,12 @@ export class Planet { this.planetApplicationService.register(apps); } - loadApp(app: InternalPlanetApplication): Observable<[AssetsLoadResult[], AssetsLoadResult[]]> { - if (app.loaded) { - return of(null); - } - if (app.manifest) { - return this.assetsLoader.loadManifest(`${app.manifest}?t=${new Date().getTime()}`).pipe( - switchMap(manifestResult => { - const { scripts, styles } = this.getScriptsAndStylesFullPaths(app, manifestResult); - return this.assetsLoader.loadScriptsAndStyles(scripts, styles, app.loadSerial); - }) - ); - } else { - const { scripts, styles } = this.getScriptsAndStylesFullPaths(app); - return this.assetsLoader.loadScriptsAndStyles(scripts, styles, app.loadSerial); - } - } - - bootstrapApp(planetApp: InternalPlanetApplication): PlanetApplicationRef { - const appRef = this.getPlanetApplicationRef(planetApp); - if (appRef && appRef.bootstrap) { - const container = getHTMLElement(planetApp.host); - if (container) { - let appRootElement = container.querySelector(planetApp.selector); - if (!appRootElement) { - appRootElement = document.createElement(planetApp.selector); - if (planetApp.hostClass) { - appRootElement.classList.add(...coerceArray(planetApp.hostClass)); - } - container.appendChild(appRootElement); - } - } - appRef.bootstrap(this.portalApp); - return appRef; - } - return null; - } - - preloadAndBootstrapApp(planetApp: InternalPlanetApplication) { - return new Promise((resolve, reject) => { - this.ngZone.runOutsideAngular(() => { - if (planetApp.loaded) { - return; - } else { - this.loadApp(planetApp).subscribe( - result => { - this.bootstrapApp(planetApp); - this.hideApplication(planetApp); - planetApp.loaded = true; - resolve(); - }, - error => { - this.options.errorHandler(error); - this.applicationRef.tick(); - reject(error); - } - ); - } - }); - }); - } - - loadAndBootstrapApp(planetApp: InternalPlanetApplication, event?: PlanetRouterEvent) { - return new Promise((resolve, reject) => { - this.ngZone.runOutsideAngular(() => { - this.setLoadingDoneInNgZone(false); - this.currentApp = planetApp; - if (planetApp.loaded) { - if (this.switchModeIsCoexist(planetApp)) { - this.showApplication(planetApp); - const appRef = this.getPlanetApplicationRef(planetApp); - appRef.onRouteChange(event); - } else { - this.bootstrapApp(planetApp); - } - this.setLoadingDoneInNgZone(true); - resolve(); - } else { - this.loadApp(planetApp).subscribe( - result => { - this.bootstrapApp(planetApp); - planetApp.loaded = true; - this.setLoadingDoneInNgZone(true); - resolve(); - }, - error => { - this.options.errorHandler(error); - this.applicationRef.tick(); - reject(error); - } - ); - } - }); - }); - } - - destroyApp(planetApp: InternalPlanetApplication) { - const appRef = this.getPlanetApplicationRef(planetApp); - if (appRef) { - appRef.destroy(); - } - const container = getHTMLElement(planetApp.host); - const appRootElement = container.querySelector(planetApp.selector); - if (appRootElement) { - container.removeChild(appRootElement); - } - } - - resetByRoute(event: PlanetRouterEvent) { - const matchedApp = this.planetApplicationService.getAppByMatchedUrl(event.url); - if (this.currentApp) { - if (this.switchModeIsCoexist(this.currentApp)) { - const appRef = this.getPlanetApplicationRef(this.currentApp); - if (appRef) { - this.hideApplication(this.currentApp); - // 从其他应用切换到主应用的时候会有视图卡顿现象,所以先等主应用渲染完毕后再加载其他应用 - setTimeout(() => { - appRef.onRouteChange(event); - }); - } - } else { - setTimeout(() => { - this.destroyApp(this.currentApp); - }); - } - this.currentApp = null; - } - - if (matchedApp) { - setTimeout(() => { - this.loadAndBootstrapApp(matchedApp, event).then(() => { - this.preloadApps(matchedApp); - }); - }); - } else { - this.preloadApps(); - } - } - - preloadApps(matchedApp?: InternalPlanetApplication) { - if (this.firstLoad) { - setTimeout(() => { - const toPreloadApps = this.planetApplicationService.getAppsToPreload( - matchedApp ? [matchedApp.name] : null - ); - toPreloadApps.forEach(preloadApp => { - this.preloadAndBootstrapApp(preloadApp).then(() => {}, error => {}); - }); - }, 200); - this.firstLoad = true; - } - } - start() { this.router.events.subscribe((event: RouterEvent) => { if (event instanceof NavigationEnd) { - this.resetByRoute(event); + this.planetApplicationLoader.reroute({ + url: event.url + }); } }); } diff --git a/tslint.json b/tslint.json index 6318afc..1256c53 100644 --- a/tslint.json +++ b/tslint.json @@ -1,68 +1,39 @@ { - "rulesDirectory": [ - "codelyzer" - ], + "rulesDirectory": ["codelyzer"], "rules": { "arrow-return-shorthand": true, "callable-types": true, "class-name": true, - "comment-format": [ - true, - "check-space" - ], + "comment-format": [true, "check-space"], "curly": true, "deprecation": { "severity": "warn" }, "eofline": true, "forin": true, - "import-blacklist": [ - true, - "rxjs/Rx" - ], + "import-blacklist": [true, "rxjs/Rx"], "import-spacing": true, - "indent": [ - true, - "spaces" - ], + "indent": [true, "spaces"], "interface-over-type-literal": true, "label-position": true, - "max-line-length": [ - true, - 140 - ], + "max-line-length": [true, 140], "member-access": false, "member-ordering": [ true, { - "order": [ - "static-field", - "instance-field", - "static-method", - "instance-method" - ] + "order": ["static-field", "instance-field", "static-method", "instance-method"] } ], "no-arg": true, "no-bitwise": false, - "no-console": [ - true, - "debug", - "info", - "time", - "timeEnd", - "trace" - ], + "no-console": [true, "debug", "info", "time", "timeEnd", "trace"], "no-construct": true, "no-debugger": true, "no-duplicate-super": true, "no-empty": false, "no-empty-interface": true, "no-eval": true, - "no-inferrable-types": [ - true, - "ignore-params" - ], + "no-inferrable-types": [true, "ignore-params"], "no-misused-new": true, "no-non-null-assertion": true, "no-redundant-jsdoc": true, @@ -76,27 +47,12 @@ "no-use-before-declare": true, "no-var-keyword": true, "object-literal-sort-keys": false, - "one-line": [ - true, - "check-open-brace", - "check-catch", - "check-else", - "check-whitespace" - ], + "one-line": [true, "check-open-brace", "check-catch", "check-else", "check-whitespace"], "prefer-const": true, - "quotemark": [ - true, - "single" - ], + "quotemark": [true, "single"], "radix": true, - "semicolon": [ - true, - "always" - ], - "triple-equals": [ - true, - "allow-null-check" - ], + "semicolon": [true, "always", "strict-bound-class-methods"], + "triple-equals": [true, "allow-null-check"], "typedef-whitespace": [ true, { @@ -109,14 +65,7 @@ ], "unified-signatures": true, "variable-name": false, - "whitespace": [ - true, - "check-branch", - "check-decl", - "check-operator", - "check-separator", - "check-type" - ], + "whitespace": [true, "check-branch", "check-decl", "check-operator", "check-separator", "check-type"], "no-output-on-prefix": true, "use-input-property-decorator": true, "use-output-property-decorator": true,