中安拓也のブログ

プログラミングについて書くブログ

【Angular + Firebase】アプリケーションをデプロイする

はじめに

AngularとFirebaseを使って作成したアプリをFirebaseのHostingという機能を使ってデプロイします。

前提条件

  • Angularプロジェクトは作成ずみ
  • FirestoreやAutheticationを使用するためにFirebaseのプロジェクトも既に作成している

環境

ng --versionコマンドの実行結果

Angular CLI: 8.3.20
Node: 12.13.1
OS: darwin x64
Angular: 8.2.14
... animations, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.803.20
@angular-devkit/build-angular     0.803.20
@angular-devkit/build-optimizer   0.803.20
@angular-devkit/build-webpack     0.803.20
@angular-devkit/core              8.3.20
@angular-devkit/schematics        8.3.20
@angular/cdk                      7.3.7
@angular/cli                      8.3.20
@angular/fire                     5.2.3
@angular/material                 7.3.7
@ngtools/webpack                  8.3.20
@schematics/angular               8.3.20
@schematics/update                0.803.20
rxjs                              6.5.3
typescript                        3.5.3
webpack                           4.39.2

デプロイ準備

では、Firebase Hostingへのデプロイの準備を初めていきます。まず、ターミナルからfirebaseを扱うツールをグローバルインストールします。

sudo npm i -g firebase-tools

インストールが終わったら下記のコマンドでfirebaseにアクセスします。

firebase login

firebase loginコマンドを実行すると、Firebaseがエラーレポートを収集していいか聞かれますので、お好みでYかnを入力してエンターキーを押下します。

i  Firebase optionally collects CLI usage and error reporting information to help improve our products. Data is collected in accordance with Google's privacy policy (https://policies.google.com/privacy) and is not used to identify you.

? Allow Firebase to collect CLI usage and error reporting information? Yes
i  To change your data collection preference at any time, run `firebase logout` and log in again.

質問に答えると、ブラウザが開かれてGoogleアカウントでのログインを求められるので、Firebaseプロジェクトを作成した時のGoogleアカウントでログインします。

f:id:l08084:20200620175304p:plain
Googleアカウントでのログインを求められる

firebaseへのログインが完了したら、firebase initというコマンドをターミナルに入力します。

firebase init

コマンドを入力すると、Firebaseのどの機能をセットアップするか聞かれるので、Hostingを選択します。

f:id:l08084:20200620175806p:plain
Hostingを選択する

続いて、「このディレクトリーに対してデフォルトで設定するFirebaseプロジェクトを選択してください」という質問が表示されます。

FirestoreやAutheticationを使用するためにFirebaseのプロジェクトを既に作成ずみなので、Use an existing projectを選択します。

=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add, 
but for now we'll just set up a default project.

? Please select an option: 
  Use an existing project 
  Create a new project 
❯ Add Firebase to an existing Google Cloud Platform project 
  Don't set up a default project

Use an existing projectを選択した後に、Firebaseプロジェクトの一覧が表示されるので、該当のプロジェクトを選択します。(今回はtaikinプロジェクトを選択する)

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add, 
but for now we'll just set up a default project.

? Please select an option: Use an existing project
? Select a default Firebase project for this directory: taikin (taikin)
i  Using project taikin (taikin)

続いて、「公開用のディレクトリーとしてどのディレクトリを使用するか」質問されるので、dist/project_nameを入力します。(今回はdist/kintai)

=== Hosting Setup

Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.

? What do you want to use as your public directory? 

最後に、SPAアプリケーションかどうか質問されるので、YESと回答します。

? Configure as a single-page app (rewrite all urls to /index.html)? Yes

このように質問に回答していくと、次のようなfirebase.jsonファイルが作成されます。

もし、質問に対する回答を間違えても最終的にfirebase.jsonファイルを手動で修正すれば大丈夫です。

{
  "hosting": {
    "public": "dist/project_name",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [{
      "source": "**",
      "destination": "/index.html"
    }]
  }
}

アプリケーションを本番ビルドする

Angularプロジェクトを本番ビルドして、dist配下に成果物が作成されるのを確認します。

