蒋海云个人博客,日拱一卒。 2017-10-28T16:28:49+08:00 jiang.haiyun#gmail.com Angular docs-测试 2017-10-28T00:00:00+08:00 Haiiiiiyun haiiiiiyun.github.io/angular-docs-testing 测试

工具和技术

技术 目的
Jasmine Jasmine 框架 可用来写基本测试,提供了在浏览器中运行测试的工具。
Angular 测试工作 可为测试创建 Angular 环境。
Karma karma test runner 适于在开发时编写和测试。可集成到开发过程和持续集成过程中。
Protractor 用来编写和运行 end-to-end(e2e) 测试。在 e2e 测试时,一个进程开启应用,另一个进行模块用户进行操作。

隔离的单元测试与 Angular 测试工具

适用于对管道和服务进行测试。而组件类通常与环境交互,通常要通过 TestBed 创建 Angular 测试环境。

首个 karma 测试

用 Jasmine 写的测试叫 specs,文件扩展名为 .spec.ts,例如:

//src/app/1st.spec.ts
describe('1st tests', () => {
  it('true is true', () => expect(true).toBe(true));
});

在项目目录下运行:

npm test

测试内联模板的组件

测试文件和组件文件一般放在相同的目录下,例如 src/app/banner-inline.component.ts 对应 src/app/banner-inline.component.spec.ts

//src/app/banner-inline.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-banner',
  template: '<h1></h1>'
})
export class BannerComponent {
  title = 'Test Tour of Heroes';
}
//src/app/banner-inline.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By }              from '@angular/platform-browser';
import { DebugElement }    from '@angular/core';

import { BannerComponent } from './banner-inline.component';

describe('BannerComponent (inline template)', () => {

  let comp:    BannerComponent;
  let fixture: ComponentFixture<BannerComponent>;
  let de:      DebugElement;
  let el:      HTMLElement;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [ BannerComponent ], // declare the test component
    });

    fixture = TestBed.createComponent(BannerComponent);

    comp = fixture.componentInstance; // BannerComponent test instance

    // query for the title <h1> by CSS element selector
    de = fixture.debugElement.query(By.css('h1'));
    el = de.nativeElement;
  });
});

通过 TestBed 创建一个测试的 @NgModule 类,其 metadata 可以通过 configureTestingModule 方法来设置。从而创建一个与生产环境隔离的 Angular 测试环境。

beforeEach 方法用来定义每个测试运行前都要运行的任务。

