Help us understand the problem. What is going on with this article?

Angular入門 未経験から1ヶ月でサービス作れるようにする その6. ngModelを使ったフォーム

More than 1 year has passed since last update.

前回は、

  • ES6とTypescriptの基礎
    • Typescriptの型定義について
  • Angularでのデータ管理の基礎2
    • サービスクラスの作成

を学びました。

本記事では、

  • HTMLのフォーム要素とTypescriptの連携
    • [(ngModel)] を利用した双方向バインディング
  • クリックイベント
  • フォームの送信

について学んでいきます。

この記事のソースコード

https://github.com/seteen/AngularGuides/tree/入門その06

HTMLのフォーム要素とTypescriptの連携

HTMLのフォームの要素である、 <input> , <select> などの要素は、
画面での入力が内部のTypescriptのコードに反映してほしかったり、逆にTypescriptのコードで計算した値を入れたいような場合があります。
そのような場合に Angluar では、 [(ngModel)] という要素を使います。

[(ngModel)] は、双方向バインディングというもので、TypescriptからHTMLへのバインドと、HTMLからTypescriptへのバインドの双方向でデータが同期される仕組みです。

ちなみに、今までに何度か出てきた [property] のように [] で囲むのは、 Typescript から HTMLへのバインド

(click) のように () で囲むのが HTML から Typescript へのバインドになります。 [(ngModel)] はどちらからも囲まれているので双方向ということですね。

TIPS: Angularのバインディング
詳しくは、こちらを参照ください。
https://angular.io/guide/architecture-components#data-binding

まずは、この [(ngModel)] を解説していきます。

編集用のページの作成

最初に、フォームを利用するページを新しく作成します。

AngularCLI を利用してコマンドで作りましょう。

# ng g component product/product-edit
CREATE src/app/product/product-edit/product-edit.component.scss (0 bytes)
CREATE src/app/product/product-edit/product-edit.component.html (31 bytes)
CREATE src/app/product/product-edit/product-edit.component.spec.ts (664 bytes)
CREATE src/app/product/product-edit/product-edit.component.ts (293 bytes)
UPDATE src/app/app.module.ts (738 bytes)

コンポーネントを作成したので、ルートに追加します。

src/app/app-routing.module.ts
import { RouterModule, Routes } from '@angular/router';
import { NgModule } from '@angular/core';
import { ProductListComponent } from './product/product-list/product-list.component';
import { ProductDetailComponent } from './product/product-detail/product-detail.component';
import { ProductEditComponent } from './product/product-edit/product-edit.component';

const routes: Routes = [
  { path: 'products', component: ProductListComponent },
  { path: 'products/:id', component: ProductDetailComponent },
  { path: 'products/:id/edit', component: ProductEditComponent },
  { path: '', redirectTo: '/products', pathMatch: 'prefix' },
];
@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
  providers: []
})
export class AppRoutingModule {}

ng serve で起動して、 http://localhost:4200/products/test/edit にアクセスしてみましょう。

001.png

ちゃんと画面が追加されていますね。

AngularCLI を使ってコンポーネントを作成したので、実は気づいていないかもしれませんが、実は app.module.ts が自動で変更されています。

CLIを使わずに自分で追加する場合は、気をつけましょう。

