athome-developer’s blog

不動産情報サービスのアットホームの開発者が発信するブログ

Angularのコンポーネント間の値受け渡しについて

こんにちは、情報システム部の高野です。
ここのところバタバタしていて全然ブログが書けませんでした。
本当に継続は難しい・・・

ということでブログが書けているってことは少し落ち着いてきたってことです(笑)
まだリリースはしていないのですが、現在携わっているシステムはフロントをAngular*1で作っています。
かなり入力項目が多いシステムなのでカテゴリごとにコンポーネント化しています。
Angularをはじめ最近のWebフロントエンドのフレームワークコンポーネント化が簡単にできるので便利ですよね!


今回の仕組みは下図のようにコンポーネントを分けています。*2
f:id:taktak1974:20180913170420p:plain
コンポーネントは、Angularのルーティングでページ遷移しますので
コンポーネントAを表示している時に存在する子コンポーネントはaとbだけです。


コンポーネント化は簡単なのですが、コンポーネント間の値の受け渡しが発生すると
気を付けて設計しないとコードがとても読みづらいものになってしまいます。
今回の仕組みで発生したコンポーネント間の受け渡しのパターンは

  1. コンポーネントAから子コンポーネントaのように「親から子」に値を受け渡しするケース
  2. コンポーネントaから子コンポーネントbのように「子から子」に値を受け渡しするケース
  3. コンポーネントaから子コンポーネントcのように「親が違う子」に値を受け渡しするケース

です。

下記で示すサンプルは全てパターン02を実装していますが、
パターン03にも応用可能です。

Serviceクラス経由で値を受け渡す

先に結論から言うとServiceクラスを経由して値を受け渡すのが簡単でシンプルです。
(公式のチュートリアルにも載ってるので一番オーソドックスなやり方なのでしょう)

コードは次のような感じになります。*3
サンプルとしては子コンポーネントaで入力し、子コンポーネントbでそれを表示するようにしています。*4

Serviceクラス
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class ValueSharedService {
  // 受け渡しを行うプロパティ
  firstName = '';
  lastName = '';
}
コンポーネントa
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormBuilder } from '@angular/forms';
import { ValueSharedService } from '../../value-shared.service';

@Component({
  selector: 'app-child-a',
  templateUrl: './child-a.component.html',
  styleUrls: ['./child-a.component.css']
})
export class ChildAComponent implements OnInit {

  form: FormGroup;

  constructor(private valueSharedService: ValueSharedService, private fb: FormBuilder) { }

  ngOnInit() {
    this.form = this.fb.group({
      lastName: this.valueSharedService.lastName,
      firstName: this.valueSharedService.firstName,
    });
  }

  onChangeName() {
    this.valueSharedService.lastName = this.form.get('lastName').value;
    this.valueSharedService.firstName = this.form.get('firstName').value;
  }
}
<div [formGroup]="form">
  <div>
    <label>姓:<input type="text" formControlName="lastName" (change)="onChangeName()"></label>
  </div>
  <div>
    <label>名:<input type="text" formControlName="firstName" (change)="onChangeName()"></label>
  </div>
</div>

入力に応じてイベントが実行され、Serviceクラスのプロパティを書き換えます。

コンポーネントb(表示側)
import { Component} from '@angular/core';
import { ValueSharedService } from '../../value-shared.service';

@Component({
  selector: 'app-child-b',
  templateUrl: './child-b.component.html',
  styleUrls: ['./child-b.component.css']
})
export class ChildBComponent {

  get fullName() {
    return `${this.valueSharedService.lastName} ${this.valueSharedService.firstName}`;
  }

  constructor(private valueSharedService: ValueSharedService) { }
}
<p>
  {{fullName}}
</p>

コンポーネントbは、Serviceクラスの値を連結して表示するだけです。


Serviceクラス経由で値を受け渡す方法は、直感的でなんら引っかかるところが無いと思います。
まあServiceクラスがシングルトンだということだけ意識してれば良いかと。
非常にシンプルで分かりやすい!


さて、これを別の方法でやってみたいと思います。

@Input()と@Output()を使う

まず親から子にデータを渡す方法としては、@Input()を使います。

コンポーネントb
import { Component, Input } from '@angular/core';
import { Name } from '../../name';

@Component({
  selector: 'app-child-b',
  templateUrl: './child-b.component.html',
  styleUrls: ['./child-b.component.css']
})
export class ChildBComponent {