TestBed.createComponent() 用来创建组件实例,并返回一个 component test fixturecreateComponent() 方法调用后,TestBed 实例就不能再进行配置了。返回的 component test fixture 对象 ComponentFixture 实际上是一个被测试环境包围的组件实例,可从中抽取组件实例(fixture.componentInstance 和组件的 DOM 元素对象 (fixture.DebugElement)。

DebugElement 上使用 queryqueryAll 方法来过滤获取相关元素。

//src/app/banner-inline.component.spec.ts (tests)
it('should display original title', () => {
  fixture.detectChanges();
  expect(el.textContent).toContain(comp.title);
});

it('should display a different test title', () => {
  comp.title = 'Test Title';
  fixture.detectChanges();
  expect(el.textContent).toContain('Test Title');
});

fixture.detectChanges() 用来执行修改检测,即完成数据绑定等工作。生产环境下修改检测是自动触发的,而测试环境下一般需要手动触发。

自动触发

//src/app/banner.component.detect-changes.spec.ts (import)
import { ComponentFixtureAutoDetect } from '@angular/core/testing';


//src/app/banner.component.detect-changes.spec.ts (AutoDetect)
TestBed.configureTestingModule({
  declarations: [ BannerComponent ],
  providers: [
    { provide: ComponentFixtureAutoDetect, useValue: true }
  ]
})

//src/app/banner.component.detect-changes.spec.ts (AutoDetect Tests)
it('should display original title', () => {
  // Hooray! No `fixture.detectChanges()` needed
  expect(el.textContent).toContain(comp.title);
});

it('should still see original title after comp.title change', () => {
  const oldTitle = comp.title;
  comp.title = 'Test Title';
  // Displayed title is old because Angular didn't hear the change :(
  expect(el.textContent).toContain(oldTitle);
});

it('should display updated title after detectChanges', () => {
  comp.title = 'Test Title';
  fixture.detectChanges(); // detect changes explicitly
  expect(el.textContent).toContain(comp.title);
});

这种自动触发也只由异步事件(如 promise resolution, timers, DOM events) 等触发,而同步的属性修改不能触发,还是要通过 detectChagnes() 手动触发。

测试有外部模板文件的组件

首个异步 beforeEach

需要 2 个 beforeEach,第 1 个使用异步方式,使 Angular 模板编译器有时间读取文件:

//src/app/banner.component.spec.ts (first beforeEach)
import { async } from '@angular/core/testing';

// async beforeEach
beforeEach(async(() => {
  TestBed.configureTestingModule({
    declarations: [ BannerComponent ], // declare the test component
  })
  .compileComponents();  // compile template and css
}));

async() 将作为其参数的任务安排到一个特殊的 async test zone 中执行。TestBed 调用异步操作 compileComponents() 后就不能再进行配置了。

测试有依赖的组件

为依赖的服务创建替身,注入时不直接注入原来的服务,通过 useValue 注入该替身:

//src/app/welcome.component.spec.ts
userServiceStub = {
  isLoggedIn: true,
  user: { name: 'Test User'}
};

//src/app/welcome.component.spec.ts
TestBed.configureTestingModule({
   declarations: [ WelcomeComponent ],
// providers:    [ UserService ]  // NO! Don't provide the real service!
                                  // Provide a test-double instead
   providers:    [ {provide: UserService, useValue: userServiceStub } ]
});

获取注入服务对象的最安全方式是通过组件上的注入器获取:

//WelcomeComponent's injector
// UserService actually injected into the component
userService = fixture.debugElement.injector.get(UserService);

Test.get(userService) 是通过根注入器获取的。

测试依赖异步服务的组件

通过 Jasmine spy 来替换真实服务上的方法

注入的是真实的服务,但是调用的服务方法被替换:

// src/app/shared/twain.component.ts
@Component({
  selector: 'twain-quote',
  template: '<p class="twain"><i></i></p>'
})
export class TwainComponent  implements OnInit {
  intervalId: number;
  quote = '...';
  constructor(private twainService: TwainService) { }

  ngOnInit(): void {
    this.twainService.getQuote().then(quote => this.quote = quote);
  }
}


//src/app/shared/twain.component.spec.ts (setup)
beforeEach(() => {
  TestBed.configureTestingModule({
     declarations: [ TwainComponent ],
     providers:    [ TwainService ],
  });

  fixture = TestBed.createComponent(TwainComponent);
  comp    = fixture.componentInstance;

  // TwainService actually injected into the component
  twainService = fixture.debugElement.injector.get(TwainService);

  // Setup spy on the `getQuote` method
  spy = spyOn(twainService, 'getQuote')
        .and.returnValue(Promise.resolve(testQuote));

  // Get the Twain quote element by CSS selector (e.g., by class name)
  de = fixture.debugElement.query(By.css('.twain'));
  el = de.nativeElement;
});

//src/app/shared/twain.component.spec.ts (tests)
it('should not show quote before OnInit', () => {
  expect(el.textContent).toBe('', 'nothing displayed');
  // 在 OnInit 之前没有对 spy 上的 getQuote 进行调用
  expect(spy.calls.any()).toBe(false, 'getQuote not yet called');
});

it('should still not show quote after component initialized', () => {
  fixture.detectChanges();
  // getQuote service is async => still has not returned with quote
  expect(el.textContent).toBe('...', 'no quote yet');
  // 在 OnInit 之后对 spy 上的 getQuote 进行了调用,
  // 但由于是异步操作,只返回一个 resolved Promise,
  // 需要等待一个 tick 后才能使用返回值
  // 因此测试也必须使用异步方式
  expect(spy.calls.any()).toBe(true, 'getQuote called');
});

//异步方式的测试,async() 将包裹的测试任务安排到一个特殊的 async test zone 中执行。
it('should show quote after getQuote promise (async)', async(() => {
  fixture.detectChanges();

  // `ComponentFixture.whenStable` 方法也返回一个自己的 Promise,
  // 当 `getQuote` 的 Promise 结束时会 resolve,也就是当该测试中的
  // 所有异步活动都结束时。  
  fixture.whenStable().then(() => { // wait for async getQuote
    fixture.detectChanges();        // update view with quote
    expect(el.textContent).toBe(testQuote);
  });
}));

//另一种异步方式的测试,fakeAsync() 也将包裹的测试任务安排到一个特殊的 fakeAsync test zone 中执行。
//使用 fakeAsync() 能使其中的测试代码以同步的方式编写。
// 而其中的 `tick()`(只能在 `fackAsync` 异步体中调用)功能等同于 `fixture.whenStable`
it('should show quote after getQuote promise (fakeAsync)', fakeAsync(() => {
  fixture.detectChanges();
  tick();                  // wait for async getQuote
  fixture.detectChanges(); // update view with quote
  expect(el.textContent).toBe(testQuote);
}));

也可以通过 jasmine.done 回调函数来手动编写异步测试:

//src/app/shared/twain.component.spec.ts (done test)
it('should show quote after getQuote promise (done)', (done: any) => {
  fixture.detectChanges();

  // get the spy promise and wait for it to resolve
  spy.calls.mostRecent().returnValue.then(() => {
    fixture.detectChanges(); // update view with quote
    expect(el.textContent).toBe(testQuote);
    done();
  });
});

测试有输入和输出属性的组件

beforeEach 中直接设置输入属性,在测试用例中通过 triggerEventHandler 来触发组件的发出属性:

//src/app/dashboard/dashboard-hero.component.spec.ts (setup)
// async beforeEach
beforeEach( async(() => {
  TestBed.configureTestingModule({
    declarations: [ DashboardHeroComponent ],
  })
  .compileComponents(); // compile template and css
}));

// synchronous beforeEach
beforeEach(() => {
  fixture = TestBed.createComponent(DashboardHeroComponent);
  comp    = fixture.componentInstance;
  heroEl  = fixture.debugElement.query(By.css('.hero')); // find hero element

  // pretend that it was wired to something that supplied a hero
  expectedHero = new Hero(42, 'Test Name');
  comp.hero = expectedHero;
  fixture.detectChanges(); // trigger initial data binding
});


//src/app/dashboard/dashboard-hero.component.spec.ts (name test)
it('should display hero name', () => {
  const expectedPipedName = expectedHero.name.toUpperCase();
  expect(heroEl.nativeElement.textContent).toContain(expectedPipedName);
});

//src/app/dashboard/dashboard-hero.component.spec.ts (click test)
it('should raise selected event when clicked', () => {
  let selectedHero: Hero;
  //组件的输出属性是一个 `EventEmitter`,像它注册就相当于对组件事件的绑定
  comp.selected.subscribe((hero: Hero) => selectedHero = hero);

  //DebugElement.triggerEventHandler() 触发事件,第 2 个参数是传给
  //事件处理函数的参数对象
  heroEl.triggerEventHandler('click', null);
  expect(selectedHero).toBe(expectedHero);
});

触发事件

RouterLink 指令的点击事件处理函数需要传入一个含 button 属性的参数对象,用来表示鼠标哪个按键按下。可将触发所有元素上的点击封装成一个通用函数如下:

//testing/index.ts (click helper)
/** Button events to pass to `DebugElement.triggerEventHandler` for RouterLink event handler */
export const ButtonClickEvents = {
   left:  { button: 0 },
   right: { button: 2 }
};

/** Simulate element click. Defaults to mouse left-button click event. */
export function click(el: DebugElement | HTMLElement, eventObj: any = ButtonClickEvents.left): void {
  if (el instanceof HTMLElement) {
    el.click();
  } else {
    el.triggerEventHandler('click', eventObj);
  }
}

对在一个托管组件中的组件进行测试

先定义一个测试用的托管组件,进来简化实际的托管组件。

//src/app/dashboard/dashboard-hero.component.spec.ts (test host)
@Component({
  template: `
    <dashboard-hero  [hero]="hero"  (selected)="onSelected($event)"></dashboard-hero>`
})
class TestHostComponent {
  hero = new Hero(42, 'Test Name');
  selectedHero: Hero;
  onSelected(hero: Hero) { this.selectedHero = hero; }
}


//rc/app/dashboard/dashboard-hero.component.spec.ts (test host setup)
beforeEach( async(() => {
  TestBed.configureTestingModule({
    declarations: [ DashboardHeroComponent, TestHostComponent ], // declare both
  }).compileComponents();
}));

beforeEach(() => {
  // create TestHostComponent instead of DashboardHeroComponent
  fixture  = TestBed.createComponent(TestHostComponent);
  testHost = fixture.componentInstance;
  heroEl   = fixture.debugElement.query(By.css('.hero')); // find hero
  fixture.detectChanges(); // trigger initial data binding
});


//src/app/dashboard/dashboard-hero.component.spec.ts (test-host)
it('should display hero name', () => {
  const expectedPipedName = testHost.hero.name.toUpperCase();
  expect(heroEl.nativeElement.textContent).toContain(expectedPipedName);
});

it('should raise selected event when clicked', () => {
  click(heroEl);
  // selected hero should be the same data bound hero
  expect(testHost.selectedHero).toBe(testHost.hero);
});

测试一个带路由的组件

这些组件通常会注入 Router,必须只使用 Router 中的某些方法,因此可以为 Router 创建一个 RouterStub 来注入:

//src/app/dashboard/dashboard.component.ts (constructor)
constructor(
  private router: Router,
  private heroService: HeroService) {
}

//src/app/dashboard/dashboard.component.ts (goToDetail)
//该组件只使用了 `Router` 的 `navigateByUrl` 功能
gotoDetail(hero: Hero) {
  let url = `/heroes/${hero.id}`;
  this.router.navigateByUrl(url);
}


//src/app/dashboard/dashboard.component.spec.ts (Router Stub)
//创建一个 RouterStub
class RouterStub {
  navigateByUrl(url: string) { return url; }
}


//src/app/dashboard/dashboard.component.spec.ts (compile and create)
beforeEach( async(() => {
  TestBed.configureTestingModule({
    providers: [
      { provide: HeroService, useClass: FakeHeroService },
      { provide: Router,      useClass: RouterStub }
    ]
  })
  .compileComponents().then(() => {
    fixture = TestBed.createComponent(DashboardComponent);
    comp = fixture.componentInstance;
  });
  

//src/app/dashboard/dashboard.component.spec.ts (navigate test)
it('should tell ROUTER to navigate when hero clicked',
  inject([Router], (router: Router) => { // ...

  const spy = spyOn(router, 'navigateByUrl');

  heroClick(); // trigger click on first inner <div class="hero">

  // args passed to router.navigateByUrl()
  const navArgs = spy.calls.first().args[0];

  // expecting to navigate to id of the component's first hero
  const id = comp.heroes[0].id;
  expect(navArgs).toBe('/heroes/' + id,
    'should nav to HeroDetail for first hero');
}));

inject() 函数来自 Angular 测试工具集,通过它注入的服务,在测试中可进行修改、spy on 和处理。而通过 inject() 获取的服务都来自 TestBed 注入器,不能调整为来自组件的注入器。

对带参数路由的组件进行测试

路由器会将参数推到 ActivatedRoute.params Observable 属性上,组件通过注入 ActivatedRoute,在 ngOnInit 中获取并处理参数:

//src/app/hero/hero-detail.component.ts (ngOnInit)
ngOnInit(): void {
  // get hero when `id` param changes
  this.route.paramMap.subscribe(p => this.getHero(p.has('id') && p.get('id')));
}

测试时创建一个 ActivatedRouteStub,为测试提供参数值。下面是一个通用的 ActivatedRouteStub:

//testing/router-stubs.ts (ActivatedRouteStub)
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { convertToParamMap, ParamMap } from '@angular/router';

@Injectable()
export class ActivatedRouteStub {

  // ActivatedRoute.paramMap is Observable
  private subject = new BehaviorSubject(convertToParamMap(this.testParamMap));
  paramMap = this.subject.asObservable();

  // Test parameters
  private _testParamMap: ParamMap;
  get testParamMap() { return this._testParamMap; }
  set testParamMap(params: {}) {
    this._testParamMap = convertToParamMap(params);
    this.subject.next(this._testParamMap);
  }

  // ActivatedRoute.snapshot.paramMap
  get snapshot() {
    return { paramMap: this.testParamMap };
  }
}

通过对 testParamMap 赋值,模拟 URL 参数:

//src/app/hero/hero-detail.component.spec.ts
//对 id 参数有效的情况进行测试
describe('when navigate to existing hero', () => {
  let expectedHero: Hero;

  beforeEach( async(() => {
    expectedHero = firstHero;
    activatedRoute.testParamMap = { id: expectedHero.id };
    createComponent();
  }));

  it('should display that hero\'s name', () => {
    expect(page.nameDisplay.textContent).toBe(expectedHero.name);
  });
});


//对 id 参数无效的情况进行测试,此时会重定向到 List 页面
describe('when navigate to non-existent hero id', () => {
  beforeEach( async(() => {
    activatedRoute.testParamMap = { id: 99999 };
    createComponent();
  }));

  it('should try to navigate back to hero list', () => {
    expect(page.gotoSpy.calls.any()).toBe(true, 'comp.gotoList called');
    expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called');
  });
});

// 对无 id 值的情况进行测试
describe('when navigate with no hero id', () => {
  beforeEach( async( createComponent ));

  it('should have hero.id === 0', () => {
    expect(comp.hero.id).toBe(0);
  });

  it('should display empty hero name', () => {
    expect(page.nameDisplay.textContent).toBe('');
  });
});

使用一个 page 对象来简化设置

组件是一个视图,将视图中包含的所有测试时要用到的元素全部抽取到一个 Page 中,方便对各元素的存取,例如上例中测试的组件视图可组织为:

//src/app/hero/hero-detail.component.spec.ts (Page)
class Page {
  gotoSpy:      jasmine.Spy;
  navSpy:       jasmine.Spy;

  saveBtn:      DebugElement;
  cancelBtn:    DebugElement;
  nameDisplay:  HTMLElement;
  nameInput:    HTMLInputElement;

  constructor() {
    const router = TestBed.get(Router); // get router from root injector
    this.gotoSpy = spyOn(comp, 'gotoList').and.callThrough();
    this.navSpy  = spyOn(router, 'navigate');
  }

  /** Add page elements after hero arrives */
  addPageElements() {
    if (comp.hero) {
      // have a hero so these elements are now in the DOM
      const buttons    = fixture.debugElement.queryAll(By.css('button'));
      this.saveBtn     = buttons[0];
      this.cancelBtn   = buttons[1];
      this.nameDisplay = fixture.debugElement.query(By.css('span')).nativeElement;
      this.nameInput   = fixture.debugElement.query(By.css('input')).nativeElement;
    }
  }
}

再创建一个 createComponent() 方法来创建一个组件及其 page 对象,并当组件所需的异步数据返回时填入 page 中:

//src/app/hero/hero-detail.component.spec.ts (createComponent)
/** Create the HeroDetailComponent, initialize it, set test variables  */
function createComponent() {
  fixture = TestBed.createComponent(HeroDetailComponent);
  comp    = fixture.componentInstance;
  page    = new Page();

  // 1st change detection triggers ngOnInit which gets a hero
  fixture.detectChanges();
  return fixture.whenStable().then(() => {
    // 2nd change detection displays the async-fetched hero
    fixture.detectChanges();
    page.addPageElements();
  });
}

下面是一些测试举例:

//src/app/hero/hero-detail.component.spec.ts (selected tests)
it('should display that hero\'s name', () => {
  expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});

it('should navigate when click cancel', () => {
  click(page.cancelBtn);
  expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called');
});

it('should save when click save but not navigate immediately', () => {
  // Get service injected into component and spy on its`saveHero` method.
  // It delegates to fake `HeroService.updateHero` which delivers a safe test result.
  const hds = fixture.debugElement.injector.get(HeroDetailService);
  const saveSpy = spyOn(hds, 'saveHero').and.callThrough();

  click(page.saveBtn);
  expect(saveSpy.calls.any()).toBe(true, 'HeroDetailService.save called');
  expect(page.navSpy.calls.any()).toBe(false, 'router.navigate not called');
});

it('should navigate when click save and save resolves', fakeAsync(() => {
  click(page.saveBtn);
  tick(); // wait for async save to complete
  expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called');
}));

it('should convert hero name to Title Case', () => {
  const inputName = 'quick BROWN  fox';
  const titleCaseName = 'Quick Brown  Fox';

  // simulate user entering new name into the input box
  page.nameInput.value = inputName;

  // dispatch a DOM event so that Angular learns of input value change.
  page.nameInput.dispatchEvent(newEvent('input'));

  // Tell Angular to update the output span through the title pipe
  fixture.detectChanges();

  expect(page.nameDisplay.textContent).toBe(titleCaseName);
});

重载组件的提供者

通过 TestBed.overrideComponent() 来重载:

//src/app/hero/hero-detail.component.spec.ts (Override setup)
beforeEach( async(() => {
  TestBed.configureTestingModule({
    imports:   [ HeroModule ],
    providers: [
      { provide: ActivatedRoute, useValue: activatedRoute },
      { provide: Router,         useClass: RouterStub},
    ]
  })

  // Override component's own provider
  .overrideComponent(HeroDetailComponent, {
    set: {
      providers: [
        { provide: HeroDetailService, useClass: HeroDetailServiceSpy }
      ]
    }
  })

  .compileComponents();
}));

重载时组件的 metadata 可以添加,删除和重设置,参数类型为:

type MetadataOverride = {
    add?: T;
    remove?: T;
    set?: T;
  };

替换服务实现为:

//src/app/hero/hero-detail.component.spec.ts (HeroDetailServiceSpy)
class HeroDetailServiceSpy {
  testHero = new Hero(42, 'Test Hero');

  getHero = jasmine.createSpy('getHero').and.callFake(
    () => Promise
      .resolve(true)
      .then(() => Object.assign({}, this.testHero))
  );

  saveHero = jasmine.createSpy('saveHero').and.callFake(
    (hero: Hero) => Promise
      .resolve(true)
      .then(() => Object.assign(this.testHero, hero))
  );
}

测试为:

//src/app/hero/hero-detail.component.spec.ts (override tests)
let hdsSpy: HeroDetailServiceSpy;

beforeEach( async(() => {
  createComponent();
  // get the component's injected HeroDetailServiceSpy
  hdsSpy = fixture.debugElement.injector.get(HeroDetailService) as any;
}));

it('should have called `getHero`', () => {
  expect(hdsSpy.getHero.calls.count()).toBe(1, 'getHero called once');
});

it('should display stub hero\'s name', () => {
  expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);
});

it('should save stub hero change', fakeAsync(() => {
  const origName = hdsSpy.testHero.name;
  const newName = 'New Name';

  page.nameInput.value = newName;
  page.nameInput.dispatchEvent(newEvent('input')); // tell Angular

  expect(comp.hero.name).toBe(newName, 'component hero has new name');
  expect(hdsSpy.testHero.name).toBe(origName, 'service hero unchanged before save');

  click(page.saveBtn);
  expect(hdsSpy.saveHero.calls.count()).toBe(1, 'saveHero called once');

  tick(); // wait for async save to complete
  expect(hdsSpy.testHero.name).toBe(newName, 'service hero has new name after save');
  expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called');
}));

TestBed 中也提供了 overrideDirective, overrideModule, overridePipe 方法。

测试 RouterOutlet 组件

这种组件一般有一组导航及一个 <router-outlet> 元素。要为 RouterLink 指令创建 RouterLinkStubDirective,以测试链接是否正确。

//src/app/app.component.spec.ts (Stub Setup)
beforeEach( async(() => {
  TestBed.configureTestingModule({
    declarations: [
      AppComponent,
      BannerComponent, WelcomeStubComponent,
      RouterLinkStubDirective, RouterOutletStubComponent
    ]
  })

  .compileComponents()
  .then(() => {
    fixture = TestBed.createComponent(AppComponent);
    comp    = fixture.componentInstance;
  });
}));


//testing/router-stubs.ts (RouterLinkStubDirective)
@Directive({
  selector: '[routerLink]',
  host: {
    '(click)': 'onClick()'
  }
})
export class RouterLinkStubDirective {
  @Input('routerLink') linkParams: any;
  navigatedTo: any = null;

  onClick() {
    this.navigatedTo = this.linkParams;
  }
}

//src/app/app.component.spec.ts (test setup)
beforeEach(() => {
  // trigger initial data binding
  fixture.detectChanges();

  // find DebugElements with an attached RouterLinkStubDirective
  linkDes = fixture.debugElement
    .queryAll(By.directive(RouterLinkStubDirective));

  // get the attached link directive instances using the DebugElement injectors
  links = linkDes
    .map(de => de.injector.get(RouterLinkStubDirective) as RouterLinkStubDirective);
});

RouterLinkStubDirective 指令 metadata 中的 host 属性将指令托管元素(如 <a>) 上的点击与 onClick() 关联。

元素即可通过 CSS 选择子查询,也可用 By.directive 即指令查询到。因为 Angular 会将组件中的关联指令对象都放入到组件的注入器上,因此可通过注入到获致指令对象。

//src/app/app.component.spec.ts (selected tests)
it('can get RouterLinks from template', () => {
  expect(links.length).toBe(3, 'should have 3 links');
  expect(links[0].linkParams).toBe('/dashboard', '1st link should go to Dashboard');
  expect(links[1].linkParams).toBe('/heroes', '1st link should go to Heroes');
});

it('can click Heroes link in template', () => {
  const heroesLinkDe = linkDes[1];
  const heroesLink = links[1];

  expect(heroesLink.navigatedTo).toBeNull('link should not have navigated yet');

  heroesLinkDe.triggerEventHandler('click', null);
  fixture.detectChanges();

  expect(heroesLink.navigatedTo).toBe('/heroes');
});

测试环境配置文件

  • karma.conf.js: 指定了使用哪些插件,加载哪个应用及测试文件,使用哪个浏览器,及如何报道结果。它还会加载另外 3 个设置文件:systemjs.config.js, systemjs.config.extras.js, karma-test-shim.js
  • karma-test.shim.js: 用于为 Angular 测试环境作准备,同时加载 karma 本身,同时加载 systemjs.config.js
  • systemjs.config.js: SystemJS 加载应用和测试文件。该脚本告诉 SystemJS 到哪里及如何加载。
  • system.config.extras.js: 可选的额外配置。

参考

]]>
Angular docs-路由 2017-10-23T00:00:00+08:00 Haiiiiiyun haiiiiiyun.github.io/angular-docs-router 路由

Angular 的 Router 借鉴了浏览器的导航模型。它将浏览器的 URL 解析为导航到一个视图的指令。它能将可选的参数传给支持的视图组件以呈现特定的内容。可将路由器绑定到页面的链接中,当用户点击时导航到相应视图。当用户点击按钮、选择一个下拉框等时可用命令式进行导航。路由器的操作事件也被同步到浏览器的浏览器历史中,从而可以使用浏览器中的按钮进行前进后退操作。

基础

需要路由功能的应用在其 index.html<head> 中要添加 <base> 元素,用来告诉路由器如何组合导航 URL。

如果 app 目录就是应用的根目录,则设置为:

<!--src/index.html (base-href)-->
<base href="/">

导入路由器功能

//src/app/app.module.ts (import)
import { RouterModule, Routes } from '@angular/router';

配置

一个有路由功能的应用中,只有一个单例的 Router 服务实例。当 URL 有变化时,路由器将从中匹配到相应的 Route 来呈现相应组件。

下例通过 RouterModule.forRoot 方法在根模块中定义了 4 个路由:

//src/app/app.module.ts (excerpt)
const appRoutes: Routes = [
  { path: 'crisis-center', component: CrisisListComponent },
  { path: 'hero/:id',      component: HeroDetailComponent },
  {
    path: 'heroes',
    component: HeroListComponent,
    data: { title: 'Heroes List' }
  },
  { path: '',
    redirectTo: '/heroes',
    pathMatch: 'full'
  },
  { path: '**', component: PageNotFoundComponent }
];

@NgModule({
  imports: [
    RouterModule.forRoot(
      appRoutes,
      { enableTracing: true } // <-- debugging purposes only
    )
    // other imports here
  ],
  ...
})
export class AppModule { }

每个 Route 将一个 URL path 映射到一个组件。path 中没有 / 前缀。

:id 表示路由参数,在 URL 像 /hero/42 中,id 参数值为 42。

data 属性中可以保存任意数据,以与该路由关联。这些数据可在每个激活的路由中访问。因此可用来保存页面标题等只读数据。

重定向路由需要 pathMatch 属性说明匹配方法,值为 full 是完全匹配,值为 prefix 表示是前缀匹配。

** 能匹配任何 URL,而 Django 中用的是 *

当在 RouterModule.forRoot() 的第 2 个参数对象中传入 enableTracing:true 后,导航生命周期中发生的所有事件都会显示到浏览器的 console 中,便于调试。

Router outlet

该标签放在主视图页面的 HTML 中,当用匹配的路由要显示时,将显示在该标签位置之后:

<router-outlet></router-outlet>
<!-- Routed views go here -->

制作路由器链接

//src/app/app.component.ts (template)
template: `
  <h1>Angular Router</h1>
  <nav>
    <a routerLink="/crisis-center" routerLinkActive="active">Crisis Center</a>
    <a routerLink="/heroes" routerLinkActive="active">Heroes</a>
  </nav>
  <router-outlet></router-outlet>
`

RouterLink 指令使得路由器能控制该元素,进行航导。URL 的 query parameters 参数可以通过 [queryParams] 绑定一个键值对对象进行,而 URL fragment 参数可以通过 [fragment] 绑定一个字符串进行。

RouterLinkActive 指令用来指定当链接状态为活跃或不活跃时要切换的 CSS 类名。它基于当前 RouterState 中的值来判断当前活跃的 RouterLink,并且默认将当前活跃 RouterLink 及其子一并设置为活跃。如果不想对其子链接产生作用,可以绑定 [routerLinkActiveOptions]={exact:true}

路由器状态

当导航完成后,路由器会构建一个 ActivedRoute 对象树,用来表示路由器的当前状态。可以通过注入 Router 服务,访问其 routerState 属性来访问路由器的当前状态(RouterState)。

RouterState 中的 ActivatedRoute 都有进行上下遍历相关路由信息的函数。

Activated route

路由的路径和参数可以通过注入 ActivatedRoute 服务来获取。该服务中包含如下信息:

属性 描述
url 一个包含路由路径的 Observable,路由路径中的所有部分以字符串列表表示。
data 一个包含该路由中 data 对象及其它数据的 Observable
paramMap 一个包含该路由中必需和可选参数信息的 mapObservable
queryParamMap 类似 paramMap,但是是 query parameters
fragment 一个包含对所有路由都可用的 URL fragment 的 Observable
outlet 用来呈现该路由的 RouterOutlet 的名字,未命令的自动命令为 primary
routeConfig 该路由的配置对象,包含 origin path。
parent 该路由的父 ActivatedRoute
firstChild 该路由的第一个子 ActivatedRoute
children 该路由的所有子 ActivatedRoute

路由器事件

Router 在导航过程中通过 Router.events 属性发送出导航事件,事件表如下:

路由器事件 描述
NavigationStart 导航开始时
RoutesRecognized 当路由器解析 URL 并匹配到路由时
RouteConfigLoadStart 在路由器加载一个路由配置信息前
RouterConfigLoadEnd 当路由完成加载后
NavigationEnd 导航成功完成后
NavigationCancel 导航取消后
NavigationError 导航由于未知错误而失败后

base href 和 history.pushState

路由器使用浏览器的 history.pushState 来实现导航。从而我们可以使用应用内 URL(和服务端 URL 无区别)。pushState 技术能对浏览器的地址栏和历史记录进行修改,并不向服务端发送请求。

浏览器需要使用 <base href> 值作为相对路径的前缀来引用 CSS,脚本和图片文件。

专门路由的模块 routing module

一般将应用的路由信息写在独立文件 app/app-routing.module.ts 中,将 RouterModule.forRoot() 这该模块中调用。

依照惯例,创建类名 AppRoutingModule,export,使它能在 AppModule 中使用。

例如:

//src/app/app-routing.module.ts
import { NgModule }              from '@angular/core';
import { RouterModule, Routes }  from '@angular/router';

import { CrisisListComponent }   from './crisis-list.component';
import { HeroListComponent }     from './hero-list.component';
import { PageNotFoundComponent } from './not-found.component';

const appRoutes: Routes = [
  { path: 'crisis-center', component: CrisisListComponent },
  { path: 'heroes',        component: HeroListComponent },
  { path: '',   redirectTo: '/heroes', pathMatch: 'full' },
  { path: '**', component: PageNotFoundComponent }
];

@NgModule({
  imports: [
    RouterModule.forRoot(
      appRoutes,
      { enableTracing: true } // <-- debugging purposes only
    )
  ],
  exports: [
    RouterModule
  ]
})
export class AppRoutingModule {}

之后,根模块 app.module.ts 中只需导入 AppRoutingModule 来替换 RouterModule.forRoot 即可:

//src/app/app.module.ts
import { NgModule }       from '@angular/core';
import { BrowserModule }  from '@angular/platform-browser';
import { FormsModule }    from '@angular/forms';

import { AppComponent }     from './app.component';
import { AppRoutingModule } from './app-routing.module';

import { CrisisListComponent }   from './crisis-list.component';
import { HeroListComponent }     from './hero-list.component';
import { PageNotFoundComponent } from './not-found.component';

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    AppRoutingModule
  ],
  declarations: [
    AppComponent,
    HeroListComponent,
    CrisisListComponent,
    PageNotFoundComponent
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }

功能模块中的路由功能

一般将相关的业务功能封装在独立功能模块中,例如将有关 hero 管理的功能全部组织到 src/app/heroes 目录下。并将该模块中所有的组件、服务等都组织成一个模块,例如:

//src/app/heroes/heroes.module.ts (pre-routing)
import { NgModule }       from '@angular/core';
import { CommonModule }   from '@angular/common';
import { FormsModule }    from '@angular/forms';

import { HeroListComponent }    from './hero-list.component';
import { HeroDetailComponent }  from './hero-detail.component';

import { HeroService } from './hero.service';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
  ],
  declarations: [
    HeroListComponent,
    HeroDetailComponent
  ],
  providers: [ HeroService ]
})
export class HeroesModule {}

而功能模块中的路由配置信息一般也独立成一个文件,例如这里放在 heroes-routing.module.ts 中,但这里只能调用 RouterModule.forChild() 来配置路由,不能调用 RouterModule.forRoot(),例如:

//src/app/heroes/heroes-routing.module.ts
import { NgModule }             from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

import { HeroListComponent }    from './hero-list.component';
import { HeroDetailComponent }  from './hero-detail.component';

const heroesRoutes: Routes = [
  { path: 'heroes',  component: HeroListComponent },
  { path: 'hero/:id', component: HeroDetailComponent }
];

