feat(router): add router preloader to optimistically preload routes

This commit is contained in:
vsavkin 2016-09-16 15:08:15 -07:00 committed by Alex Eagle
parent 671f73448c
commit 5a849829c4
7 changed files with 344 additions and 14 deletions

View File

@ -15,7 +15,9 @@ export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './
export {Event, NavigationCancel, NavigationEnd, NavigationError, NavigationExtras, NavigationStart, Router, RoutesRecognized} from './router'; export {Event, NavigationCancel, NavigationEnd, NavigationError, NavigationExtras, NavigationStart, Router, RoutesRecognized} from './router';
export {ExtraOptions, RouterModule, provideRoutes} from './router_module'; export {ExtraOptions, RouterModule, provideRoutes} from './router_module';
export {RouterOutletMap} from './router_outlet_map'; export {RouterOutletMap} from './router_outlet_map';
export {NoPreloading, PreloadAllModules, PreloadingStrategy} from './router_preloader';
export {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot} from './router_state'; export {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot} from './router_state';
export {PRIMARY_OUTLET, Params} from './shared'; export {PRIMARY_OUTLET, Params} from './shared';
export {DefaultUrlSerializer, UrlSegment, UrlSerializer, UrlTree} from './url_tree'; export {DefaultUrlSerializer, UrlSegment, UrlSerializer, UrlTree} from './url_tree';
export * from './private_export' export * from './private_export'

View File

@ -317,6 +317,15 @@ export class Router {
this.currentRouterState = createEmptyState(this.currentUrlTree, this.rootComponentType); this.currentRouterState = createEmptyState(this.currentUrlTree, this.rootComponentType);
} }
/**
* @internal
* TODO: this should be removed once the constructor of the router made internal
*/
resetRootComponentType(rootComponentType: Type<any>): void {
this.rootComponentType = rootComponentType;
this.currentRouterState = createEmptyState(this.currentUrlTree, this.rootComponentType);
}
/** /**
* Sets up the location change listener and performs the initial navigation. * Sets up the location change listener and performs the initial navigation.
*/ */

View File