もし、src/enviroment.prod.tsファイルに適切な設定をしていない場合は、事前に設定を完了させておく必要があります。今回は本番環境と開発環境で同じFirebaseのプロジェクトを使用しているため、src/enviroment.tsの内容をそのままsrc/enviroment.prod.tsにコピーしました。

ng build --prod

デプロイ実施

ng build --prodによる本番ビルドが完了したら、firebase deployコマンドを実施して、いよいよデプロイを実施します。

$ firebase deploy

=== Deploying to 'taikin'...

i  deploying hosting
i  hosting[taikin]: beginning deploy...
i  hosting[taikin]: found 25 files in dist/kintai
✔  hosting[taikin]: file upload complete
i  hosting[taikin]: finalizing version...
✔  hosting[taikin]: version finalized
i  hosting[taikin]: releasing new version...
✔  hosting[taikin]: release complete

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/taikin/overview
Hosting URL: https://taikin.web.app

デプロイが正常に完了すると、上記のようにURLが表示されるので、デプロイしたアプリケーションにアクセスすることができます。

参考サイト

Firebase CLIでAngularアプリをFirebase Hostingにデプロイする - Qiita

5分でAngularチュートリアルアプリをFirebaseにデプロイする方法 - Qiita

Angular + Firebase でメモアプリを作りました

f:id:l08084:20200620195338p:plain
作ったメモアプリ。名前はスリーメモ

はじめに

Firebaseの勉強のためにスリーメモというアプリを作りました。Evernoteみたいなメモアプリです。モバイル対応はまだできていないのですが、パソコンで何かメモ取りたいんだよね〜という人は試してみてください。

スリーメモのURL: https://taikin.web.app/lp

スリーメモの機能

スリーメモの機能について紹介します。

SNS認証

f:id:l08084:20200620201505p:plain
スリーメモのログイン画面

FacebookとTwitterのアカウントを持っている場合は、SNSのアカウントを使ってスリーメモのアカウント登録・認証を実施することができます。

フォルダ

f:id:l08084:20200620204142p:plain
フォルダでメモを分類

フォルダを作成することでメモを分類ごとに整理することができます。

スリーメモで使われている技術

スリーメモは、フロントエンドフレームワークにAngular、CSSフレームワークにAngular Materialを使用しています。Firebaseの機能のうち、Hosting, Authentication, Firestoreの3種類の機能を使用しています。

【JavaScript】[バグ]配列にemptyという要素が挿入されてしまう

はじめに

TypeScriptを使用し、配列に配列の 要素数 + 1 の数値を代入するメソッドを3回呼んだところ、配列が[1, 2, 3]ではなく、[empty, 1, empty, 3, empty, 5]になってしまった。

本事象が発生した誤ったコード

  public array: number[] = [];

  constructor() {
    this.addArray();
    this.addArray();
    this.addArray();
    console.log(this.array);
    // [1, 2, 3]ではなく、[empty, 1, empty, 3, empty, 5]と表示されてしまう
  }

  /**
   * 配列に 配列の要素数 + 1 の数字を代入する
   *
   * @memberof AppComponent
   */
  public addArray() {
    let item = this.array.length++;
    this.array.push(item);
  }

環境

  • typescript: 3.5.3

本事象が発生した原因

this.array.length++の部分でArray.lengthに1を加算することによって、意図せず配列の要素数(Array.length)を一つ増加し空白(empty)を作ってしまっていることが原因となります。

Array.lengthは書き込み可能なプロパティであるため、加算するとその分配列の要素数が増加して空白ができてしまいます。

例えば、次の例のように空の配列のArray.lengthに5を加算するとemptyが5件代入されている配列が作成されます。

    this.array = [];
    this.array.length += 5;
    console.log(this.array); // [empty × 5]

最初の誤ったコードを意図した通りに動くように修正すると次のようなコードになります。

  /**
   * 配列に 配列の要素数 + 1 の数字を代入する
   *
   * @memberof AppComponent
   */
  public addArray() {
    let item = this.array.length;
    this.array.push(++item);
  }