@NgModule({
  imports: [
    RouterModule.forChild(heroesRoutes)
  ],
  exports: [
    RouterModule
  ]
})
export class HeroRoutingModule { }

和将 appRootingModule 添加到 AppModule 模块类似,将功能模块中的路由信息也添加到功能模块中,例如:

//src/app/heroes/heroes.module.ts
import { NgModule }       from '@angular/core';
import { CommonModule }   from '@angular/common';
import { FormsModule }    from '@angular/forms';

import { HeroListComponent }    from './hero-list.component';
import { HeroDetailComponent }  from './hero-detail.component';

import { HeroService } from './hero.service';

import { HeroRoutingModule } from './heroes-routing.module';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    HeroRoutingModule
  ],
  declarations: [
    HeroListComponent,
    HeroDetailComponent
  ],
  providers: [ HeroService ]
})
export class HeroesModule {}

之后,在 AppRoutingModule 中去除有关 heroes 的路由信息,在 AppModule 中通过 imports 列表导入功能模块,这样功能模块中的路由信息会自动合并到应用的路由信息中。那么 imports 中功能模块的导入顺序就很重要,因为路由顺序以该顺序组织。

例如:

//src/app/app.module.ts
import { NgModule }       from '@angular/core';
import { BrowserModule }  from '@angular/platform-browser';
import { FormsModule }    from '@angular/forms';

import { AppComponent }     from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { HeroesModule }     from './heroes/heroes.module';

import { CrisisListComponent }   from './crisis-list.component';
import { PageNotFoundComponent } from './not-found.component';

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    HeroesModule,
    AppRoutingModule
  ],
  declarations: [
    AppComponent,
    CrisisListComponent,
    PageNotFoundComponent
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }

路由参数

例如路由定义: { path: 'hero/:id', component: HeroDetailComponent },对应的 URL 应该像 localhost/hero/15。因此在模板中构建路由链接时,要以数组形式传入 path 和 param:['/hero', hero.id] // { 15 }

路由器会从 URL 中抽取出路由参数 (id:15),并通过 ActivatedRoute 服务提供级目标组件。

下面是组件抽取参数进行处理的例如:

import { Router, ActivatedRoute, ParamMap } from '@angular/router';
import 'rxjs/add/operator/switchMap';


//src/app/heroes/hero-detail.component.ts (constructor)
constructor(
  private route: ActivatedRoute,
  private router: Router,
  private service: HeroService
) {}

//src/app/heroes/hero-detail.component.ts (ngOnInit)
ngOnInit() {
  this.hero$ = this.route.paramMap
    .switchMap((params: ParamMap) =>
      this.service.getHero(params.get('id')));
}

HeroService 返回一个 Observable<Hero>,故用 switchMap 操作符对其扁平化。switchMap 还能取消之前进行中的请求。当用户用不能的 id 重新导航到该路由时,switchMap 会放弃旧的请求并返回新的 id 对应的返回值。

由于返回值是一个 Observable,在模板中要用 AsyncPipe 进行处理。

ParamMap

是一个 Observable,因此其包含的路由参数映射值在组件使用过程中能更新。当用户用不能的参数值访问相同的组件时,路由器可重用该组件实例(如访问 “上一条”,“下一条”)。

ngOnInit 只在每个组件初始化时调用一次,故只能通过 paramMapObservable 性质解决。

如果不想重用组件(即每次都重新创建),则可通过 ActivatedRoutesnapshot 属性获取当前的参数值,这是一种 no-observable 的方式。

//src/app/heroes/hero-detail.component.ts (ngOnInit snapshot)
ngOnInit() {
  let id = this.route.snapshot.paramMap.get('id');

  this.hero$ = this.service.getHero(id);
}

后退:用命令式调用导航

//src/app/heroes/hero-detail.component.ts (excerpt)
gotoHeroes() {
  this.router.navigate(['/heroes']);
}

可选路由参数

后退时,可传入一个可选路由参数,用来表示从哪个页面返回。可选参数不需要在路由定义的 URL 模块中匹配,它通过独立的对象加入:

//src/app/heroes/hero-detail.component.ts (go to heroes)
gotoHeroes(hero: Hero) {
  let heroId = hero ? hero.id : null;
  // Pass along the hero id if available
  // so that the HeroList component can select that hero.
  // Include a junk 'foo' property for fun.
  this.router.navigate(['/heroes', { id: heroId, foo: 'foo' }]);
}

可选路由参数在 URL 中是以 ; 分隔的,如: localhost:3000/heroes;id=15;foo=foo

而在目标组件中,抽取可选参数和必要参数的方法是一样的,如:

//src/app/heroes/hero-list.component.ts
import { ActivatedRoute, ParamMap } from '@angular/router';
import 'rxjs/add/operator/switchMap';
import { Observable } from 'rxjs/Observable';

//src/app/heroes/hero-list.component.ts (constructor and ngOnInit)
export class HeroListComponent implements OnInit {
  heroes$: Observable<Hero[]>;

  private selectedId: number;

  constructor(
    private service: HeroService,
    private route: ActivatedRoute
  ) {}

  ngOnInit() {
    this.heroes$ = this.route.paramMap
      .switchMap((params: ParamMap) => {
        // (+) before `params.get()` turns the string into a number
        this.selectedId = +params.get('id');
        return this.service.getHeroes();
      });
  }
}

为路由组件添加动画

创建文件 /src/app/animations.ts:

//src/app/animations.ts (excerpt)
import { animate, AnimationEntryMetadata, state, style, transition, trigger } from '@angular/core';

// Component transition animations
export const slideInDownAnimation: AnimationEntryMetadata =
  trigger('routeAnimation', [
    state('*',
      style({
        opacity: 1,
        transform: 'translateX(0)'
      })
    ),
    transition(':enter', [
      style({
        opacity: 0,
        transform: 'translateX(-100%)'
      }),
      animate('0.2s ease-in')
    ]),
    transition(':leave', [
      animate('0.5s ease-out', style({
        opacity: 0,
        transform: 'translateY(100%)'
      }))
    ])
  ]);
  • 创建了一个名为 slideInDownAnimation 常量,指向名为 routeAnimation 的 animation trigger,给需要动画效果的组件引用。
  • 定义了 2 个转变效果,进入时(:enter) 是从左到右,离开时(:leave) 是向下。

在目标组件中,通过 @HostBing() 为组件的托管元素设置动画名及样式:

//hero-detail.component.ts
import 'rxjs/add/operator/switchMap';
import { Component, OnInit, HostBinding } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Router, ActivatedRoute, ParamMap } from '@angular/router';

import { slideInDownAnimation } from '../animations';

import { Hero, HeroService }  from './hero.service';

@Component({
  template: `
  <h2>HEROES</h2>
  <div *ngIf="hero$ | async as hero">
    <h3>""</h3>
    <div>
      <label>Id: </label></div>
    <div>
      <label>Name: </label>
      <input [(ngModel)]="hero.name" placeholder="name"/>
    </div>
    <p>
      <button (click)="gotoHeroes(hero)">Back</button>
    </p>
  </div>
  `,
  animations: [ slideInDownAnimation ]
})
export class HeroDetailComponent implements OnInit {
  @HostBinding('@routeAnimation') routeAnimation = true;
  @HostBinding('style.display')   display = 'block';
  @HostBinding('style.position')  position = 'absolute';

  hero$: Observable<Hero>;

  constructor(
    private route: ActivatedRoute,
    private router: Router,
    private service: HeroService
  ) {}

  ngOnInit() {
    this.hero$ = this.route.paramMap
      .switchMap((params: ParamMap) =>
        this.service.getHero(params.get('id')));
  }

  gotoHeroes(hero: Hero) {
    let heroId = hero ? hero.id : null;
    // Pass along the hero id if available
    // so that the HeroList component can select that hero.
    // Include a junk 'foo' property for fun.
    this.router.navigate(['/heroes', { id: heroId, foo: 'foo' }]);
  }
}

子路由

  • 每个功能都有自己的目录
  • 每个功能都有自己的功能模块
  • 每个功能都有其根组件
  • 每个功能根组件都有自己的 router outlet 及子路由
  • 功能模块中的路由很少与其它功能模块中的路由有交叉

功能模块中的根组件

//src/app/crisis-center/crisis-center.component.ts
import { Component } from '@angular/core';

@Component({
  template:  `
    <h2>CRISIS CENTER</h2>
    <router-outlet></router-outlet>
  `
})
export class CrisisCenterComponent { }

它是访功能区域中的根组件,地位等同于整个应用中的 AppComponent,其模板中只有 <router-outlet>

它没有选择子,因为我们无需将它嵌入到其它组件的模板中,而是通过路由器来导航。

功能模块中的子路由配置

例如在 crisis-center-routing.module.ts 中:

//src/app/crisis-center/crisis-center-routing.module.ts (Routes)
const crisisCenterRoutes: Routes = [
  {
    path: 'crisis-center',
    component: CrisisCenterComponent,
    children: [
      {
        path: '',
        component: CrisisListComponent,
        children: [
          {
            path: ':id',
            component: CrisisDetailComponent
          },
          {
            path: '',
            component: CrisisCenterHomeComponent
          }
        ]
      }
    ]
  }
];

这里的路由定义中使用了 children,因此 CrisisCenterComponent 路由定义中其 children 下的子组件经路由后都呈现在 CrisisCenterComponent 模板的 <router-outlet> 下。类似地,CrisisListComponent 路由定义中其 children 下的子组件经路由后都呈现在 CrisisListComponent 模板中 <router-outlet> 下。

相对路径导航

使用 Router.navigate 进行相对路径导航时,要先注入 ActivatedRoute 来获取当前路由状态,在链接参数数组时,添加一个带有 relativeTo 属性值为 ActivatedRoute 注入值的参数对象:

//src/app/crisis-center/crisis-detail.component.ts (relative navigation)
// Relative navigation back to the crises
this.router.navigate(['../', { id: crisisId, foo: 'foo' }], { relativeTo: this.route });

而在模板中通过 routerLink 时,参数值相同,但不需要加 relativeTo 参数。

secondary route (第二路由)

第二路由和主路由的功能:

  • 它们相互独立
  • 同时相互组合使用
  • 第二路由组件只呈现在 named outlet 中

在应用的主组件模板中添加 named outlet:

<router-outlet></router-outlet>
<router-outlet name="popup"></router-outlet>

AppRoutingModule 中添加最二路由(即指定显示的 named outlet):

//src/app/app-routing.module.ts (compose route)
{
  path: 'compose',
  component: ComposeMessageComponent,
  outlet: 'popup'
},

添加链接,当用户点击时,在 named outlet 下呈现组件:

<a [routerLink]="[{ outlets: { popup: ['compose'] } }]">Contact</a>

之后,用户点击时,compose 路由关联的组件将呈现在 popup named outlet 下。

当导航到 Cris Center 后再点击上面链接时,浏览器地址栏中的链接为: http://.../crisis-center(popup:compose),解析为:

  • crisis-center 是主导航部分
  • 第二路由部分用 () 包围
  • 第二路由中包含一个 outlet 名 (popup),: 为分隔符,最后是第二路由路径(compose)

此时当点击 Heroes 链接时,地址变为 http://.../heroes(popup:compose),即主路由部分变了,而第二路由部分不变,它们是独立的。路由器为导航树维护多个独立的分枝。即基于不同的 outlet 呈现位置维护不同的分枝。

消除第二路由的 URL 部分:

//src/app/compose-message.component.ts (closePopup)
closePopup() {
  // Providing a `null` value to the named outlet
  // clears the contents of the named outlet
  this.router.navigate([{ outlets: { popup: null }}]);
}

路由保护

通过将各种路由保护类添加到路由配置中,路由器在路由处理时,会预告调用该保护类中的相应接口,根据返回值决定继续下去还是取消,并且保护类接口中还能进行导航转向(也是取消当前导航的一种方式)。

保护接口可返回一个立即的布尔值,也可以返回一个 Observable<boolean>Promise<boolean>,此时路由器要等待它的异步操作(如向用户提问题,将数据保存到后端等)完成。

路由器支持下面几种保护接口:

  • CanActivate:一般用于授权验证
  • CanActivateChild: 对子路由统一验证
  • CanDeactivate: 验证当前路由中有没有数据没有保存
  • Resolve: 在激活路由前执行数据获取操作
  • CanLoad: 能否按需异步加载功能模块

CanActivate

未授权用户访问受限路由时,会重定向到登录页面,登录后再返回。

实现一个登录验证服务:

//src/app/auth.service.ts (excerpt)
import { Injectable } from '@angular/core';

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/delay';

@Injectable()
export class AuthService {
  isLoggedIn = false;

  // store the URL so we can redirect after logging in
  redirectUrl: string;

  login(): Observable<boolean> {
    return Observable.of(true).delay(1000).do(val => this.isLoggedIn = true);
  }

  logout(): void {
    this.isLoggedIn = false;
  }
}

实现一个符合 CanActivate 接口的保护类 AuthGuard,当用户未登录时,重定义向登录组件:

//src/app/auth-guard.service.ts (v2)
import { Injectable }       from '@angular/core';
import {
  CanActivate, Router,
  ActivatedRouteSnapshot,
  RouterStateSnapshot
}                           from '@angular/router';
import { AuthService }      from './auth.service';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private authService: AuthService, private router: Router) {}

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
    let url: string = state.url;

    return this.checkLogin(url);
  }

  checkLogin(url: string): boolean {
    if (this.authService.isLoggedIn) { return true; }

    // Store the attempted URL for redirecting
    this.authService.redirectUrl = url;

    // Navigate to the login page with extras
    this.router.navigate(['/login']);
    return false;
  }
}

登录组件使用 AuthService 服务来进行登录,登录完成后重导航回原来的路由:

//src/app/login.component.ts
import { Component }   from '@angular/core';
import { Router }      from '@angular/router';
import { AuthService } from './auth.service';

@Component({
  template: `
    <h2>LOGIN</h2>
    <p></p>
    <p>
      <button (click)="login()"  *ngIf="!authService.isLoggedIn">Login</button>
      <button (click)="logout()" *ngIf="authService.isLoggedIn">Logout</button>
    </p>`
})
export class LoginComponent {
  message: string;

  constructor(public authService: AuthService, public router: Router) {
    this.setMessage();
  }

  setMessage() {
    this.message = 'Logged ' + (this.authService.isLoggedIn ? 'in' : 'out');
  }

  login() {
    this.message = 'Trying to log in ...';

    this.authService.login().subscribe(() => {
      this.setMessage();
      if (this.authService.isLoggedIn) {
        // Get the redirect URL from our auth service
        // If no redirect has been set, use the default
        let redirect = this.authService.redirectUrl ? this.authService.redirectUrl : '/crisis-center/admin';

        // Redirect the user
        this.router.navigate([redirect]);
      }
    });
  }

  logout() {
    this.authService.logout();
    this.setMessage();
  }
}

路由定义中加入保护接口:

//src/app/admin/admin-routing.module.ts (admin routing)
import { AuthGuard }  from '../auth-guard.service';

const adminRoutes: Routes = [
  {
    path: 'admin',
    component: AdminComponent,
    canActivate: [AuthGuard],
    children: [
      {
        path: '',
        children: [
          { path: 'crises', component: ManageCrisesComponent },
          { path: 'heroes', component: ManageHeroesComponent },
          { path: '', component: AdminDashboardComponent }
        ]
      }
    ]
  }
];

@NgModule({
  imports: [
    RouterModule.forChild(adminRoutes)
  ],
  exports: [
    RouterModule
  ]
})
export class AdminRoutingModule {}

上面的第一个 children 路由中没有指定组件,这是一个 component-less 路由,专门用来组织多个子路由的。

CanActivateChild: 保护子路由

在父路由上使用,那么其下的所有子路由在激活来都要才受它保护。

实现一个符合 CanActivatedChild 的保护类:

//src/app/auth-guard.service.ts (excerpt)
import { Injectable }       from '@angular/core';
import {
  CanActivate, Router,
  ActivatedRouteSnapshot,
  RouterStateSnapshot,
  CanActivateChild
}                           from '@angular/router';
import { AuthService }      from './auth.service';

@Injectable()
export class AuthGuard implements CanActivate, CanActivateChild {
  constructor(private authService: AuthService, private router: Router) {}

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
    let url: string = state.url;

    return this.checkLogin(url);
  }

  canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
    return this.canActivate(route, state);
  }

/* . . . */
}

添加到路由定义中:

//src/app/admin/admin-routing.module.ts (excerpt)
const adminRoutes: Routes = [
  {
    path: 'admin',
    component: AdminComponent,
    canActivate: [AuthGuard],
    children: [
      {
        path: '',
        canActivateChild: [AuthGuard],
        children: [
          { path: 'crises', component: ManageCrisesComponent },
          { path: 'heroes', component: ManageHeroesComponent },
          { path: '', component: AdminDashboardComponent }
        ]
      }
    ]
  }
];

@NgModule({
  imports: [
    RouterModule.forChild(adminRoutes)
  ],
  exports: [
    RouterModule
  ]
})
export class AdminRoutingModule {}

CanDeactivate: 处理未保存的修改

有些页面是数据是通过点击页面上的取消或保存按钮来进行数据处理的。当用户直接点击其它链接要离开当前页时,需要通过确定窗口明确用户的操作选择,并用异步等待方式等待用户的回答。

创建一个通用的保护类,对所有组件中是否有 canDeactivate() 方法进行检测:

//src/app/can-deactivate-guard.service.ts
import { Injectable }    from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { Observable }    from 'rxjs/Observable';

export interface CanComponentDeactivate {
 canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
}

@Injectable()
export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
  canDeactivate(component: CanComponentDeactivate) {
    return component.canDeactivate ? component.canDeactivate() : true;
  }
}