@ -16,6 +16,7 @@ import {RouterOutlet} from './directives/router_outlet';
import {ErrorHandler, Router} from './router'; import {ErrorHandler, Router} from './router';
import {ROUTES} from './router_config_loader'; import {ROUTES} from './router_config_loader';
import {RouterOutletMap} from './router_outlet_map'; import {RouterOutletMap} from './router_outlet_map';
import {NoPreloading, PreloadAllModules, PreloadingStrategy, RouterPreloader} from './router_preloader';
import {ActivatedRoute} from './router_state'; import {ActivatedRoute} from './router_state';
import {DefaultUrlSerializer, UrlSerializer} from './url_tree'; import {DefaultUrlSerializer, UrlSerializer} from './url_tree';
import {flatten} from './utils/collection'; import {flatten} from './utils/collection';
@ -58,8 +59,8 @@ export const ROUTER_PROVIDERS: Provider[] = [
] ]
}, },
RouterOutletMap, {provide: ActivatedRoute, useFactory: rootRoute, deps: [Router]}, RouterOutletMap, {provide: ActivatedRoute, useFactory: rootRoute, deps: [Router]},
{provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader}, {provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader}, RouterPreloader, NoPreloading,
{provide: ROUTER_CONFIGURATION, useValue: {enableTracing: false}} PreloadAllModules, {provide: ROUTER_CONFIGURATION, useValue: {enableTracing: false}}
]; ];
/** /**
@ -145,6 +146,11 @@ export class RouterModule {
PlatformLocation, [new Inject(APP_BASE_HREF), new Optional()], ROUTER_CONFIGURATION PlatformLocation, [new Inject(APP_BASE_HREF), new Optional()], ROUTER_CONFIGURATION
] ]
}, },
{
provide: PreloadingStrategy,
useExisting: config && config.preloadingStrategy ? config.preloadingStrategy :
NoPreloading
},
provideRouterInitializer() provideRouterInitializer()
] ]
}; };
@ -220,19 +226,19 @@ export interface ExtraOptions {
* A custom error handler. * A custom error handler.
*/ */
errorHandler?: ErrorHandler; errorHandler?: ErrorHandler;
/**
* Configures a preloading strategy. See {@link PreloadAllModules}.
*/
preloadingStrategy?: any;
} }
export function setupRouter( export function setupRouter(
ref: ApplicationRef, urlSerializer: UrlSerializer, outletMap: RouterOutletMap, ref: ApplicationRef, urlSerializer: UrlSerializer, outletMap: RouterOutletMap,
location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler, location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler,
config: Route[][], opts: ExtraOptions = {}) { config: Route[][], opts: ExtraOptions = {}) {
if (ref.componentTypes.length == 0) {
throw new Error('Bootstrap at least one component before injecting Router.');
}
const componentType = ref.componentTypes[0];
const r = new Router( const r = new Router(
componentType, urlSerializer, outletMap, location, injector, loader, compiler, null, urlSerializer, outletMap, location, injector, loader, compiler, flatten(config));
flatten(config));
if (opts.errorHandler) { if (opts.errorHandler) {
r.errorHandler = opts.errorHandler; r.errorHandler = opts.errorHandler;
@ -254,8 +260,11 @@ export function rootRoute(router: Router): ActivatedRoute {
return router.routerState.root; return router.routerState.root;
} }
export function initialRouterNavigation(router: Router, opts: ExtraOptions) { export function initialRouterNavigation(
router: Router, ref: ApplicationRef, preloader: RouterPreloader, opts: ExtraOptions) {
return () => { return () => {
router.resetRootComponentType(ref.componentTypes[0]);
preloader.setUpPreloading();
if (opts.initialNavigation === false) { if (opts.initialNavigation === false) {
router.setUpLocationChangeListener(); router.setUpLocationChangeListener();
} else { } else {
@ -269,6 +278,6 @@ export function provideRouterInitializer() {
provide: APP_BOOTSTRAP_LISTENER, provide: APP_BOOTSTRAP_LISTENER,
multi: true, multi: true,
useFactory: initialRouterNavigation, useFactory: initialRouterNavigation,
deps: [Router, ROUTER_CONFIGURATION] deps: [Router, ApplicationRef, RouterPreloader, ROUTER_CONFIGURATION]
}; };
} }

View File

@ -0,0 +1,124 @@
/**
*@license
*Copyright Google Inc. All Rights Reserved.
*
*Use of this source code is governed by an MIT-style license that can be
*found in the LICENSE file at https://angular.io/license
*/
import {Compiler, ComponentFactoryResolver, Injectable, Injector, NgModuleFactory, NgModuleFactoryLoader} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import {Subscription} from 'rxjs/Subscription';
import {from} from 'rxjs/observable/from';
import {of } from 'rxjs/observable/of';
import {_catch} from 'rxjs/operator/catch';
import {concatMap} from 'rxjs/operator/concatMap';
import {filter} from 'rxjs/operator/filter';
import {map} from 'rxjs/operator/map';
import {mergeAll} from 'rxjs/operator/mergeAll';
import {mergeMap} from 'rxjs/operator/mergeMap';
import {Route, Routes} from './config';
import {NavigationEnd, Router} from './router';
import {RouterConfigLoader} from './router_config_loader';
/**
* @whatItDoes Provides a preloading strategy.
*
* @experimental
*/
export abstract class PreloadingStrategy {
abstract preload(route: Route, fn: () => Observable<any>): Observable<any>;
}
/**
* @whatItDoes Provides a preloading strategy that preloads all modules as quicky as possible.
*
* @howToUse
*
* ```
* RouteModule.forRoot(ROUTES, {preloadingStrategy: PreloadAllModules})
* ```
*
* @experimental
*/
export class PreloadAllModules implements PreloadingStrategy {
preload(route: Route, fn: () => Observable<any>): Observable<any> {
return _catch.call(fn(), () => of (null));
}
}
/**
* @whatItDoes Provides a preloading strategy that does not preload any modules.
*
* @description
*
* This strategy is enabled by default.
*
* @experimental
*/
export class NoPreloading implements PreloadingStrategy {
preload(route: Route, fn: () => Observable<any>): Observable<any> { return of (null); }
}
/**
* The preloader optimistically loads all router configurations to
* make navigations into lazily-loaded sections of the application faster.
*
* The preloader runs in the background. When the router bootstraps, the preloader
* starts listening to all navigation events. After every such event, the preloader
* will check if any configurations can be loaded lazily.
*
* If a route is protected by `canLoad` guards, the preloaded will not load it.
*/
@Injectable()
export class RouterPreloader {
private loader: RouterConfigLoader;
private subscription: Subscription;
constructor(
private router: Router, moduleLoader: NgModuleFactoryLoader, compiler: Compiler,
private injector: Injector, private preloadingStrategy: PreloadingStrategy) {
this.loader = new RouterConfigLoader(moduleLoader, compiler);
};
setUpPreloading(): void {
const navigations = filter.call(this.router.events, (e: any) => e instanceof NavigationEnd);
this.subscription = concatMap.call(navigations, () => this.preload()).subscribe((v: any) => {});
}
preload(): Observable<any> { return this.processRoutes(this.injector, this.router.config); }
ngOnDestroy() { this.subscription.unsubscribe(); }
private processRoutes(injector: Injector, routes: Routes): Observable<void> {
const res: Observable<any>[] = [];
for (let c of routes) {
// we already have the config loaded, just recurce
if (c.loadChildren && !c.canLoad && (<any>c)._loadedConfig) {
const childConfig = (<any>c)._loadedConfig;
res.push(this.processRoutes(childConfig.injector, childConfig.routes));
// no config loaded, fetch the config
} else if (c.loadChildren && !c.canLoad) {
res.push(this.preloadConfig(injector, c));
// recurse into children
} else if (c.children) {
res.push(this.processRoutes(injector, c.children));
}
}
return mergeAll.call(from(res));
}
private preloadConfig(injector: Injector, route: Route): Observable<void> {
return this.preloadingStrategy.preload(route, () => {
const loaded = this.loader.load(injector, route.loadChildren);
return mergeMap.call(loaded, (config: any): any => {
const c: any = route;
c._loadedConfig = config;
return this.processRoutes(config.injector, config.routes);
});
});
}
}

View File

@ -14,7 +14,8 @@ import {Observable} from 'rxjs/Observable';
import {of } from 'rxjs/observable/of'; import {of } from 'rxjs/observable/of';
import {map} from 'rxjs/operator/map'; import {map} from 'rxjs/operator/map';
import {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Params, Resolve, Router, RouterModule, RouterStateSnapshot, RoutesRecognized} from '../index'; import {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Params, PreloadAllModules, PreloadingStrategy, Resolve, Router, RouterModule, RouterStateSnapshot, RoutesRecognized} from '../index';
import {RouterPreloader} from '../src/router_preloader';
import {RouterTestingModule, SpyNgModuleFactoryLoader} from '../testing'; import {RouterTestingModule, SpyNgModuleFactoryLoader} from '../testing';
@ -1413,7 +1414,7 @@ describe('Integration', () => {
it('should not set the class until the first navigation succeeds', fakeAsync(() => { it('should not set the class until the first navigation succeeds', fakeAsync(() => {
@Component({ @Component({
template: template:
'<router-outlet></router-outlet><a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" >' '<router-outlet></router-outlet><a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" ></a>'
}) })
class RootCmpWithLink { class RootCmpWithLink {
} }
@ -1532,6 +1533,7 @@ describe('Integration', () => {
expect(location.path()).toEqual('/lazy/loaded/child'); expect(location.path()).toEqual('/lazy/loaded/child');
expect(fixture.nativeElement).toHaveText('lazy-loaded-parent [lazy-loaded-child]'); expect(fixture.nativeElement).toHaveText('lazy-loaded-parent [lazy-loaded-child]');
}))); })));
it('throws an error when forRoot() is used in a lazy context', it('throws an error when forRoot() is used in a lazy context',
fakeAsync(inject( fakeAsync(inject(
[Router, Location, NgModuleFactoryLoader], [Router, Location, NgModuleFactoryLoader],
@ -1702,6 +1704,60 @@ describe('Integration', () => {
recordedEvents, recordedEvents,
[[NavigationStart, '/lazy/loaded'], [NavigationError, '/lazy/loaded']]); [[NavigationStart, '/lazy/loaded'], [NavigationError, '/lazy/loaded']]);
}))); })));
describe('preloading', () => {
beforeEach(() => {
TestBed.configureTestingModule(
{providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}]});
const preloader = TestBed.get(RouterPreloader);
preloader.setUpPreloading();
});
it('should work',
fakeAsync(inject(
[Router, Location, NgModuleFactoryLoader],
(router: Router, location: Location, loader: SpyNgModuleFactoryLoader) => {
@Component({selector: 'lazy', template: 'should not show'})
class LazyLoadedComponent {
}
@NgModule({
declarations: [LazyLoadedComponent],
imports: [RouterModule.forChild(
[{path: 'LoadedModule2', component: LazyLoadedComponent}])]
})
class LoadedModule2 {
}
@NgModule({
imports:
[RouterModule.forChild([{path: 'LoadedModule1', loadChildren: 'expected2'}])]
})
class LoadedModule1 {
}
loader.stubbedModules = {expected: LoadedModule1, expected2: LoadedModule2};
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{path: 'blank', component: BlankCmp}, {path: 'lazy', loadChildren: 'expected'}
]);
router.navigateByUrl('/blank');
advance(fixture);
const config: any = router.config;
const firstConfig = config[1]._loadedConfig;
expect(firstConfig).toBeDefined();
expect(firstConfig.routes[0].path).toEqual('LoadedModule1');
const secondConfig = firstConfig.routes[0]._loadedConfig;
expect(secondConfig).toBeDefined();
expect(secondConfig.routes[0].path).toEqual('LoadedModule2');
})));
});
}); });
}); });