  @Input() name: Name;

  get fullName() {
    return `${this.name.lastName} ${this.name.firstName}`;
  }
}

HTMLは、Service経由の時と変わらないので割愛します。

コンポーネント(表示側の指定)

@Input()を使う場合は、親側からHTMLで渡したい値を指定するだけです。

<div>
  <app-child-b [name]="name"></app-child-b>
</div>

[name]の部分が子コンポーネントの@Input()を指定した変数名になります。


逆に子コンポーネントから親コンポーネントに値を渡すときは@Output()を使います。

コンポーネントa
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
import { Name } from '../../name';
import { FormGroup, FormBuilder } from '@angular/forms';

@Component({
  selector: 'app-child-a',
  templateUrl: './child-a.component.html',
  styleUrls: ['./child-a.component.css']
})
export class ChildAComponent implements OnInit {

  @Output() public nameChanged = new EventEmitter<Name>();

  form: FormGroup;

  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    this.form = this.fb.group({
      lastName: '',
      firstName: '',
    });
  }

  onChangeName() {
    this.nameChanged.emit({
      lastName: this.form.get('lastName').value,
      firstName: this.form.get('firstName').value
    });
  }
}

HTMLは、Serviceクラス経由の時と変わらないので割愛します。
@Output()は、EventEmitter型になります。
onChangeName関数でこれを呼び出すことにより親コンポーネントで指定したコールバック関数が実行されます。
引数で渡したい値を指定すれば親コンポーネントに値が渡せます。

コンポーネント(入力側の指定)
<div>
  <app-child-a (nameChanged)="onChangeName($event)"></app-child-a>
</div>

HTMLで@Output()の変数に実行したい関数を渡します。
この方法は、サンプルだとシンプルであまり問題無いように見えますが
子から親に値を渡す方法がコールバック関数の引数経由になるので
処理が複雑になってくるとバグの温床になりかねません。
コンポーネントの値が変わった時に親コンポーネントでなにかしらの処理を実行したい時には有用ですが
単純に値を渡すだけのために使わない方が良いのではないかと個人的には思います。
それとパターン03に対応するのはとても複雑になります。

@ViewChild()を使う

@ViewChild()を使うと親コンポーネントに子コンポーネントインスタンスを持つことができるようになります。
コンポーネントインスタンスがあるのでpublicメンバーであればやりたい放題になります。


@ViewChildを使ったサンプルコードです。

コンポーネント
import { Component, OnInit, ViewChild } from '@angular/core';
import { Name } from '../name';
import { ChildAComponent } from './child-a/child-a.component';
import { ChildBComponent } from './child-b/child-b.component';

@Component({
  selector: 'app-parent-a',
  templateUrl: './parent-a.component.html',
  styleUrls: ['./parent-a.component.css']
})
export class ParentAComponent implements OnInit {

  @ViewChild(ChildAComponent) childA: ChildAComponent;
  @ViewChild(ChildBComponent) childB: ChildBComponent;

  constructor() { }

  ngOnInit() {
    this.childA.nameChanged = this.onChangeName;
  }

  onChangeName = (name: Name) => {
    this.childB.name = name;
  }
}

@ViewChildを使って子コンポーネントインスタンスを持つようにしています。
ngOnInit内で子コンポーネントa に対して関数を渡します。
渡した関数onChangeName 内で子コンポーネントbに対して
コンポーネントaから渡ってきたnameを渡します。

<div>
  <app-child-a></app-child-a>
</div>
<div>
  <app-child-b></app-child-b>
</div>

HTMLでは、子コンポーネントを指定するだけで値の受け渡しは無くなります。

コンポーネント側は何も工夫が無いので載せる必要も無いのですが一応

コンポーネントa
import { Component, OnInit } from '@angular/core';
import { Name } from '../../name';
import { FormGroup, FormBuilder } from '@angular/forms';

@Component({
  selector: 'app-child-a',
  templateUrl: './child-a.component.html',
  styleUrls: ['./child-a.component.css']
})
export class ChildAComponent implements OnInit {

  public nameChanged: (name: Name) => void;

  form: FormGroup;

  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    this.form = this.fb.group({
      lastName: '',
      firstName: '',
    });
  }

  onChangeName() {
    this.nameChanged({
      lastName: this.form.get('lastName').value,
      firstName: this.form.get('firstName').value
    });
  }
}
コンポーネントb
import { Component, OnInit } from '@angular/core';
import { Name } from '../../name';