具体的组件中实现 canDeactive():

//src/app/crisis-center/crisis-detail.component.ts (excerpt)
canDeactivate(): Observable<boolean> | boolean {
  // Allow synchronous navigation (`true`) if no crisis or the crisis is unchanged
  if (!this.crisis || this.crisis.name === this.editName) {
    return true;
  }
  // Otherwise ask the user with the dialog service and return its
  // observable which resolves to true or false when the user decides
  return this.dialogService.confirm('Discard changes?');
}

路由定义中使用:

//src/app/crisis-center/crisis-center-routing.module.ts (can deactivate guard)
const crisisCenterRoutes: Routes = [
  {
    path: '',
    redirectTo: '/crisis-center',
    pathMatch: 'full'
  },
  {
    path: 'crisis-center',
    component: CrisisCenterComponent,
    children: [
      {
        path: '',
        component: CrisisListComponent,
        children: [
          {
            path: ':id',
            component: CrisisDetailComponent,
            canDeactivate: [CanDeactivateGuard]
          },
          {
            path: '',
            component: CrisisCenterHomeComponent
          }
        ]
      }
    ]
  }
];

Resolve: 预先获取组件数据

先获取组件所需的所有数据后再呈现组件,如果没有获取到相应数据,再重导航到相关其它组件。

路由器会等待 Resolve 保护类操作完成后,才激活相应的路由导航。

实现一个符合 Resolve 接口的保护类:

//src/app/crisis-center/crisis-detail-resolver.service.ts
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/take';
import { Injectable }             from '@angular/core';
import { Observable }             from 'rxjs/Observable';
import { Router, Resolve, RouterStateSnapshot,
         ActivatedRouteSnapshot } from '@angular/router';

import { Crisis, CrisisService }  from './crisis.service';

@Injectable()
export class CrisisDetailResolver implements Resolve<Crisis> {
  constructor(private cs: CrisisService, private router: Router) {}

  resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Crisis> {
    let id = route.paramMap.get('id');

    return this.cs.getCrisis(id).take(1).map(crisis => {
      if (crisis) {
        return crisis;
      } else { // id not found
        this.router.navigate(['/crisis-center']);
        return null;
      }
    });
  }
}

CrisisService.getCrisis() 方法返回一个 Observable,从而防止路由器在数据获取完前进行加载。take(1) 操作使 Observable 发送出一个值时就表示操作完成。

使用保护:

import { CrisisDetailResolver }   from './crisis-detail-resolver.service';
const crisisCenterRoutes: Routes = [
  {
    path: '',
    redirectTo: '/crisis-center',
    pathMatch: 'full'
  },
  {
    path: 'crisis-center',
    component: CrisisCenterComponent,
    children: [
      {
        path: '',
        component: CrisisListComponent,
        children: [
          {
            path: ':id',
            component: CrisisDetailComponent,
            canDeactivate: [CanDeactivateGuard],
            resolve: {
              crisis: CrisisDetailResolver
            }
          },
          {
            path: '',
            component: CrisisCenterHomeComponent
          }
        ]
      }
    ]
  }
];

这里的 Resolve 保护是基于 Crisis 数据模型实现的,用来预先获取 Crisis 数据,而获取的数据保存在注入的 ActivatedRoutedata 属性中,访问该数据举例如下:

//src/app/crisis-center/crisis-detail.component.ts (ngOnInit v2)
ngOnInit() {
  this.route.data
    .subscribe((data: { crisis: Crisis }) => {
      this.editName = data.crisis.name;
      this.crisis = data.crisis;
    });
}

路由的可选参数

fragment 参数通过页面元素的 id 属性值来引用元素位置。

router.navigate() 方法中通过传入 NavigationExtras 对象来设置路由的可选参数(QueryParms, Fragment):

//src/app/auth-guard.service.ts (v3)
checkLogin(url: string): boolean {
    if (this.authService.isLoggedIn) { return true; }

    // Store the attempted URL for redirecting
    this.authService.redirectUrl = url;

    // Create a dummy session id
    let sessionId = 123456789;

    // Set our navigation extras object
    // that contains our global query params and fragment
    let navigationExtras: NavigationExtras = {
      queryParams: { 'session_id': sessionId },
      fragment: 'anchor'
    };

    // Navigate to the login page with extras
    this.router.navigate(['/login'], navigationExtras);
    return false;
  }

类似地,如果想在导航过程中一起保持这些可选参数,可:

//src/app/login.component.ts (preserve)
// Set our navigation extras object
// that passes on our global query params and fragment
let navigationExtras: NavigationExtras = {
  queryParamsHandling: 'preserve',
  preserveFragment: true
};

// Redirect the user
this.router.navigate([redirect], navigationExtras);

如果 queryParamsHandling 的值为 merge,则进行组合。

组件中对这些可选参数的抽取:

//src/app/admin/admin-dashboard.component.ts (v2)
import { Component, OnInit }  from '@angular/core';
import { ActivatedRoute }     from '@angular/router';
import { Observable }         from 'rxjs/Observable';
import 'rxjs/add/operator/map';

@Component({
  template:  `
    <p>Dashboard</p>

    <p>Session ID: </p>
    <a id="anchor"></a>
    <p>Token: </p>
  `
})
export class AdminDashboardComponent implements OnInit {
  sessionId: Observable<string>;
  token: Observable<string>;

  constructor(private route: ActivatedRoute) {}

  ngOnInit() {
    // Capture the session ID if available
    this.sessionId = this.route
      .queryParamMap
      .map(params => params.get('session_id') || 'None');

    // Capture the fragment if available
    this.token = this.route
      .fragment
      .map(fragment => fragment || 'None');
  }
}

RouterLink 指令中也可通过 queryParamsHandlingpreserveFragment 绑定实现相同的效果。

异步加载路由

路由定义中用 loadChildren 指定路由模块的文件路径及模块类名:

//app-routing.module.ts (load children)
{
  path: 'admin',
  loadChildren: 'app/admin/admin.module#AdminModule',
},

CanLoad:保护未授权加载

实现一个符合 CanLoad 接口的保护类并应用与路由定义中:

//src/app/auth-guard.service.ts (CanLoad guard)
canLoad(route: Route): boolean {
  let url = `/${route.path}`;

  return this.checkLogin(url);
}

//app-routing.module.ts (lazy admin route)
{
  path: 'admin',
  loadChildren: 'app/admin/admin.module#AdminModule',
  canLoad: [AuthGuard]
},

后台预先加载

Router 中提供了 2 种预先加载策略:

  1. 不预先加载所有按需加载的模块(默认)
  2. 预先加载所有按需加载的模块

通过 RouterModule.forRoot() 设置预先加载策略:

//src/app/app-routing.module.ts (preload all)
import {
  PreloadAllModules
} from '@angular/router';
RouterModule.forRoot(
  appRoutes,
  {
    enableTracing: true, // <-- debugging purposes only
    preloadingStrategy: PreloadAllModules
  }
)

自定义预先加载策略

该策略中只预先加载路由定义中 data.preload 值为 true 的路由。

设置路由定义数据:

//src/app/app-routing.module.ts (route data preload)
{
  path: 'crisis-center',
  loadChildren: 'app/crisis-center/crisis-center.module#CrisisCenterModule',
  data: { preload: true }
},

自定义策略:

//src/app/selective-preloading-strategy.ts (excerpt)
import 'rxjs/add/observable/of';
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class SelectivePreloadingStrategy implements PreloadingStrategy {
  preloadedModules: string[] = [];

  preload(route: Route, load: () => Observable<any>): Observable<any> {
    if (route.data && route.data['preload']) {
      // add the route path to the preloaded module array
      this.preloadedModules.push(route.path);

      // log the route path to the console
      console.log('Preloaded: ' + route.path);

      return load();
    } else {
      return Observable.of(null);
    }
  }
}

URL 调整

例如 heroes 调整为 superheroeshero/:id 调整为 superhero/:id:

//src/app/heroes/heroes-routing.module.ts (heroes redirects)
const heroesRoutes: Routes = [
  { path: 'heroes', redirectTo: '/superheroes' },
  { path: 'hero/:id', redirectTo: '/superhero/:id' },
  { path: 'superheroes',  component: HeroListComponent },
  { path: 'superhero/:id', component: HeroDetailComponent }
];

获取路由的最终配置信息(调试用)

//src/app/app.module.ts (inspect the router config)
import { Router } from '@angular/router';

export class AppModule {
  // Diagnostic only: inspect router configuration
  constructor(router: Router) {
    console.log('Routes: ', JSON.stringify(router.config, undefined, 2));
  }
}

参考

]]>
Angular docs-Http 2017-10-21T00:00:00+08:00 Haiiiiiyun haiiiiiyun.github.io/angular-docs-http Http

HttpClient

现代浏览器一般用 XMLHttpRequest 接口和 fetch() API 来完成 HTTP 请求。@angular/common/http 中的 HttpClient 是基于 XMLHttpRequest 接口实现的。

设置

// app.module.ts:

import {NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';

// Import HttpClientModule from @angular/common/http
import {HttpClientModule} from '@angular/common/http';

@NgModule({
  imports: [
    BrowserModule,
    // Include it under 'imports' in your application module
    // after BrowserModule.
    HttpClientModule,
  ],
})
export class MyAppModule {}

请求 JSON 数据

使用 HttpClient.get() 直接访问数据:

@Component(...)
export class MyComponent implements OnInit {

  results: string[];

  // Inject HttpClient into your component or service.
  constructor(private http: HttpClient) {}

  ngOnInit(): void {
    // Make the HTTP request:
    this.http.get('/api/items').subscribe(data => {
      // Read the result field from the JSON response.
      this.results = data['results'];
    });
  }
}

应答对象的类型

上面返回的应答对象 dataObject,没有类型信息,因此只能用 data['results'] 访问,不能用 data.results 访问。

可以为应答对象创建接口,将接口作为类型参数传入 get(),可实现对返回的应答对象的类型限定:

interface ItemsResponse {
  results: string[];
}

http.get<ItemsResponse>('/api/items').subscribe(data => {
  // data is now an instance of type ItemsResponse, so you can do this:
  this.results = data.results;
});

读取全部的应答信息

上面只读取了应答的 body 部分,要读取 header, status codes 等全部应答信息,可为 get() 提供 observe 选项实现:

http
  .get<MyJsonData>('/data.json', {observe: 'response'})
  .subscribe(resp => {
    // Here, resp is of type HttpResponse<MyJsonData>.
    // You can inspect its headers:
    console.log(resp.headers.get('X-Custom-Header'));
    // And access the body directly, which is typed as MyJsonData as requested.
    // resp.body is type of MyJsonData
    console.log(resp.body.someField);
  });

错误处理

.subscribe 的第 2 个参数是错误处理回调函数:

http
  .get<ItemsResponse>('/api/items')
  .subscribe(
    // Successful responses call the first callback.
    data => {...},
    // Errors will call this callback instead:
    err => {
      console.log('Something went wrong!');
    }
  );

错误详细信息

上面的 err 参数是 HttpErrorResponse 类型。共有 2 种类型的错误,一种是后端返回失败代码(如 404, 500),另一种是客户端出错(如网络问题请求未发出),此时会抛出 Error。这种错误都能通过 HttpErrorRespose 对象识别出:

http
  .get<ItemsResponse>('/api/items')
  .subscribe(
    data => {...},
    (err: HttpErrorResponse) => {
      if (err.error instanceof Error) {
        // A client-side or network error occurred. Handle it accordingly.
        console.log('An error occurred:', err.error.message);
      } else {
        // The backend returned an unsuccessful response code.
        // The response body may contain clues as to what went wrong,
        console.log(`Backend returned code ${err.status}, body was: ${err.error}`);
      }
    }
  );

重发请求

import 'rxjs/add/operator/retry';

http
  .get<ItemsResponse>('/api/items')
  // Retry this request up to 3 times.
  .retry(3)
  // Any errors after the 3rd retry will fall through to the app.
  .subscribe(...);

请求非 JSON 数据

http
  .get('/textfile.txt', {responseType: 'text'})
  // The Observable returned by get() is of type Observable<string>
  // because a text response was specified. There's no need to pass
  // a <string> type parameter to get().
  .subscribe(data => console.log(data));

发送 POST 请求

const body = {name: 'Brad'};

http
  .post('/api/developers/add', body)
  // See below - subscribe() is still necessary when using post().
  .subscribe(...);

注意 subscribe() 方法,HttpClient 中所有其它方法返回都是 Observable ,它只是一个发送请求的 blueprint, 没有实际发送出去,只有调用 subscribe() 后才实际发送出去,而且调用一次即发送一次:

const req = http.post('/api/items/add', body);
// 0 requests made - .subscribe() not called.
req.subscribe();
// 1 request made.
req.subscribe();
// 2 requests made.

配置 POST 请求头

http
  .post('/api/items/add', body, {
    headers: new HttpHeaders().set('Authorization', 'my-auth-token'),
  })
  .subscribe();

HttpHeaders 类是不能修改的,因此每次 set() 后才会返回一个应用修改后的新实例。

配置 POST 的 URL 参数

http
  .post('/api/items/add', body, {
    params: new HttpParams().set('id', '3'),
  })
  .subscribe();

此时的 URL 为 /api/items/add?id=3

高级用法

使用拦截功能,可以在请求发送到服务端前进行修改,应答在应用接收到前进行修改。

编写一个拦截器

实现一个拦截器,即实现一个 HttpInterceptor 接口,其中只有一个 intercept 方法,例如:

import {Injectable} from '@angular/core';
import {HttpEvent, HttpInterceptor, HttpHandler, HttpRequest} from '@angular/common/http';

@Injectable()
export class NoopInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(req);
  }
}

intercept 方法实现将一个请求对象转换成一个 Observable, 该 Observable 最终返回一个应答对象。拦截器对请求进行修改后,传到拦截链的下一站,其处理模式类似一些系统中的 middleware 框架。

使用自定义拦截器

import {NgModule} from '@angular/core';
import {HTTP_INTERCEPTORS} from '@angular/common/http';

@NgModule({
  providers: [{
    provide: HTTP_INTERCEPTORS,
    useClass: NoopInterceptor,
    multi: true,
  }],
})
export class AppModule {}

注意 mult:true 选项,表示 HTTP_INTERCEPTORS 是一个列表值,HTTP_INTERCEPTORS 会收集所有的拦截器。

事件

interceptHttpHandler.handle 返回的是 Observable<HttpEvent<any>> 而不是 Observable<HttpResponse<any>>,这是因为拦截器工作在 HttpClient 接口的更低层。每个请求都会产生多个事件,包括上传和下载过程事件。HttpResponse 类实际上是一个 typeHttpEventType.HttpResponseEvent 的事件。

次序

多个拦截器的应用次序,就是其在 providers 中的次序。

不可修改性

HttpRequestHttpResponse 类是不可修改的,这是因为可能有请求重发的情况。

不可修改性确保了在请求重试时,拦截器看到的请求对象是一样的。

同样,在拦截器中也不能直接对请求体进行修改。要修改时,先创建请求体的复本,修改后,再用 clone() 创建一个新的请求对象,再设置修改后的请求体:

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  // This is a duplicate. It is exactly the same as the original.
  const dupReq = req.clone();

  // Change the URL and replace 'http://' with 'https://'
  const secureReq = req.clone({url: req.url.replace('http://', 'https://')});
}

下面是设置请求头的例子:

import {Injectable} from '@angular/core';
import {HttpEvent, HttpInterceptor, HttpHandler, HttpRequest} from '@angular/common/http';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  constructor(private auth: AuthService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    // Get the auth header from the service.
    const authHeader = this.auth.getAuthorizationHeader();
    // Clone the request to add the new header.
    const authReq = req.clone({headers: req.headers.set('Authorization', authHeader)});
    // Pass on the cloned request instead of the original request.
    return next.handle(authReq);
  }
}

对请求对象设置请求头可以简写为:

const authReq = req.clone({setHeaders: {Authorization: authHeader}});

拦截器实现请求记时功能

import 'rxjs/add/operator/do';

export class TimingInterceptor implements HttpInterceptor {
  constructor(private auth: AuthService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  	const started = Date.now();
    return next
      .handle(req)
      .do(event => {
        if (event instanceof HttpResponse) {
          const elapsed = Date.now() - started;
          console.log(`Request for ${req.urlWithParams} took ${elapsed} ms.`);
        }
      });
  }
}

RxJS 的 do() 操作符在 Observable 上添加了一个操作,并不影响流上的值。

拦截器实现缓存

例如下面是缓存接口:

abstract class HttpCache {
  /**
   * Returns a cached response, if any, or null if not present.
   */
  abstract get(req: HttpRequest<any>): HttpResponse<any>|null;

  /**
   * Adds or updates the response in the cache.
   */
  abstract put(req: HttpRequest<any>, resp: HttpResponse<any>): void;
}

下面是用法:

@Injectable()
export class CachingInterceptor implements HttpInterceptor {
  constructor(private cache: HttpCache) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  	// Before doing anything, it's important to only cache GET requests.
    // Skip this interceptor if the request method isn't GET.
    if (req.method !== 'GET') {
      return next.handle(req);
    }

    // First, check the cache to see if this request exists.
    const cachedResponse = this.cache.get(req);
    if (cachedResponse) {
      // A cached response exists. Serve it instead of forwarding
      // the request to the next handler.
      return Observable.of(cachedResponse);
    }

    // No cached response exists. Go to the network, and cache
    // the response when it arrives.
    return next.handle(req).do(event => {
      // Remember, there may be other events besides just the response.
      if (event instanceof HttpResponse) {
      	// Update the cache.
      	this.cache.put(req, event);
      }
    });
  }
}

