Angularのフォーム
Angularを使って入力フォームを構築しようとしている筆者ですがAngularにおいてフォームの作り方は2種類あることを知りました。
そこでそれらふたつの入力フォームについて整理をしました。知らずにコードを書いていると、Webページの参考文献に従って記述しても競合して動かなかったり、統一感のないコードになってしまいます。
2種類のフォーム
Angularではリアクティブ型(Reactive Forms)とテンプレート駆動型(template-driven)という2種のフォームがあります。
リアクティブ型は汎用化に優れ規模の大きなアプリケーションに向いています。テンプレート型はシンプルで規模の小さいアプリケーションに向いています。
基礎となる構成要素
次の基礎となる構成要素は共通しています。
- FormContorl
個々のフォームコントロールの値と、バリデーション(エラーチェック)ステータスを監視するものです。
input等のひとつの要素にひとつのインスタンスを割り当てます。
- FormGroup
フォームコントロールのコレクションに対して値とステータスを監視します。
FormControlのまとまりです。Form要素に対して設定することが多いと思います。
- FormArray
フォームコントロールの配列に対して値とステータスを監視します。
- ControlValueAccessor
設置されたフォームコントロールとDOMの間の橋渡しをします。
コードにおける違い
コードにおける2つの違いを見ていきます。ここでは@Componentでtemplateを指定してHTMLタグをコード内で記述しています。
リアクティブ型.cmponent.ts
import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
@Component({
selector: 'app-reactive-favorite-color',
template: `
Your Name: <input type="text" [formControl]="frmCont">
`
})
export class FavoriteColorComponent {
frmCont = new FormControl('');
}
テンプレート駆動型.cmponent.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-template-favorite-color',
template: `
Your Name: <input type="text" [(ngModel)]="userName">
`
})
export class FavoriteColorComponent {
userName = '';
}
こうしてコードを比べてみると、以前学んだチュートリアルではテンプレート駆動型が使われていたことがわかります。
先にFormControl(フォームコントロール)はどちらも共通で持っているという話がありました。リアクティブ側ではそれがコード内定義されていますが、テンプレート駆動型では記述されていません。 テンプレート駆動型では、ngModelを通じてフォームコントロールにデータを伝えます。
リアクティブフォーム
テンプレート駆動型は以前学んだ記述法だったので、ここからはリアクティブフォームについてもう少し詳しく学びます。
FormGroupとFormControlは次のように定義します。FormGroupは複数の階層を持つことができます。
cmponent.ts
import { Component } from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';
@Component({
selector: 'app-profile-editor',
templateUrl: './profile-editor.component.html',
styleUrls: ['./profile-editor.component.css']
})
export class ProfileEditorComponent {
profileForm = new FormGroup({
firstName: new FormControl(''),
lastName: new FormControl(''),
address: new FormGroup({
street: new FormControl(''),
city: new FormControl(''),
state: new FormControl(''),
zip: new FormControl('')
})
});
}
FormControlを配置するにはひとつの要素(input、select等)に対して[formControl]バインディングを使いスクリプトで定義したインスタンスを設定します。
FormGroupを配置するにはひとつの要素(form、div等)に対して[formGroup]バインディングを使いスクリプトで定義したインスタンスを設定します。
FormControlやFormGroupのインスタンスがFormGroupの内側にある場合は[formControl]の代わりに、formControlNameを使います。この時、親としてformGroupをバインディングした要素があることが前提です。[formGroup]も同様に、formGroupNameを使います。
HTML側とスクリプト側で階層構造が違うとformControlやformGroupが見つけられずエラーになります。
cmponent.html
<!-- 単独で定義したFormControlを使う場合 -->
<input [formControl]="fullname" />
<!-- FormGroup使う場合 -->
<form [formGroup]="profileForm">
<input formControlName="firstName" type="text" />
<input formControlName="lastName" type="text" />
<div formGroupName="addresss">
<input formControlName="street" type="text" />
<input formControlName="city" type="text" />
<input formControlName="state" type="text" />
<input formControlName="zip" type="text" />
</div>
</form>
FromGroupを使わない場合は、formControlName属性は利用できません。
FormBuilder
先ほどスクリプトでFormオブジェクトを作成する際、同じような文字列の打ち込みが続き冗長だと思った人もいるかもしれません。
FormBuilderを使えば、その表記を簡略化できます。利用するためにはFormBuilderをインポートし、constractorに追加して依存性の注入(DI)をします。
前述のコードをFormBuilderを使ったものに書き換えると次のようになります。FormControlとFromGroupのimportが不要になっていることにも注目してください。
cmponent.ts
import { Component } from '@angular/core';
import { FormBuilder } from '@angular/forms';
@Component({
selector: 'app-profile-editor',
templateUrl: './profile-editor.component.html',
styleUrls: ['./profile-editor.component.css']
})
export class ProfileEditorComponent {
profileForm = this.fb.group({
firstName: [''],
lastName: [''],
address: this.fb.group({
street: [''],
city: [''],
state: [''],
zip: ['']
}),
});
constructor(private fb: FormBuilder) { }
}
フォームデータの取り出し方
FormGroupインスタンスからはFormGroup.valueで配下にあるFormControlの値をオブジェクトの形で取得できます。
console.log(FormGroup.value)
{ firstName: '家康', lastName: '徳川', address: { street: 1, city: '', state: '江戸', zip:'000-0000' }}
また、FormGroupインスタンスからFromGroup.get('name')で、FormControlを取得できます。
FormControlインスタンスからは.valueプロパティで値を取得、setValue(xxx)メソッドで値のセットができます。
パイプを利用
リアクティブフォーム内のinput要素でパイプを適用したい場合はstack overflowに次のような解決策がありました。
cmponent.html
<form [formGroup]="frmG">
<input [value]="frmG.get('keyname').value | pipe" formControlName="keyname" />
</form>
イベント駆動型でもそうですがinput要素でのパイプは、変換式によってはIMEが機能しているとおかしくなるようです。
変更イベント
リアクティブフォームで変更イベントを設定する方法にvalueChangesプロパティがあります。これにはObservableが設定されています。RxJSのObservableは非同期処理用のオブジェクトで、これを次のようにすると変更のたびにフォームの値をログ出力します。
cmponent.ts(一部)
...
this.profileForm.controls['firstName'].valueChanges.subscribe(v=>{
console.log(v)
})
...
さらにRxJSのパイプを使うことで、指定した時間中にキー入力があった場合は処理しない=入力完了処理を待つdebounceTimeを使ったり、指定の文字長に達した場合にのみ反応するように、filterを使ったり、元の値と同じ場合は処理をしないdistinctUnitChangedを使ったりできます。
これらの方法は「リアクティブフォームでの valueChanges の利用方法」を参考にさせていただきました。
cmponent.ts(一部)
...
this.profileForm.controls['firstName'].valueChanges.pipe(
debounceTime(400),
filter(v=> v.length >= 2),
distinctUntilChanged())
.subscribe(v=>{
console.log(v)
})
...
先ほどリアクティブフォームにスクリプト側からsetValueで値をセットしましたが、上記で設定したイベントはsetValueによる値の変更でも発動します。逆にスクリプト経由での値のセットの際にイベントを発動させたくなかったら、stack overflowにあるように「emitEvent: false」のパラメータを一緒に渡します。
cmponent.ts(一部)
...
this.profileForm.controls['firstName'].setValue('中村',{emitEvent: false});
...
動的にフォームを追加する
先に紹介した公式ページに動的にフォームを追加する方法が書かれていましたが、執筆時点ではうまく動きませんでした。そこでその修正を行います。
まずは書かれている内容に沿って作成していきます。
動的にフォームコントロールを追加するためにはFormArrayクラスを利用するので、インポートします。
component.ts(一部)
import { FormArray } from '@angular/forms';FormArrayを利用するFormGroupのコンストラクタに追加します。
component.ts(一部)
//fbという名前でconstructorにFormBuilderが設定してある前提です。
forms = this.fb.group({
...
extraforms: this.fb.array([]),
...
});
テンプレート内で*ngForループで表示する際に利用したいので、ゲッターでFormArrayコントロールにアクセスするための別名(エイリアス)を作ります。
component.ts(一部)
//formsという名前でフォームグループが設定してある前提です
get extraforms():FormArray {
return this.forms.get('extraforms') as FormArray;
}
またゲッターを使って、フォームを追加する際のスクリプトも記述しておきます。
component.ts(一部)
addForm() {
this.extraforms.push(this.fb.control(''));
}
テンプレート(HTML)で*ngForを使って動的に追加したフォームを表示するには次のようにします。ここが公式の案内と違うところです。先のページではformControlNameにi(インデックス)をバインドさせていますが、スクリプト実行時に「Cannot find control with name: '0'」というエラーがでてしまいます。
そこで「formControlName」ディレクティブではなく「formControl」ディレクティブをつかいます。実際に*ngForでループするのはformControlなので、そのまま渡したいところですが、この時の型がAbstractControlとなるため、そのままではコンパイルが通りません。
そこでテンプレート内で$any()でキャストするか、indexを受け取りFormControlを返すメソッドをコンポーネント(.ts)に追加します。
component.html(一部)
<form [formGroup]="forms">
<ol>
<li *ngFor="let f of extraforms.controls;let i=index">
<input id="form-{{i}}" type="text" [formControl]="$any(f)"/>
</li>
</ol>
</form>
上記コードはテンプレート内で$anyでキャストしていますが、コンポーネントでスクリプトを書くなら次のようになると思います。
component.ts(一部)
public getForm(i:number):FormControl {
return this.itemforms.at(i) as FormControl;
}
動的フォームの変更イベント
動的に作成したフォームに変更イベントをつけたい場合は一旦コントロールを変数に受け、valueChangesイベントを付けます。
component.ts(一部)
let form = this.fb.control('');
form.valueChanges.subscribe(_=>{this.changeEvent()});
this.forms.push(form);
この時、どのインデックスのフォームが変更になったかを知りたいケースがあると思いますが、prinstineプロパティを使ってチェックするぐらいしか方法がみつかりませんでした。
インデックスを使ったループでprinstineがfalseになっているフォームのインデックスを見つけます。見つけたあとはmarkAsPristine()で元に戻し次のイベントを待機します。pristineはread-only属性がついているので直接変更はできません。
component.ts(一部)
changeEvent() {
for (let i = 0; i < this.itemforms.controls.length; i++) {
if (this.itemforms.at(i).pristine==false) {
//なにかしらの処理
this.itemforms.at(i).markAsPristine();//手つかずに戻す
}
}
}
リアクティブフォームの要素を動的に使用不可にする場合
フォームの一部を動的に使用不可にしたい場合は.tsファイルで、フォームコントロール名.contols['コントロール名'].disable()を呼びます。この時も前述同様に{emitEvent: false}のパラメータを渡せます。再び使用可能にするには.enable()を呼びます。
HTMLのdisabledディレクティブをバインドすると次のようなワーニングがでます。「changed after checked」エラーが出るのを避けるためにこのようにすべきだといいうことです。
参考にさせていただきましたサイトの皆様ありがとうございました。