View File

@ -0,0 +1,129 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Component, NgModule, NgModuleFactoryLoader} from '@angular/core';
import {ComponentFixture, TestBed, fakeAsync, inject, tick} from '@angular/core/testing';
import {Router, RouterModule} from '../index';
import {PreloadAllModules, PreloadingStrategy, RouterPreloader} from '../src/router_preloader';
import {RouterTestingModule, SpyNgModuleFactoryLoader} from '../testing';
describe('RouterPreloader', () => {
@Component({template: ''})
class LazyLoadedCmp {
}
@Component({})
class BlankCmp {
}
describe('should preload configurations', () => {
@NgModule({
declarations: [LazyLoadedCmp],
imports: [RouterModule.forChild([{path: 'LoadedModule2', component: LazyLoadedCmp}])]
})
class LoadedModule2 {
}
@NgModule(
{imports: [RouterModule.forChild([{path: 'LoadedModule1', loadChildren: 'expected2'}])]})
class LoadedModule1 {
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([{path: 'lazy', loadChildren: 'expected'}])],
providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}]
});
});
it('should work',
fakeAsync(inject(
[NgModuleFactoryLoader, RouterPreloader, Router],
(loader: SpyNgModuleFactoryLoader, preloader: RouterPreloader, router: Router) => {
loader.stubbedModules = {expected: LoadedModule1, expected2: LoadedModule2};
preloader.preload().subscribe(() => {});
tick();
const c = router.config;
expect(c[0].loadChildren).toEqual('expected');
const loaded: any = (<any>c[0])._loadedConfig.routes;
expect(loaded[0].path).toEqual('LoadedModule1');
const loaded2: any = (<any>loaded[0])._loadedConfig.routes;
expect(loaded2[0].path).toEqual('LoadedModule2');
})));
});
describe('should not load configurations with canLoad guard', () => {
@NgModule({
declarations: [LazyLoadedCmp],
imports: [RouterModule.forChild([{path: 'LoadedModule1', component: LazyLoadedCmp}])]
})
class LoadedModule {
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes(
[{path: 'lazy', loadChildren: 'expected', canLoad: ['someGuard']}])],
providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}]
});
});
it('should work',
fakeAsync(inject(
[NgModuleFactoryLoader, RouterPreloader, Router],
(loader: SpyNgModuleFactoryLoader, preloader: RouterPreloader, router: Router) => {
loader.stubbedModules = {expected: LoadedModule};
preloader.preload().subscribe(() => {});
tick();
const c = router.config;
expect(!!((<any>c[0])._loadedConfig)).toBe(false);
})));
});
describe('should ignore errors', () => {
@NgModule({
declarations: [LazyLoadedCmp],
imports: [RouterModule.forChild([{path: 'LoadedModule1', component: LazyLoadedCmp}])]
})
class LoadedModule {
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([
{path: 'lazy1', loadChildren: 'expected1'}, {path: 'lazy2', loadChildren: 'expected2'}
])],
providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}]
});
});
it('should work',
fakeAsync(inject(
[NgModuleFactoryLoader, RouterPreloader, Router],
(loader: SpyNgModuleFactoryLoader, preloader: RouterPreloader, router: Router) => {
loader.stubbedModules = {expected2: LoadedModule};
preloader.preload().subscribe(() => {});
tick();
const c = router.config;
expect(!!((<any>c[0])._loadedConfig)).toBe(false);
expect(!!((<any>c[1])._loadedConfig)).toBe(true);
})));
});
});