下面的用法中,如果请求有缓存,即返回缓存的应答,也返回最新的应答:

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  // Still skip non-GET requests.
  if (req.method !== 'GET') {
    return next.handle(req);
  }

  // This will be an Observable of the cached value if there is one,
  // or an empty Observable otherwise. It starts out empty.
  let maybeCachedResponse: Observable<HttpEvent<any>> = Observable.empty();

  // Check the cache.
  const cachedResponse = this.cache.get(req);
  if (cachedResponse) {
    maybeCachedResponse = Observable.of(cachedResponse);
  }

  // Create an Observable (but don't subscribe) that represents making
  // the network request and caching the value.
  const networkResponse = next.handle(req).do(event => {
    // Just like before, check for the HttpResponse event and cache it.
    if (event instanceof HttpResponse) {
      this.cache.put(req, event);
    }
  });

  // Now, combine the two and send the cached response first (if there is
  // one), and the network response second.
  return Observable.concat(maybeCachedResponse, networkResponse);
}

侦听过程事件

用来在传输大文件时提供反馈。

在创建 HttpRequest 实例时传入 reportProgress 选项后,该请求就会产生过程事件:

const req = new HttpRequest('POST', '/upload/file', file, {
  reportProgress: true,
});

之后 HttpClient.request() 会产生一个事件的 Observable 对象:

http.request(req).subscribe(event => {
  // Via this API, you get access to the raw event stream.
  // Look for upload progress events.
  if (event.type === HttpEventType.UploadProgress) {
    // This is an upload progress event. Compute and show the % done:
    const percentDone = Math.round(100 * event.loaded / event.total);
    console.log(`File is ${percentDone}% uploaded.`);
  } else if (event instanceof HttpResponse) {
    console.log('File is completely uploaded!');
  }
});

XSRF 保护

在进行 HTTP 请求时,某拦截器会从 cookie 中读取一个 token 值(通常键为 XSRF-TOKEN),将值设置为头 X-XSRF-TOKEN 的值。由于只有本域内的代码能读取 cookie,从而后端能确保该请求来自自己的客户端。

自定义 cookie/header 名如下:

imports: [
  HttpClientModule,
  HttpClientXsrfModule.withConfig({
    cookieName: 'My-Xsrf-Cookie',
    headerName: 'My-Xsrf-Header',
  }),
]

参考

]]>
Angular docs-依赖注入 (Dependency Injection) 2017-10-20T00:00:00+08:00 Haiiiiiyun haiiiiiyun.github.io/angular-docs-dependency-injection 依赖注入 (Dependency Injection)

这是一种重要的设计模式。

为什么要用依赖注入

下面例子中没有用依赖注入:

export class Car {

  public engine: Engine;
  public tires: Tires;
  public description = 'No DI';

  constructor() {
    this.engine = new Engine();
    this.tires = new Tires();
  }

  // Method using the engine and tires
  drive() {
    return `${this.description} car with ` +
      `${this.engine.cylinders} cylinders and ${this.tires.make} tires.`;
  }
}

Car 构造函数内部创建和管理依赖的外部类,当外部类有变化时,Car 代码也要相应变化。

而使用依赖注入,即将创建 Car 对象时所需的外部依赖以构造器参数的形式传入。从而进行了依赖解耦,当外部依赖变化时,Car 类中的代码本身就不需要变化了:

public description = 'DI';

constructor(public engine: Engine, public tires: Tires) { }

当然,创建外部依赖类对象的代码还是要变化,这一般通过工厂类来实现:

import { Engine, Tires, Car } from './car';

// BAD pattern!
export class CarFactory {
  createCar() {
    let car = new Car(this.createEngine(), this.createTires());
    car.description = 'Factory';
    return car;
  }

  createEngine() {
    return new Engine();
  }

  createTires() {
    return new Tires();
  }
}

工厂类的维护量会很大。

Angular 内置了一个依赖注入框架,相当于能自动维护这个工厂类。它相当于一个注入器,或喷嘴。当将 Car 等类登记到这个注入器中后,在需要类实例时只需要求注入器返回即可:

let car = injector.get(Car);

Angular 的依赖注入

Angular 在启动应用过程中会自动创建一个应用级的注入器:

//src/main.ts (bootstrap)
platformBrowserDynamic().bootstrapModule(AppModule);

在 NgModule 中登记提供者

AppModule@NgModule 中登记的提供者具有应用级作用域。而在所有即时导入的模块中登记的提供者,在 AppModuleimports 列表中导入后,其提供者会自动加到应用级的所有提供者列表的后面。

//src/app/app.module.ts (excerpt)
@NgModule({
  imports: [
    BrowserModule
  ],
  declarations: [
    AppComponent,
    CarComponent,
    HeroesComponent,
/* . . . */
  ],
  providers: [
    UserService,
    { provide: APP_CONFIG, useValue: HERO_DI_CONFIG }
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }

在组件中登记提供者

每个组件实现都会有一个独立的注入器,因此这些提供者的作用域是该组件实现及其所有子组件。

依赖注入的使用方法

依赖注入模式中,组件必须通过在其构造器函数来要求服务注入,例如:

//src/app/heroes/hero-list.component (with DI)
import { Component }   from '@angular/core';

import { Hero }        from './hero';
import { HeroService } from './hero.service';

@Component({
  selector: 'hero-list',
  template: `
  <div *ngFor="let hero of heroes">
     - 
  </div>
  `
})
export class HeroListComponent {
  heroes: Hero[];

  constructor(heroService: HeroService) {
    this.heroes = heroService.getHeroes();
  }
}

这里的组件构造器参数类型为 HeroService,运行时,Angular 会先在本组件实现时先查找相应服务(本组件中没有 providers 登记),没有时再从其类组件中找(父组件 HeroesComponent 中有 providers 登记,找到!),找完父组件上再到应用级的注入器中找(即 AppModule 中的 providers 中)。

找到后,当创建 HeroListComponet 组件实例时,会要求那个注入器注入一个服务实例。

服务实例是由注入器隐式自动创建的,当然也可以显式创建,例如:

//src/app/car/car-injector.ts
injector = ReflectiveInjector.resolveAndCreate([Car, Engine, Tires]);
let car = injector.get(Car);

单例

在同一个注入器时,返回的依赖对象都是单例实例。

依赖注入使测试更方便

只需为注入部分创建一个 mock 服务,例如:

//src/app/test.component.ts
let expectedHeroes = [{name: 'A'}, {name: 'B'}]
let mockService = <HeroService> {getHeroes: () => expectedHeroes }

it('should have heroes when HeroListComponent created', () => {
  let hlc = new HeroListComponent(mockService);
  expect(hlc.heroes.length).toEqual(expectedHeroes.length);
});

当一个服务依赖于其它服务时

@Injectable() 装饰器用来表示该类的构造器参数可以通过注入器注入依赖。

//src/app/heroes/hero.service (v2)
import { Injectable } from '@angular/core';

import { HEROES }     from './mock-heroes';
import { Logger }     from '../logger.service';

@Injectable()
export class HeroService {

  constructor(private logger: Logger) {  }

  getHeroes() {
    this.logger.log('Getting heroes ...');
    return HEROES;
  }
}

对于组件等来说,@Component@Directive@Pipe 都是 @Injectable() 的子类,因此组件无需再装饰 @Injectable()

所有服务最好都要添加 @Injectable() 装饰。

注入器提供者

例如:

//src/app/providers.component.ts
providers: [Logger]

以上实际是下面的简写:

[{ provide: Logger, useClass: Logger }]

第 1 个参数是 token,即作为定位依赖的键,也作为登记提供者的键。第 2 个参数是提供者的定义对象,相当于用来创建依赖值的菜谱(可以有多种菜谱)。

使用依赖后,可以在键不变的情况下,更新提供者,从而实现无缝升级:

//src/app/providers.component.ts
@Injectable()
class EvenBetterLogger extends Logger {
  constructor(private userService: UserService) { super(); }

  log(message: string) {
    let name = this.userService.user.name;
    super.log(`Message to ${name}: ${message}`);
  }
}

//src/app/providers.component.ts
[ UserService,
  { provide: Logger, useClass: EvenBetterLogger }]

类提供者的别名

例如原来依赖 OldLogger,现升级为 NewLogger(接口不变),有些旧组件必须使用 OldLogger 依赖,可以用 useExisting 来为 NewLogger 提供者创建一个别名,从而两者都使用同一个单例实例:

[ NewLogger,
  // Alias OldLogger w/ reference to NewLogger
  { provide: OldLogger, useExisting: NewLogger}]

值提供者

要求注入器提供返回一个值,不用通过类创建一个实例:

//src/app/providers.component.ts
// An object in the shape of the logger service
let silentLogger = {
  logs: ['Silent logger says "Shhhhh!". Provided via "useValue"'],
  log: () => {}
};

//register
[{ provide: Logger, useValue: silentLogger }]

工厂提供者

有时需要基于当时的信息动态生成一个依赖值。例如 HeroService 需要基于当前用户返回不同的列表:

//src/app/heroes/hero.service.ts (excerpt)
constructor(
  private logger: Logger,
  private isAuthorized: boolean) { }

getHeroes() {
  let auth = this.isAuthorized ? 'authorized ' : 'unauthorized';
  this.logger.log(`Getting heroes for ${auth} user.`);
  return HEROES.filter(hero => this.isAuthorized || !hero.isSecret);
}


//src/app/heroes/hero.service.provider.ts (excerpt)
let heroServiceFactory = (logger: Logger, userService: UserService) => {
  return new HeroService(logger, userService.user.isAuthorized);
};

export let heroServiceProvider =
  { provide: HeroService,
    useFactory: heroServiceFactory,
    deps: [Logger, UserService]
  };
  
//src/app/heroes/heroes.component (v3)
import { Component }          from '@angular/core';

import { heroServiceProvider } from './hero.service.provider';

@Component({
  selector: 'my-heroes',
  template: `
  <h2>Heroes</h2>
  <hero-list></hero-list>
  `,
  providers: [heroServiceProvider]
})
export class HeroesComponent { }

注入器在创建实例时,会调用工厂类来创建。

依赖注入的 token

在登记时,用依赖注入的 token 来关联其提供者,注入器会维护一个映射关系。

在使用基于类的依赖时,类对象作为 token,而组件构造器中也可正好通过类来匹配:

//src/app/injector.component.ts
heroService: HeroService;

//src/app/heroes/hero-list.component.ts
constructor(heroService: HeroService)

非类依赖

即有时注入的是一个字符串,函数或对象

例如要注入一个配置对象:

//src/app/app-config.ts (excerpt)
export interface AppConfig {
  apiEndpoint: string;
  title: string;
}

export const HERO_DI_CONFIG: AppConfig = {
  apiEndpoint: 'api.heroes.com',
  title: 'Dependency Injection'
};

该对象可以用 useValue 来登记,但是这里没有一个 AppConfig 类,而接口 interface 在最终生成的 JS 中是无效的,因此也不能作为 token 使用。

此时可以用 InjectionToken:

//src/app/app.config.ts
import { InjectionToken } from '@angular/core';

export let APP_CONFIG = new InjectionToken<AppConfig>('app.config');

类型参数 <AppConfig> 是可选的,token 描述 app.config 也是可选的。

登记如下:

providers: [{ provide: APP_CONFIG, useValue: HERO_DI_CONFIG }]

使用如下:

//src/app/app.component.ts
constructor(@Inject(APP_CONFIG) config: AppConfig) {
  this.title = config.title;
}

可选依赖

import { Optional } from '@angular/core';

constructor(@Optional() private logger: Logger) {
  if (this.logger) {
    this.logger.log(some_message);
  }
}

@Host 限制注入器的查找到托管组件为止

托管组件通常就是请求依赖的组件,但是当组件被投入到另一个组件时,父组件就是托管组件。

例如:

//dependency-injection-in-action/src/app/hero-bios.component.ts
template: `
  <hero-bio [heroId]="1"> <hero-contact></hero-contact> </hero-bio>
  <hero-bio [heroId]="2"> <hero-contact></hero-contact> </hero-bio>
  <hero-bio [heroId]="3"> <hero-contact></hero-contact> </hero-bio>`,

以上的 <hero-contact> 组件就放替换父组件 <hero-bio> 模板中的 <ng-content> 元素。

注入器树

一个应用可能有多个注入器。Angular 应用是一个组件树,而每个组件实例都有其自己的注入器,从而组件树和注入器树是并行的。

如果组件本身没有 providers 登记,那么该组件的注入器是其祖先组件注入器的一个代理 (proxy),从而提高性能。

注入器冒泡访问

先在本组件实例的注入器查找,再逐级访问其祖先组件的注入器,最后访问应用级的根注入器。

因此在底层的注入器,通过登记相同 token 的提供者,可以实现对高层注入器中相应提供者的 shadow 效果。

基于注入查找父组件

有相应的 API (如 Query, ViewChildren, ContentChildren) 来获取子组件,但没有查找父组件的相关 API。

由于每个组件实例都会添加到其本身对应的注入器的内部容器中,因此可以通过依赖注入查找到父组件。

知道父组件的具体类型,进行查找

例如父组件 AlexComponent 中有子组件 CathyComponent:

//parent-finder.component.ts (AlexComponent v.1)
@Component({
  selector: 'alex',
  template: `
    <div class="a">
      <h3></h3>
      <cathy></cathy>
      <craig></craig>
      <carol></carol>
    </div>`,
})
export class AlexComponent extends Base
{
  name= 'Alex';
}

子组件中通过注入 AlexComponent 来查找到父组件:

//parent-finder.component.ts (CathyComponent)
@Component({
  selector: 'cathy',
  template: `
  <div class="c">
    <h3>Cathy</h3>
     Alex via the component class.<br>
  </div>`
})
export class CathyComponent {
  constructor( @Optional() public alex: AlexComponent ) { }
}

通过注入父组件的基类,无法查找到父组件

//parent-finder.component.ts (CraigComponent)
@Component({
  selector: 'craig',
  template: `
  <div class="c">
    <h3>Craig</h3>
     Alex via the base class.
  </div>`
})
export class CraigComponent {
  constructor( @Optional() public alex: Base ) { }
}

由于组件可能有多个基类(多重继承),这种方法无效。

通过类接口 class-interface 来查找

父组件实例已经添加到其对应注入器的容器中了,而本方法中父组件还要为其自身实现提供一个别名:

//parent-finder.component.ts (AlexComponent providers)
providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }],

//parent-finder.component.ts (CarolComponent class)
export class CarolComponent {
  name= 'Carol';
  constructor( @Optional() public parent: Parent ) { }
}

Parent 是一个类接口(抽象类),forwardRef 用来去除循环引用。

由于 TypeScript 中只能引用已经定义了的对象,因此当 class A 引用 class B, 而 class B 又引用 class A 时,会出错。forwarRef() 函数会返回一个非直接引用。

通过 @SkipSelf 在组件树中查找父组件

假如组件层级为 Alice->Barry->Carol,其中 AliceBarry 都实现了 Parent 类接口。此时,使用上面方法时,Barry 会有问题,因为它即要通过类接口注入来获取其父组件 Alice,又要为 Carol 提供服务,此时要用 @SkipSelf,即在本身构造器注入时,直接从父组件上的注入器开始查找,跳过本身的注入器:

//parent-finder.component.ts (BarryComponent)
const templateB = `
  <div class="b">
    <div>
      <h3></h3>
      <p>My parent is </p>
    </div>
    <carol></carol>
    <chris></chris>
  </div>`;

@Component({
  selector:   'barry',
  template:   templateB,
  providers:  [{ provide: Parent, useExisting: forwardRef(() => BarryComponent) }]
})
export class BarryComponent implements Parent {
  name = 'Barry';
  constructor( @SkipSelf() @Optional() public parent: Parent ) { }
}

参考

  • https://angular.io/guide/dependency-injection
  • https://angular.io/guide/hierarchical-dependency-injection
  • https://angular.io/guide/dependency-injection-in-action
  • 对应的 jupyter notebook
]]>
Angular docs-NgModule 2017-10-17T00:00:00+08:00 Haiiiiiyun haiiiiiyun.github.io/angular-docs-ngmodule NgModule

NgModule 用来将应用组织成一个个内聚的功能块。

一个 NgModule 就是一个用 @NgModule 装饰器函数装饰的类。@NgModule 的 metadata 对象告诉 Angular 如何编译和运行该模块代码。它标识了该模块自己的组件、指令和管道,公开一些功能,从而外部组件可以使用。@NgModule 也可以将服务提供者 (service providers) 添加到应用的依赖注入器中 (dependency injectors)。

模块可以在应用启动时立即加载,也可以通过路由在需要时异步加载(lazy load)。

@NgModule metadata 的功能:

  • 声明哪些组件、指令和管道属于该模块
  • 公开其中的一些类,从而其它组件的模块中可以使用
  • 导入其它模块,从而其它模块中定义的组件、指令和管道可以在本模块的组件中使用
  • 提供应用层的服务,从而应用中的任何组件都能使用

根模块 AppModule

每个应用至少有一个模块类,即根模块类。

//src/app/app.module.ts (minimal)
import { NgModule }      from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent }  from './app.component';

@NgModule({
  imports:      [ BrowserModule ],
  declarations: [ AppComponent ],
  bootstrap:    [ AppComponent ]
})
export class AppModule { }

BrowserModule 会登录一些关键的服务提供者,同时也包含一些常用指令如 NgIfNgFor

在 main.ts 中启动

Angular 针对不同的平台都提供了多种启动方式。对于浏览器应用,主要有 2 种方式。

just-in-time(JIT) 编译

即动态方式,Angular 编译器在浏览器中编译后再启动应用。