src/app/app.module.ts
@NgModule({
  declarations: [
    AppComponent,
    ProductListComponent,
    ProductDetailComponent,
    ProductEditComponent // <= 勝手に追加されている
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

ここまでのコミット

https://github.com/seteen/AngularGuides/commit/3a556c59adab8ebb05a227b96aa6451dc3f78f29

フォームを使って編集画面を作る

コンポーネントが追加できたので、スタイルを当てていきます。

まずは、HTMLとCSSを修正して編集画面にしていきます。

src/app/product/product-edit/product-edit.component.html
<div class="container">
  <div class="title">商品編集</div>
  <form class="edit-form">
    <div class="edit-line">
      <label>ID</label>
      <span>1</span>
    </div>
    <div class="edit-line">
      <label>名前</label>
      <input type="text">
    </div>
    <div class="edit-line">
      <label>価格</label>
      <input type="number">
    </div>
    <div class="edit-line">
      <label>説明</label>
      <input type="text">
    </div>
  </form>
  <div class="footer">
    <div class="button black" routerLink="/products">保存</div>
  </div>
</div>
src/app/product/product-edit/product-edit.component.scss
.container {
  margin: auto;
  padding: 32px 0;
  width: 800px;
  .title {
    padding: 8px 0;
    text-align: center;
    width: 100%;
    font-weight: 600;
    font-size: 18px;
  }

  .edit-form {
    padding: 16px 48px;
    border: 1px solid #D9DBDE;
    border-radius: 4px;
    background-color: #FFFFFF;

    .edit-line {
      display: flex;
      align-items: center;
      padding: 16px 0;
      width: 100%;

      label {
        width: 15%;
        font-size: 16px;
        font-weight: 600;
      }

      input {
        border: 1px solid #BDBDBD;
        border-radius: 4px;
        padding: 0 8px;
        width: 560px;
        height: 40px;
        font-size: 14px;
      }
    }
  }

  .footer {
    display: flex;
    justify-content: center;
    padding: 24px 0;
  }
}

この段階で再度起動して確認してみます。

ng serve で起動して、 http://localhost:4200/products/test/edit にアクセスしてみましょう。

002.png

ちゃんとスタイルがあたり、編集画面っぽくなりました。

ここまでのコミット

https://github.com/seteen/AngularGuides/commit/b35ca0d53655de2a31a1cb924453e6ef1561af93

[(ngModel)] を使ってデータを反映する

編集画面では、データを API で取得し、そのデータを編集したいという場合が多いです。

この場合、画面を始めて開いたときにAPIで取得したデータが入っていることが望ましいです。

また、このとき、ブラウザ上でユーザが編集したデータをそのままAPIで登録するという場合も多いです。

このような場合は Typescript -> HTML および HTML -> Typescript の双方向のバインドが適しています。

実際に [(ngModel)] を利用してみましょう。

src/app/product/product-edit/product-edit.component.ts
import { Component, OnInit } from '@angular/core';
import { Product } from '../../shared/models/product';
import { ProductService } from '../../shared/services/product.service';

@Component({
  selector: 'app-product-edit',
  templateUrl: './product-edit.component.html',
  styleUrls: ['./product-edit.component.scss']
})
export class ProductEditComponent implements OnInit {
  product: Product;

  constructor(
    private productService: ProductService,
  ) {}

  ngOnInit() {
    this.productService.get(2).subscribe((product: Product) => {
      this.product = product;
    });
  }
}
src/app/product/product-edit/product-edit.component.html
<div class="container">
  <div class="title">商品編集</div>
  <form class="edit-form">
    <div class="edit-line">
      <label>ID</label>
      <span>{{ product.id }}</span>
    </div>
    <div class="edit-line">
      <label>名前</label>
      <input type="text" name="name" [(ngModel)]="product.name">
    </div>
    <div class="edit-line">
      <label>価格</label>
      <input type="number" name="price" [(ngModel)]="product.price">
    </div>
    <div class="edit-line">
      <label>説明</label>
      <input type="text" name="description" [(ngModel)]="product.description">
    </div>
  </form>
  <div class="footer">
    <div class="button black" routerLink="/products">保存</div>
  </div>
</div>

注意
Form 内で利用する [(ngModel)] は、実は name 属性を指定しないとエラーになります。
そのため、各 input 要素に name 属性を追加しています。

ここで、もう再度起動して動作確認してみましょう。

残念ながら、真っ白な画面になっています。

Chrome の デベロッパーツールを起動してエラーを確認します。

003.png

compiler.js:215 Uncaught Error: Template parse errors:
Can't bind to 'ngModel' since it isn't a known property of 'input'.

これは、 [(ngModel)] がAngularに存在を認識されていないという意味です。 謎の要素があるけど、これ何? ということですね。

実はAngularでは、モジュール (今回のプロジェクトでは、 app.module.ts) に登録していない機能はAngularのプロジェクトに認識されないので利用できません。

これは、本番用にビルドされるJSに不要なデータを入れず小さくするためのものだと思います。

このエラーは今後頻繁に遭遇することになると思いますので対応を覚えましょう。

まず、今回は自分で追加した要素ではなくAngularの標準機能を利用しています。
そのため、公式サイトの https://angular.io で ngModel で検索してみます。

検索して、 ngModel のページに行きます。

004.png

このページは、 ngModel の解説をしてくれているページです。

このページで、 module で検索すると、下記が引っかかります。

005.png

NgModule: FormsModule と書いてあります。

これはつまり、 ngModelFormsModule に所属しているという意味です。

ここまでの手順は、どのクラスがどのモジュールに所属しているか覚えない限り同じことをやると思いますので、覚えておきましょう。

app.module.tsFormsModule を追加します。

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

import { AppComponent } from './app.component';
import { ProductListComponent } from './product/product-list/product-list.component';
import { ProductDetailComponent } from './product/product-detail/product-detail.component';
import { AppRoutingModule } from './app-routing.module';
import { ProductEditComponent } from './product/product-edit/product-edit.component';
import { FormsModule } from '@angular/forms';

@NgModule({
  declarations: [
    AppComponent,
    ProductListComponent,
    ProductDetailComponent,
    ProductEditComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule, // <- ここを追加
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

この状態で再度動作確認してみましょう。

006.png

input に値が反映されていますね。

これまでのコミット

https://github.com/seteen/AngularGuides/commit/e45c320ddd7fea842b9ab1aa05dbfb16bc5a368c

クリックイベントで保存処理を行う

Webサイトの開発において、頻繁に利用するクリックイベント、Angularにおけるクリックイベントの取得方法を解説していきます。

クリックなどのHTML要素に何かしらのアクションを起こしたときのイベントは、 HTML -> Typescript の向きのバインドになります。

そのため、 () で囲うことで実現されます。 ( 反対に [] で囲うと Typescript -> HTML のバインドでしたね)

クリックやスクロールなどのJavascriptが標準で準備しているイベントは、Angularが最初から用意してくれています。

クリックイベントは、 (click) で定義されています。

TIPS: 使えるイベントを調べる
WebStorm では、実は下記のようにHTMLタグに ( を書くと補完で使えるイベントを出してくれます。
便利です。
007.png

では、編集ページの保存ボタンをクリックすることで保存処理 ( saveProduct() ) が実行されるとして実装を進めていきます。

src/app/product/product-edit/product-edit.component.html
<div class="container">
  <div class="title">商品編集</div>
  <form class="edit-form">
    <div class="edit-line">
      <label>ID</label>
      <span>{{ product.id }}</span>
    </div>
    <div class="edit-line">
      <label>名前</label>
      <input type="text" name="name" [(ngModel)]="product.name">
    </div>
    <div class="edit-line">
      <label>価格</label>
      <input type="number" name="price" [(ngModel)]="product.price">
    </div>
    <div class="edit-line">
      <label>説明</label>
      <input type="text" name="description" [(ngModel)]="product.description">
    </div>
  </form>
  <div class="footer">
    <div class="button black" (click)="saveProduct()">保存</div>
  </div>
</div>
src/app/product/product-edit/product-edit.component.ts
import { Component, OnInit } from '@angular/core';
import { Product } from '../../shared/models/product';
import { ProductService } from '../../shared/services/product.service';
import { Router } from '@angular/router';

@Component({
  selector: 'app-product-edit',
  templateUrl: './product-edit.component.html',
  styleUrls: ['./product-edit.component.scss']
})
export class ProductEditComponent implements OnInit {
  product: Product;

  constructor(
    private router: Router,
    private productService: ProductService,
  ) {}

  ngOnInit() {
    this.productService.get(2).subscribe((product: Product) => {
      this.product = product;
    });
  }

  saveProduct(): void {
    console.log(this.product);
    this.router.navigate(['/products']);
  }
}

保存ボタンをクリックすると、 saveProduct() の処理が走り、商品一覧ページに遷移するようにしました。

起動して動作確認してみましょう。

008.gif

解説

クリックイベント

まず、 Typescript に saveProduct() というメソッドを用意しました。

(click) 要素は、 同じコンポーネントのTypescript に記載されているメソッドを入力としてとります。

(click)="saveProduct()" と書くことで、要素がクリックされると saveProduct() を呼び出すという意味になります。

saveProduct() の 内容

saveProduct() メソッドは、 console.log で Product の情報を出力し、 /products に遷移するというものです。

実は今回、 router: Router という部分を constructor に追加しています。

Router というのは、Angular内でのルートの移動や現在のルートの情報を持っているクラスです。

navigate メソッドは、 HTML上で記載していた routerLink と同じような動きをし、
this.router.navigate(['/products']); と書くことで http://localhost:4200/products に遷移させることができます。

本来はこのメソッドで編集用のAPIを呼び出すところですが、今回は双方向バインドによりメモリ上の保存はすでに終わっているので、保存処理を実行していません。

TIPS: Router
Routerで他に何ができるのか、詳しく知りたい方は公式ページで確認してみましょう。
https://angular.io/api/router/Router

TIPS: 双方向バインディングの罠
今回の例でも見た通り、双方向バインドでは、HTMLで入力したものがそのままTypescript上の変数の値に反映されます。
その場合、例えばキャンセルボタンのようなものを用意している場合、今回のケースでは以前のProductの状態を保存していないため、もとに戻すことができません。
このように双方向バインディングを用いると、予期しない挙動をすることもあるので、注意しましょう。
そうならないようにするために、フォームで利用するデータは新しく用意した要素を用いる、そもそも双方向バインディングを使わないなどの工夫とすると良いと思います。

ここまでのコミット

https://github.com/seteen/AngularGuides/commit/6fba04dd14a48dd661d5ca375d10aed9329259bd

クリックイベントおまけ。イベント情報の取得

クリックイベントなどで、イベントの情報を利用したいときがあります。
クリックだと良い例が思いつかなかったのですが、例えばスクロールイベントだと、スクロール位置が取得したい、などです。

このような場合は、 (click)="saveProduct($event) のように記述します。

Typescript 側の定義を saveProduct(event: MouseEvent): void のように変更すれば、 この引数の event に値が入ります。

型がわからない、という場合は、いったん any で定義して実際に動かしてみて 型を調べると良いです。

ngSubmit を利用したフォームの制御

クリックイベントについて学んできましたが、実はこの方法では Form を利用していません。
この場合、例えばInput要素でEnterを押したら自動でフォームを送信する、などの恩恵は受けられません。

ここからは、 Formsubmit イベントをAngularで補足し、 Typescript のメソッドを呼び出すように改造していきます。

そこで登場するのが、 ngSubmit です。

これは、 form 要素が持てるディレクティブなので、 form 要素に追加してみます。

src/app/product/product-edit/product-edit.component.html
<div class="container">
  <div class="title">商品編集</div>
  <form (ngSubmit)="saveProduct()">
    <div class="edit-form">
      <div class="edit-line">
        <label>ID</label>
        <span>{{ product.id }}</span>
      </div>
      <div class="edit-line">
        <label>名前</label>
        <input type="text" name="name" [(ngModel)]="product.name">
      </div>
      <div class="edit-line">
        <label>価格</label>
        <input type="number" name="price" [(ngModel)]="product.price">
      </div>
      <div class="edit-line">
        <label>説明</label>
        <input type="text" name="description" [(ngModel)]="product.description">
      </div>
    </div>
    <div class="footer">
      <button class="button black">保存</button>
    </div>
  </form>
</div>

今回は、HTMLだけ変更しています。

保存ボタンを div から button 要素にし、 form タグの中に移動しました。 (click イベントを消しました。 button に変更したので、クリックすると自動的にformを送信しようとします)
また、その影響でスタイルが崩れないように formdiv 要素を追加しています。

動作確認をしてみましょう。

009.gif

input 中で Enter を押すだけでフォームが反応して、 saveProduct() が動き、 http://localhost:4200/products へ遷移していることがわかります。

TIPS: (submit) vs (ngSubmit)
実は、Angularには (submit) というものも用意されています。
(ngSubmit) は、メソッド内部で throw するとフォームの送信を停止してくれる、などの違いがあるので、 (ngSubmit) を利用すると良いです。
参考↓
https://stackoverflow.com/questions/41448038/difference-between-angular-submit-and-ngsubmit-events?rq=1

ここまでのコミット

https://github.com/seteen/AngularGuides/commit/6978aab8bb3c4e1410448f2c818efdb37f681e44

まとめ

今回は、Angularにおけるフォームの使い方を通して [(ngModel)] の双方向バインディング、クリックイベント、 ngSubmit の使い方を学びました。

次回 は、これまでの学習内容の実践編として、これまで学んだ技術を使って各ページを改善したり、 URL パラメータを取得してその内容に応じてページの内容を変更する方法を学んでいきます。

Angular入門 未経験から1ヶ月でサービス作れるようにする その6.5 URLパラメータの利用とここまで学んだことの実践

入門記事一覧

「Angular入門 未経験から1ヶ月でサービス作れるようにする」は、記事数が多いため、まとめ記事 を作っています。
https://qiita.com/seteen/items/43908e33e08a39612a07

seteen
Angularを広めたいエンジニア Ruby, Angularが好きです。
https://twitter.com/yazumoto
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away