View File

@ -9,10 +9,11 @@
import {Location, LocationStrategy} from '@angular/common'; import {Location, LocationStrategy} from '@angular/common';
import {MockLocationStrategy, SpyLocation} from '@angular/common/testing'; import {MockLocationStrategy, SpyLocation} from '@angular/common/testing';
import {Compiler, Injectable, Injector, ModuleWithProviders, NgModule, NgModuleFactory, NgModuleFactoryLoader} from '@angular/core'; import {Compiler, Injectable, Injector, ModuleWithProviders, NgModule, NgModuleFactory, NgModuleFactoryLoader} from '@angular/core';
import {Route, Router, RouterModule, RouterOutletMap, Routes, UrlSerializer, provideRoutes} from '@angular/router'; import {NoPreloading, PreloadAllModules, PreloadingStrategy, Route, Router, RouterModule, RouterOutletMap, Routes, UrlSerializer, provideRoutes} from '@angular/router';
import {ROUTER_PROVIDERS, ROUTES, flatten} from './private_import_router'; import {ROUTER_PROVIDERS, ROUTES, flatten} from './private_import_router';
/** /**
* @whatItDoes Allows to simulate the loading of ng modules in tests. * @whatItDoes Allows to simulate the loading of ng modules in tests.
* *
@ -109,7 +110,7 @@ export function setupTestingRouter(
UrlSerializer, RouterOutletMap, Location, NgModuleFactoryLoader, Compiler, Injector, ROUTES UrlSerializer, RouterOutletMap, Location, NgModuleFactoryLoader, Compiler, Injector, ROUTES
] ]
}, },
provideRoutes([]) {provide: PreloadingStrategy, useExisting: NoPreloading}, provideRoutes([])
] ]
}) })
export class RouterTestingModule { export class RouterTestingModule {