//src/main.ts (dynamic)
// The browser platform with a compiler
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

// The app module
import { AppModule } from './app/app.module';

// Compile and launch the module
platformBrowserDynamic().bootstrapModule(AppModule);

ahead-of-time(AOT) 编译

即静态方式。它作为构建过程的一部分,事先进行编译,生成一些类工厂(class factories),保存在各自的文件中。

事先生成的类工厂中有一个为 AppModuleNgFactory,它对应 AppModule,启动方式也类似:

//src/main.ts (static)
// The browser platform without a compiler
import { platformBrowser } from '@angular/platform-browser';

// The app module factory produced by the static offline compiler
import { AppModuleNgFactory } from './app/app.module.ngfactory';

// Launch with the app module factory.
platformBrowser().bootstrapModuleFactory(AppModuleNgFactory);

由于应用已事先编译了,Angular 不会再将编译器包含到应用代码中分发到浏览器上,从而将应用代码量更少,加载再快(很显著)。

JIT 和 AOT 编译器都从相同的 AppModule 源代码创建 AppModuleNgFactory 类。JIT 是动态创建的,将结果存在浏览器的内存中,而 AOT 是将结果保存在文件中。

服务提供者

先创建一个服务,例如 userService,再在模块 @NgModule 的 metadata 中添加 providers 属性:

//src/app/app.module.ts (providers)
providers: [ UserService ],

由于注册在应用模块中(应用级根注入器 root injector),从而应用中的所有组件都能使用该服务。

导入所需的模块

//src/app/app.module.ts (imports)
imports: [ BrowserModule ],

导入 BrowserModule 后,该模块中所有公开的组件、指令和管道殾能在本 AppModule 的组件模板中使用。

NgIf 等指令实际上是在 @angular/commonCommonModule 中定义的。而BrowserModule 中对 CommonModule 进行了重导出 (re-export)。

同名冲突

当导入的多个对象名字相同的,可用 import .. as ..:

import {
  HighlightDirective as ContactHighlightDirective
} from './contact/highlight.directive';

功能模块

随着应用代码的增加,可将 AppModule 中的一些独立相关代码,分离出来组织成一个功能模板。而在根模板中,导入该功能模块。功能模块中的组件、指令和管道都是内聚的和隐藏的,只能指定公开后才能在外部使用,从而也解决了名字冲突的问题。

使用路由来按需加载模块

假设 ContactComponent 是应用启动时的页面,从而 ContactModule 是立即加载的,而 HeroModuleCrisisModule 是按需加载的。

先在根组件模板中用路由实现 3 个链接:

template: `
  <app-title [subtitle]="subtitle"></app-title>
  <nav>
    <a routerLink="contact" routerLinkActive="active">Contact</a>
    <a routerLink="crisis"  routerLinkActive="active">Crisis Center</a>
    <a routerLink="heroes"  routerLinkActive="active">Heroes</a>
  </nav>
  <router-outlet></router-outlet>
`

而在 AppModule 中导入 ContactModule,从而实现立即加载,而 HeroModuleCrisisModule 没有导入,它们会当用户点击其链接时异步加载:

//src/app/app.module.ts (v3)
import { NgModule }           from '@angular/core';
import { BrowserModule }      from '@angular/platform-browser';

/* App Root */
import { AppComponent }       from './app.component.3';
import { HighlightDirective } from './highlight.directive';
import { TitleComponent }     from './title.component';
import { UserService }        from './user.service';

/* Feature Modules */
import { ContactModule }      from './contact/contact.module.3';

/* Routing Module */
import { AppRoutingModule }   from './app-routing.module.3';

@NgModule({
  imports:      [
    BrowserModule,
    ContactModule,
    AppRoutingModule
  ],
  providers:    [ UserService ],
  declarations: [ AppComponent, HighlightDirective, TitleComponent ],
  bootstrap:    [ AppComponent ]
})
export class AppModule { }

AppRoutingModule 是路由模块:

//src/app/app-routing.module.ts
import { NgModule }             from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

export const routes: Routes = [
  { path: '', redirectTo: 'contact', pathMatch: 'full'},
  { path: 'crisis', loadChildren: 'app/crisis/crisis.module#CrisisModule' },
  { path: 'heroes', loadChildren: 'app/hero/hero.module#HeroModule' }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {}

里面定义了 3 个路由。contact 路由没有在这定义,而是定义在其功能模块中。通常每个功能模块内部都有一个路由组件来定义其自己的路由。

指定路由的模块文件和模块类的方式来定义按需加载路由:

//src/app/app-routing.module.ts
{ path: 'crisis', loadChildren: 'app/crisis/crisis.module#CrisisModule' },
{ path: 'heroes', loadChildren: 'app/hero/hero.module#HeroModule' }

根模块中调用 RouterModule.forRoot

RouterModule 的静态类方法 forRoot,接收一个配置对象,返回一个配置过后的 RouterModule(ModuleWithProviders)。

//src/app/app-routing.module.ts
@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {}

返回的 AppRoutingModule 类是一个路由模块,它包含 RouterModule 中的指令和 能产生路由配置信息的 dependency-injection providers。

AppRoutingModule 只用在应用的根模块中,即不要在功能模块中调用 RouterModule.forRoot

功能模块中调用 RouterModule.forChild

功能模块中的路由组件:

//src/app/contact/contact-routing.module.ts (routing)
@NgModule({
  imports: [RouterModule.forChild([
    { path: 'contact', component: ContactComponent }
  ])],
  exports: [RouterModule]
})
export class ContactRoutingModule {}

Core 模块

当其它功能都分离成一个个功能模块后,一些可imports: [ BrowserModule, ContactModule, CoreModule.forRoot({userName: ‘Miss Marple’}), AppRoutingModule ],共享的指令和组件组成成一个 SharedModule 后,AppModule 中的核心功能组成到 CoreModule 中:

//src/app/src/app/core/core.module.ts
import {
  ModuleWithProviders, NgModule,
  Optional, SkipSelf }       from '@angular/core';

import { CommonModule }      from '@angular/common';

import { TitleComponent }    from './title.component';
import { UserService }       from './user.service';
@NgModule({
  imports:      [ CommonModule ],
  declarations: [ TitleComponent ],
  exports:      [ TitleComponent ],
  providers:    [ UserService ]
})
export class CoreModule {
}
//src/app/app.module.ts (v4)
import { NgModule }       from '@angular/core';
import { BrowserModule }  from '@angular/platform-browser';

/* App Root */
import { AppComponent }   from './app.component';

/* Feature Modules */
import { ContactModule }    from './contact/contact.module';
import { CoreModule }       from './core/core.module';

/* Routing Module */
import { AppRoutingModule } from './app-routing.module';

@NgModule({
  imports: [
    BrowserModule,
    ContactModule,
    CoreModule,
    AppRoutingModule
  ],
  declarations: [ AppComponent ],
  bootstrap:    [ AppComponent ]
})
export class AppModule { }

使用 CoreModule.forRoot 来配置核心服务

按惯例,forRoot 静态方法同时提供并配置服务。它接收一个服务配置对象,并返回一个 ModuleWithProviders,即返回一个有下面属性的简单对象:

  • ngModule: 该 CoreModule
  • providers: 已配置了的提供者(the configured providers)

AppModule 导入 CoreModule,并将其 providers 添加到自身的 providers。即 Angular 先积累所有导入的 providers,再将它们放置到 @NgModule.providers 的后面。

更新 CoreModule 中的服务,使其能提供配置对象:

//src/app/core/user.service.ts (constructor)
constructor(@Optional() config: UserServiceConfig) {
  if (config) { this._userName = config.userName; }
}

CoreModule.forRoot

//src/app/core/core.module.ts (forRoot)
static forRoot(config: UserServiceConfig): ModuleWithProviders {
  return {
    ngModule: CoreModule,
    providers: [
      {provide: UserServiceConfig, useValue: config }
    ]
  };
}

最后在 AppModule 中导入:

//src/app//app.module.ts (imports)
imports: [
  BrowserModule,
  ContactModule,
  CoreModule.forRoot({userName: 'Miss Marple'}),
  AppRoutingModule
],

避免对 CoreModule 的重导入

只有根 AppModule 才能导入 CoreModule,其它按需加载的模块对其加载会出错。

//src/app/core/core.module.ts
constructor (@Optional() @SkipSelf() parentModule: CoreModule) {
  if (parentModule) {
    throw new Error(
      'CoreModule is already loaded. Import it in the AppModule only');
  }
}

参考

]]>
Angular docs-NgModule FAQs 2017-10-17T00:00:00+08:00 Haiiiiiyun haiiiiiyun.github.io/angular-docs-ngmodule-faq NgModule FAQs

什么类需回到 declarations 列表中?

declarable 类:组件、指令、管道。

应用中的每个第些类只能在一个模块中声明。

“Can’t bind to ‘x’ since it isn’t a known property of ‘y’” 什么意思?

通常指还没有声明 “x” 指令,或 “x” 所属的模块还没有被导入。

例如 “x” 是 ngModel 时,可能你还没有从 @angular/forms 中导入 FormsModule

可能 “x” 在应用的子模块中声明了,并还没有导出,”x” 需要放在 exports 列表中导出后才能被外部使用。

需要 import 什么?

若你的组件模板中要使用其它模块中导出的 declarable 类,即 import 该模块。

例如要用 NgIf 时需从 @angular/common 中导入 CommonModule

先可导入共享和功能模块。

BrowserModule 只能在根 AppModule 中导入。

导入 BrowserModule 还是 CommonModule?

BrowserModule 中对 CommonModule 进行了重导出,它提供的另外服务对加载和运行浏览器应用很重要。

只有根应用模块 AppModule 中才能在 @angular/platform-browser 中导入 BrowserModule,而其它模块只能在 @angular/common 中导入 CommonModule,从而可在组件中使用 NgIf 等指令。

需要 export 什么?

导入需要在其它模块模板中使用的组件、指令和管道类(declarable 类)。不显式导出的都默认是私有的。

而服务、函数、数据模型等非 declarable 类都无法通过 exports 列表导出。

可以重导出类和模块吗?

可以。

重导出模块时,即重导出该模块中 exports 列表中的所有 declarable 类。

一些纯服务模块(pure service module,例如 HttpModule),由于其自身没有导出任何类,即重导出没有意义。

什么是 forRoot 方法

forRoot 静态方法方便开发者来配置模块的提供者 (module’s providers),它只是一个按惯例取的名字。

例如 RouterModule.forRoot 方法:应用传送一个 Routers 对象给它,从而用该路由信息列表来配置出一个应用级的 Router 服务,它返回一个 ModuleWithProviers,可以添加到 AppModuleimports 列表中。

只有根 AppModule 才能调用并导入 .forRoot 的结果,而其它模块需要调用 RouterModule.forChild 静态方法。

功能模块中提供的服务为什么全局可见?

@NgModule.providers 列表中的提供者都是在整个应用中可用的。

当导入一个模块中,Angular 将该模块中 providers 列表中列出的服务提供者全部都添加到应用的根注入器(root injector) 中,从而使它们全局可见。也因为这样,只需在根模块中导入一次 HttpModule,在应用中都可使用该模块进行请求。

而按需加载(lazy-loaded) 模块中提供的服务只对本模块可见?

当 Angular 路由器按需加载一个模块时,它会创建一个新的执行上下文,该上下文有它自己的注入器,它是应用根注入器的直接子注入器。

路由器将该模块中的提供者及其导入模块的提供者都添加到该子注入器。Router 创建按需加载的模块中的组件时使用该上下文,即优先使用该子注入器中的同名服务。

两个模块提供相同的服务会怎样?

当两个模块一起导入,并且提供相同的服务时(provider with the same token),最后导入的会覆盖之前导入的,因此它们都添加到相同的注入器中。

如果模块 A 中定义有名为 ‘X’ 的服务,而其导入的模块 B 中也提供相同的服务,那么优先使用 A 中的服务。

AppModule 中提供的服务最优先。

如何限制服务作用域到一个模块

AppModule 中导入的模块服务都会添加到 @NgModule.providers 中,即添加到应用级的注入器中,从而是全局的。因此,在后序导入的同名服务有可能会覆盖之前导入的服务。

按需加载的模块,有自己独立的注入器,它是应用级根注入器的直接子注入器,因此,它内部的服务都是添加到该子注入器的,因而作为域为该模块。

如果不想用按需加载,那么在模块中可创建一个顶层组件,将服务添加到该顶层组件的 providers 列表中,而不是放在模块的 providers 列表中。因为 Angular 会为每个组件实例创建一个子注入器,并将该组件(及子组件)中的服务提供者添加到该组件的注入器中。从而实现服务作用域的限制。

应用级的服务提供者放在根 AppModule 中还是根 AppComponent 中?

登记在 AppModule 中的服务,在按需加载的模块中也能使用,而登记在 AppComponent 中的不能。

AppModule 对应的注入器是根注入器,是全局的,而 AppComponent 有自己的注入器(是一个子注入器),只针对该组件树中的对象。

一些服务如 Router 只能登记到全局的根注入器中。

为什么不要在共享模块中提供服务

例如在 SharedModule 中的 providers 中提供 UserService,由于共享模块会在不同的组件中被导入多次,当在立即导入的模块中使用时,由于该服务添加到了根注入器,虽然被导入了多次,但是各组件在注入后使用的都是根注入器中的一个单例实例。

但是当按需加载的模块使用时,由于它有自己的注入器,服务会添加到该子注入器中,从而使用的是另一个实例。

为什么按需加载会创建一个子注入器?

这是由 Angular 的依赖注入(dependency-injection) 系统的特性决定的。一个注入器开始被使用后就不能再登记服务了。

应用启动时,在并创建任何组件实例前,Angular 会创建一个根注入器,并将立即导入的模块中的提供者都添加到该根注入器中。一旦该注入器开始注入和分发服务时,就不再能添加新的服务了。

而按需加载的模块加载时,Angular 只能创建一个新的子注入器来登记新的服务。

如果避免模块或服务多次加载?

一些模块及它们的服务只能被根 AppModule 加载一次。如果再次在按需加载的模板中导入会出错问题,并且不好诊断错误。

只需在构造器中,尝试将本模块或服务从根注入器中注入到本身,如果注入成功,那么肯定就是第二次导入了,此时可抛出异常。

BrowserModule 及之前的 CoreModule 都有这种措施:

//src/app/core/core.module.ts (Constructor)
constructor (@Optional() @SkipSelf() parentModule: CoreModule) {
  if (parentModule) {
    throw new Error(
      'CoreModule is already loaded. Import it in the AppModule only');
  }
}

什么是 entry component

一个 entry component 就是 Angular 通过其类型以命令方式加载的任何组件。

通过组件的选择子声明式的加载的组件不是 entry component。

大部分组件都是声明式加载的,即在组件模板中使用选择子加载。

有些组件只能动态加载,并且不能在组件模板中使用。

启动根 AppComponent 就是一个 entry component。虽然其选择子在 index.html 中使用,但是 index.html 不是组件模板。Angular 动态加载 AppComponent,因为它要么通过列在 @NgModule.bootstrap 中加载,要么使用模块的 ngDoBootstrape 方法命令式的加载。

在路由定义中的组件也都是 entry component。因为路由定义通过组件类型来引用。路由会忽略组件的选择子,而通过其类型动态将组件加载到 RouterOutlet 中。

entry component 必须在 @NgModule.entryComponents 列表中列出才会被编译器识别出,此外下列的组件也会自动添加到该列表:

  • @NgModule.bootstrap 列表中的组件
  • 在路由配置中引用的组件

何时需将组件添加到 entryComponents 列表?

上个 FAQ 中,自动添加的情况已经包括了最常见的情景。因此一般的一般都无需再手工添加组件到 entryComponents。

必须手工添加到该列表的组件不能有在其它组件的模板中引用。

为什么 Angular 需要 entryComponents?

这是为编译生成的代码的性能考虑的(tree shaking,将用不到的组件排除到最终的代码外)。

Angular 编译器使用递归策略只为使用到的组件生成代码。模板中使用选择子引用的都包括,在 entryComponents 声明的也包括进行,而在包括进来的所有组件的模板中全部递归查找到还没有找到的组件,说明是未使用的,而排序掉,不包含在最终编译后的代码中。

需要哪些模块,如果使用?

SharedModule

共享模块只封装共享的组件、指令和管道,是一个 declarations 型模块。也可以重导出 CommonModule, FormsModule 等模块。

但是共享模块不能提供服务,即不能有 providers,并且重导出的模块中也不能有 providers

CoreModule

CoreModuleproviders 中可登记当应用开始时能使用的单例服务。它只能在 AppModule 中导入。一般是一个纯服务型模块(pure services module),没有 declarations

各种功能模块

Angular 和 JS 模块的区别

JS 中每个文件就是一个模块,文件中可用 export 导出,而在其它文件中用 import 导入。

Angular 的模块 NgModule 也有 importsexports。一个 NgModule 导入另一个 NgModule 后,可以在本模块的组件中使用另一个模块是导出的类。

有以下区别:

  • NgModule 只关注 declarable 类:组件、指令和管道。
  • NgModule 中的所有类在 @NgModule.delarations 中声明。
  • NgModule 只能导出其 declarable 类,只能导入其它模块的 declarable 类。
  • NgModule.providers 中的服务可扩展应用的服务功能。

下面是一个 NgModule 的例子:

//ngmodule/src/app/contact/contact.module.ts
@NgModule({
  imports:      [ CommonModule, FormsModule ],
  declarations: [ ContactComponent, HighlightDirective, AwesomePipe ],
  exports:      [ ContactComponent ],
  providers:    [ ContactService ]
})
export class ContactModule { }

