填寫這份《一分鐘調查》,幫我們(開發組)做得更好!去填寫Home

元件之間的互動

Component interaction

本文包含了常見的元件通訊場景,也就是讓兩個或多個元件之間共享資訊的方法。

This cookbook contains recipes for common component communication scenarios in which two or more components share information.

參閱現場演練 / 下載範例

See the現場演練 / 下載範例.

透過輸入型繫結把資料從父元件傳到子元件。

Pass data from parent to child with input binding

HeroChildComponent 有兩個輸入型屬性,它們通常帶@Input 裝飾器

HeroChildComponent has two input properties, typically adorned with @Input() decorator.

import { Component, Input } from '@angular/core'; import { Hero } from './hero'; @Component({ selector: 'app-hero-child', template: ` <h3>{{hero.name}} says:</h3> <p>I, {{hero.name}}, am at your service, {{masterName}}.</p> ` }) export class HeroChildComponent { @Input() hero: Hero; @Input('master') masterName: string; // tslint:disable-line: no-input-rename }
component-interaction/src/app/hero-child.component.ts
      
      import { Component, Input } from '@angular/core';

import { Hero } from './hero';

@Component({
  selector: 'app-hero-child',
  template: `
    <h3>{{hero.name}} says:</h3>
    <p>I, {{hero.name}}, am at your service, {{masterName}}.</p>
  `
})
export class HeroChildComponent {
  @Input() hero: Hero;
  @Input('master') masterName: string; // tslint:disable-line: no-input-rename
}
    

第二個 @Input 為子元件的屬性名 masterName 指定一個別名 master(譯者注:不推薦為起別名,請參閱風格指南).

The second @Input aliases the child component property name masterName as 'master'.

父元件 HeroParentComponent 把子元件的 HeroChildComponent 放到 *ngFor 迴圈器中,把自己的 master 字串屬性繫結到子元件的 master 別名上,並把每個迴圈的 hero 實例繫結到子元件的 hero 屬性。

The HeroParentComponent nests the child HeroChildComponent inside an *ngFor repeater, binding its master string property to the child's master alias, and each iteration's hero instance to the child's hero property.

import { Component } from '@angular/core'; import { HEROES } from './hero'; @Component({ selector: 'app-hero-parent', template: ` <h2>{{master}} controls {{heroes.length}} heroes</h2> <app-hero-child *ngFor="let hero of heroes" [hero]="hero" [master]="master"> </app-hero-child> ` }) export class HeroParentComponent { heroes = HEROES; master = 'Master'; }
component-interaction/src/app/hero-parent.component.ts
      
      import { Component } from '@angular/core';

import { HEROES } from './hero';

@Component({
  selector: 'app-hero-parent',
  template: `
    <h2>{{master}} controls {{heroes.length}} heroes</h2>
    <app-hero-child *ngFor="let hero of heroes"
      [hero]="hero"
      [master]="master">
    </app-hero-child>
  `
})
export class HeroParentComponent {
  heroes = HEROES;
  master = 'Master';
}
    

執行應用程式會顯示三個英雄:

The running application displays three heroes:

測試一下!

Test it

端到端測試,用於確保所有的子元件都如預期般初始化並顯示出來:

E2E test that all children were instantiated and displayed as expected:

// ... const heroNames = ['Dr IQ', 'Magneta', 'Bombasto']; const masterName = 'Master'; it('should pass properties to children properly', async () => { const parent = element(by.tagName('app-hero-parent')); const heroes = parent.all(by.tagName('app-hero-child')); for (let i = 0; i < heroNames.length; i++) { const childTitle = await heroes.get(i).element(by.tagName('h3')).getText(); const childDetail = await heroes.get(i).element(by.tagName('p')).getText(); expect(childTitle).toEqual(heroNames[i] + ' says:'); expect(childDetail).toContain(masterName); } }); // ...
component-interaction/e2e/src/app.e2e-spec.ts
      
      // ...
const heroNames = ['Dr IQ', 'Magneta', 'Bombasto'];
const masterName = 'Master';

it('should pass properties to children properly', async () => {
  const parent = element(by.tagName('app-hero-parent'));
  const heroes = parent.all(by.tagName('app-hero-child'));

  for (let i = 0; i < heroNames.length; i++) {
    const childTitle = await heroes.get(i).element(by.tagName('h3')).getText();
    const childDetail = await heroes.get(i).element(by.tagName('p')).getText();
    expect(childTitle).toEqual(heroNames[i] + ' says:');
    expect(childDetail).toContain(masterName);
  }
});
// ...
    

回到頂部

Back to top

透過 setter 截聽輸入屬性值的變化

Intercept input property changes with a setter

使用一個輸入屬性的 setter,以攔截父元件中值的變化,並採取行動。

Use an input property setter to intercept and act upon a value from the parent.

子元件 NameChildComponent 的輸入屬性 name 上的這個 setter,會 trim 掉名字裡的空格,並把空值替換成預設字串。

The setter of the name input property in the child NameChildComponent trims the whitespace from a name and replaces an empty value with default text.

import { Component, Input } from '@angular/core'; @Component({ selector: 'app-name-child', template: '<h3>"{{name}}"</h3>' }) export class NameChildComponent { @Input() get name(): string { return this._name; } set name(name: string) { this._name = (name && name.trim()) || '<no name set>'; } private _name = ''; }
component-interaction/src/app/name-child.component.ts
      
      import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-name-child',
  template: '<h3>"{{name}}"</h3>'
})
export class NameChildComponent {
  @Input()
  get name(): string { return this._name; }
  set name(name: string) {
    this._name = (name && name.trim()) || '<no name set>';
  }
  private _name = '';
}
    

下面的 NameParentComponent 展示了各種名字的處理方式,包括一個全是空格的名字。

Here's the NameParentComponent demonstrating name variations including a name with all spaces:

