Эта поваренная книга содержит рецепты для распространенных сценариев взаимодействия компонентов, в которых два или более компонентов обмениваются информацией.
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> `,})exportclassHeroChildComponent{@Input()hero!:Hero;@Input('master')masterName='';}
Второй @Input псевдонизирует имя свойства дочернего компонента masterName как 'master'.
Родительский компонент HeroParentComponent вставляет дочерний компонент HeroChildComponent внутрь повторителя *ngFor, связывая его строковое свойство master с псевдонимом master дочернего компонента, а экземпляр hero каждой итерации — со свойством hero дочернего компонента.
Протестируйте его на передачу данных от родителя к ребенку с привязкой к входу
E2E проверка того, что все дочерние элементы были созданы и отображены, как ожидалось:
component-interaction/e2e/src/app.e2e-spec.ts
1 2 3 4 5 6 7 8 910111213141516171819202122
// ...constheroNames=['Dr. IQ','Magneta','Bombasto'];constmasterName='Master';it('should pass properties to children properly',async()=>{constparent=element(by.tagName('app-hero-parent'));constheroes=parent.all(by.tagName('app-hero-child'));for(leti=0;i<heroNames.length;i++){constchildTitle=awaitheroes.get(i).element(by.tagName('h3')).getText();constchildDetail=awaitheroes.get(i).element(by.tagName('p')).getText();expect(childTitle).toEqual(heroNames[i]+' says:');expect(childDetail).toContain(masterName);}});// ...
Перехват изменений входного свойства с помощью сеттера¶
Используйте сеттер входного свойства, чтобы перехватить значение от родителя и действовать в соответствии с ним.
Сеттер входного свойства name в дочернем NameChildComponent удаляет пробелы из имени и заменяет пустое значение текстом по умолчанию.
import{Component,Input}from'@angular/core';@Component({selector:'app-name-child',template:'<h3>"{{name}}"</h3>',})exportclassNameChildComponent{@Input()getname():string{returnthis._name;}setname(name:string){this._name=(name&&name.trim())||'<no name set>';}private_name='';}
Вот NameParentComponent, демонстрирующий варианты имен, включая имя со всеми пробелами:
// ...it('should display trimmed, non-empty names',async()=>{constnonEmptyNameIndex=0;constnonEmptyName='"Dr. IQ"';constparent=element(by.tagName('app-name-parent'));consthero=parent.all(by.tagName('app-name-child')).get(nonEmptyNameIndex);constdisplayName=awaithero.element(by.tagName('h3')).getText();expect(displayName).toEqual(nonEmptyName);});it('should replace empty name with default name',async()=>{constemptyNameIndex=1;constdefaultName='"<no name set>"';constparent=element(by.tagName('app-name-parent'));consthero=parent.all(by.tagName('app-name-child')).get(emptyNameIndex);constdisplayName=awaithero.element(by.tagName('h3')).getText();expect(displayName).toEqual(defaultName);});// ...
Перехват изменений свойств ввода с помощью ngOnChanges().¶
Обнаруживайте изменения значений входных свойств и действуйте в соответствии с ними с помощью метода ngOnChanges() интерфейса хука жизненного цикла OnChanges.
Вы можете предпочесть этот подход к установщику свойств при наблюдении за несколькими взаимодействующими свойствами ввода.
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> `,})exportclassVersionChildComponentimplementsOnChanges{@Input()major=0;@Input()minor=0;changeLog:string[]=[];ngOnChanges(changes:SimpleChanges){constlog:string[]=[];for(constpropNameinchanges){constchangedProp=changes[propName];constto=JSON.stringify(changedProp.currentValue);if(changedProp.isFirstChange()){log.push(`Initial value of ${propName} set to ${to}`);}else{constfrom=JSON.stringify(changedProp.previousValue);log.push(`${propName} changed from ${from} to ${to}`);}}this.changeLog.push(log.join(', '));}}
Компонент VersionParentComponent предоставляет значения minor и major и привязывает кнопки к методам, которые их изменяют.
import{Component}from'@angular/core';@Component({selector:'app-version-parent',template:` <h2>Source code version</h2> <button type="button" (click)="newMinor()"> New minor version </button> <button type="button" (click)="newMajor()"> New major version </button> <app-version-child [major]="major" [minor]="minor" ></app-version-child> `,})exportclassVersionParentComponent{major=1;minor=23;newMinor(){this.minor++;}newMajor(){this.major++;this.minor=0;}}
Вот результат последовательности нажатия кнопки:
Протестируйте его на перехват изменений свойств ввода с помощью ngOnChanges()
Проверьте, что обои входные свойства установлены изначально и что нажатие на кнопку вызывает ожидаемые вызовы и значения ngOnChanges:
// ...// Test must all execute in this exact orderit('should set expected initial values',async()=>{constactual=awaitgetActual();constinitialLabel='Version 1.23';constinitialLog='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(awaitactual.logs.get(0).getText()).toBe(initialLog);});it("should set expected values after clicking 'Minor' twice",async()=>{constrepoTag=element(by.tagName('app-version-parent'));constnewMinorButton=repoTag.all(by.tagName('button')).get(0);awaitnewMinorButton.click();awaitnewMinorButton.click();constactual=awaitgetActual();constlabelAfter2Minor='Version 1.25';constlogAfter2Minor='minor changed from 24 to 25';expect(actual.label).toBe(labelAfter2Minor);expect(actual.count).toBe(3);expect(awaitactual.logs.get(2).getText()).toBe(logAfter2Minor);});it("should set expected values after clicking 'Major' once",async()=>{constrepoTag=element(by.tagName('app-version-parent'));constnewMajorButton=repoTag.all(by.tagName('button')).get(1);awaitnewMajorButton.click();constactual=awaitgetActual();constlabelAfterMajor='Version 2.0';constlogAfterMajor='major changed from 1 to 2, minor changed from 23 to 0';expect(actual.label).toBe(labelAfterMajor);expect(actual.count).toBe(2);expect(awaitactual.logs.get(1).getText()).toBe(logAfterMajor);});asyncfunctiongetActual(){constversionTag=element(by.tagName('app-version-child'));constlabel=awaitversionTag.element(by.tagName('h3')).getText();constul=versionTag.element(by.tagName('ul'));constlogs=ul.all(by.tagName('li'));return{label,logs,count:awaitlogs.count(),};}// ...
Дочерний компонент раскрывает свойство EventEmitter, с помощью которого он издает события, когда что-то происходит. Родитель привязывается к этому свойству событий и реагирует на эти события.
Свойство дочернего компонента EventEmitter является выводным свойством, обычно украшенным декоратором @Output(), как показано в этом VoterComponent:
Нажатие на кнопку вызывает испускание true или false, булевой payload.
Родительский VoteTakerComponent связывает обработчик события onVoted(), который реагирует на дочернее событие полезной нагрузки $event и обновляет счетчик.
// ...it('should not emit the event initially',async()=>{constvoteLabel=element(by.tagName('app-vote-taker')).element(by.tagName('h3'));expect(awaitvoteLabel.getText()).toBe('Agree: 0, Disagree: 0');});it('should process Agree vote',async()=>{constvoteLabel=element(by.tagName('app-vote-taker')).element(by.tagName('h3'));constagreeButton1=element.all(by.tagName('app-voter')).get(0).all(by.tagName('button')).get(0);awaitagreeButton1.click();expect(awaitvoteLabel.getText()).toBe('Agree: 1, Disagree: 0');});it('should process Disagree vote',async()=>{constvoteLabel=element(by.tagName('app-vote-taker')).element(by.tagName('h3'));constagreeButton1=element.all(by.tagName('app-voter')).get(1).all(by.tagName('button')).get(1);awaitagreeButton1.click();expect(awaitvoteLabel.getText()).toBe('Agree: 0, Disagree: 1');});// ...
Родитель взаимодействует с ребенком, используя локальную переменную¶
Родительский компонент не может использовать привязку данных для чтения дочерних свойств или вызова дочерних методов. Для этого нужно создать переменную-ссылку шаблона для дочернего элемента, а затем ссылаться на эту переменную в родительском шаблоне, как показано в следующем примере.
Ниже представлен дочерний CountdownTimerComponent, который многократно отсчитывает время до нуля и запускает ракету. Методы start и stop управляют часами, а сообщение о состоянии обратного отсчета отображается в собственном шаблоне.
import{Component,OnDestroy}from'@angular/core';@Component({selector:'app-countdown-timer',template:'<p>{{message}}</p>',})exportclassCountdownTimerComponentimplementsOnDestroy{message='';seconds=11;ngOnDestroy(){this.clearTimer?.();}start(){this.countDown();}stop(){this.clearTimer?.();this.message=`Holding at T-${this.seconds} seconds`;}privateclearTimer:VoidFunction|undefined;privatecountDown(){this.clearTimer?.();constinterval=setInterval(()=>{this.seconds-=1;if(this.seconds===0){this.message='Blast off!';}else{if(this.seconds<0){this.seconds=10;}// resetthis.message=`T-${this.seconds} seconds and counting`;}},1000);this.clearTimer=()=>clearInterval(interval);}}
Родительский компонент CountdownLocalVarParentComponent, в котором размещается компонент таймера, выглядит следующим образом:
Родительский компонент не может привязать данные к методам start и stop дочернего компонента, а также к его свойству seconds.
Поместите локальную переменную #timer на тег <app-countdown-timer>, представляющий дочерний компонент. Это даст вам ссылку на дочерний компонент и возможность доступа к любым его свойствам или методам из родительского шаблона.
В этом примере родительские кнопки подключаются к дочерним start и stop, а для отображения дочернего свойства seconds используется интерполяция.
Здесь родитель и ребенок работают вместе.
Проверьте, что родитель взаимодействует с ребенком с помощью локальной переменной
Проверьте, что секунды, отображаемые в шаблоне родителя, совпадают с секундами, отображаемыми в сообщении о статусе ребенка. Проверьте также, что нажатие кнопки Stop приостанавливает таймер обратного отсчета:
// ...// 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()=>{constparent=element(by.tagName(parentTag));conststartButton=parent.element(by.buttonText('Start'));constseconds=parent.element(by.className('seconds'));consttimer=parent.element(by.tagName('app-countdown-timer'));awaitstartButton.click();// Wait for `<app-countdown-timer>` to be populated with any text.awaitbrowser.wait(()=>timer.getText(),2000);expect(awaittimer.getText()).toContain(awaitseconds.getText());});it('should stop the countdown',async()=>{constparent=element(by.tagName(parentTag));conststartButton=parent.element(by.buttonText('Start'));conststopButton=parent.element(by.buttonText('Stop'));consttimer=parent.element(by.tagName('app-countdown-timer'));awaitstartButton.click();expect(awaittimer.getText()).not.toContain('Holding');awaitstopButton.click();expect(awaittimer.getText()).toContain('Holding');});// ...
Подход локальной переменной является простым. Но он ограничен, поскольку связь между родителями и детьми должна осуществляться исключительно в родительском шаблоне.
Родительский компонент сам по себе не имеет доступа к дочернему.
Вы не можете использовать технику локальной переменной, если класс родительского компонента зависит от класса дочернего компонента. Отношения "родитель-ребенок" компонентов не устанавливаются внутри соответствующего класса каждого компонента с помощью техники локальной переменной.
Поскольку экземпляры класса не связаны друг с другом, родительский класс не может получить доступ к свойствам и методам дочернего класса.
Когда родительский компонент класса требует такого доступа, вставьте дочерний компонент в родительский как ViewChild.
Следующий пример иллюстрирует эту технику на примере того же Countdown Timer. Ни его внешний вид, ни поведение не меняются.
Дочерний CountdownTimerComponent также не меняется.
Переход от локальной переменной к технике ViewChild осуществляется исключительно в целях демонстрации.
Здесь находится родитель, 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 type="button" (click)="start()"> Start </button> <button type="button" (click)="stop()">Stop</button> <div class="seconds">{{ seconds() }}</div> <app-countdown-timer></app-countdown-timer> `,styleUrls:['../assets/demo.css'],})exportclassCountdownViewChildParentComponentimplementsAfterViewInit{@ViewChild(CountdownTimerComponent)privatetimerComponent!:CountdownTimerComponent;seconds(){return0;}ngAfterViewInit(){// Redefine `seconds()` to get from the `CountdownTimerComponent.seconds` ...// but wait a tick first to avoid one-time devMode// unidirectional-data-flow-violation errorsetTimeout(()=>(this.seconds=()=>this.timerComponent.seconds),0);}start(){this.timerComponent.start();}stop(){this.timerComponent.stop();}}
Потребуется немного больше работы, чтобы вписать дочернее представление в класс класса родительского компонента.
Сначала нужно импортировать ссылки на декоратор ViewChild и хук жизненного цикла AfterViewInit.
Затем внедрите дочерний CountdownTimerComponent в частное свойство timerComponent, используя декоратор свойства @ViewChild.
Локальная переменная #timer исчезнет из метаданных компонента. Вместо этого привяжите кнопки к собственным методам родительского компонента start и stop и представьте тикающие секунды в интерполяции вокруг метода родительского компонента seconds.
Эти методы обращаются непосредственно к инжектированному компоненту таймера.
Хук жизненного цикла ngAfterViewInit() является важным моментом. Компонент таймера становится доступным только после того, как Angular отобразит родительское представление.
Поэтому первоначально он отображает 0 секунд.
Затем Angular вызывает хук жизненного цикла ngAfterViewInit, и в это время уже слишком поздно обновлять отображение секунд обратного отсчета в родительском представлении. Правило однонаправленного потока данных Angular не позволяет обновить родительское представление в том же цикле.
Приложение должно выждать один оборот, прежде чем оно сможет отобразить секунды.
Используйте setTimeout() для ожидания одного такта, а затем пересмотрите метод seconds() так, чтобы он принимал будущие значения от компонента таймера.
Протестируйте его на вызов родительского компонента @ViewChild().
Используйте те же тесты таймера обратного отсчета, что и раньше.
Родительский и дочерний компоненты общаются с помощью сервиса¶
Родительский компонент и его дочерние компоненты совместно используют сервис, интерфейс которого обеспечивает двунаправленную связь в пределах семейства.
Областью действия экземпляра сервиса является родительский компонент и его дочерние компоненты. Компоненты вне этого поддерева компонентов не имеют доступа к сервису или их коммуникациям.
Этот MissionService соединяет MissionControlComponent с несколькими дочерними AstronautComponent.
component-interaction/src/app/mission.service.ts
1 2 3 4 5 6 7 8 910111213141516171819202122
import{Injectable}from'@angular/core';import{Subject}from'rxjs';@Injectable()exportclassMissionService{// Observable string sourcesprivatemissionAnnouncedSource=newSubject<string>();privatemissionConfirmedSource=newSubject<string>();// Observable string streamsmissionAnnounced$=this.missionAnnouncedSource.asObservable();missionConfirmed$=this.missionConfirmedSource.asObservable();// Service message commandsannounceMission(mission:string){this.missionAnnouncedSource.next(mission);}confirmMission(astronaut:string){this.missionConfirmedSource.next(astronaut);}}
Компонент MissionControlComponent предоставляет экземпляр сервиса, который он разделяет со своими дочерними компонентами (через массив метаданных providers) и внедряет этот экземпляр в себя через свой конструктор:
import{Component}from'@angular/core';import{MissionService}from'./mission.service';@Component({selector:'app-mission-control',template:` <h2>Mission Control</h2> <button type="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],})exportclassMissionControlComponent{astronauts=['Lovell','Swigert','Haise'];history:string[]=[];missions=['Fly to the moon!','Fly to mars!','Fly to Vegas!',];nextMission=0;constructor(privatemissionService:MissionService){missionService.missionConfirmed$.subscribe((astronaut)=>{this.history.push(`${astronaut} confirmed the mission`);});}announce(){constmission=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 и поэтому получает экземпляр службы своего родителя:
Обратите внимание, что в этом примере фиксируется subscription и unsubscribe() при уничтожении AstronautComponent. Это шаг защиты от утечки памяти. В этом приложении нет фактического риска, потому что время жизни AstronautComponent равно времени жизни самого приложения.
Это не всегда будет верно в более сложном приложении.
Вы не добавляете эту защиту к MissionControlComponent, потому что, как родитель, он контролирует время жизни MissionService.
Журнал History демонстрирует, что сообщения перемещаются в обоих направлениях между родительским MissionControlComponent и дочерними AstronautComponent, чему способствует служба:
Тестирование взаимодействия родительского и дочернего компонентов с помощью службы
Тестирует нажатие на кнопки как родительского MissionControlComponent, так и дочернего AstronautComponent и проверяет, что история соответствует ожиданиям:
// ...it('should announce a mission',async()=>{constmissionControl=element(by.tagName('app-mission-control'));constannounceButton=missionControl.all(by.tagName('button')).get(0);consthistory=missionControl.all(by.tagName('li'));awaitannounceButton.click();expect(awaithistory.count()).toBe(1);expect(awaithistory.get(0).getText()).toMatch(/Mission.* announced/);});it('should confirm the mission by Lovell',async()=>{awaittestConfirmMission(1,'Lovell');});it('should confirm the mission by Haise',async()=>{awaittestConfirmMission(3,'Haise');});it('should confirm the mission by Swigert',async()=>{awaittestConfirmMission(2,'Swigert');});asyncfunctiontestConfirmMission(buttonIndex:number,astronaut:string){constmissionControl=element(by.tagName('app-mission-control'));constannounceButton=missionControl.all(by.tagName('button')).get(0);constconfirmButton=missionControl.all(by.tagName('button')).get(buttonIndex);consthistory=missionControl.all(by.tagName('li'));awaitannounceButton.click();awaitconfirmButton.click();expect(awaithistory.count()).toBe(2);expect(awaithistory.get(1).getText()).toBe(`${astronaut} confirmed the mission`);}// ...