Angular 如何在模板中查找组件、指令和管道?什么是模板引用?

Angular 编译器在模板中通过选择子、管道语法来匹配查找,找到时,就是一个模板引用。

什么是 Angular 编译器?

编译器将我们编译的代码转换成高性能的 JS 代码。而 @NgModule 中的 metadata 将引导编译的过程。

NgModule 的 API

下面是 NgModule metadata 中的属性:

  • declarations: 属性该模块的一组 declarable 类:组件、指令和管道类。这些类只能在一个模板中声明。
  • providers: 一组 dependency-injection providers。立即加载的模块中提供的服务都登记在主注入器中,全局可见,而按需加载的模块中提供的服务都登记在另一个子注入器中,只那个模板可见了。
  • imports: 一组支持模块。导入后可在本模块的组件模板中使用导入模板中导出的组件、指令和管道。
  • exports: 一组要导出的 declarable 类,也可以包括重导出的其它模块。
  • bootstrap: 一组启动组件。通常只有一个,即根组件。
  • entryComponents: 一组不是通过模板中的选择子引用的组件。## 参考

参考

]]>
Angular docs-表单-使用 reactive forms 创建动态表单 2017-10-16T00:00:00+08:00 Haiiiiiyun haiiiiiyun.github.io/angular-docs-forms-dynamic-form 使用 reactive forms 创建动态表单

使用 formGroup 动态呈现不能控件和验证器的表单。

Bootstrap

在 AppModule 中加载 ReactiveFormsModule:

//app.module.ts
import { BrowserModule }                from '@angular/platform-browser';
import { ReactiveFormsModule }          from '@angular/forms';
import { NgModule }                     from '@angular/core';

import { AppComponent }                 from './app.component';
import { DynamicFormComponent }         from './dynamic-form.component';
import { DynamicFormQuestionComponent } from './dynamic-form-question.component';

@NgModule({
  imports: [ BrowserModule, ReactiveFormsModule ],
  declarations: [ AppComponent, DynamicFormComponent, DynamicFormQuestionComponent ],
  bootstrap: [ AppComponent ]
})
export class AppModule {
  constructor() {
  }
}


//main.ts
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';

platformBrowserDynamic().bootstrapModule(AppModule);

Question 数据模型

每个动态呈现的表单都表示一个问题。先创建基本的类 QuestionBase

//src/app/question-base.ts
export class QuestionBase<T>{
  value: T;
  key: string;
  label: string;
  required: boolean;
  order: number;
  controlType: string;

  constructor(options: {
      value?: T,
      key?: string,
      label?: string,
      required?: boolean,
      order?: number,
      controlType?: string
    } = {}) {
    this.value = options.value;
    this.key = options.key || '';
    this.label = options.label || '';
    this.required = !!options.required;
    this.order = options.order === undefined ? 1 : options.order;
    this.controlType = options.controlType || '';
  }
}

再创建呈现文件框问题的子类 TextboxQuestion 和呈现下拉选择框的问题子类 DropdownQuestion

TextboxQuestion 中能基于 type 属性支持 text, email, url 等类型:

//src/app/question-textbox.ts
import { QuestionBase } from './question-base';

export class TextboxQuestion extends QuestionBase<string> {
  controlType = 'textbox';
  type: string;

  constructor(options: {} = {}) {
    super(options);
    this.type = options['type'] || '';
  }
}
//src/app/question-dropdown.ts
import { QuestionBase } from './question-base';

export class DropdownQuestion extends QuestionBase<string> {
  controlType = 'dropdown';
  options: {key: string, value: string}[] = [];

  constructor(options: {} = {}) {
    super(options);
    this.options = options['options'] || [];
  }
}

创建 QuestionControlService 将各问题数据模型转换返回 FormGroup

//src/app/question-control.service.ts
import { Injectable }   from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';

import { QuestionBase } from './question-base';

@Injectable()
export class QuestionControlService {
  constructor() { }

  toFormGroup(questions: QuestionBase<any>[] ) {
    let group: any = {};

    questions.forEach(question => {
      group[question.key] = question.required ? new FormControl(question.value || '', Validators.required)
                                              : new FormControl(question.value || '');
    });
    return new FormGroup(group);
  }
}

Question 表单组件

DynamicFormComponent 是入口及表单的主容器:

//dynamic-form.component.ts
import { Component, Input, OnInit }  from '@angular/core';
import { FormGroup }                 from '@angular/forms';

import { QuestionBase }              from './question-base';
import { QuestionControlService }    from './question-control.service';

@Component({
  selector: 'dynamic-form',
  templateUrl: './dynamic-form.component.html',
  providers: [ QuestionControlService ]
})
export class DynamicFormComponent implements OnInit {

  @Input() questions: QuestionBase<any>[] = [];
  form: FormGroup;
  payLoad = '';

  constructor(private qcs: QuestionControlService) {  }

  ngOnInit() {
    this.form = this.qcs.toFormGroup(this.questions);
  }

  onSubmit() {
    this.payLoad = JSON.stringify(this.form.value);
  }
}
<!--dynamic-form.component.html-->
<div>
  <form (ngSubmit)="onSubmit()" [formGroup]="form">

    <div *ngFor="let question of questions" class="form-row">
      <df-question [question]="question" [form]="form"></df-question>
    </div>

    <div class="form-row">
      <button type="submit" [disabled]="!form.valid">Save</button>
    </div>
  </form>

  <div *ngIf="payLoad" class="form-row">
    <strong>Saved the following values</strong><br>
  </div>
</div>

DynamicFormQuestionComponent 基于绑定的 Question 内的数值,呈现具体的 Question:

//dynamic-form-question.component.ts
import { Component, Input } from '@angular/core';
import { FormGroup }        from '@angular/forms';

import { QuestionBase }     from './question-base';

@Component({
  selector: 'df-question',
  templateUrl: './dynamic-form-question.component.html'
})
export class DynamicFormQuestionComponent {
  @Input() question: QuestionBase<any>;
  @Input() form: FormGroup;
  get isValid() { return this.form.controls[this.question.key].valid; }
}
<!--dynamic-form-question.component.html-->
<div [formGroup]="form">
  <label [attr.for]="question.key"></label>

  <div [ngSwitch]="question.controlType">

    <input *ngSwitchCase="'textbox'" [formControlName]="question.key"
            [id]="question.key" [type]="question.type">

    <select [id]="question.key" *ngSwitchCase="'dropdown'" [formControlName]="question.key">
      <option *ngFor="let opt of question.options" [value]="opt.key"></option>
    </select>

  </div> 

  <div class="errorMessage" *ngIf="!isValid"> is required</div>
</div>

获取问题集

//src/app/question.service.ts
import { Injectable }       from '@angular/core';

import { DropdownQuestion } from './question-dropdown';
import { QuestionBase }     from './question-base';
import { TextboxQuestion }  from './question-textbox';

@Injectable()
export class QuestionService {

  // Todo: get from a remote source of question metadata
  // Todo: make asynchronous
  getQuestions() {

    let questions: QuestionBase<any>[] = [

      new DropdownQuestion({
        key: 'brave',
        label: 'Bravery Rating',
        options: [
          {key: 'solid',  value: 'Solid'},
          {key: 'great',  value: 'Great'},
          {key: 'good',   value: 'Good'},
          {key: 'unproven', value: 'Unproven'}
        ],
        order: 3
      }),

      new TextboxQuestion({
        key: 'firstName',
        label: 'First name',
        value: 'Bombasto',
        required: true,
        order: 1
      }),

      new TextboxQuestion({
        key: 'emailAddress',
        label: 'Email',
        type: 'email',
        order: 2
      })
    ];

    return questions.sort((a, b) => a.order - b.order);
  }
}

AppComponent 中显示一个表单实例:

//app.component.ts
import { Component }       from '@angular/core';

import { QuestionService } from './question.service';

@Component({
  selector: 'my-app',
  template: `
    <div>
      <h2>Job Application for Heroes</h2>
      <dynamic-form [questions]="questions"></dynamic-form>
    </div>
  `,
  providers:  [QuestionService]
})
export class AppComponent {
  questions: any[];

  constructor(service: QuestionService) {
    this.questions = service.getQuestions();
  }
}

参考

]]>
Angular docs-表单-启动 2017-10-16T00:00:00+08:00 Haiiiiiyun haiiiiiyun.github.io/angular-docs-bootstrapping 启动

新建的工程都会如下的 AppModule:

//src/app/app.module.ts
import { NgModule }      from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent }  from './app.component';

@NgModule({
  imports:      [ BrowserModule ],
  declarations: [ AppComponent ],
  bootstrap:    [ AppComponent ]
})
export class AppModule { }

@NgModule 装饰器将 AppModule 类装饰为一个 NgModule 类,其 metadata 告诉 Angular 如何编译和加载应用:

  • imports: 所有需要在浏览器中运行的应用都需要 BrowserModule
  • declarations: 应用包含的所有组件
  • bootstrap: 所有的根组件,Angular 会创建并插入 index.html

imports 数组

NgModule(即 Angular 模块)是将相关功能组合在一起的一种方式。Angular 中的很多功能都组合为 NgModule 的形式,如 HTTP 服务的 HttpModule,路由功能的 RouterModule 等。

当应用需要需要某些功能时,则将相关 NgModule 加入到 imports 数组中。同时,只有 NgModule 类才能加入该 imports 数组中,其它类都不行。

declarations 数组

通过将组件在该数组中列出,来告诉 Angular 哪些组件属性该 AppModule。用个的每个组件都必须先要在一个 NgModule 类(这里是 AppModule) 中声明。

自定义的指令和管道,也必须在该数组中声明。除这 3 种类型的类之外的类,都不能放在该数组中(如 NgModule, service, model 类等)。

bootstrap 数组

启动时,Angular 会创建该数组中列出的所有组件实例,并全部插入到 DOM 中。

每个启动组件都是一个组件树的根。虽然是数组,但一般的应用都只列出一个组件,即根组件(如 AppComponent)。

main.ts 中启动

启动应用有多种方式,基于采取何种编译方式和在哪里运行应用。

开始时,可采用 Just-in-Time(JIT) 编译器来动态编译应用,并在浏览器中运行。

推荐在 src/main.ts 中启动 JIT 编译型的浏览器应用:

//src/main.ts
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule }              from './app/app.module';

platformBrowserDynamic().bootstrapModule(AppModule);

参考

]]>
Angular docs-表单-Reactive 表单 2017-10-15T00:00:00+08:00 Haiiiiiyun haiiiiiyun.github.io/angular-docs-forms-reactive-forms Reactive forms 是用 reactive 风格创建表单的一种技术。

Reactive forms 和 template-driven forms 是 Angular 中创建表单的 2 种技术。它们都位于 @angular/forms 库,共享一组通用的表单控件类。它们在理念、编程风格上有区别。它们有不同的模块名: ReactiveFormsModuleFormsModule

Reactive 表单

Reactive 编码风格偏好显式管理数据流:即管理非 UI 的数据模型(data model, 通常从服务端获取)和 UI 的表单模型 (form model) 间的数据流,而表单模型可用来保存屏幕上 HTML 控件的值和状态。同时 reactive forms 提供了使用 reactive 模式、测试和验证的使用方法。

使用 reactive forms 时,我们在组件中先创建 Angular 表单控件对象树,再在模板中将它们绑定到表单元素上。

直接在组件类中创建和处理表单控件对象。由于组件类可直接访问数据模型和表单控件结构,我们可以将数据模板的值推送到表单控件中,也能抽回用户修改的值。组件能观测到表单控件的状态并作出响应。

直接操作表单控件对象的一个好处是:值和有效性的更新 总是同步的并且在你的控制下,从而不会碰到模板驱动的表单中的时间点的问题,因此,reactive forms 的单元测试也更加容易。

按照 reactive 范式,组件会保持数据模型的不变性,只将它作为一个纯粹的数值源。组件不直接更新数据模型,它先将用户的修改抽取出来,转发给外部组件或服务,外部组件或服务进行处理后(例如进行保存)将返回一个新的数据模型,组件将返回的新数据模型用来更新数据模型。

模板驱动的表单

将 HTML 表单控制(如 <input>) 放在组件模板中,使用 ngModel 将它们绑定到组件的数据模型的属性上。

我们不直接创建 Angular 表单控件对象,Angular 基于数据绑定信息为我们自动创建。我们不抽取的推送数据值,Angular 通过 ngModel 进行处理。当有修改时,Angular 更新那个可修改的数据模型。

因此,ngModle 指令不存在于 ReactiveFormsModule

异步 vs. 同步

reactive 表单是同步的,模板驱动的表单是异步的。

在 reactive 表单中,我们直接创建整个表单控件对象树,因此可以更新所有控件数据。

而模板驱动的表单中,表单控件的创建是由指令进行的。为避免 “changed after checked” 错误,这些指令都要花费多一个周期来创建整个控件树,即必须等待一个 tick 后才能在组件类中处理控件。

例如,如果用 @ViewChild(NgForm) 语句来获取注入的表单控件,并在 ngAfterViewInit 挂钩中检查,我们会发现组件没有该子对象。必须等待一个 tick( 使用 setTimeout) 后,才能访问该控件。

模板驱动表单的异步性也使单元测试很复杂。必须将测试块包装在 async()fakeAsync() 中来避免表单还不可用的问题。

下面以例子说明

创建数据模型

//src/app/data-model.ts
export class Hero {
  id = 0;
  name = '';
  addresses: Address[];
}

export class Address {
  street = '';
  city   = '';
  state  = '';
  zip    = '';
}

export const heroes: Hero[] = [
  {
    id: 1,
    name: 'Whirlwind',
    addresses: [
      {street: '123 Main',  city: 'Anywhere', state: 'CA',  zip: '94801'},
      {street: '456 Maple', city: 'Somewhere', state: 'VA', zip: '23226'},
    ]
  },
  {
    id: 2,
    name: 'Bombastic',
    addresses: [
      {street: '789 Elm',  city: 'Smallville', state: 'OH',  zip: '04501'},
    ]
  },
  {
    id: 3,
    name: 'Magneta',
    addresses: [ ]
  },
];

export const states = ['CA', 'MD', 'OH', 'VA'];

创建 reactive forms 的组件

//src/app/hero-detail.component.ts
import { Component }              from '@angular/core';
import { FormControl }            from '@angular/forms';

@Component({
  selector: 'hero-detail',
  templateUrl: './hero-detail.component.html'
})

export class HeroDetailComponent1 {
  name = new FormControl();
}

FormControl 是一个指令,可用来创建和管理 FormControl 实例,实例将绑定到模板中的 <input> 上。其构造器接收 3 个可选参数:初始值,一组验证器,另一个异步验证器。

创建模板

<!--src/app/hero-detail.component.html-->
<h2>Hero Detail</h2>
<h3><i>Just a FormControl</i></h3>
<label class="center-block">Name:
  <input class="form-control" [formControl]="name">
</label>

使用 [formControl]="name" 将 input 元素绑定到组件中的一个名为 name 的 FormControl 实例属性。

导入 ReactiveFormsModule

//src/app/app.module.ts (excerpt)
import { NgModule }            from '@angular/core';
import { BrowserModule }       from '@angular/platform-browser';
import { ReactiveFormsModule } from '@angular/forms';  // <-- #1 import module

import { AppComponent }        from './app.component';
import { HeroDetailComponent } from './hero-detail.component'; // <-- #1 import component