@Component({
  selector: 'app-child-b',
  templateUrl: './child-b.component.html',
  styleUrls: ['./child-b.component.css']
})
export class ChildBComponent {

  name: Name = { lastName: '', firstName: '' };

  get fullName() {
    return `${this.name.lastName} ${this.name.firstName}`;
  }

  constructor() { }
}

このやり方では、親コンポーネント以外はAngular特有の処理が無くなるのでシンプルに見えますが
コンポーネントから子コンポーネントに対して何でもやれてしまうので
きちんとルールを設けないとカオスに陥ります。
それとこの例では問題無いのですがAngularのライフサイクルを意識して作らないと思わぬバグに嵌ることにもなります。

ReactiveForms専用の方法

この方法は今回のプロジェクトでは使用していません。
このブログを書くにあたり色々調べていたら出てきたやり方です。
これをもっと早く知っていればこの方法で実装したのになっていう方法です。

コンポーネント

import { Component, OnInit } from '@angular/core';
import { Name } from '../name';
import { FormGroup, FormBuilder } from '@angular/forms';

@Component({
  selector: 'app-parent-a',
  templateUrl: './parent-a.component.html',
  styleUrls: ['./parent-a.component.css']
})
export class ParentAComponent implements OnInit {

  form: FormGroup;

  get name(): Name {
    return {
      lastName: this.form.get('name').get('lastName').value,
      firstName: this.form.get('name').get('firstName').value,
    };
  }

  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    this.form = this.fb.group({});
  }
}

ここでは空のFormGroupを作っておきます。
nameプロパティは、FormGroupから取り出した値を返すようになっています。

<div [formGroup]="form">
  <app-child-a></app-child-a>
</div>
<div>
  <app-child-b [name]="name"></app-child-b>
</div>

HTML側にはformGroupの指定を追加しています。

コンポーネントa
import { Component, OnInit } from '@angular/core';
import { FormGroupDirective, FormGroup, FormControl, ControlContainer } from '@angular/forms';

@Component({
  selector: 'app-child-a',
  templateUrl: './child-a.component.html',
  styleUrls: ['./child-a.component.css'],
  viewProviders: [
    {
      provide: ControlContainer,
      useExisting: FormGroupDirective
    }
  ]
})
export class ChildAComponent implements OnInit {

  constructor(private parent: FormGroupDirective) { }

  ngOnInit() {
    const form = this.parent.form;

    form.addControl('name', new FormGroup({
      lastName: new FormControl(''),
      firstName: new FormControl(''),
    }));
  }
}

constructorでFormGroupDirectiveを受け取ります。
このインスタンスから親のFormGroupが取得できます。

<div formGroupName="name">
  <div>
    <label>姓:<input type="text" formControlName="lastName"></label>
  </div>
  <div>
    <label>名:<input type="text" formControlName="firstName"></label>
  </div>
</div>
コンポーネントb

こちらはコンポーネントaと同様にFormGroupを貰っても良いのですが
表示するだけであれば@Input()でやるのが無難な気がします。
コードは上記@Input()を使う方法とまったく同じで動きます。


この方法であればパターン03での値の受け渡しもそこまで大変なことにはならないと思います。


ReactiveFormを使っていてパターン01・パターン02の場合は、この方法が割と良いのではないかと思いますが
実際にプロジェクトでこの方法を使っていないので複雑になってくるとどうなるかは未知数です。

まとめ

今回のプロジェクトでAngularのコンポーネント分割と値の受け渡しに関して
良いところも大変なところも大分見えました。
Angularは、コンポーネント間で値を受け渡す方法がいくつもあります。
どれが良いとは一概に言えないので都度判断して最良の方法を選択する必要があります。
なにか他のライブラリを使えばもっと簡潔に分かりやすいコードにできたかもしれません。
その辺りはまた次のプロジェクトで検討していきたいと考えてます。


弊社ではエンジニアを募集しています。
Angular以外にもVue.jsを使ったシステムもあります。
興味がある方は下記からエントリお願いします。
athome-inc.jp

*1:バージョンは5を使ってますが、このブログのサンプルコードはAngular6で書いてます

*2:本当はちょっと違うのですが、説明が大変なので簡略化しています。

*3:今回のプロジェクトではReactiveFormを使ったのでサンプルも同様にReactiveFormにしています。

*4:実際はそんな単純な感じでは無いのですが・・・