参考サイト

Push an empty element into javascript array - Stack Overflow

Array.length - JavaScript | MDN

【Angular + Firebase】ログイン中のアカウントの情報を表示する

f:id:l08084:20200606211323p:plain
ログイン中のアカウントの情報を表示している

はじめに

ログインしているアカウントのサムネイル画像とアカウント名を表示する機能を実装します。

環境

フロントサイドのフレームワークとしてAngularを、バックエンドにFirebaseを使用しています。CSSフレームワークはAngular Materialです。

  • Angular CLI@8.3.20
  • Node@12.13.1
  • Angular@8.2.14
  • firebase@6.3.4
  • angular/fire@5.2.1

ログイン中のアカウントの情報を表示する

それでは、ヘッダー上にログイン中のアカウントの情報を表示する機能を実装していきます。

認証のサービスクラスを作成する

まず、ログインしているアカウントの情報を取得するため、認証関連の処理をまとめたサービスクラスであるAuthenticationServiceを作成します。

authentication.service.ts

import { Injectable } from '@angular/core';
import { AngularFireAuth } from '@angular/fire/auth';
import { Observable } from 'rxjs';
import { User } from 'firebase';

/**
 * 認証関連のサービスクラス
 *
 * @export
 * @class AuthenticationService
 */
@Injectable({
  providedIn: 'root'
})
export class AuthenticationService {
  constructor(public afAuth: AngularFireAuth) {}

  /**
   * ログインしているアカウントの情報を返す
   *
   * @returns {User}
   * @memberof AuthenticationService
   */
  public getCurrentUser(): User {
    return this.afAuth.auth.currentUser;
  }

  /**
   * ログアウトする
   *
   * @returns {Promise<void>}
   * @memberof AuthenticationService
   */
  public signOut(): Promise<void> {
    return this.afAuth.auth.signOut();
  }

  /**
   * ログインしているアカウントの情報を返す
   *
   * @returns {Observable<User>}
   * @memberof AuthenticationService
   */
  public getUser(): Observable<User> {
    return this.afAuth.user;
  }
}

AuthenticationServiceは、ログインしているアカウントの情報を返すメソッドや、ログアウトするメソッドを持っています。

ヘッダーにアカウントの情報を表示する

HTMLテンプレート

ヘッダー上でアカウントの情報を表示している部分のテンプレート(HTML)について説明します。

header.component.html

<!-- ログインアカウント情報(ログインしている時のみ表示される) -->
<!-- ログインしているアカウントの情報を表示したい -->
<div class="right-icon" *ngIf="authenticationService.getUser() | async">
  <button mat-button class="account-link" [matMenuTriggerFor]="menu">
    <img class="thumbnail" *ngIf="currentUser.photoURL" src="{{currentUser.photoURL}}" />
    {{currentUser.displayName}}<mat-icon>expand_more
    </mat-icon>
  </button>
  <!-- Angular Materialのメニューを使用している -->
  <mat-menu #menu="matMenu">
    <button mat-menu-item (click)="signOut()">
      <mat-icon>exit_to_app</mat-icon>ログアウト
    </button>
  </mat-menu>
</div>

*ngIf="authenticationService.getUser() | async"と書くことでログイン時のみ(アカウントが存在する時のみ)にヘッダー上にアカウントの情報を表示することができます。

また、photoURLプロパティとdisplayNameを表示することでアカウントのサムネイル画像とアカウント名を表示しているのと、Angular MaterialのMenuを使用することで、アカウント名をクリックするとメニューでログアウトボタンを表示する仕様になっています。

コンポーネントクラス

コンポーネントクラスではAuthenticationServiceを呼び出すことで、ログアウト、アカウント情報の取得処理を実装しています。

header.component.ts

import { Component, OnInit, Input, EventEmitter, Output } from '@angular/core';
import { SpinnerService } from 'src/app/services/spinner.service';
import { Router } from '@angular/router';
import { ToastService } from '../../services/toast.service';
import { AuthenticationService } from 'src/app/services/authentication.service';
import { Observable } from 'rxjs';