@NgModule({
  imports: [
    BrowserModule,
    ReactiveFormsModule // <-- #2 add to @NgModule imports
  ],
  declarations: [
    AppComponent,
    HeroDetailComponent, // <-- #3 declare app component
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }

核心的表单类

  • AbstractControl 是 3 个具体表单控制类 FormControl, FormGroup, FormArray 的抽象基类。它提供它们的通用行为和属性,有些是可 observalbe 的。
  • FormControl 用来跟踪单个表单控件的值和有效性状态。它对应于一个 HTML 表单控件。
  • FormGroup 用来跟踪一组 AbstractControl 实例的值和有效性状态。组属性中包含它的所有子控件。组件中的顶层表单就是一个 FormGroup
  • FormArray 用来跟踪一组用数字索引的 AbstractControl 实例的值和有效性状态。

添加一个 FormGroup

如果有多个 FormControl 的话,通常将它们登记在一个父 FormGroup 下:

//src/app/hero-detail.component.ts
import { Component }              from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';

export class HeroDetailComponent2 {
  heroForm = new FormGroup ({
    name: new FormControl()
  });
}

同步更新模板中的绑定方式:

<!--src/app/hero-detail.component.html-->
<h2>Hero Detail</h2>
<h3><i>FormControl in a FormGroup</i></h3>
<form [formGroup]="heroForm" novalidate>
  <div class="form-group">
    <label class="center-block">Name:
      <input class="form-control" formControlName="name">
    </label>
  </div>
</form>
  • <form> 元素中的 novalidate 属性避免浏览器使用本地的 HTML 验证。
  • formGroup 是一个 reactive forms 中的指令,它将当前表单关联到一个 FormGoup 实例。
  • <form> 中的 <input> 使用 fromControlName="name" 绑定,它将绑定到父 FormGroup 实例中的一个名为 name 的控件实例。

查看表单的数据模型

<!--src/app/hero-detail.component.html-->
<p>Form value: </p>
<p>Form status: </p>

heroForm.value 返回表单的数据模型。

使用 FormBuilder 来创建和维护复杂的表单

//src/app/hero-detail.component.ts (excerpt)
import { Component }              from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';

export class HeroDetailComponent3 {
  heroForm: FormGroup; // <--- heroForm is of type FormGroup

  constructor(private fb: FormBuilder) { // <--- inject FormBuilder
    this.createForm();
  }

  createForm() {
    this.heroForm = this.fb.group({
      name: '', // <--- the FormControl called "name"
    });
  }
}
  • 先声明 heroForm 属性为 FormGroup
  • 将 FormBuilder 注入到构造器
  • FormBuilder.group 是一个能创建 FormGroup 实例的工厂函数,接收的参数是一个键值对对象,用来定义 FormControl 的名字和定义。本例中, name 控件的初始值被设置为 ‘’

使用 Validator.required 验证器

//src/app/hero-detail.component.ts (excerpt)
import { Component }                          from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

//...
this.heroForm = this.fb.group({
  name: ['', Validators.required ],
});
  • 这里 name 控件的定义体是一个数组,有 2 元素分别是:初始值,验证器
  • Reactive 验证器都是简单的,可组合的函数。使用 Validators.required 后,heroForm.status 的值可为 "INVALID""VALID"

更多 FormControl

添加 address 等,address 还有 state 属性,需要使用 <select> 来实现选择:

//src/app/hero-detail.component.ts (excerpt)
import { Component }                          from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

import { states } from './data-model';

//...
export class HeroDetailComponent4 {
  heroForm: FormGroup;
  states = states;

  constructor(private fb: FormBuilder) {
    this.createForm();
  }

  createForm() {
    this.heroForm = this.fb.group({
      name: ['', Validators.required ],
      street: '',
      city: '',
      state: '',
      zip: '',
      power: '',
      sidekick: ''
    });
  }
}

在模板中绑定:

<!--src/app/hero-detail.component.html-->
<h2>Hero Detail</h2>
<h3><i>A FormGroup with multiple FormControls</i></h3>
<form [formGroup]="heroForm" novalidate>
  <div class="form-group">
    <label class="center-block">Name:
      <input class="form-control" formControlName="name">
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">Street:
      <input class="form-control" formControlName="street">
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">City:
      <input class="form-control" formControlName="city">
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">State:
      <select class="form-control" formControlName="state">
          <option *ngFor="let state of states" [value]="state"></option>
      </select>
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">Zip Code:
      <input class="form-control" formControlName="zip">
    </label>
  </div>
  <div class="form-group radio">
    <h4>Super power:</h4>
    <label class="center-block"><input type="radio" formControlName="power" value="flight">Flight</label>
    <label class="center-block"><input type="radio" formControlName="power" value="x-ray vision">X-ray vision</label>
    <label class="center-block"><input type="radio" formControlName="power" value="strength">Strength</label>
  </div>
  <div class="checkbox">
    <label class="center-block">
      <input type="checkbox" formControlName="sidekick">I have a sidekick.
    </label>
  </div>
</form>


<p>Form value: </p>

嵌套的 FormGroup

可将 street, city 等地址相关的控件组合成一个名为 address的 FormGroup,这样可以对这组控件的状态和有效性进行集中跟踪。

之前用 FormBuilder.group 为表单创建了一个 FormGroup,现可再用 group 创建嵌套的 FormGroup

//src/app/hero-detail.component.ts (excerpt)
export class HeroDetailComponent5 {
  heroForm: FormGroup;
  states = states;

  constructor(private fb: FormBuilder) {
    this.createForm();
  }

  createForm() {
    this.heroForm = this.fb.group({ // <-- the parent FormGroup
      name: ['', Validators.required ],
      address: this.fb.group({ // <-- the child FormGroup
        street: '',
        city: '',
        state: '',
        zip: ''
      }),
      power: '',
      sidekick: ''
    });
  }
}

在模板中绑定时,将创建一个 <div> 来包围所有地址控件,其 formGroupName 绑定到 address(从而创建了一个类型 FormGroup 的父对象),再在 <div> 内部声明各控件,并通过 formControlName 进行绑定(相对于其直接父对象):

//src/app/hero-detail.component.html (excerpt)
<div formGroupName="address" class="well well-lg">
  <h4>Secret Lair</h4>
  <div class="form-group">
    <label class="center-block">Street:
      <input class="form-control" formControlName="street">
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">City:
      <input class="form-control" formControlName="city">
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">State:
      <select class="form-control" formControlName="state">
        <option *ngFor="let state of states" [value]="state"></option>
      </select>
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">Zip Code:
      <input class="form-control" formControlName="zip">
    </label>
  </div>
</div>

FormControl 属性研究

FormGroup 对象中,可直接用 get() 来获取其包含的 FormControl 实例,例如:

<p>Name value: </p>
<p>Street value: </p>

FormControl 有如下属性:

属性名 描述
obj.value 控件的值
obj.status 控件的状态值,有 VALID, INVALID, PENDING, DISABLED
obj.pristine 当用户没有进行修改时为真,与 obj.dirty 相反
obj.untouched 当用户没有使用过该控件时为真,与 obj.touched 相反

数据模型和表单模型

数据模型(本例是 hero)一般从服务端获取。 FormControl 树就是表单模型。

组件必须将数据模型中的 hero 值复制到表单模型中。需注意 2 点:

  1. 必须理解数据模型中的属性是如何映射到表单模型中的属性上的
  2. 用户的修改从 DOM 元素流向表单模型,而不是到数据模型。表单控件不可能会修改数据模型。

表单和数据模型结果无需完全匹配,我们一般只在特定页面上显示数据模型中的一个子块。

本例中,数据模型和表单模型非常相似,如下:

//src/app/data-model.ts (classes)
export class Hero {
  id = 0;
  name = '';
  addresses: Address[];
}

export class Address {
  street = '';
  city   = '';
  state  = '';
  zip    = '';
}
//src/app/hero-detail.component.ts (excerpt)
this.heroForm = this.fb.group({
  name: ['', Validators.required ],
  address: this.fb.group({
    street: '',
    city: '',
    state: '',
    zip: ''
  }),
  power: '',
  sidekick: ''
});

为简洁起见,重构表单模型为:

//src/app/hero-detail-7.component.ts
this.heroForm = this.fb.group({
  name: ['', Validators.required ],
  address: this.fb.group(new Address()), // <-- a FormGroup with a new address
  power: '',
  sidekick: ''
});

通过 setValue 和 patchValue 为表单模型设置值

setValue

参数是一个键值对,键值对中的结构(个数、名字)必须与表单模型完全匹配,不然会出错并返回错误消息,例如:

this.heroForm.setValue({
  name:    this.hero.name,
  address: this.hero.addresses[0] || new Address()
});

patchValue

只需传入感兴趣的控件设置信息,如:

this.heroForm.patchValue({
  name: this.hero.name
});

何时设置表单模型的值 (ngOnChanges)

当组件获取到数据模型值时即要更新表单模型的值。

本例中 HeroDetailComponent 嵌套在一个 master/detail 的 HeroListComponent 中。当用户点击列表中的一个 hero 时,会将选中的 hero 通过属性绑定传给 HeroDetailComponent

<!--hero-list.component.html (simplified)-->
<nav>
  <a *ngFor="let hero of heroes | async" (click)="select(hero)"></a>
</nav>

<div *ngIf="selectedHero">
  <hero-detail [hero]="selectedHero"></hero-detail>
</div>

每当用户选中一个新 hero 时 HeroDetailComponent 中的 hero 即有修改,会触发 ngOnChanges 挂钩调用,因此可在组件的 ngOnChanges 挂钩中使用 setValue 来更新表单模型值:

//src/app/hero-detail.component.ts (core imports)
import { Component, Input, OnChanges }             from '@angular/core';

//src/app/hero-detail-6.component.ts
@Input() hero: Hero;

//src/app/hero-detail.component.ts (ngOnchanges)
ngOnChanges()
  this.heroForm.setValue({
    name:    this.hero.name,
    address: this.hero.addresses[0] || new Address()
  });
}

重置表单状态

选中的 hero 修改后应重置表单,将控件值清空,状态重置为 pristine,可在 ngOnChanges 中首行调用 this.heroForm.reset() 实现。

reset()setValue() 也可以合并为如下:

//src/app/hero-detail.component.ts (ngOnchanges - revised)
ngOnChanges() {
  this.heroForm.reset({
    name: this.hero.name,
    address: this.hero.addresses[0] || new Address()
  });
}

使用 FormArray 来表示一组 FormGroup

FormGroup 可以嵌套 FormControlFormGroup,而 FormArray 能表示一组 FormGroup

本例中 address 是一个 FormGroup,而 hero 有多个地址,可用 FormArray 表示(重命名为 secretLaires):

//src/app/hero-detail-8.component.ts
this.heroForm = this.fb.group({
  name: ['', Validators.required ],
  secretLairs: this.fb.array([]), // <-- secretLairs as an empty FormArray
  power: '',
  sidekick: ''
});

初始化 FormArray “secretLairs”

//src/app/hero-detail-8.component.ts
setAddresses(addresses: Address[]) {
  const addressFGs = addresses.map(address => this.fb.group(address));
  const addressFormArray = this.fb.array(addressFGs);
  this.heroForm.setControl('secretLairs', addressFormArray);
}

FormGroup.setControl() 方法用来替换原来的 FormArray 实例。

获取 FormArray

//src/app/hero-detail.component.ts (secretLayers property)
get secretLairs(): FormArray {
  return this.heroForm.get('secretLairs') as FormArray;
};

显示 FormArray

使用 *ngFor 来迭代显示,有如下关键点:

  • 添加一层 <div>,应用 *ngFor,设置 formArrayName 指令为 "secretLairs",从而将该 FormArray 实例设置为内部模板中的表单控件的上下文。
  • 迭代的是 FormArray.controls,而不是 FormArray 实例本身,每个 control 就是一个 address FormGroup
  • 每个迭代的 FormGroup 需要一个唯一的 formGroupName,其值就是该 FormGroupFormArray 中的索引值。
<!--src/app/hero-detail.component.html (excerpt)-->
<div formArrayName="secretLairs" class="well well-lg">
  <div *ngFor="let address of secretLairs.controls; let i=index" [formGroupName]="i" >
    <!-- The repeated address template -->
    <h4>Address #</h4>
    <div style="margin-left: 1em;">
      <div class="form-group">
        <label class="center-block">Street:
          <input class="form-control" formControlName="street">
        </label>
      </div>
      <div class="form-group">
        <label class="center-block">City:
          <input class="form-control" formControlName="city">
        </label>
      </div>
      <div class="form-group">
        <label class="center-block">State:
          <select class="form-control" formControlName="state">
            <option *ngFor="let state of states" [value]="state"></option>
          </select>
        </label>
      </div>
      <div class="form-group">
        <label class="center-block">Zip Code:
          <input class="form-control" formControlName="zip">
        </label>
      </div>
    </div>
    <br>
    <!-- End of the repeated address template -->
  </div>
</div>

为 FormArray 添加新元素

//src/app/hero-detail.component.ts (addLair method)
addLair() {
  this.secretLairs.push(this.fb.group(new Address()));
}

观测控件的修改

ngOnChanges 无法观测到 hero 中名字,secret lairs 值的修改情况。

不过每个 FormControl 中的 valueChanges 属性,会返回 RxJS Observable,可以进行订阅,从而观测到控件的修改情况:

//src/app/hero-detail.component.ts (logNameChange)
nameChangeLog: string[] = [];
logNameChange() {
  const nameControl = this.heroForm.get('name');
  nameControl.valueChanges.forEach(
    (value: string) => this.nameChangeLog.push(value)
  );
}

//Call it in the constructor, after creating the form.
//src/app/hero-detail-8.component.ts
constructor(private fb: FormBuilder) {
  this.createForm();
  this.logNameChange();
}

保存表单数据

保存

当点击提交时,先根据原数据模型中的数据和当前表单模型中的数据(用深度复制的方法)准备好要提交的数据; 提交更新,完成后设置新的数据模型数据;最后调用 ngOnChanges() 刷新表单模型数据:

//src/app/hero-detail.component.ts (onSubmit)
onSubmit() {
  this.hero = this.prepareSaveHero();
  this.heroService.updateHero(this.hero).subscribe(/* error handling */);
  this.ngOnChanges();
}

//src/app/hero-detail.component.ts (prepareSaveHero)
prepareSaveHero(): Hero {
  const formModel = this.heroForm.value;

  // deep copy of form model lairs
  const secretLairsDeepCopy: Address[] = formModel.secretLairs.map(
    (address: Address) => Object.assign({}, address)
  );

  // return new `Hero` object containing a combination of original hero value(s)
  // and deep copies of changed form model values
  const saveHero: Hero = {
    id: this.hero.id,
    name: formModel.name as string,
    // addresses: formModel.secretLairs // <-- bad!
    addresses: secretLairsDeepCopy
  };
  return saveHero;
}

撤销修改

由于当表单模型数据修改后,数据模型不会自动修改,故只有调用 ngOnChanges() 恢复表单模型数据即可:

//src/app/hero-detail.component.ts (revert)
revert() { this.ngOnChanges(); }

参考

]]>
Angular docs-表单-表单验证 2017-10-15T00:00:00+08:00 Haiiiiiyun haiiiiiyun.github.io/angular-docs-forms-form-validation 表单验证

模板驱动表单的验证

添加验证器跟在本地 HTML 表单中添加验证器是一样的。Angular 用指令将这些验证器属性匹配成框架中的验证器函数。

每当表单控件中的值有修改时,Angular 将运行验证器,生成一组验证错误信息(使控件的状态为 INVALID),或生成 null(控件的状态为 VALID)。

通过将控件的 ngModel 导出为一个本地模板变量,可通过该变量查看控件的状态:

<!--template/hero-form-template.component.html (name)-->
<input id="name" name="name" class="form-control"
       required minlength="4" forbiddenName="bob"
       [(ngModel)]="hero.name" #name="ngModel" >

<div *ngIf="name.invalid && (name.dirty || name.touched)"
     class="alert alert-danger">

  <div *ngIf="name.errors.required">
    Name is required.
  </div>
  <div *ngIf="name.errors.minlength">
    Name must be at least 4 characters long.
  </div>
  <div *ngIf="name.errors.forbiddenName">
    Name cannot be Bob.
  </div>

</div>

<input> 以普通属性的形式(required, minlength, 及自定义验证器指令 forbiddenName) 来添加验证器。

Reactive 表单的验证

可直接在组件类中的表单控件模型中添加。

验证函数

共有 2 种:

  • 同步型验证函数接收一个控件实例为参数,立即返回,要么返回一组验证错误,要么返回 null。它能作为第 2 个参数传给 FormControl
  • 异步型验证函数接收一个控件实例为参数,返回 Promise 或 Observable,之后再触发返回一组验证错误或 null。能作为第 3 个参数传给 FormControl

为性能的原因,只有全部同步型验证函数通过后才会运行异步型验证函数。每个函数都必须运行完成后才能设置错误。

内置验证器

Validators

这些验证器都定义在 Validators 类中。使用在模板驱动的表单中时,直接使用其名字,而使用时 reactive 表单中时,使用时函数形式,如 Validators.required

//reactive/hero-form-reactive.component.ts (validator functions)
ngOnInit(): void {
  this.heroForm = new FormGroup({
    'name': new FormControl(this.hero.name, [
      Validators.required,
      Validators.minLength(4),
      forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.
    ]),
    'alterEgo': new FormControl(this.hero.alterEgo),
    'power': new FormControl(this.hero.power, Validators.required)
  });
}

get name() { return this.heroForm.get('name'); }

get power() { return this.heroForm.get('power'); }

自定义验证器

//shared/forbidden-name.directive.ts (forbiddenNameValidator)
/** A hero's name can't match the given regular expression */
export function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
  return (control: AbstractControl): {[key: string]: any} => {
    const forbidden = nameRe.test(control.value);
    return forbidden ? {'forbiddenName': {value: control.value}} : null;
  };
}

这个函数实际是一个工厂函数,它返回一个 Validator 函数。

添加到 Reactive 表单

只需简单地将函数添加入即可:

//reactive/hero-form-reactive.component.ts (validator functions)
this.heroForm = new FormGroup({
  'name': new FormControl(this.hero.name, [
    Validators.required,
    Validators.minLength(4),
    forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.
  ]),
  'alterEgo': new FormControl(this.hero.alterEgo),
  'power': new FormControl(this.hero.power, Validators.required)
});

添加到模板驱动型表单

模板中需要以指令的形式进行添加,故用 ForbiddenValidatorDirective 指令来包装 forbiddenNameValidator 验证器。

//shared/forbidden-name.directive.ts (directive)
@Directive({
  selector: '[forbiddenName]',
  providers: [{provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true}]
})
export class ForbiddenValidatorDirective implements Validator {
  @Input() forbiddenName: string;

  validate(control: AbstractControl): {[key: string]: any} {
    return this.forbiddenName ? forbiddenNameValidator(new RegExp(this.forbiddenName, 'i'))(control)
                              : null;
  }
}

该指令实现了 Validator 接口,从而能与表单进行整合。指令通过将它自身登记为 NG_VALIDATORS 的提供者,从而在验证进程中,Angular 能识别出该指令的角色。

之后,在模板中,直接用指令的选择子进行添加:

<input id="name" name="name" class="form-control"
       required minlength="4" forbiddenName="bob"
       [(ngModel)]="hero.name" #name="ngModel" >

控件状态的 CSS 类

Angular 会自动将控件中的许多属性以 CSS 类的形式反映到表单控件上。当前支持的 CSS 类有:

  • .ng-valid
  • .ng-invalid
  • .ng-pending
  • .ng-pristine
  • .ng-dirty
  • .ng-untouched
  • .ng-touched

下面是根据状态 CSS 设置不能边框颜色的例子:

.ng-valid[required], .ng-valid.required  {
  border-left: 5px solid #42A948; /* green */
}

.ng-invalid:not(form)  {
  border-left: 5px solid #a94442; /* red */
}

参考

]]>