import { Component } from '@angular/core'; @Component({ selector: 'app-name-parent', template: ` <h2>Master controls {{names.length}} names</h2> <app-name-child *ngFor="let name of names" [name]="name"></app-name-child> ` }) export class NameParentComponent { // Displays 'Dr IQ', '<no name set>', 'Bombasto' names = ['Dr IQ', ' ', ' Bombasto ']; }
component-interaction/src/app/name-parent.component.ts
      
      import { Component } from '@angular/core';

@Component({
  selector: 'app-name-parent',
  template: `
  <h2>Master controls {{names.length}} names</h2>
  <app-name-child *ngFor="let name of names" [name]="name"></app-name-child>
  `
})
export class NameParentComponent {
  // Displays 'Dr IQ', '<no name set>', 'Bombasto'
  names = ['Dr IQ', '   ', '  Bombasto  '];
}
    

測試一下!

Test it

端到端測試:輸入屬性的 setter,分別使用空名字和非空名字。

E2E tests of input property setter with empty and non-empty names:

// ... it('should display trimmed, non-empty names', async () => { const nonEmptyNameIndex = 0; const nonEmptyName = '"Dr IQ"'; const parent = element(by.tagName('app-name-parent')); const hero = parent.all(by.tagName('app-name-child')).get(nonEmptyNameIndex); const displayName = await hero.element(by.tagName('h3')).getText(); expect(displayName).toEqual(nonEmptyName); }); it('should replace empty name with default name', async () => { const emptyNameIndex = 1; const defaultName = '"<no name set>"'; const parent = element(by.tagName('app-name-parent')); const hero = parent.all(by.tagName('app-name-child')).get(emptyNameIndex); const displayName = await hero.element(by.tagName('h3')).getText(); expect(displayName).toEqual(defaultName); }); // ...
component-interaction/e2e/src/app.e2e-spec.ts
      
      // ...
it('should display trimmed, non-empty names', async () => {
  const nonEmptyNameIndex = 0;
  const nonEmptyName = '"Dr IQ"';
  const parent = element(by.tagName('app-name-parent'));
  const hero = parent.all(by.tagName('app-name-child')).get(nonEmptyNameIndex);

  const displayName = await hero.element(by.tagName('h3')).getText();
  expect(displayName).toEqual(nonEmptyName);
});

it('should replace empty name with default name', async () => {
  const emptyNameIndex = 1;
  const defaultName = '"<no name set>"';
  const parent = element(by.tagName('app-name-parent'));
  const hero = parent.all(by.tagName('app-name-child')).get(emptyNameIndex);

  const displayName = await hero.element(by.tagName('h3')).getText();
  expect(displayName).toEqual(defaultName);
});
// ...
    

回到頂部

Back to top

透過ngOnChanges()來截聽輸入屬性值的變化

Intercept input property changes with ngOnChanges()

使用 OnChanges 生命週期鉤子介面的 ngOnChanges() 方法來監測輸入屬性值的變化並做出迴應。

Detect and act upon changes to input property values with the ngOnChanges() method of the OnChanges lifecycle hook interface.

當需要監視多個、互動式輸入屬性的時候,本方法比用屬性的 setter 更合適。

You may prefer this approach to the property setter when watching multiple, interacting input properties.

學習關於 ngOnChanges() 的更多知識,參閱生命週期鉤子一章。

Learn about ngOnChanges() in the Lifecycle Hooks chapter.

這個 VersionChildComponent 會監測輸入屬性 majorminor 的變化,並把這些變化編寫成日誌以報告這些變化。

This VersionChildComponent detects changes to the major and minor input properties and composes a log message reporting these changes:

import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; @Component({ selector: 'app-version-child', template: ` <h3>Version {{major}}.{{minor}}</h3> <h4>Change log:</h4> <ul> <li *ngFor="let change of changeLog">{{change}}</li> </ul> ` }) export class VersionChildComponent implements OnChanges { @Input() major: number; @Input() minor: number; changeLog: string[] = []; ngOnChanges(changes: SimpleChanges) { const log: string[] = []; for (const propName in changes) { const changedProp = changes[propName]; const to = JSON.stringify(changedProp.currentValue); if (changedProp.isFirstChange()) { log.push(`Initial value of ${propName} set to ${to}`); } else { const from = JSON.stringify(changedProp.previousValue); log.push(`${propName} changed from ${from} to ${to}`); } } this.changeLog.push(log.join(', ')); } }
component-interaction/src/app/version-child.component.ts
      
      import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';

@Component({
  selector: 'app-version-child',
  template: `
    <h3>Version {{major}}.{{minor}}</h3>
    <h4>Change log:</h4>
    <ul>
      <li *ngFor="let change of changeLog">{{change}}</li>
    </ul>
  `
})
export class VersionChildComponent implements OnChanges {
  @Input() major: number;
  @Input() minor: number;
  changeLog: string[] = [];

  ngOnChanges(changes: SimpleChanges) {
    const log: string[] = [];
    for (const propName in changes) {
      const changedProp = changes[propName];
      const to = JSON.stringify(changedProp.currentValue);
      if (changedProp.isFirstChange()) {
        log.push(`Initial value of ${propName} set to ${to}`);
      } else {
        const from = JSON.stringify(changedProp.previousValue);
        log.push(`${propName} changed from ${from} to ${to}`);
      }
    }
    this.changeLog.push(log.join(', '));
  }
}
    

VersionParentComponent 提供 minormajor 值,把修改它們值的方法繫結到按鈕上。

The VersionParentComponent supplies the minor and major values and binds buttons to methods that change them.

import { Component } from '@angular/core'; @Component({ selector: 'app-version-parent', template: ` <h2>Source code version</h2> <button (click)="newMinor()">New minor version</button> <button (click)="newMajor()">New major version</button> <app-version-child [major]="major" [minor]="minor"></app-version-child> ` }) export class VersionParentComponent { major = 1; minor = 23; newMinor() { this.minor++; } newMajor() { this.major++; this.minor = 0; } }
component-interaction/src/app/version-parent.component.ts
      
      import { Component } from '@angular/core';

@Component({
  selector: 'app-version-parent',
  template: `
    <h2>Source code version</h2>
    <button (click)="newMinor()">New minor version</button>
    <button (click)="newMajor()">New major version</button>
    <app-version-child [major]="major" [minor]="minor"></app-version-child>
  `
})
export class VersionParentComponent {
  major = 1;
  minor = 23;

  newMinor() {
    this.minor++;
  }

  newMajor() {
    this.major++;
    this.minor = 0;
  }
}
    

下面是點選按鈕的結果。

Here's the output of a button-pushing sequence:

測試一下!

Test it

測試確保這兩個輸入屬性值都被初始化了,當點選按鈕後,ngOnChanges 應該被呼叫,屬性的值也符合預期。

Test that both input properties are set initially and that button clicks trigger the expected ngOnChanges calls and values:

// ... // Test must all execute in this exact order it('should set expected initial values', async () => { const actual = await getActual(); const initialLabel = 'Version 1.23'; const initialLog = 'Initial value of major set to 1, Initial value of minor set to 23'; expect(actual.label).toBe(initialLabel); expect(actual.count).toBe(1); expect(await actual.logs.get(0).getText()).toBe(initialLog); }); it('should set expected values after clicking \'Minor\' twice', async () => { const repoTag = element(by.tagName('app-version-parent')); const newMinorButton = repoTag.all(by.tagName('button')).get(0); await newMinorButton.click(); await newMinorButton.click(); const actual = await getActual(); const labelAfter2Minor = 'Version 1.25'; const logAfter2Minor = 'minor changed from 24 to 25'; expect(actual.label).toBe(labelAfter2Minor); expect(actual.count).toBe(3); expect(await actual.logs.get(2).getText()).toBe(logAfter2Minor); }); it('should set expected values after clicking \'Major\' once', async () => { const repoTag = element(by.tagName('app-version-parent')); const newMajorButton = repoTag.all(by.tagName('button')).get(1); await newMajorButton.click(); const actual = await getActual(); const labelAfterMajor = 'Version 2.0'; const logAfterMajor = 'major changed from 1 to 2, minor changed from 23 to 0'; expect(actual.label).toBe(labelAfterMajor); expect(actual.count).toBe(2); expect(await actual.logs.get(1).getText()).toBe(logAfterMajor); }); async function getActual() { const versionTag = element(by.tagName('app-version-child')); const label = await versionTag.element(by.tagName('h3')).getText(); const ul = versionTag.element((by.tagName('ul'))); const logs = ul.all(by.tagName('li')); return { label, logs, count: await logs.count(), }; } // ...
component-interaction/e2e/src/app.e2e-spec.ts
      
      // ...
// Test must all execute in this exact order
it('should set expected initial values', async () => {
  const actual = await getActual();

  const initialLabel = 'Version 1.23';
  const initialLog = 'Initial value of major set to 1, Initial value of minor set to 23';

  expect(actual.label).toBe(initialLabel);
  expect(actual.count).toBe(1);
  expect(await actual.logs.get(0).getText()).toBe(initialLog);
});

it('should set expected values after clicking \'Minor\' twice', async () => {
  const repoTag = element(by.tagName('app-version-parent'));
  const newMinorButton = repoTag.all(by.tagName('button')).get(0);

  await newMinorButton.click();
  await newMinorButton.click();

  const actual = await getActual();

  const labelAfter2Minor = 'Version 1.25';
  const logAfter2Minor = 'minor changed from 24 to 25';

  expect(actual.label).toBe(labelAfter2Minor);
  expect(actual.count).toBe(3);
  expect(await actual.logs.get(2).getText()).toBe(logAfter2Minor);
});

it('should set expected values after clicking \'Major\' once', async () => {
  const repoTag = element(by.tagName('app-version-parent'));
  const newMajorButton = repoTag.all(by.tagName('button')).get(1);

  await newMajorButton.click();
  const actual = await getActual();

  const labelAfterMajor = 'Version 2.0';
  const logAfterMajor = 'major changed from 1 to 2, minor changed from 23 to 0';

  expect(actual.label).toBe(labelAfterMajor);
  expect(actual.count).toBe(2);
  expect(await actual.logs.get(1).getText()).toBe(logAfterMajor);
});

async function getActual() {
  const versionTag = element(by.tagName('app-version-child'));
  const label = await versionTag.element(by.tagName('h3')).getText();
  const ul = versionTag.element((by.tagName('ul')));
  const logs = ul.all(by.tagName('li'));

  return {
    label,
    logs,
    count: await logs.count(),
  };
}
// ...
    

回到頂部

Back to top

父元件監聽子元件的事件

Parent listens for child event

子元件暴露一個 EventEmitter 屬性,當事件發生時,子元件利用該屬性 emits(向上彈射)事件。父元件繫結到這個事件屬性,並在事件發生時作出迴應。

The child component exposes an EventEmitter property with which it emits events when something happens. The parent binds to that event property and reacts to those events.

子元件的 EventEmitter 屬性是一個輸出屬性,通常帶有@Output 裝飾器,就像在 VoterComponent 中看到的。

The child's EventEmitter property is an output property, typically adorned with an @Output() decorator as seen in this VoterComponent:

import { Component, EventEmitter, Input, Output } from '@angular/core'; @Component({ selector: 'app-voter', template: ` <h4>{{name}}</h4> <button (click)="vote(true)" [disabled]="didVote">Agree</button> <button (click)="vote(false)" [disabled]="didVote">Disagree</button> ` }) export class VoterComponent { @Input() name: string; @Output() voted = new EventEmitter<boolean>(); didVote = false; vote(agreed: boolean) { this.voted.emit(agreed); this.didVote = true; } }
component-interaction/src/app/voter.component.ts
      
      import { Component, EventEmitter, Input, Output } from '@angular/core';

@Component({
  selector: 'app-voter',
  template: `
    <h4>{{name}}</h4>
    <button (click)="vote(true)"  [disabled]="didVote">Agree</button>
    <button (click)="vote(false)" [disabled]="didVote">Disagree</button>
  `
})
export class VoterComponent {
  @Input()  name: string;
  @Output() voted = new EventEmitter<boolean>();
  didVote = false;

  vote(agreed: boolean) {
    this.voted.emit(agreed);
    this.didVote = true;
  }
}
    

點選按鈕會觸發 truefalse(布林型有效載荷)的事件。

Clicking a button triggers emission of a true or false, the boolean payload.

父元件 VoteTakerComponent 綁定了一個事件處理器(onVoted()),用來響應子元件的事件($event)並更新一個計數器。

The parent VoteTakerComponent binds an event handler called onVoted() that responds to the child event payload $event and updates a counter.

import { Component } from '@angular/core'; @Component({ selector: 'app-vote-taker', template: ` <h2>Should mankind colonize the Universe?</h2> <h3>Agree: {{agreed}}, Disagree: {{disagreed}}</h3> <app-voter *ngFor="let voter of voters" [name]="voter" (voted)="onVoted($event)"> </app-voter> ` }) export class VoteTakerComponent { agreed = 0; disagreed = 0; voters = ['Narco', 'Celeritas', 'Bombasto']; onVoted(agreed: boolean) { agreed ? this.agreed++ : this.disagreed++; } }
component-interaction/src/app/votetaker.component.ts
      
      import { Component } from '@angular/core';

@Component({
  selector: 'app-vote-taker',
  template: `
    <h2>Should mankind colonize the Universe?</h2>
    <h3>Agree: {{agreed}}, Disagree: {{disagreed}}</h3>
    <app-voter *ngFor="let voter of voters"
      [name]="voter"
      (voted)="onVoted($event)">
    </app-voter>
  `
})
export class VoteTakerComponent {
  agreed = 0;
  disagreed = 0;
  voters = ['Narco', 'Celeritas', 'Bombasto'];

  onVoted(agreed: boolean) {
    agreed ? this.agreed++ : this.disagreed++;
  }
}
    

本框架把事件引數(用 $event 表示)傳給事件處理方法,該方法會處理它:

The framework passes the event argument—represented by $event—to the handler method, and the method processes it:

測試一下!

Test it

測試確保點選 AgreeDisagree 按鈕時,計數器被正確更新。

Test that clicking the Agree and Disagree buttons update the appropriate counters:

// ... it('should not emit the event initially', async () => { const voteLabel = element(by.tagName('app-vote-taker')).element(by.tagName('h3')); expect(await voteLabel.getText()).toBe('Agree: 0, Disagree: 0'); }); it('should process Agree vote', async () => { const voteLabel = element(by.tagName('app-vote-taker')).element(by.tagName('h3')); const agreeButton1 = element.all(by.tagName('app-voter')).get(0) .all(by.tagName('button')).get(0); await agreeButton1.click(); expect(await voteLabel.getText()).toBe('Agree: 1, Disagree: 0'); }); it('should process Disagree vote', async () => { const voteLabel = element(by.tagName('app-vote-taker')).element(by.tagName('h3')); const agreeButton1 = element.all(by.tagName('app-voter')).get(1) .all(by.tagName('button')).get(1); await agreeButton1.click(); expect(await voteLabel.getText()).toBe('Agree: 0, Disagree: 1'); }); // ...
component-interaction/e2e/src/app.e2e-spec.ts
      
      // ...
it('should not emit the event initially', async () => {
  const voteLabel = element(by.tagName('app-vote-taker')).element(by.tagName('h3'));
  expect(await voteLabel.getText()).toBe('Agree: 0, Disagree: 0');
});

it('should process Agree vote', async () => {
  const voteLabel = element(by.tagName('app-vote-taker')).element(by.tagName('h3'));
  const agreeButton1 = element.all(by.tagName('app-voter')).get(0)
    .all(by.tagName('button')).get(0);

  await agreeButton1.click();

  expect(await voteLabel.getText()).toBe('Agree: 1, Disagree: 0');
});

it('should process Disagree vote', async () => {
  const voteLabel = element(by.tagName('app-vote-taker')).element(by.tagName('h3'));
  const agreeButton1 = element.all(by.tagName('app-voter')).get(1)
    .all(by.tagName('button')).get(1);

  await agreeButton1.click();

  expect(await voteLabel.getText()).toBe('Agree: 0, Disagree: 1');
});
// ...
    

回到頂部

Back to top

父元件與子元件透過本地變數互動

Parent interacts with child via local variable

父元件不能使用資料繫結來讀取子元件的屬性或呼叫子元件的方法。但可以在父元件範本裡,新建一個本地變數來代表子元件,然後利用這個變數來讀取子元件的屬性和呼叫子元件的方法,如下例所示。

A parent component cannot use data binding to read child properties or invoke child methods. You can do both by creating a template reference variable for the child element and then reference that variable within the parent template as seen in the following example.

子元件 CountdownTimerComponent 進行倒計時,歸零時發射一個導彈。startstop 方法負責控制時鐘並在範本裡顯示倒計時的狀態資訊。

The following is a child CountdownTimerComponent that repeatedly counts down to zero and launches a rocket. It has start and stop methods that control the clock and it displays a countdown status message in its own template.

import { Component, OnDestroy } from '@angular/core'; @Component({ selector: 'app-countdown-timer', template: '<p>{{message}}</p>' }) export class CountdownTimerComponent implements OnDestroy { intervalId = 0; message = ''; seconds = 11; ngOnDestroy() { this.clearTimer(); } start() { this.countDown(); } stop() { this.clearTimer(); this.message = `Holding at T-${this.seconds} seconds`; } private clearTimer() { clearInterval(this.intervalId); } private countDown() { this.clearTimer(); this.intervalId = window.setInterval(() => { this.seconds -= 1; if (this.seconds === 0) { this.message = 'Blast off!'; } else { if (this.seconds < 0) { this.seconds = 10; } // reset this.message = `T-${this.seconds} seconds and counting`; } }, 1000); } }
component-interaction/src/app/countdown-timer.component.ts
      
      import { Component, OnDestroy } from '@angular/core';

@Component({
  selector: 'app-countdown-timer',
  template: '<p>{{message}}</p>'
})
export class CountdownTimerComponent implements OnDestroy {

  intervalId = 0;
  message = '';
  seconds = 11;

  ngOnDestroy() { this.clearTimer(); }

  start() { this.countDown(); }
  stop()  {
    this.clearTimer();
    this.message = `Holding at T-${this.seconds} seconds`;
  }

  private clearTimer() { clearInterval(this.intervalId); }

  private countDown() {
    this.clearTimer();
    this.intervalId = window.setInterval(() => {
      this.seconds -= 1;
      if (this.seconds === 0) {
        this.message = 'Blast off!';
      } else {
        if (this.seconds < 0) { this.seconds = 10; } // reset
        this.message = `T-${this.seconds} seconds and counting`;
      }
    }, 1000);
  }
}
    

計時器元件的宿主元件 CountdownLocalVarParentComponent 如下:

The CountdownLocalVarParentComponent that hosts the timer component is as follows:

import { Component } from '@angular/core'; import { CountdownTimerComponent } from './countdown-timer.component'; @Component({ selector: 'app-countdown-parent-lv', template: ` <h3>Countdown to Liftoff (via local variable)</h3> <button (click)="timer.start()">Start</button> <button (click)="timer.stop()">Stop</button> <div class="seconds">{{timer.seconds}}</div> <app-countdown-timer #timer></app-countdown-timer> `, styleUrls: ['../assets/demo.css'] }) export class CountdownLocalVarParentComponent { }
component-interaction/src/app/countdown-parent.component.ts
      
      import { Component } from '@angular/core';
import { CountdownTimerComponent } from './countdown-timer.component';

@Component({
  selector: 'app-countdown-parent-lv',
  template: `
  <h3>Countdown to Liftoff (via local variable)</h3>
  <button (click)="timer.start()">Start</button>
  <button (click)="timer.stop()">Stop</button>
  <div class="seconds">{{timer.seconds}}</div>
  <app-countdown-timer #timer></app-countdown-timer>
  `,
  styleUrls: ['../assets/demo.css']
})
export class CountdownLocalVarParentComponent { }
    

父元件不能透過資料繫結使用子元件的 startstop 方法,也不能訪問子元件的 seconds 屬性。

The parent component cannot data bind to the child's start and stop methods nor to its seconds property.

把本地變數(#timer)放到(<countdown-timer>)標籤中,用來代表子元件。這樣父元件的範本就得到了子元件的參考,於是可以在父元件的範本中訪問子元件的所有屬性和方法。

You can place a local variable, #timer, on the tag <countdown-timer> representing the child component. That gives you a reference to the child component and the ability to access any of its properties or methods from within the parent template.

這個例子把父元件的按鈕繫結到子元件的 startstop 方法,並用插值來顯示子元件的 seconds 屬性。

This example wires parent buttons to the child's start and stop and uses interpolation to display the child's seconds property.

下面是父元件和子元件一起工作時的效果。

Here we see the parent and child working together.

測試一下!

Test it

測試確保在父元件範本中顯示的秒數和子元件狀態資訊裡的秒數同步。它還會點選 Stop 按鈕來停止倒計時:

Test that the seconds displayed in the parent template match the seconds displayed in the child's status message. Test also that clicking the Stop button pauses the countdown timer:

// ... // The tests trigger periodic asynchronous operations (via `setInterval()`), which will prevent // the app from stabilizing. See https://angular.io/api/core/ApplicationRef#is-stable-examples // for more details. // To allow the tests to complete, we will disable automatically waiting for the Angular app to // stabilize. beforeEach(() => browser.waitForAngularEnabled(false)); afterEach(() => browser.waitForAngularEnabled(true)); it('timer and parent seconds should match', async () => { const parent = element(by.tagName(parentTag)); const startButton = parent.element(by.buttonText('Start')); const seconds = parent.element(by.className('seconds')); const timer = parent.element(by.tagName('app-countdown-timer')); await startButton.click(); // Wait for `<app-countdown-timer>` to be populated with any text. await browser.wait(() => timer.getText(), 2000); expect(await timer.getText()).toContain(await seconds.getText()); }); it('should stop the countdown', async () => { const parent = element(by.tagName(parentTag)); const startButton = parent.element(by.buttonText('Start')); const stopButton = parent.element(by.buttonText('Stop')); const timer = parent.element(by.tagName('app-countdown-timer')); await startButton.click(); expect(await timer.getText()).not.toContain('Holding'); await stopButton.click(); expect(await timer.getText()).toContain('Holding'); }); // ...
component-interaction/e2e/src/app.e2e-spec.ts
      
      // ...
// The tests trigger periodic asynchronous operations (via `setInterval()`), which will prevent
// the app from stabilizing. See https://angular.io/api/core/ApplicationRef#is-stable-examples
// for more details.
// To allow the tests to complete, we will disable automatically waiting for the Angular app to
// stabilize.
beforeEach(() => browser.waitForAngularEnabled(false));
afterEach(() => browser.waitForAngularEnabled(true));

it('timer and parent seconds should match', async () => {
  const parent = element(by.tagName(parentTag));
  const startButton = parent.element(by.buttonText('Start'));
  const seconds = parent.element(by.className('seconds'));
  const timer = parent.element(by.tagName('app-countdown-timer'));

  await startButton.click();

  // Wait for `<app-countdown-timer>` to be populated with any text.
  await browser.wait(() => timer.getText(), 2000);

  expect(await timer.getText()).toContain(await seconds.getText());
});

it('should stop the countdown', async () => {
  const parent = element(by.tagName(parentTag));
  const startButton = parent.element(by.buttonText('Start'));
  const stopButton = parent.element(by.buttonText('Stop'));
  const timer = parent.element(by.tagName('app-countdown-timer'));

  await startButton.click();
  expect(await timer.getText()).not.toContain('Holding');

  await stopButton.click();
  expect(await timer.getText()).toContain('Holding');
});
// ...
    

回到頂部

Back to top

父元件呼叫@ViewChild()

Parent calls an @ViewChild()

這個本地變數方法是個簡單便利的方法。但是它也有侷限性,因為父元件-子元件的連線必須全部在父元件的範本中進行。父元件本身的程式碼對子元件沒有訪問權。

The local variable approach is simple and easy. But it is limited because the parent-child wiring must be done entirely within the parent template. The parent component itself has no access to the child.

如果父元件的類別需要讀取子元件的屬性值或呼叫子元件的方法,就不能使用本地變數方法。

You can't use the local variable technique if an instance of the parent component class must read or write child component values or must call child component methods.

當父元件類別需要這種訪問時,可以把子元件作為 ViewChild注入到父元件裡面。

When the parent component class requires that kind of access, inject the child component into the parent as a ViewChild.

下面的例子用與倒計時相同的範例來解釋這種技術。 它的外觀或行為沒有變化。子元件CountdownTimerComponent也和原來一樣。

The following example illustrates this technique with the same Countdown Timer example. Neither its appearance nor its behavior will change. The child CountdownTimerComponent is the same as well.

本地變數切換到 ViewChild 技術的唯一目的就是做示範。

The switch from the local variable to the ViewChild technique is solely for the purpose of demonstration.

下面是父元件 CountdownViewChildParentComponent:

Here is the parent, CountdownViewChildParentComponent:

import { AfterViewInit, ViewChild } from '@angular/core'; import { Component } from '@angular/core'; import { CountdownTimerComponent } from './countdown-timer.component'; @Component({ selector: 'app-countdown-parent-vc', template: ` <h3>Countdown to Liftoff (via ViewChild)</h3> <button (click)="start()">Start</button> <button (click)="stop()">Stop</button> <div class="seconds">{{ seconds() }}</div> <app-countdown-timer></app-countdown-timer> `, styleUrls: ['../assets/demo.css'] }) export class CountdownViewChildParentComponent implements AfterViewInit { @ViewChild(CountdownTimerComponent) private timerComponent: CountdownTimerComponent; seconds() { return 0; } ngAfterViewInit() { // Redefine `seconds()` to get from the `CountdownTimerComponent.seconds` ... // but wait a tick first to avoid one-time devMode // unidirectional-data-flow-violation error setTimeout(() => this.seconds = () => this.timerComponent.seconds, 0); } start() { this.timerComponent.start(); } stop() { this.timerComponent.stop(); } }
component-interaction/src/app/countdown-parent.component.ts
      
      import { AfterViewInit, ViewChild } from '@angular/core';
import { Component } from '@angular/core';
import { CountdownTimerComponent } from './countdown-timer.component';

@Component({
  selector: 'app-countdown-parent-vc',
  template: `
  <h3>Countdown to Liftoff (via ViewChild)</h3>
  <button (click)="start()">Start</button>
  <button (click)="stop()">Stop</button>
  <div class="seconds">{{ seconds() }}</div>
  <app-countdown-timer></app-countdown-timer>
  `,
  styleUrls: ['../assets/demo.css']
})
export class CountdownViewChildParentComponent implements AfterViewInit {

  @ViewChild(CountdownTimerComponent)
  private timerComponent: CountdownTimerComponent;

  seconds() { return 0; }

  ngAfterViewInit() {
    // Redefine `seconds()` to get from the `CountdownTimerComponent.seconds` ...
    // but wait a tick first to avoid one-time devMode
    // unidirectional-data-flow-violation error
    setTimeout(() => this.seconds = () => this.timerComponent.seconds, 0);
  }

  start() { this.timerComponent.start(); }
  stop() { this.timerComponent.stop(); }
}
    

把子元件的檢視插入到父元件類別需要做一點額外的工作。

It takes a bit more work to get the child view into the parent component class.

首先,你必須匯入對裝飾器 ViewChild 以及生命週期鉤子 AfterViewInit 的參考。

First, you have to import references to the ViewChild decorator and the AfterViewInit lifecycle hook.

接著,透過 @ViewChild 屬性裝飾器,將子元件 CountdownTimerComponent 注入到私有屬性 timerComponent 裡面。

Next, inject the child CountdownTimerComponent into the private timerComponent property via the @ViewChild property decoration.

元件元資料裡就不再需要 #timer 本地變量了。而是把按鈕繫結到父元件自己的 startstop 方法,使用父元件的 seconds 方法的插值來展示秒數變化。

The #timer local variable is gone from the component metadata. Instead, bind the buttons to the parent component's own start and stop methods and present the ticking seconds in an interpolation around the parent component's seconds method.

這些方法可以直接訪問被注入的計時器元件。

These methods access the injected timer component directly.

ngAfterViewInit() 生命週期鉤子是非常重要的一步。被注入的計時器元件只有在 Angular 顯示了父元件檢視之後才能訪問,所以它先把秒數顯示為 0.

The ngAfterViewInit() lifecycle hook is an important wrinkle. The timer component isn't available until after Angular displays the parent view. So it displays 0 seconds initially.

然後 Angular 會呼叫 ngAfterViewInit 生命週期鉤子,但這時候再更新父元件檢視的倒計時就已經太晚了。Angular 的單向資料流規則會阻止在同一個週期內更新父元件檢視。應用在顯示秒數之前會被迫再等一輪

Then Angular calls the ngAfterViewInit lifecycle hook at which time it is too late to update the parent view's display of the countdown seconds. Angular's unidirectional data flow rule prevents updating the parent view's in the same cycle. The app has to wait one turn before it can display the seconds.

使用 setTimeout() 來等下一輪,然後改寫 seconds() 方法,這樣它接下來就會從注入的這個計時器元件裡獲取秒數的值。

Use setTimeout() to wait one tick and then revise the seconds() method so that it takes future values from the timer component.

測試一下!

Test it

使用和之前一樣的倒計時測試

Use the same countdown timer tests as before.

回到頂部

Back to top

父元件和子元件透過服務來通訊

Parent and children communicate via a service

父元件和它的子元件共享同一個服務,利用該服務在元件家族內部實現雙向通訊。

A parent component and its children share a service whose interface enables bi-directional communication within the family.

該服務實例的作用域被限制在父元件和其子元件內。這個元件子樹之外的元件將無法訪問該服務或者與它們通訊。

The scope of the service instance is the parent component and its children. Components outside this component subtree have no access to the service or their communications.

這個 MissionServiceMissionControlComponent 和多個 AstronautComponent 子元件連線起來。

This MissionService connects the MissionControlComponent to multiple AstronautComponent children.

import { Injectable } from '@angular/core'; import { Subject } from 'rxjs'; @Injectable() export class MissionService { // Observable string sources private missionAnnouncedSource = new Subject<string>(); private missionConfirmedSource = new Subject<string>(); // Observable string streams missionAnnounced$ = this.missionAnnouncedSource.asObservable(); missionConfirmed$ = this.missionConfirmedSource.asObservable(); // Service message commands announceMission(mission: string) { this.missionAnnouncedSource.next(mission); } confirmMission(astronaut: string) { this.missionConfirmedSource.next(astronaut); } }
component-interaction/src/app/mission.service.ts
      
      import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable()
export class MissionService {

  // Observable string sources
  private missionAnnouncedSource = new Subject<string>();
  private missionConfirmedSource = new Subject<string>();

  // Observable string streams
  missionAnnounced$ = this.missionAnnouncedSource.asObservable();
  missionConfirmed$ = this.missionConfirmedSource.asObservable();

  // Service message commands
  announceMission(mission: string) {
    this.missionAnnouncedSource.next(mission);
  }

  confirmMission(astronaut: string) {
    this.missionConfirmedSource.next(astronaut);
  }
}
    

MissionControlComponent 提供服務的實例,並將其共享給它的子元件(透過 providers 元資料陣列),子元件可以透過建構函式將該實例注入到自身。

The MissionControlComponent both provides the instance of the service that it shares with its children (through the providers metadata array) and injects that instance into itself through its constructor:

import { Component } from '@angular/core'; import { MissionService } from './mission.service'; @Component({ selector: 'app-mission-control', template: ` <h2>Mission Control</h2> <button (click)="announce()">Announce mission</button> <app-astronaut *ngFor="let astronaut of astronauts" [astronaut]="astronaut"> </app-astronaut> <h3>History</h3> <ul> <li *ngFor="let event of history">{{event}}</li> </ul> `, providers: [MissionService] }) export class MissionControlComponent { astronauts = ['Lovell', 'Swigert', 'Haise']; history: string[] = []; missions = ['Fly to the moon!', 'Fly to mars!', 'Fly to Vegas!']; nextMission = 0; constructor(private missionService: MissionService) { missionService.missionConfirmed$.subscribe( astronaut => { this.history.push(`${astronaut} confirmed the mission`); }); } announce() { const mission = this.missions[this.nextMission++]; this.missionService.announceMission(mission); this.history.push(`Mission "${mission}" announced`); if (this.nextMission >= this.missions.length) { this.nextMission = 0; } } }
component-interaction/src/app/missioncontrol.component.ts
      
      import { Component } from '@angular/core';

import { MissionService } from './mission.service';

@Component({
  selector: 'app-mission-control',
  template: `
    <h2>Mission Control</h2>
    <button (click)="announce()">Announce mission</button>
    <app-astronaut *ngFor="let astronaut of astronauts"
      [astronaut]="astronaut">
    </app-astronaut>
    <h3>History</h3>
    <ul>
      <li *ngFor="let event of history">{{event}}</li>
    </ul>
  `,
  providers: [MissionService]
})
export class MissionControlComponent {
  astronauts = ['Lovell', 'Swigert', 'Haise'];
  history: string[] = [];
  missions = ['Fly to the moon!',
              'Fly to mars!',
              'Fly to Vegas!'];
  nextMission = 0;

  constructor(private missionService: MissionService) {
    missionService.missionConfirmed$.subscribe(
      astronaut => {
        this.history.push(`${astronaut} confirmed the mission`);
      });
  }

  announce() {
    const mission = this.missions[this.nextMission++];
    this.missionService.announceMission(mission);
    this.history.push(`Mission "${mission}" announced`);
    if (this.nextMission >= this.missions.length) { this.nextMission = 0; }
  }
}
    

AstronautComponent 也透過自己的建構函式注入該服務。由於每個 AstronautComponent 都是 MissionControlComponent 的子元件,所以它們獲取到的也是父元件的這個服務實例。

The AstronautComponent also injects the service in its constructor. Each AstronautComponent is a child of the MissionControlComponent and therefore receives its parent's service instance:

import { Component, Input, OnDestroy } from '@angular/core'; import { MissionService } from './mission.service'; import { Subscription } from 'rxjs'; @Component({ selector: 'app-astronaut', template: ` <p> {{astronaut}}: <strong>{{mission}}</strong> <button (click)="confirm()" [disabled]="!announced || confirmed"> Confirm </button> </p> ` }) export class AstronautComponent implements OnDestroy { @Input() astronaut: string; mission = '<no mission announced>'; confirmed = false; announced = false; subscription: Subscription; constructor(private missionService: MissionService) { this.subscription = missionService.missionAnnounced$.subscribe( mission => { this.mission = mission; this.announced = true; this.confirmed = false; }); } confirm() { this.confirmed = true; this.missionService.confirmMission(this.astronaut); } ngOnDestroy() { // prevent memory leak when component destroyed this.subscription.unsubscribe(); } }
component-interaction/src/app/astronaut.component.ts
      
      import { Component, Input, OnDestroy } from '@angular/core';

import { MissionService } from './mission.service';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-astronaut',
  template: `
    <p>
      {{astronaut}}: <strong>{{mission}}</strong>
      <button
        (click)="confirm()"
        [disabled]="!announced || confirmed">
        Confirm
      </button>
    </p>
  `
})
export class AstronautComponent implements OnDestroy {
  @Input() astronaut: string;
  mission = '<no mission announced>';
  confirmed = false;
  announced = false;
  subscription: Subscription;

  constructor(private missionService: MissionService) {
    this.subscription = missionService.missionAnnounced$.subscribe(
      mission => {
        this.mission = mission;
        this.announced = true;
        this.confirmed = false;
    });
  }

  confirm() {
    this.confirmed = true;
    this.missionService.confirmMission(this.astronaut);
  }

  ngOnDestroy() {
    // prevent memory leak when component destroyed
    this.subscription.unsubscribe();
  }
}
    

注意,這個例子儲存了 subscription 變數,並在 AstronautComponent 被銷燬時呼叫 unsubscribe() 退訂。 這是一個用於防止記憶體洩漏的保護措施。實際上,在這個應用程式中並沒有這個風險,因為 AstronautComponent 的生命期和應用程式的生命期一樣長。但在更復雜的應用程式環境中就不一定了。

Notice that this example captures the subscription and unsubscribe() when the AstronautComponent is destroyed. This is a memory-leak guard step. There is no actual risk in this app because the lifetime of a AstronautComponent is the same as the lifetime of the app itself. That would not always be true in a more complex application.

不需要在 MissionControlComponent 中新增這個保護措施,因為它作為父元件,控制著 MissionService 的生命期。

You don't add this guard to the MissionControlComponent because, as the parent, it controls the lifetime of the MissionService.

History 日誌證明了:在父元件 MissionControlComponent 和子元件 AstronautComponent 之間,資訊透過該服務實現了雙向傳遞。

The History log demonstrates that messages travel in both directions between the parent MissionControlComponent and the AstronautComponent children, facilitated by the service:

測試一下!

Test it

測試確保點選父元件 MissionControlComponent 和子元件 AstronautComponent 兩個的元件的按鈕時,History 日誌和預期的一樣。

Tests click buttons of both the parent MissionControlComponent and the AstronautComponent children and verify that the history meets expectations:

// ... it('should announce a mission', async () => { const missionControl = element(by.tagName('app-mission-control')); const announceButton = missionControl.all(by.tagName('button')).get(0); const history = missionControl.all(by.tagName('li')); await announceButton.click(); expect(await history.count()).toBe(1); expect(await history.get(0).getText()).toMatch(/Mission.* announced/); }); it('should confirm the mission by Lovell', async () => { await testConfirmMission(1, 'Lovell'); }); it('should confirm the mission by Haise', async () => { await testConfirmMission(3, 'Haise'); }); it('should confirm the mission by Swigert', async () => { await testConfirmMission(2, 'Swigert'); }); async function testConfirmMission(buttonIndex: number, astronaut: string) { const missionControl = element(by.tagName('app-mission-control')); const announceButton = missionControl.all(by.tagName('button')).get(0); const confirmButton = missionControl.all(by.tagName('button')).get(buttonIndex); const history = missionControl.all(by.tagName('li')); await announceButton.click(); await confirmButton.click(); expect(await history.count()).toBe(2); expect(await history.get(1).getText()).toBe(`${astronaut} confirmed the mission`); } // ...
component-interaction/e2e/src/app.e2e-spec.ts
      
      // ...
it('should announce a mission', async () => {
  const missionControl = element(by.tagName('app-mission-control'));
  const announceButton = missionControl.all(by.tagName('button')).get(0);
  const history = missionControl.all(by.tagName('li'));

  await announceButton.click();

  expect(await history.count()).toBe(1);
  expect(await history.get(0).getText()).toMatch(/Mission.* announced/);
});

it('should confirm the mission by Lovell', async () => {
  await testConfirmMission(1, 'Lovell');
});

it('should confirm the mission by Haise', async () => {
  await testConfirmMission(3, 'Haise');
});

it('should confirm the mission by Swigert', async () => {
  await testConfirmMission(2, 'Swigert');
});

async function testConfirmMission(buttonIndex: number, astronaut: string) {
  const missionControl = element(by.tagName('app-mission-control'));
  const announceButton = missionControl.all(by.tagName('button')).get(0);
  const confirmButton = missionControl.all(by.tagName('button')).get(buttonIndex);
  const history = missionControl.all(by.tagName('li'));

  await announceButton.click();
  await confirmButton.click();

  expect(await history.count()).toBe(2);
  expect(await history.get(1).getText()).toBe(`${astronaut} confirmed the mission`);
}
// ...
    

回到頂部

Back to top