/**
 * 画面ヘッダーのコンポーネントクラス
 *
 * @export
 * @class HeaderComponent
 * @implements {OnInit}
 */
@Component({
  selector: 'app-header',
  templateUrl: './header.component.html',
  styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnInit {
  @Input() isHandset$: Observable<boolean>;
  @Output() drawerToggled = new EventEmitter<void>();

  public currentUser: firebase.User;

  constructor(
    private router: Router,
    private _toastService: ToastService,
    private authenticationService: AuthenticationService,
    private spinnerService: SpinnerService
  ) {}

  ngOnInit() {
    this.retrieveUserProfile();
  }

  /**
   * ログアウト処理
   *
   * @memberof HeaderComponent
   */
  public async signOut() {
    // スピナー表示
    this.spinnerService.show();
    // ログアウトAPIを呼び出す
    try {
      await this.authenticationService.signOut();
      // ログアウトが成功したら、ログイン画面に遷移
      this.router.navigate(['/login']);
      this._toastService.open('ログアウトしました。');
    } catch (error) {
      this._toastService.open('ログアウトに失敗しました。');
      console.log(error);
    } finally {
      this.spinnerService.hide();
    }
  }

  /**
   * ログインしているユーザーの情報を取得する
   *
   * @private
   * @memberof HeaderComponent
   */
  private retrieveUserProfile() {
    this.currentUser = this.authenticationService.getCurrentUser();
  }
}

header.component.scss

.right-icon {
  align-items: baseline;
  cursor: pointer;
  display: flex;
  margin: 0 0 0 auto;

  .thumbnail {
    border-radius: 12px;
    width: 24px;
    height: 24px;
  }
}

上記のようにコードを書くと、ヘッダーにアカウントの情報を表示することができるようになります。

f:id:l08084:20200607185126p:plain
Twitterでログインした場合の表示例

参考サイト

Firebase でユーザーを管理する

【Java8】正規表現で半角数字だけの文字列を特定する

はじめに

Javaの正規表現を使って、半角数字だけで構成された文字列かどうかをチェックする方法について説明します。

環境

  • java version "1.8.0_45"

文字列が正規表現のパターンに適合するかチェックする

半角数字を1文字以上の(小数点やマイナス記号も含まない)正規表現([0-9]+)を使って、半角数字だけの文字列を特定していきます。

App.java

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class App {
    public static void main(String[] args) throws Exception {
        // (1) 正規表現のパターンを生成
        Pattern pattern = Pattern.compile("[0-9]+");

        // (2) 正規表現のパターンに適合するかをチェックする文字列を定義
        String sentence1 = "12";

        // (3) 正規表現処理をおこなうためのクラスを取得
        Matcher matcher1 = pattern.matcher(sentence1);

        // (4) 正規表現のパターンに適合するかチェック
        System.out.println(matcher1.matches());

        // (2) 正規表現のパターンに適合するかをチェックする文字列を定義
        String sentence2 = "1a2";

        // (3) 正規表現処理をおこなうためのクラスを取得
        Matcher matcher2 = pattern.matcher(sentence2);

        // (4) 正規表現のパターンに適合するかチェック
        System.out.println(matcher2.matches());

        // (2) 正規表現のパターンに適合するかをチェックする文字列を定義
        String sentence3 = "1.0";

        // (3) 正規表現処理をおこなうためのクラスを取得
        Matcher matcher3 = pattern.matcher(sentence3);

        // (4) 正規表現のパターンに適合するかチェック
        System.out.println(matcher3.matches());

        // (2) 正規表現のパターンに適合するかをチェックする文字列を定義
        String sentence4 = "-1";

        // (3) 正規表現処理をおこなうためのクラスを取得
        Matcher matcher4 = pattern.matcher(sentence4);

        // (4) 正規表現のパターンに適合するかチェック
        System.out.println(matcher4.matches());

        // (2) 正規表現のパターンに適合するかをチェックする文字列を定義
        String sentence5 = "485617";

        // (3) 正規表現処理をおこなうためのクラスを取得
        Matcher matcher5 = pattern.matcher(sentence5);

        // (4) 正規表現のパターンに適合するかチェック
        System.out.println(matcher5.matches());
    }
}

実行結果

true
false
false
false
true

処理の流れとしては、下記のようになります。

  1. 正規表現のパターンを生成(Pattern pattern = Pattern.compile("[0-9]+"))
  2. 正規表現のパターンに適合するかをチェックする文字列を定義(String sentence1 = "12";)
  3. 正規表現処理をおこなうためのクラスを取得(Matcher matcher1 = pattern.matcher(sentence1);)
  4. 正規表現のパターンに適合するかチェック(System.out.println(matcher3.matches());)

もっと簡単に正規表現を使う

先ほどの例と同じ処理をPatternクラス、Matcherクラスを使用せずに実施することもできます。

App.java

public class App {
    public static void main(String[] args) throws Exception {
        // (1) 正規表現の文字列を定義
        String regex = "[0-9]+";

        // (2) 正規表現のパターンに適合するかをチェックする文字列を定義
        String sentence1 = "12";

        // (3) 正規表現のパターンに適合するかチェック
        System.out.println(sentence1.matches(regex));

        // (2) 正規表現のパターンに適合するかをチェックする文字列を定義
        String sentence2 = "1a2";

        // (3) 正規表現のパターンに適合するかチェック
        System.out.println(sentence2.matches(regex));

        // (2) 正規表現のパターンに適合するかをチェックする文字列を定義
        String sentence3 = "1.0";

        // (3) 正規表現のパターンに適合するかチェック
        System.out.println(sentence3.matches(regex));

        // (2) 正規表現のパターンに適合するかをチェックする文字列を定義
        String sentence4 = "-1";

        // (3) 正規表現のパターンに適合するかチェック
        System.out.println(sentence4.matches(regex));

        // (2) 正規表現のパターンに適合するかをチェックする文字列を定義
        String sentence5 = "485617";

        // (3) 正規表現のパターンに適合するかチェック
        System.out.println(sentence5.matches(regex));
    }
}

実行結果

true
false
false
false
true

Stringクラスのmatchesメソッドを使うことで最初の例よりも短い処理で正規表現が実装できていることがわかります。

Stringクラスのmatchesメソッドを使うことでPatternクラス、Matcherクラス使う場合よりも簡単に正規表現の処理を実装することができますが、デメリットとしてStringクラスのmatchesメソッドを使うと内部でその度にPatternクラスやMatcherクラスを生成するため、何度も繰り返して処理をするような場合だと動作が遅くなってしまいます。

そのため、大量の文字列を繰り返し処理する必要があるような場合は、Patternクラス、Matcherクラスを使って正規表現の処理を実装したほうが良いでしょう。

参考文献

【Angular】クロスフィールドバリデーションを使用した時にフォーム全体をエラーにする

はじめに

Angularでクロスフィールドバリデーション(複数項目にまたがるバリデーション )を使った時に、フォーム全体をエラーにする方法を説明します。

なお、クロスフィールドバリデーションでフォームのなかのある項目のみをエラーにする方法についてはこちらの記事をご参照ください。

環境

SPAフレームワークのAngular/TypeScriptを使用しています。

  • Angular: 8.2.14
  • Node: 12.13.1

やりたいこと

本記事では、タイトル or 本文 のどちらかの項目に文字を入力しないと保存ボタンが有効にならないバリデーションを実装します。

f:id:l08084:20200513092345p:plain
タイトルか本文に何か入力しないと保存できない

f:id:l08084:20200513092853p:plain
本文に文字を入力したので保存ボタンが有効になる

バリデーションの実装

HTMLテンプレート

まず、HTMLテンプレートの実装について説明します。HTMLでは、タイトル欄、本文欄、保存/更新ボタンを定義しています。

upsert-form.component.html

<form class="example-form" (ngSubmit)="onSubmit(upsertNgForm)" [formGroup]="createFormGroup" #upsertNgForm="ngForm">
  <!-- タイトル欄 -->
  <mat-form-field class="example-full-width">
    <input matInput class="title" placeholder="タイトル" formControlName="title" />
  </mat-form-field>
  <!-- 本文欄 -->
  <mat-form-field class="example-full-width">
    <textarea matInput cdkTextareaAutosize cdkAutosizeMinRows="10" cdkAutosizeMaxRows="30" placeholder="本文"
      formControlName="description"></textarea>
  </mat-form-field>
  <!-- 保存/更新ボタン -->
  <button type="submit" class="login-button" mat-raised-button [disabled]="!createFormGroup.valid" color="primary">
    <ng-container *ngIf="selectedMemoId; else createLabel">更新</ng-container>
    <ng-template #createLabel>保存</ng-template>
  </button>
</form>

ポイントとしては、[disabled]="!createFormGroup.valid"と記載することで、FormGroup内でバリデーションエラーが発生している時には、ボタンが非活性になるようにしているところです。

コンポーネントクラス

続いて、コンポーネントクラスの実装について説明します。コンポーネントクラスでは、フォームグループ、フォームコントロールの設定とバリデーションの設定をしています。

upsert-form.component.ts

import { Component } from '@angular/core';
import { FormGroup, FormControl, FormBuilder, NgForm } from '@angular/forms';
import { CustomValidator } from '../../validation/custom-validator';

/**
 * メモの新規作成・更新フォーム
 *
 * @export
 * @class UpsertFormComponent
 * @implements {OnInit}
 * @implements {OnChanges}
 */
@Component({
  selector: 'app-upsert-form',
  templateUrl: './upsert-form.component.html',
  styleUrls: ['./upsert-form.component.scss']
})
export class UpsertFormComponent implements OnInit, OnChanges {
  // FormGroup定義
  public createFormGroup: FormGroup;
  // タイトルフォームのコントロール定義
  public titleControl: FormControl;
  // 本文フォームのコントロール定義
  public descriptionControl: FormControl;

  constructor(private fb: FormBuilder) {
    this.createForm();
    this.folderControl = this.createFormGroup.get('folder') as FormControl;
    this.titleControl = this.createFormGroup.get('title') as FormControl;
    this.descriptionControl = this.createFormGroup.get(
      'description'
    ) as FormControl;
  }

  /**
   * フォーム設定の作成
   *
   */
  private createForm() {
    this.createFormGroup = this.fb.group(
      {
        title: ['', []],
        description: ['', []]
      },
      {
        validators: CustomValidator.titleOrDescriptionRequired
      }
    );
  }
}

フォームグループとタイトルと本文のどちらかを必須にするバリデーション を設定している部分です。

    this.createFormGroup = this.fb.group(
      {
        title: ['', []],
        description: ['', []]
      },
      {
        validators: CustomValidator.titleOrDescriptionRequired
      }
    );

validators: CustomValidator.titleOrDescriptionRequiredという風に書くことで、クロスフィールドバリデーション (項目をまたがったバリデーション )を実装することができます。

カスタムバリデーター

最後にタイトルまたは本文のどちらかを入力しないとバリデーションエラーを返すバリデーターの実装について説明します。

custom-validator.ts

import { ValidationErrors, FormGroup } from '@angular/forms';

  /**
   * 「タイトルと本文のどちらかは必須」バリデーション
   *
   * @static
   * @param {*} ac
   * @param {*} AbstractControl
   * @memberof CustomValidator
   */
  public static titleOrDescriptionRequired(
    control: FormGroup
  ): ValidationErrors | null {
    const title = control.get('title').value;
    const description = control.get('description').value;
    return !title && !description
      ? { notTitleOrDescriptionRequired: true }
      : null;
  }
}

タイトルと本文の値をそれぞれ取得して、どちらも未入力の場合は、{ notTitleOrDescriptionRequired: true }を返しています。このようにすることで、ValidationErrorsを返されたFormGouptinvalidな状態になり、保存ボタンが非活性になります。

参考サイト

https://angular.jp/guide/form-validation

【Angular】自作コンポーネントにngModelを使用する

はじめに

自作コンポーネントで双方向バインディング機能(ngModel)を使えるようにしたい。

環境

CSSフレームワークとしてAngular Materialを使用しています

  • Angular: 8.2.14
  • Node: 12.13.1
  • Angular Material: 8.2.3

今回作るもの

双方向バインディング機能([(ngModel)])を持った分数入力用のコンポーネントを作成します。

f:id:l08084:20200517222300p:plain
分数入力コンポーネント

分数入力コンポーネントの実装

カスタムコンポーネントに[()]構文を使用するには、@Inputプロパティx@OutputプロパティxChangeを実装する必要があります。

fraction-input.component.ts

import { Component, EventEmitter, OnInit, Input, Output } from '@angular/core';
import { FormGroup, FormControl, FormBuilder } from '@angular/forms';
import { Fraction } from 'src/app/entity/fraction';

/**
 * 分数入力用コンポーネント
 *
 * @export
 * @class FractionInputComponent
 * @implements {OnInit}
 */
@Component({
  selector: 'app-fraction-input',
  templateUrl: './fraction-input.component.html',
  styleUrls: ['./fraction-input.component.scss'],
})
export class FractionInputComponent implements OnInit {
  @Input() fraction: Fraction = new Fraction();
  @Output() fractionChange = new EventEmitter<Fraction>();

  public fractionFormGroup: FormGroup;
  // 分子
  public numeratorControl: FormControl;
  // 分母
  public denominatorControl: FormControl;

  constructor(private fb: FormBuilder) {}

  public ngOnInit() {
    this.createForm();
    this.numeratorControl = this.fractionFormGroup.get(
      'numerator'
    ) as FormControl;
    this.denominatorControl = this.fractionFormGroup.get(
      'denominator'
    ) as FormControl;
  }

  /**
   * reactive formの設定
   *
   * @private
   * @memberof FractionInputComponent
   */
  private createForm() {
    this.fractionFormGroup = this.fb.group({
      numerator: ['', []],
      denominator: ['', []],
    });
  }
}

fraction-input.component.html

<form [formGroup]="fractionFormGroup">
  <div class="fraction-group">
    <!-- 分子 -->
    <mat-form-field>
      <input matInput class="input" placeholder="分子" formControlName="numerator" [(ngModel)]="fraction.numerator">
    </mat-form-field>
    <div class="split">/</div>
    <!-- 分母 -->
    <mat-form-field>
      <input matInput class="input" placeholder="分母" formControlName="denominator" [(ngModel)]="fraction.denominator">
    </mat-form-field>
  </div>
</form>

app.component.html

<div class="wrapper">
  <mat-card class="form-card">
    <div>分数</div>
    <!-- 分数コンポーネント -->
    <app-fraction-input [(fraction)]="fraction"></app-fraction-input>
    {{fraction | json}}
  </mat-card>
</div>

FractionInputComponent@Input() fraction@Output() fractionChangeを実装することでAppComponentとの間に双方向バインディングを実現しています([(fraction)])。

なお、AppComponentFractionInputComponent間ではFractionオブジェクトを双方向にバインドしています。

fraction.ts

/**
 * 分数オブジェクト
 *
 * @export
 * @class Fraction
 */
export class Fraction {
  // 分子
  public numerator: number;
  // 分母
  public denominator: number;

  constructor() {
    this.numerator = undefined;
    this.denominator = undefined;
  }
}

作成したコンポーネントを動作させると次のキャプチャーのようになり、子コンポーネントに入力した値が双方向バインディングによって親コンポーネントに反映されていることがわかります。

f:id:l08084:20200517225149p:plain
AppComponentの動作例: 双方向バインディングが機能していることがわかる

参考サイト

双方向バインディング(ngModel)対応のComponentを自作する - Qiita

[Angular] カスタムコンポーネント(Custom Component)で ngModel を使う - Qiita

How to create custom input component with ngModel working in angular 6? - Stack Overflow

https://angular.jp/guide/template-syntax#ngmodel-%E3%81%A8%E5%80%A4%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B5

https://angular.jp/guide/template-syntax#%E5%8F%8C%E6%96%B9%E5%90%91%E3%83%90%E3%82%A4%E3%83%B3%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0-