2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JSフレームワークAngularでデータ処理、まとめ版(standalone、signal対応版)

Last updated at Posted at 2023-03-31

前記事の続編をそれぞれのフレームワーク毎に分類し、時流の変化を追いやすくしたものです。

今回は、前回で補完できなかった、コンポーネント周りのもっと突っ込んだ操作を含めた解説ができたらと思います。また、前回まではAngularの具体的なモジュールやコンポーネントの具体的な働きは見ていませんでしたが、実践的な力を身に付けていくために、その辺りも掘り下げられたらと思っています。

Angularは目まぐるしく変化を遂げており、Angular14からはリアクティブフォームの正規実装、Angular15からはスタンドアロンコンポーネントの正規実装、そしてAngular16からはシグナルの実装、Angular17からはビルトインコントロールフローの実装…とどんどん進化を続けています。

なにより、Angular18からViteとesbuildの併用による超高速起動になっているので、起動の遅さを気にしていた人も気兼ねなく触ることができるようになったと思います。

今回学習する内容は

  • 5章 コンポーネント制御(電卓)
  • 6章 ルーティング制御(買い物かご)
  • 7章 スタイル制御(写真検索システム)
  • 8章 リアクティブフォーム(Todoアプリ)

となります。

※Angular19からはstandalone標準となったため、standalone: true のフラグ制御が不要となっています。

import構文の共通ルール

その前に、JavaScriptのimport構文のルールを把握しておく必要があるはずです。

  • A:import Fuga from './Hoge'
  • B:import {Fuga} from './Hoge'

この2つの違いを把握しておかないと後々エラーを多発することになります。AはHogeという外部ファイルをFugaという名称で定義するという意味です(敢えて別名にする必要はないので、普通はインポートファイル名と同一です)。対してBはHogeという外部ファイルの中から定義されたFugaというオブジェクトを利用するという意味です。なので、次の例でいえば

import Angular,{ Component, OnInit,signal } from '@angular/core';

これはAngularという外部ファイルをAngularという名称で利用する、かつComponent,onInit,signalというオブジェクトを利用するという命令になります。

※Angularという外部ファイル名を呼び出すことは仕様上ありませんが、便宜上説明のために書いてます。

演習5 コンポーネント制御(電卓)

その前に、なぜ親子のコンポーネントに分割、階層化するかですが、結論からいえば冗長な記述を回避するためです。

ここでは子コンポーネントを用いて、親コンポーネントに簡易な電卓を作成していきます。それにはプッシュキーとそれを押下したタイミングでの制御が必要となりますが、その際にプッシュキーの部品を子コンポーネント化することで、効率よくシステムを構築することができます。

もし、コンポーネント化しなかった場合は、16個の操作キーごとに酷似した記述を延々と繰り返す羽目になります。こうするとプログラムが冗長になって可読性ダウンにつながるだけでなく、保守性も極めて低くなる(たとえば、バグが発生した場合、その発生源を突き止めにくい。そして、修正箇所がわかったとしても、全く同じミスを別の箇所でも冒している可能性が高いので、大幅に時間ロスする)ので、部品は極力子孫コンポーネント化することを意識しておきましょう。

◆Angularのアプリケーション構造

Angularのアプリケーションは、以前、非常に複雑な構造だったのですが、スタンドアロンコンポーネントになってからは非常にわかりやすくなっています。

◆コンポーネントの構造

Angularの場合はindexファイルの中にある<app-root />がアプリケーションコンポーネントとなります。

index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Angular19</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root />
</body>
</html>

メインファイル

このmain.tsが従来の設定ファイルです。ただ機能分離を繰り返した結果、Angular19現在、アプリケーションコンポーネント、設定ファイルを紐づける外箱となっています。

main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; //設定ファイルの呼び出し
import { AppComponent } from './app/app.component'; //アプリケーションコンポーネント

bootstrapApplication(AppComponent, appConfig)
  .catch((err) => console.error(err));

設定ファイル

app.config.tsがアプリケーションの設定ファイルです。Angular18からは設定ファイルも独立しているので、ルーティングなどの設定も容易になりました。configとはconfiguration(設定、仕様など)の略称ですが、昨今では普通の英単語化しているようです。

app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
	provideZoneChangeDetection({ eventCoalescing: true }),
	provideRouter(routes)]
};

ルーティングファイル

ルーティングファイルもデフォルトで用意されているので、設定も簡単です(ルーティングについては演習6で詳しく触れます)。

app.routes.ts
export const routes: Routes = []; //空オブジェクトだけ準備してくれている

アプリケーションコンポーネント

アプリケーションコンポーネントファイルは以下のようになっています。ここから、演習用コンポーネントを作成していきます。注意点として@Componentデコレータからのインポート指定を忘れないことです。また、app-rootはindex.htmlを紐づける大事なセレクタなので、これもうっかり消去しないようにしましょう。

ここで大事なものは各種処理を行うための部品を紐づける場所をコンポーネントデコーダ、具体的な処理を行うクラス部分をアーキテクチャ(architecture)といいます。

app.component.ts
//外部ファイルを呼び出す
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { CalcComponent } from './calc/calc.component'; //演習用のコンポーネント

//コンポーネントデコーダ
@Component({
  selector: 'app-root', //index.htmlを紐づける
  imports: [CalcComponent], //コンポーネントは必ずインポートしておくこと
  template: `<Calc />`, //コンポーネントタグ
  styleUrl: './app.component.css' //スタイル制御※
})
//アーキテクチャ(具体的なデータ処理を記述していく)
export class AppComponent {
}

また、テンプレートの場所はコンポーネントデコーダ内で制御しているため、プロパティをtemplateとすれば直接記述することもできます。

※スタイル制御は演習7まで触れません。

※アプリケーションの作成

Angularは基本、ngというコマンドを用いてアプリケーションやモジュール、コンポーネントの作成を行っていきます。したがって、Angularでアプリケーションを作成したい場合は、以下のコマンドだけで作成可能です。

また、任意のコンポーネントファイルを作成するコマンドが以下になります(gはgenertateの省略コマンド、generateとは生成という意味です)。

app # ng g component hoge

このコマンドを実行すると、新規にディレクトリが作成され、以下の4ファイルがワンセットで構築されます。

■Hoge
 - hoge.component.css //CSS制御用
 - hoge.component.html //テンプレートファイル用
 - hoge.component.spec.ts //※テスト用ファイル
 - hoge.component.ts //コンポーネント制御用

※テスト用ファイルは現時点、こういうのもあるとだけ覚えておけばいいです(アプリケーションをビルドするときに必要となります)。

電卓を作る

では、今回の演習として電卓を作っていきたいと思います。テンプレートも外部呼び出しではなく、プロパティ内に実装していきます。おさらいですが、テンプレートは必ずバッククォート(`)で囲んでください。

親コンポーネント

親コンポーネントは電卓の表側を制御します。また、今回は合計値だけでなく、打ち込んだ文字も表示されるようにします。

calc.componet.ts
import { Component, OnInit,signal } from '@angular/core';
import { SetkeysComponent } from './setkeys.component';

@Component({
  selector: 'Calc', //appコンポーネントから紐づけているコンポーネント名
  imports:[SetkeysComponent],
	template:
		`
		<div>
			<Setkeys [pushkeys]='pushkeys()' (ev)="onReceive($event)" />
			<div>
				<p>打ち込んだ文字:{{str()}}</p>
				<p>合計:{{sum()}}</p>
			</div>
		</div>
		`
})

export class CalcComponent{
	str = signal('')
	sum = signal(0)
	pushkeys=signal([
			[['7','7'],['8','8'],['9','9'],['div','÷']],
			[['4','4'],['5','5'],['6','6'],['mul','×']],
			[['1','1'],['2','2'],['3','3'],['sub','-']],
			[['0','0'],['eq','='],['c','C'],['add','+']]
	]) //プッシュキーの部品
		//子コンポーネントから値の受け取り
		onReceive(data){
			this.str.update((c)=> c + data[0])
			this.sum.set(data[1])
             			
		}
}

重要なポイントは

ts
			<Setkeys [pushkeys]='pushkeys()'  />

です。これは親コンポーネントから子コンポーネントへの転送にはプロパティバインディングを利用しており、左辺に設定されたキー値pushkeysが、子コンポーネントで展開される変数pushkeysとなります。また右辺の'pushkeys()'はsignalによってリアクティブになったコールバック関数で、signalで出力された変数を格納しています。

※もう1つのイベントバインディング、onReceiveは後で説明します。

signal

再びsignalのおさらいです(前編の演習3を参照)。

signalはAngular16から実装された変数をリアクティブ制御するためのメソッドで、非常にルールが単純明快です(Angularは他のJSフレームワークのようにproxy制限しないので)。リアクティブに制御させたい変数に対しSignalで設定すると変数はコールバック関数となります。その関数に対し、

state = signal() //変数をリアクティブにする(stateは任意のコールバック関数)
state.set //(任意の値を代入したい場合)
state.update((arg)=> argを更に再計算したい場合)

このような制御ができます(後になればcomputed、effectedも登場します)。

そして、テンプレート上に展開したい場合は

 {{ state() }}

とします(これは、リアクティブなデータをコールバック関数として保持しているためです)。先程のpushkeys()も、これは変数を格納したコールバック関数です。

※SolidJSのcerateSignalと似ていますが、こちらの方がずっと単純です。

※テンプレートファイルを外部ファイルから呼び出す場合

もし、テンプレートを外部ファイルから呼び出したい場合は、プロパティをtemplateからtemplateURLに変更します(標準設定は外部ファイルです)。

ts
@Component({
  selector: 'Calc', //appコンポーネントから紐づけているコンポーネント名
  imports:[SetkeysComponent],
  templateURL: "./calc.component.html"
})

テンプレートファイルを外部に置いた場合は、htmlだけ記述します。

calc.component.html
		<div>
			<Setkeys [pushkeys]='pushkeys()' (ev)="onReceive($event)" />
			<div>
				<p>打ち込んだ文字:{{str()}}</p>
				<p>合計:{{sum()}}</p>
			</div>
		</div>

※変数dataの定義用ファイル

変数dataはオブジェクトなので、TypeScriptの場合はそれぞれ、メンバに対しても細かく型指定が必要となります。そこで定義ファイルを作っておきます。この定義ファイルは外部ファイルとして呼び出し、アーキテクチャ内でそのまま自由に使用できます。

※TypeScript自身がある程度、自動で変数の型を判別してくれるようになったので今は単純に定義ファイルを作れますが、このTypeScriptはバージョンによって頻繁に仕様変更されるので、Angularは極力any型でも滞りなく動作するように設計しているようです。

calc.ts
export type Data
{
	lnum: number;
	cnum: number;
	sign: string;
}

これで親コンポーネントからデータを受け渡す準備はできました。次からは、子コンポーネント側の準備です。

◆子コンポーネントの制御

子コンポーネントは、親コンポーネントから受け渡されたプッシュキーとその制御部分となります。

setkey.component.ts
import { Component, OnInit,Input,Output,EventEmitter,signal } from '@angular/core';
let status = {
	lnum: null, //被序数
	cnum: 0, //序数
	sign: "",
};

@Component({
    selector: 'Setkeys', //親のテンプレートで定義した子コンポーネントのセレクタ
	template:
		`
		@for(val of pushkeys; let idx = $index; track idx){
			<div>
			@for(v of val;track v[0]){
				<button type="button" [value]="v[0]" (click)="getChar(v[0],v[1])" >
					{{v[1]}}
				</button>
			}
			</div>
		}
		`
})

export class SetkeysComponent{
	@Input() pushkeys;
	@Output() ev = new EventEmitter<any>();
	$state = signal(status)
	
	getChar(chr: string,str:string){
		let lnum = status.lnum
		let cnum = status.cnum
		let sign = status.sign
		let sum = 0
		if(chr.match(/[0-9]/g)!== null){
			let num = parseInt(chr)
			cnum = cnum * 10 + num //数値が打ち込まれるごとに桁をずらしていく
		}else if(chr.match(/(c|eq)/g) == null){
			if(lnum != null){
					lnum = this.calc(sign,lnum,cnum)
			}else{
					if(chr == "sub"){
							lnum = 0
					}
					lnum = cnum
			}
			sign = chr
			cnum = 0
		}else if( chr == "eq"){
			lnum = this.calc(sign,lnum,cnum)
			sum = lnum
		}else{
			lnum = null
			cnum = 0
			sum = 0
			str = ''
		}
		status.lnum = lnum
		status.cnum = cnum
		status.sign = sign
		this.$state.set(status)
		this.ev.emit([str,sum]) //子コンポーネントからの転送処理
	}
	
	calc(mode,lnum,cnum){
		switch(mode){
				case "add": lnum = cnum + lnum
				break;
				case "sub": lnum = lnum - cnum
				break;
				case "mul": lnum = lnum * cnum
				break;
				case "div": lnum = lnum / cnum
				break;
		}
		return lnum
	}
}

では、この子コンポーネントの中身を解説していきますが、メソッドgetChar内で処理していることはほとんど電卓の具体的な計算処理部分であり今回の説明では枝葉にあたる部分なので、説明は割愛します(プログラム初心者が演習用に経験するカウンター作成を応用しているだけです)。

むしろ、押さえておくべき重要なポイントは、子コンポーネントがどうやって親コンポーネントからデータを受け取り、またどうやって親コンポーネントにデータを受け渡すのかという、コンポーネント間のデータのやりとり部分です。

◆親子コンポーネント間でデータをやりとりする

Angularで、親子コンポーネント間でデータをやりとりする場合はデコーダ(decoder)が必須となります(デコードとは通信用語から来ており、外部からの暗号を解読するという意味)。つまり、親コンポーネントから受け取ったデータや、逆に子コンポーネントから送り出すデータを解読する役割を果たすので、両方とも子コンポーネントに設定します。

◆入出力のデコーダとイベントエミッター

Angularで親コンポーネントから子コンポーネント、子コンポーネントから親コンポーネントへの値の受け渡しを行うには、それぞれInputデコーダOutputデコーダという二種類のデコーダをモジュールから呼び出す必要があります。また、子コンポーネントから親コンポーネントへ値を送り出す際には、EventEmitter(イベントエミッター)が必須となります。

この役割を解説していきます。

◆親コンポーネントから子コンポーネントへ値を受け渡す

親コンポーネントからのデータを受け取る場合はInputデコーダを用い、親コンポーネントに記述された子コンポーネントのプロパティバインディングに紐づいているオブジェクトを記述しておきます。

Calc.ts
			<Setkeys [pushkeys]='pushkeys()'  />
setkey.component.ts
		@Input() pushkeys; //親コンポーネントのテンプレートに記載したプロパティバインディングのオブジェクト

あとは、変数を受け取る場合、メソッド上ならばthis.pushkeysとして、テンプレート上からはpushkeysとして展開可能です(データの受取直後は、signalの制御から外れているので、pushkeys()ではありません)。

これで、あとはループ構文にしたがってプッシュキーが16個分生成されていきます。

※ビルトインコントロールフロー(ループ文)

ビルトインコントロールフローにおけるループ文のおさらいです(これも前編記事を参照)。

Angular17からは脱ngテンプレートを目指しており、SvelteやSolidJSのような埋め込み構文(ビルトインコントロールフロー)を用いることができます。今回はループ処理の@forを用いています(インデックスを付与したい場合はlet idx=$indexとして、それで一意の値を作り出すこともできます)。

@for( 展開したい値 of 元のデータオブジェクト; track キーとなる一意の値){

}
ts
		@for(val of pushkeys; let idx = $index; track idx){
			<div>
			@for(v of val;track v[0]){
				<button type="button" [value]="v[0]" (click)="getChar(v[0],v[1])" >
					{{v[1]}}
				</button>
			}
			</div>
        }

trackはループが一意であることを示すためのもので、記述必須です。一意になる値を持っていない場合、インデックスを取得して用いることもできます(let idx = $idnexがそれに該当します)。

※ループ処理用データが存在しない場合は、@emptyという例外処理も並行して記述することができます。

@for(){
   //データの展開
}@empty{
  //データがない場合の処理
}

※ビルトインコントロールフローは他に@if@switchなどもあります。

◆子コンポーネントから親コンポーネントへ値を受け渡す

ここからが子コンポーネント側のデータ転送の説明です。

子コンポーネントから親コンポーネントにデータ転送する場合は**@Outputデコーダが必須ですが、それだけで変数を送ることはできません。データを転送するためにはEventEmitter**(イベントエミッタ)も必須となります。

※JSフレームワークは基本的にストリームの概念というものがあります。それはstream(水の流れ)のようにデータは上流から下流の工程へ受け渡すべきものなので、子コンポーネントから親コンポーネントのようにデータを逆流させる場合は異なる処理となっているものが多く、それはAngularも例外ではありません。

◆Outputデコーダの準備

まずは、データを逆流できるようにOutputデコーダを設定します。これは子コンポーネントから親コンポーネントへ値を受け渡すためのイベントを設定するためのもので、データ逆流のためのメソッドを使用する前に、以下のようにイベントエミッタを設定しておきます。

ts
	@Output() ev = new EventEmitter<any>(); //Outputデコーダにイベントエミッタを設定する

ちなみにイベントエミッタはデータを逆流させるための準備装置です。これで準備完了です。コールバック関数を変数evで受け取っていることを覚えておいてください。evは親コンポーネントのテンプレートに記述されたイベントバインディングの値となり、EventEmitterの後に<転送する値の型>となります。ところが、TypeScriptの性格上Object型というのは存在しないため、ここではあらゆる型に対応しているAnyを用いています(転送する変数dataは親コンポーネントで定義しているので、これで問題ありません)。

イベントの設定

まずは子コンポーネント上に処理イベントを設定します。buttonタグの(click)=getChar(v[0],v[1])というイベントバインディングがこれに該当します。

ts
@Component({
    selector: 'Setkeys', //親のテンプレートで定義した子コンポーネントのセレクタ
	template:
		`
		@for(val of pushkeys; let idx = $index; track idx){
			<div>
			@for(v of val;track v[0]){
				<button type="button" [value]="v[0]" (click)="getChar(v[0],v[1])" >
					{{v[1]}}
				</button>
			}
			</div>
		}
		`
})

これはあくまで、子コンポーネント上の各プッシュキーに対し、イベントを設置しただけです。

◆emitメソッド

次はデータ逆流装置の設定ですが、これを、前述の処理メソッドgetDataの内部に記述します。

ts
	getChar(chr: string,str:string){
	    /* 中略 */
		this.ev.emit([str,sum]) //子コンポーネントからの逆流装置
	}

emitメソッドはEventEmitter内のメソッドなのでOutputデコーダで定義したプロトタイプevから呼び出し、以下のように記述します。

ts
this.ev.emit(data); //子コンポーネントからデータを外部に放つ

ですが、次からはかなり引っかかりやすいポイントです。

確かに、このemitメソッドはデータを親コンポーネントに送り出す働きがあるのですが、emitとは煙やガスなどをその場から外部へ放つという意味です。なので、このままではデータはネットワーク上を彷徨っているだけなので親コンポーネント側で、外部に放たれたデータを受け取る設定が必要です。

◆子コンポーネントから親コンポーネントへの値の受け取り

子コンポーネントによって外部に放たれたデータの回収はイベントバインディング(ev)="onReceive($event)"を用いることで可能となります。$eventはイベントエミッタによって外部に放たれたデータを引数として受け取ることができますが、これはイベントバインディングの働きを利用しています。

calc.component.html
		<div>
			<Setkeys [pushkeys]='pushkeys()' (ev)="onReceive($event)" />
			<div>
				<p>打ち込んだ文字:{{str()}}</p>
				<p>合計:{{sum()}}</p>
			</div>
		</div>

また、受け取り用のメソッドは親コンポーネントのアーキテクチャ上にメソッドを設定しておきます。これで子コンポーネントからのデータ受取完了です。子コンポーネントから送出された入力文字と途中を含めた計算結果の数値が、リアルタイムで表示されるようになります。

	onReceive(data){
		this.data = data //子コンポーネントから放たれたデータ
	}

Angularのコンポーネント制御、とりわけデータのやりとりにおいては少しややこしい部分もあるのですが、親コンポーネントからはそのままデータを流せるが、子コンポーネントからの場合は逆流装置と回収装置が必要だと覚えておきましょう。

次からはいよいよルーティング(SPA)制御です。そしてAngular学習の最大の壁ともいわれたRxJSにも触れていきます。

演習6 ルーティング(買い物かご)

今まで親子コンポーネントの説明はしていますが、あくまで単一ページのみの制御でした。ですが、世の中のWEBページやアプリケーションは複数のページを自在に行き来できます。それを制御しているのがルーティングという機能です。

フレームワークにおけるルーティングとは基本となるリンク元のコンポーネントがあって、パスの指定によって自在にコンポーネントを切替できるというものです。もっと専門的な言葉を用いれば、SPA(SINGLE PAGE APPLICATION)というものに対し、URIを振り分けて各種コンポーネントファイルによって紐付けられたインターフェースを表示する働きを持ちます。

Angularはルーティング機能が標準実装されているので、モジュールにインポート構文を追記します。フレームワークにリンクタグとリンク先を表示するタグが存在し、toというパス記述用のプロパティが存在しています。また、それぞれにデータ転送用、データ受取用のライブラリが用意されています。

◆ Angularでルーティング制御する

Angularのルーティングは、テンプレートに記述するのはリンクパスのみで、あとは全部モジュールに記述するようになっています。

Angularの場合でのファイル構造は以下のようになっています(最低限、処理に必要なものだけを抜粋)。

■app
   - ■cart
       - global.component.ts //トップコンポーネント(ヘッダ及び各種リンクと表示枠がある)
       - product.component.ts //商品一覧(ここに商品を入れるボタンがある)      
       - product.component.ts //買い物かご(ここに商品差し戻し、購入のボタンがある)
       - detail.component.ts / /商品の詳細
   - ■services
       - cart-service.ts //共通の処理制御用(サービス) 
   - ■context
       - cart-context.ts //共通処理用のデータ
   - ■type
       - cart-type.ts //型指定 
   - custom.module.ts //共通モジュールの制御ライブラリ
   - app.routes.ts //ルーティング設定
   - app.config.ts //アプリケーションの基本設定
   - app.component.ts //アプリケーションの親コンポーネント

※■はディレクトリ

アプリケーションの親コンポーネント

今回はアプリケーションの親コンポーネントを極力触らないで、その内部にあるcartディレクトリにアプリケーションを構築していきます。

また今回は、テンプレートは外部ファイル(templateURL)ではなく、直接記述していきます。その場合はプロパティをtemplateに書き換えておく必要があります。

app.component.ts
import { Component } from '@angular/core';
import { CustomModule } from './custom.module';
import {GlobalComponent} from './cart/global.component';

@Component({
  selector: 'app-root',
  imports: [CustomModule,GlobalComponent],
  template: `<app-global />`,
})
export class AppComponent {
  title = 'ang19';
}

ルーターモジュールの使用

ここからルーティング設定の開始ですが、ひとまずルーターモジュールを呼び出しておく必要があります。

※Angular13まではapp.module.tsという共通モジュールが必須でしたが、スタンドアロンコンポーネントでは不要となりました。ただ、このapp.module.tsは別の有益な使い方があるので、それは演習8で紹介します。

ルーティング設定

ルーティング設定はアプリケーションフォルダ(app)内にあるapp.routes.tsから設定しておきます。

各コンポーネントオブジェクトの記述ルールは以下の通りです。

  {path: 任意のディレクトリ(トップの場合は''とする。),component: インポートしたいコンポーネント名}
app.route.ts
import { Routes } from '@angular/router';
import {ProductsComponent} from './cart/products.component';
import {CartComponent} from './cart/cart.component';
import {DetailComponent} from './cart/detail.component';
export const routes: Routes = [
	{path: '',component:ProductsComponent },
	{path: 'cart',component:CartComponent },
	{path: 'detail',component:DetailComponent },
];

ルーティング設定をアプリケーションに紐づける

ルーティング設定をアプリケーションに紐づけるためのファイルがapp.config.tsとなります。ここのprovideRouter(routes)がルーティング設定の記述になります。

app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import {CartService} from './services/cart-service';
import {token} from './types/cart-types';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
	provideZoneChangeDetection({ eventCoalescing: true }),
	[{provide:token,useClass:CartService}],
	provideRouter(routes)]
};

サービスの受け渡し設定

また、今回はサービスもこの設定ファイルから注入(受け渡し)しておきます。クラスファイルを受け渡す場合はuseClassを使用します(tokenはキーワード)。

これは何を意味しているかですが、その前にサービスとは何かを把握しておく必要があります。Angularでいうサービスとはサービスファイルのことで、SPA上で共通するデータを扱うためのロジックを構築するクラスファイルのことを指します。いわば、Reactのリデューサーファイルやvueのコンポーザブルファイルのようなもので、このサービスファイルにデータ分配のための共通処理を記述しておきます。このようなサービス設定のことをAngularでは依存性注入(dependency injection)といいますが、要はSNSサービスのように、フォロワーにしかメッセージは送れませんよという設定をするようなものです。

app.config.ts
import {CartService} from './services/cart-service'; //サービスファイル
import {token} from './types/cart-types';

export const appConfig: ApplicationConfig = {
  providers: [
	[{provide:token,useClass:CartService}], //useClassでサービスクラスを注入できる
	provideRouter(routes)]
};

そして、このコンポーネントクラスを他コンポーネントに受け渡す準備をしてくれるのがuseClassプロパティで、provideプロパティはその受け渡しに必要となるパスワードトークン(いわばキーワードのようなもの)を格納します。provideは分配、供給という意味で、各コンポーネントがサービス上のデータを受け取り場合に、ここで設定されたパスワードトークンが必要となります。

今はとりあえず、これで共通のデータやりとり処理が可能なんだなという認識程度で大丈夫です。

ルーティング先のリンクと表示

ルーティング先のリンクと表示は以下のようになります。リンクはaタグを用い、routerLinkプロパティで遷移先のパスを指定します。

対するリンク先は<router-outlet />タグで表示され、ここがブラウザ(アプリケーションを表示させる場所)となります。

Global.component.ts
  template: 
  `
		<ul class="main-navigation">
			<nav>
				<li class="nav"><a routerLink="./">Products()</a></li>
				<li class="nav"><a routerLink="./cart">Cart({{ cnt.cart }})</a></li>
			</nav>
		</ul>
		<div>
			<router-outlet /><!--リンク先の枠-->
		</div>
  `,

データファイルと型ファイル

次の段階に入る前に、ひととおりのファイルを紹介しておきます。まずは型指定ファイルですが、Angularの場合、型指定はinterfaceにしておいた方が利便性も高いです。また、サービス受取用のパスワードトークンもここに記しておきます。ちなみにこのパスワードトークンですが、受け取る場合、変数tokenを代入する必要があります(つまり、Vueのように文字列のtokenがパスワードではない)。

型指定のファイル

cart-types.ts
import { InjectionToken } from '@angular/core'
export const token = new InjectionToken<string>("token") //サービス分配用のトークン

export interface Product{
	id: string,
	title: string,
	price: number,
	stock: number,
}
export interface Cart{
	id: string,
	title: string,
	price: number,
	stock: number,
    quantitiy: number,
}
export interface Store{
	products?:Product[],
	cart?:Cart[],
	articles?:[],
	money: number,
	total: number,
}

export interface Data{
	mode: string,
	dif?: any,
}

export interface CntT{
   cart: number,
   article: number,
}

データファイル

また、データファイルは以下のようになっています。このファイル上のデータをサービスファイルから呼び出します。

shop-context.ts
const ShopContext = ()=>{
	return{
		products:[
		  { id: "p1", title: "花王 バブ ゆず", price: 60, stock: 10 },
		  { id: "p2", title: "バスクリン きき湯", price: 798 , stock: 3 },
		  { id: "p3", title: "アース 温素 琥珀の湯", price: 980, stock: 2 },
		  { id: "p4", title: "白元アース いい湯旅立ちボトル", price: 398, stock: 6 },
		  { id: "p5", title: "クラシエ 旅の宿", price: 598, stock: 7 },
		],
		cart:[], //買い物かご
		articles:[], //購入リスト
		money:<number> 10000, //所持金
		total:<number> 0, //会計
	}
}
export default ShopContext

◆SPAでデータをやりとりする(RxJS)

Angularでデータをやりとりする場合は基本サービスファイルからデータをやりとりします。共通処理用のサービスは基本app.service.tsというファイルで定義され、今回はcart-service.tsという名称となっています。

そして、ここでAngular敬遠の遠因ともいわれていたRxJSが必須となってきます。RxJSは非常に複雑で、海外ではこれ一つで書籍が出ているぐらい奥が深い機能なのですが、簡潔にいえば、Angularにおいてネットワーク内で自在にデータをやりとりするためのライブラリです。これを用いることで、ルーティング先の対象コンポーネントでデータをやりとりすることが円滑になります(先程までの準備は、このサービスファイル使用のため)。

※RxJSとはReactive extensions for JavaScriptの略称です。そしてこのRxJSの部分が、一番Angularで頻繁に仕様が更新され、記述が簡略(シンタックスシュガー)化してきています。とりわけ、Angular6以降はだいぶ書き方がスリム化しており、簡単にネットワーク監視・更新用のプロトタイプを作成できるので、今までのように敬遠してしまうことも少なくなるのではないかと思っています。

※サービスファイル名は何でもいいです。

cart-service.ts
import { BehaviorSubject,Observable } from 'rxjs'
import ShopContext from '../context/shop-context';
import {Store,Data,Product,CntT} from '../types/cart-types';
const store = ShopContext()
const cnt = <CntT>{"cart":0,"article":0} //ナビに表示する個数

@Injectable({
	providedIn: 'root'
})

//export class AppService implements OnInit{
export class CartService{
	sub = new BehaviorSubject(cnt) //個数の同期を監視

	itemCounter(item){
		return item.cart.reduce((count,curItem)=>{
			return count + curItem.quantity //買い物かごの個数
		},0)
	}
	dispatch = (data:Data)=>{
		const cnt_tmp = cnt
		cnt_tmp.cart = this.itemCounter(store)
		this.sub.next(cnt_tmp) //個数の更新
	}
}

※同期対象の変数はプリミティブな値だと無効のようです(更新検知しない)。
※今回はsignalを使用しません。

◆一定のタイミングでデータを更新する(BehaviorSubject)

まずは比較的理屈が単純で、定期的なタイミングで更新したデータを転送させるBehaviorSubjectについて説明します。BehaviorSubjectを使用すると効率的なのは、一定のタイミングで更新処理を行いたい場合です。流れとしては商品個数を格納する変数cntを準備しておき、それを

public sub = new BehaviorSubject(cnt) 《cntは初期設定のデータ》

として変数sub(名前は任意)にプロトタイプを作成しておきます。そのプロトタイプsubに対し、共通で呼び出した変数内にnextという、ルーティング情報を送信するメソッドを活用します。

this.sub.next(cnt_tmp) 《cnt_tmpは更新後のデータ》

このようにプロトタイプに紐づいたnextメソッドに、同期を取りたい更新された値を代入することで同期を取ってくれるようになります。なお、nextは同期データの最終更新処理を行うためのメソッドで、これで処理されたデータは更新後情報として、各コンポーネントを経由してテンプレート上に展開することができます。

cart-service.ts
import { BehaviorSubject,Observable } from 'rxjs'
@Injectable({
	providedIn: 'root'
})
export class CartService{
	sub = new BehaviorSubject(cnt) //設定
    
	dispatch = (data:Data)=>{
		const cnt_tmp = cnt //オブジェクトの分割
		cnt_tmp.cart = this.itemCounter(store) //値の代入
		this.sub.next(cnt_tmp) //値の更新
	}
}

※Injectableデコレータはこのservice.tsは他のどのコンポーネントファイルでも使用可能であることを指示したもので、provider:rootと指定することで、どのアプリケーション(厳密にはrootコンポーネント以下)からも利用可能だということを意味しています。また、このInjectableなどのデコレータを宣言した下の行には変数指定ファイル等を記述できません。

◆リアルタイムでデータを更新する(Observable)

RxJSには同じぐらい使用頻度が高いものとして不定期でデータ更新を実行するObservableというものもあり、データ情報はこちらで制御しています。先程のBehaviorSubjectはデータ更新を任意のタイミングで反映させるものでしたが、今度のObservableは観察あるいは監視されたという和訳の通り、データの動きを監視するものです。したがって、最初から設定したデータの値をリアルタイムで監視させることで、変更を検知することができます。なお、更新用のnextメソッドはObservable内に設定しておきます。

※以前はコンストラクタ内しか指定できませんでしたが、今はダイレクトに、メソッド内の記述が可能です。

AppService.ts
export class CartService{
    //クラス内に直接記述が可能
	ob = new Observable((sb)=>{
		sb.next(store) //同期を取りたいデータを送信
	})
}

※obは監視用のオブジェクト、subは記述用のオブジェクトとなります。

◆コンポーネント側の記述

一方、購読※データを受け取るコンポーネント側の制御は以下のようになっています。コンストラクタに使用するコンポーネントを定義させておき、ngOnInit内から先程のメソッドから変数storageを呼び出すことで、モジュールに紐付けられたルーティング内でデータを共有することができます。

※subscribeメソッドで受け取ることをAngularでは購読(subscribeをそのまま和訳したものらしいです。subscribeとは定期的になにかしたりすることを意味するのですが、日本語にも英語にも適切な熟語がなかったようです…)と言います。

this.appService.sub.subscribe(v => this.storage = v)

global.component.ts
import { Component,OnInit,inject,signal,computed } from '@angular/core';
import {pipe,of} from 'rxjs'
import {tap,map} from 'rxjs/operators';
import {token,Store,CntT } from '../types/cart-types'
import {CustomModule } from '../custom.module'

@Component({
  selector: 'app-global',
  imports: [CustomModule],
  template: 
  `
		<ul class="main-navigation">
			<nav>
				<li class="nav"><a routerLink="./">Products()</a></li>
				<li class="nav"><a routerLink="./cart">Cart({{ cnt.cart }})</a></li>
			</nav>
		</ul>
		<div>
			<router-outlet /><!--ここに表示される-->
		</div>
  `,
  styleUrl: './global.component.css'
})
export class GlobalComponent implements OnInit {
	store = <Store>{}
	cnt = <CntT>{}
	svs = inject<any>(token)
	ngOnInit(){
        //observableで監視中のデータ
		this.svs.ob.subscribe(v => this.store = v) //更新を検知したデータを購読
        //BehaviorSubjectで検知した更新後の値
		this.svs.sub.subscribe(c => this.cnt = c) //個数のカウントを購読
	}
}

データの購読を行っている他コンポーネントについて

一旦は、ファイルの紹介です。後で説明に入っていきます。

※商品一覧ファイル

products.component.ts
import { Component,OnInit,inject } from '@angular/core';
import {token,Product } from '../types/cart-types'
import {CustomModule } from '../custom.module'

@Component({
  selector: 'app-products',
  imports: [CustomModule],
  template: 
  `
		<article class="products">
			<ul>
				@for(product of store.products; let i=$index; track i){
				<li>
					<a [routerLink]="['./detail']" [queryParams]="{id:product.id}">
						<div >
							<strong>{{ product.title }}</strong> - {{ product.price}}円
							@if(product.stock > 0 ){【残り{{product.stock}}個】} 
						</div>
					</a>
					<div>
						<button (click)="this.svs.dispatch({mode:'add',dif:product})">かごに入れる</button>
					</div>
				</li>
				}
			</ul>
		</article> 
  `,
  styleUrl: './products.component.css'
})
export class ProductsComponent  implements OnInit {
	store:any = []
	svs = inject<any>(token)
	ngOnInit(){
		this.svs.ob.subscribe(v => this.store = v) //更新を検知した値を購読
		console.log("N2")
	}
}

※カートファイル

cart.service.ts
import { Injectable, OnInit } from '@angular/core'
import { BehaviorSubject,Observable } from 'rxjs'
import ShopContext from '../context/shop-context';
import {Store,Data,Product,CntT} from '../types/cart-types';
const store = ShopContext()
const cnt = <CntT>{"cart":0,"article":0} //ナビに表示する個数

@Injectable({
	providedIn: 'root'
})

export class CartService{
	sub = new BehaviorSubject(cnt)
	ob = new Observable((sb)=>{
		sb.next(store) //同期を取りたいデータを送信
	})
	
	//買い物かごの調整
	addProductToCart = (product:Product)=>{
		let cartIndex = null
		const stat = {...store}
		//買い物かごの調整
		const updatedCart = stat.cart;
		const updatedItemIndex = updatedCart.findIndex(
			item => item.id === product.id
		);
		if (updatedItemIndex < 0) {
			updatedCart.push({ ...product, quantity: 1,stock: 0 });
			cartIndex = updatedCart.length -1 //カートの最後尾
		} else {
			const updatedItem = { ...updatedCart[updatedItemIndex] }
			updatedItem.quantity++;
			updatedCart[updatedItemIndex] = updatedItem;
			cartIndex = updatedItemIndex //加算対象のインデックス
		}
		//商品在庫の調整
		const updatedProducts = stat.products //商品情報
		const productid = updatedCart[cartIndex].id //在庫減算対象の商品
		const productIndex = updatedProducts.findIndex(
			p => productid === p.id
		)
		const tmpProduct = { ...updatedProducts[productIndex] }
		tmpProduct.stock-- //在庫の減算
		updatedProducts[productIndex] = tmpProduct
		//合計金額の調整
		const total = stat.total 
		const sum = this.getSummary(updatedCart,total)
		store.products = updatedProducts
		store.cart = updatedCart
		store.total = sum
		//this.sub.next(this.store)
	}
	//カートから商品の返却
	removeProductFromCart = (productId: string)=>{
		const stat = store
		const updatedCart = [...stat.cart];
		const updatedItemIndex = updatedCart.findIndex(item => item.id === productId);
		const updatedItem = { ...updatedCart[updatedItemIndex] }
		updatedItem.quantity--
		if (updatedItem.quantity <= 0) {
			updatedCart.splice(updatedItemIndex, 1);
		} else {
			updatedCart[updatedItemIndex] = updatedItem;
		}
		stat.cart = updatedCart
		//商品在庫の調整
		const updatedProducts = [...stat.products] //商品情報
		const productIndex = updatedProducts.findIndex(
			p => p.id === productId
		)
		const tmpProduct = { ...updatedProducts[productIndex] }
		tmpProduct.stock++ //在庫の加算
		updatedProducts[productIndex] = tmpProduct
		stat.products = updatedProducts
		//合計金額の調整
		let sum = this.getSummary(updatedCart,stat.total)
		stat.total = sum
	}
	//購入手続き
	buyIt = ()=>{
		const stat = store
		const articles = store.articles
		let updatedArticles = articles //所持品
		let tmp_cart = stat.cart

		for( let cart of tmp_cart){
			let articlesIndex = articles.findIndex(
			  a => a.id === cart.id
			)
			if (articlesIndex < 0) {
			  updatedArticles.push(cart);
			} else {
			  const tmpArticles = articles[articlesIndex]
			  tmpArticles.quantity++;
			  updatedArticles[articlesIndex] = tmpArticles;
			}
		}
		stat.articles = updatedArticles
		let summary = this.getSummary(tmp_cart,stat.total)
		let rest = stat.money - summary
		stat.money = rest
		tmp_cart.splice(0)
		summary = 0
		stat.cart = tmp_cart
		stat.total = summary
	}	
	getSummary(cart,total){
		const sum = cart.reduce((total,{price = 0,quantity})=> total + price * quantity,0)
		return sum
	}
	itemCounter(item){
		return item.cart.reduce((count,curItem)=>{
			return count + curItem.quantity //買い物かごの個数
		},0)
	}
	dispatch = (data:Data)=>{
		switch(data.mode){
			case "add": this.addProductToCart(data.dif)
			break
			case "remove": this.removeProductFromCart(data.dif)
			break
			case "buy": this.buyIt()
			break
		}
		const cnt_tmp = cnt
		cnt_tmp.cart = this.itemCounter(store)
		this.sub.next(cnt_tmp)
		//localStorage.setItem(token,JSON.stringify(this.store))
	}
}

サービスの受取

各種コンポーネントファイルには共通の処理があります。それが依存性注入されたサービスファイルのデータの受取部分です。

スタンドアロンコンポーネントではinjectメソッドからサービスを受け取ることができるので、そのサービスを購読すれば同期を取ることができます。トップコンポーネントの場合、二種類のデータを購読しているので以下のようになります。また、サービスの購読が可能な領域は基本ngOnInitメソッド内だけとなります。

※ngOnInitメソッドはVueのライフサイクルフックのようなもので、テンプレート読取後に処理を実行するものです。

ts
export class GlobalComponent implements OnInit {
	store = <Store>{}
	cnt = <CntT>{}
	svs = inject<any>(token)
	ngOnInit(){
        //observableで監視中のデータ
		this.svs.ob.subscribe(v => this.store = v) //更新を検知したデータを購読
        //BehaviorSubjectで検知した更新後の値
		this.svs.sub.subscribe(c => this.cnt = c) //個数のカウントを購読
	}
}

イベントの転送

サービスは、逆にイベントをサービスファイルに転送することもできます。たとえば商品一覧ファイルでは、各商品に対し「買い物かごに入れる」ボタンがあり、それと紐づいています。また、Angular17からはテンプレート内に、ダイレクトに変数を埋め込んで記述することも可能です。また、カートファイルには「商品を戻す」ボタンと購入するボタンがありますが、これも同様にサービスファイルにイベント転送ができます。

転送するイベントの中身はこのようになっており、サービスファイル内のdispatchメソッドを呼び出し、処理を分岐しています。

    <button (click)="this.svs.dispatch({mode:'add',dif:product})">かごに入れる</button>

◆更新されたデータを加工する(ofとpipe)

今までは更新データをそのまま購読するだけでしたが、それらを加工してテンプレートに渡すこともできます。それがofpipeという機能です。では、このpipeを使って、カートファイルの金額を税込料金と内税料金に分けてみます。また、これらは部品なので外部からのインポートが必要です。

cart.component.ts
import { Component,OnInit,inject,signal } from '@angular/core';
import { of,pipe } from 'rxjs'; //部品のインポート
import { map ,tap } from 'rxjs/operators';
import {token,Cart,Store } from '../types/cart-types'
import {CustomModule } from '../custom.module'
@Component({
  selector: 'app-cart',
  imports: [CustomModule],
  template: 
  `
	  <article class="cart">
	  @if(store.cart.length <= 0){
		<p v-if="">No Item in the Cart!</p>
	  }@else{
		  <ul>
			  @for(cartItem of store.cart; let i = $index; track i){
				  <li>
					<div>
					  <strong>{{ cartItem.title }}</strong> - {{ cartItem.price }}円
					  ({{ cartItem.quantity }})
					</div>
					<div>
					  <button (click)="this.svs.dispatch({mode:'remove',dif:cartItem.id})">買い物かごから戻す(1個ずつ)</button>
					</div>
				  </li>
			  }
		  </ul>
	  }
	  <h3>合計: {{ store.total }}円(税込 {{ totaltax }} 円)</h3>
	  <h3>残高: {{ store.money }}円</h3>
	  @if(store.money >0 &&  store.total >= 0){
		  <button (click)="this.svs.dispatch({mode:'buy',dif:null})">購入</button>
	  }
	</article>
  `,
  styleUrl: './cart.component.css'
})
export class CartComponent  implements OnInit {
	store = <Store>{}
	totaltax = <number>0
	svs = inject<any>(token)
	ngOnInit(){
		this.svs.ob.subscribe(v => this.store = v) //更新を検知した値を購読
		of(this.store.total).pipe(
			map((x:number): number => Math.floor(x * 1.1) ) //消費税をかける
		).subscribe((x:number):number => this.totaltax = x)
	}
}

上のようになります。ofにはループ処理させたいオブジェクトを記述したり、of(a,b,c)のように引数を複数指定したりできます。pipeはofによってループ処理される値に対して中継的に加工処理するためのメソッドです。更にpipeの中にはいろいろな処理用オペレーターがrxjs/operatorsに格納されているので、今回は加工処理を行うmapオペレーターを使い、それぞれの合計値(this.storage.total)に対し、消費税分を加算しています。その加工された値を返すのがsubscribeメソッドで、テンプレート上のtotaltaxに返しています。

したがって、このpipeを駆使すれば、totalなど本来汎用データ上に持つことができなかった変数をローカル上で制御することが可能になります。

※今はsignalで対応することもできます。また、プリミティブな値を制御したい場合はRxjsのpipeだとうまく行かないことが多いので、その場合は演習7で説明するカスタムパイプ(RxJSのパイプとは別物)を使うといいでしょう。

また、tapというオペレーターもあり、これは値に関係なく処理途中の値を参照できます。

cart.component.ts
import { of,pipe } from 'rxjs'
import { map,tap } from 'rxjs/operators'
/*中略*/
  constructor(private appService:AppService)
  {
    of(this.storage.total)
    .pipe(
      map((x:number): number => Math.floor(x * 1.1) ), //消費税をかける
      tap((x:number): void => console.log(x)) //税込価格を参照する
    ).subscribe((x:number):number => this.totaltax = x) //加工処理した値を返す
}

※pipeから用いるオペレーターは他にも色々ありますが、あくまで基本はmapです。

参考にした開発系ブログ

ビルトインコントロールフロー(分岐文)

コントロールフローには@if、@else if、@elseという分岐用もあります。

cart.componet.ts

@Component({
  template: 
  `
	  @if(store.money - store.total > 0 && store.cart.length > 0){
		  <button (click)="this.svs.dispatch({mode:'buy',dif:null})">購入</button>
	  }@else if(store.money < store.total ){
		  <p>金額が不足しています</p>
	  }@else{
		  <p>カゴに商品がありません</p>
	  }
	</article>
  `,
  styleUrl: './cart.component.css'
})

◆※モジュールからデータを取得する方法

モジュールからデータを受け渡す方法もあります。先程の変数データは任意のサービスからの転送でしたが、ルーティングからのデータ受け取りはActivatedRouteオブジェクトを用います。

まずはモジュールに転送したいdataを記述します。

app.module.ts
const Route: Routes = [
	{path: '',component: ProductsComponent,data{title: "Angularでルーティング"}},

ActivatedRouteオブジェクトを呼び出し、それをコンストラクタで定義してください。ただし、モジュールのdataプロパティから取得する場合はsubscribeメソッドではなく、snapshotを使って簡単にパラメータを取得できます。

products.component.ts
import { Component, OnInit } from '@angular/core';
import {ActivatedRoute} from '@angular/router' //ActivatedRouteオブジェクトを使用

export class ProductsComponent implements OnInit {
  constructor(private route: ActivatedRoute) {} //コンストラクタに定義しておく
  ngOnInit(){
	  this.title = this.route.snapshot.data.title //モジュールから受け渡されたデータ(title)
  }
}

◆詳細ページを表示(パラメータの送受信)

このモジュールからのパラメータの受け取り方を履修したところで、任意のパラメータを受け渡し、受け取りしてみます。このAngularの場合もテンプレート上にパラメータを埋め込む場合は以下のようにします。

ルーティングファイルにパスの設定

まずは、詳細ページのコンポーネントと対応パスの準備をします。今回のようにパスパラメータを設定したい場合、ルーティングファイルに:idと、パラメータ名を記述しておきます。

app.routes.ts
import { Routes } from '@angular/router';
import {ProductsComponent} from './cart/products.component';
import {CartComponent} from './cart/cart.component';
import { DetailComponent} from './cart/detail.component'; //追記
export const routes: Routes = [
	{path: '',component:ProductsComponent },
	{path: 'cart',component:CartComponent },
	{path: 'detail/:id',component:DetailComponent }, //追記(:idが転送パラメータとなる)
];

◆動的なパラメータを受け渡す

動的なパラメータを転送させる場合は、以下のようにしてプロパティバインディングで転送します。

<a [routerLink]="[任意のパス,対象のパラメータ]">
products.component.ts
  template: 
  `
		<article class="products">
			<ul>
				@for(product of store.products; let i=$index; track i){
				<li>
					<a [routerLink]="['./detail',product.id]">
						<div >
							<strong>{{ product.title }}</strong> - {{ product.price}}円
							@if(product.stock > 0 ){【残り{{product.stock}}個】} 
						</div>
					</a>
					<div>
						<button (click)="this.svs.dispatch({mode:'add',dif:product})">かごに入れる</button>
					</div>
				</li>
				}
			</ul>
		</article> 
  `,

パラメータの受取

パラメータの受取はsnapshotメソッドを使用すれば、簡単に取得できます。ただし、以前はプロパティから取得できましたが、現在はキーから取得します。また、ActiveRouteのインポートとコンストラクタへの設定が必要です。

products.component.ts
import { Component,OnInit,inject,signal,computed } from '@angular/core';
import { Location } from '@angular/common';
import {ActivatedRoute} from '@angular/router' //インポートする
import {token,Store,Product } from '../types/cart-types'

@Component({
  selector: 'app-detail',
  imports: [],
  template: `
		<ul>
			<li>{{ item().title }}</li>
		</ul>
		<button>戻る</button>  
  `,
})

export class DetailComponent implements OnInit {
	store = <Store>{}
	svs = inject<any>(token)
	id = signal<string>('')
	item = <Product>{}
	constructor(
		private route: ActivatedRoute, //コンストラクタに設定
		private location: Location
	){ }
	ngOnInit(){
		this.svs.ob.subscribe(v => this.store = v) //サービスから受け取ったデータ
		this.id.set(this.route.snapshot.params['id']) //paramsオブジェクトのキーから取得
		this.item = computed(()=>{
			return this.store.products.find((item)=>item.id === this.id()) //一致するアイテムを取得
		})
	}
}

◆クエリパラメータを受け渡す場合

では、Angularでもクエリからデータを受け渡してみます。Angularのnavigateは?という文字が使えないので、以下のようにしてqueryParamsをプロパディバインディングに渡します。

	<a [routerLink]="[遷移先のパス]" [queryParams]="クエリ送信対象のプロパティ">

※モジュールはパスだけを記述しておきます。

app.module.ts
const Route: Routes = [
	{path: '',component: ProductsComponent},
	{path: 'detail',component: DetailComponent},

受け取り側のコンポーネントは以下のように記述します。

detail.component.ts
	ngOnInit(){
		this.svs.ob.subscribe(v => this.store = v) //サービスから受け取ったデータ
		this.id.set(this.route.snapshot.queryParams['id'])
		this.item = computed(()=>{
			return this.store.products.find((item)=>item.id === this.id()) //一致するアイテムを取得
		})
	}

computed

この詳細ページでは取得したパラメータに対して、computedで連動処理を行っています。computedはsignalの一種で、signalの監視対象に対し、監視された値を検知し、連動処理を行ってくれます。またcomputedで返された値もコールバック関数になっているので、テンプレート上の展開はhoge()となります。

ts.detail.component.ts
import { Component,OnInit,inject,signal,computed } from '@angular/core';
import { Location } from '@angular/common';
import {ActivatedRoute} from '@angular/router'
import {token,Store,Product } from '../types/cart-types'

@Component({
  selector: 'app-detail',
  imports: [],
  template: `
		<ul>
			<li>{{ item().title }}</li>
		</ul>
		<button>戻る</button>  
  `,
})

export class DetailComponent implements OnInit {
	store = <Store>{}
	svs = inject<any>(token)
	id = signal<string>('')
	item = <any>{} //詳細の表示を格納する
	constructor(
		private route: ActivatedRoute,
		private location: Location
	){ }
	ngOnInit(){
		this.svs.ob.subscribe(v => this.store = v) //サービスから受け取ったデータ
		this.id.set(this.route.snapshot.queryParams['id']) //受け取ったパラメータの値
        //受け取ったパラメータの値に連動して、該当する商品の詳細を表示する
		this.item = computed(()=>{
			return this.store.products.find((item)=>item.id === this.id()) //一致するアイテムを取得
		})
	}
}

属性ディレクティブ1

ルーティング先から前ページに戻る場合はLocationオブジェクトのbackメソッドを用います。これにはいろいろな方法があるのですが、今回、初登場となる属性ディレクティブを実装してみます。

属性ディレクティブとは早い話、カスタマイズ可能なプロパティ制御のためのディレクティブで、各htmlタグ上のプロパティに紐づけて細かな制御が可能になります。また、イベントプロパティも制御可能なので、今回はクリックイベントに」対し、処理を紐づけ、そこに「戻る」機能を実装します。

※ちなみに、戻るだけの処理はlocation.back()だけです。

detail.component.ts
import { Component,OnInit,inject,signal,computed,Directive,HostListener } from '@angular/core';
import { Location } from '@angular/common';
import {ActivatedRoute} from '@angular/router'
import {token,Store,Product } from '../types/cart-types'

@Directive({
	selector: '[backTo]'
})

export class BackToDirective{
	constructor(private location: Location){}
	@HostListener('click')
	onClick(){
		this.location.back() //戻る処理
	}
}

@Component({
  selector: 'app-detail',
  imports: [BackToDirective],
  template: `
		<ul>
			<li>{{ item().title }}</li>
		</ul>
		<button backTo>戻る</button>  
  `,
})

/*後略*/

属性ディレクティブの構造

属性ディレクティブの構造は、コンポーネントと同様にデコーダとアーキテクチャから成ります(演習7で触れるカスタムパイプも同じ)。@Directiveデコーダは適用するイベント名を記述し、アーキテクチャ内に具体的な処理イベントを記述します。

detali.ts
import { Directive,HostListener } from '@angular/core'; //ディレクティブとHostListenerを呼び出す
import { Location } from '@angular/common';

@Directive({
	selector: '[backTo]' //テンプレートから紐づける
})

export class BackToDirective{
	constructor(private location: Location){}
	@HostListener('click') //対象のイベント 
    //イベントによって行われる処理
	onClick(){
		this.location.back()
	}
}

@Component({
  selector: 'app-detail',
  imports: [BackToDirective], //使用したいコンポーネントを呼び出す
  template: `
		<ul>
			<li>{{ item().title }}</li>
		</ul>
		<button backTo>戻る</button<!‐‐ backTo が対象のディレクティブ -->
  `,
})

※ディレクティブはスタイルに紐づけることも可能です(演習8を参照)。

演習7 スタイル制御(写真検索システム)

JSフレームワークの魅力はスタイル属性もリアルタイムに制御できることです。そこで、Angularで先程とは大きく書き直した写真検索システムにfont-awesomeのアイコンを使って気に入った画像を「いいね!」(ハートアイコンを赤に着色)できるようにしてみました。

なお、font-awesomeを使用する場合は、予めプロジェクトにインストールしておく必要があります。また、現行バージョンではFontAwesomeModuleをインポートして使用することを公式が推奨しています。

# npm install @fortawesome/free-solid-svg-icons
# npm install @fortawesome/angular-fontawesome

◆Angularでスタイルを制御する

では、次はAngularで制御してみますが、Angularでのクラス制御は非常に簡単です。方法は色々ありますが、一番よく使われるやり方がプロパティバインディングを利用した方法で、styleプロパティにメンバを代入させることで、その値を適用させることができます。

lesson7.component.html
import { Component,signal,computed,Input,Directive,HostListener,Pipe,PipeTransform } from '@angular/core';
import * as lodash from 'lodash';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; //フォントモジュール
import { faHeart } from '@fortawesome/free-solid-svg-icons';
import {ReactiveFormsModule,FormGroup,FormControl} from '@angular/forms';
import state_json from '../json/state.json';
import city_json from '../json/city.json';
const countries:any=[
				{ab:"US",name:"United States"},
				{ab:"JP",name:"Japan"},
				{ab:"CN",name:"China"},
			]

@Directive({
	selector: '[color_set]'
})

export class ColorSetDirective{
	@Input('id') id:any
	active = "red"
	cities = city_json
	@HostListener("click")
	onClick(){
		const id = this.id
		let selecteditem = this.cities.find(function(item,idx){
			return item.id === id
		})
		this.cities.filter((item,idx)=>{
		if(item.id === id){
			if(selecteditem.act !== 'red'){
				selecteditem.act = 'red'
			}else{
				selecteditem.act = ''
			}
			this.cities[idx] = selecteditem //値の代入(同期がとれる)
		}
		})
	}
}

@Pipe({
  name: 'kana_change',
})
export class KanaChangePipe implements PipeTransform {
	transform( str : string){
		console.log(str)
		let kana = ""
		if(str != undefined){
			kana = str.replace(/[\u3041-\u3096]/g, (match)=>{
				let chr = match.charCodeAt(0) + 0x60;
				return String.fromCharCode(chr);
			});
		}
		console.log(kana)
		return kana
	}
}

@Component({
  selector: 'app-lesson',
  imports:[FontAwesomeModule,ReactiveFormsModule,ColorSetDirective,KanaChangePipe],
  templateUrl: './lesson.component.html',
  styleUrls: ['./lesson.component.css']
})

export class LessonComponent{
	faHeart = faHeart
	countries = []
	states = []
	cities = []
	hit_cities_by_state = signal<any>({})
	hit_cities_by_word = signal<any>({})
	searchForm = new FormGroup({
		sel_country: new FormControl<string>(null),
		sel_state: new FormControl<string>(null),
		search_word: new FormControl<string>(null),
	})
	f = <any>{} //フォームグループの回収
    constructor(){
		this.countries = countries
		this.states = state_json
		this.cities = city_json
    }
	//国から該当するエリアを絞り込み
	selectCountry(){
		this.f = this.searchForm.value
		const opt_states = this.states.filter((item,key)=>{ return item.country == this.f.sel_country })
		this.states = opt_states
	}
	//エリアから該当する都市を絞り込み
	selectState(){
		this.f = this.searchForm.value
		const hits = this.cities.filter((item)=>{ return item.area == this.f.sel_state })
		this.hit_cities_by_state.set(hits)
	}
	//フリーワード検索
	searchWord(){
		const search_word = this.searchForm.get('search_word').value
		console.log(search_word)
		const hit = this.cities.filter((item,key)=>{
			return item.name.includes(search_word) && search_word
		})
		this.hit_cities_by_word.set(hit)
	}
	//論理積を求める
	hit_cities = computed(()=>{
		const len_state = this.hit_cities_by_state().length
		const len_word = this.hit_cities_by_word().length
		let hits = []
		if(len_state > 0 && len_word > 0 ){
			hits = lodash.intersection(this.hit_cities_by_state(), this.hit_cities_by_word())
		}else if(len_state > 0){
			hits = this.hit_cities_by_state()
		}else if(len_word > 0){
			hits = this.hit_cities_by_word()
		}else{
			hits = []
		}
		return hits
	})
	//フォームの初期化
	clear(){
		this.searchForm.reset()
		this.hit_cities_by_state.set([])
		this.hit_cities_by_word.set([])
	}
}

外部テンプレート

今回はテンプレートの行数が多いので外部ファイルとして独立させています。その場合@CompopnentsデコーダのテンプレートプロパティはtemplateURLとなり、ジェネレータで作成した場合のデフォルト設定を活用します。

lesson7.html.ts
<label> 国の選択 </label>
<form [formGroup]="searchForm">
<select id="sel1" (change)="selectCountry()" formControlName="sel_country">
	<option value=''>-- 国名を選択 --</option>
	@for(item of countries; let i=$index; track i){
		<option [value]="item.ab">
			{{item.name}}
		</option>
	}
</select>
<label> エリアの選択</label>
<select id="sel2" (change)="selectState()" formControlName="sel_state">
	<option value=''>-- エリアを選択 --</option>
	@for(item of states; let i=$index; track i){
		<option [value]="item.code">
			{{item.name}}
		</option>
	}
</select>
	<br/>
<h4>検索文字を入力してください</h4>
<input type="text" id="word" (input)="searchWord()" [value]="this.searchForm.get('search_word').value|kana_change" formControlName="search_word" />
</form>
<button type="button" (click)="clear()">clear</button>
<div>ヒット数:{{ hit_cities().length }}件</div>
<ul class="ulDatas" >
@for(item of hit_cities(); let i=$index; track i){
	<li class="liData">
		<label class="lbHit">{{ item.name }}
			<label color_set [id]="item.id">
				<fa-icon [icon]="faHeart" [style.color]="item.act"></fa-icon>
			</label>
			</label>
		<br />
		<img class="imageStyle" [src]="'../assets/img/'+item.src">
	</li>
}
</ul>

◆constructorとngOnInit

ここでconstructorとngOnInitについて軽く説明しておきます。

constructorはTypescriptに実装されているメソッドで、Angularではアーキテクチャの読込前に処理を実行するメソッドで、DOM生成前に事前処理が可能となります。今回はcountriesというオブジェクトとjsonから取得したstatesとcitiesを格納しています。

対するngOnitはライフサイクルフックの一種で、DOM生成後に初期化処理を行うメソッドです。こが、このngOnInitにサービスの設定を記述しておくことで、メソッド読込後、すぐにサービスを利用できます(演習6を参照)。

リアクティブフォーム

リアクティブフォームとはAngular13から試験実装され、14から正規実装となったフォームデータ取得のためのモジュールです。今回のようにsubmitを行わない場合でも、このリアクティブフォームモジュールを活用できます。

※リアクティブフォームは演習8で詳しく説明します。

ts
	searchForm = new FormGroup({
		sel_country: new FormControl<string>(null),
		sel_state: new FormControl<string>(null),
		search_word: new FormControl<string>(null),
	})

テンプレート上のフォームと紐づける

テンプレート上のフォームを紐づける場合は、formタグ上にformGroupの名称を、また各種フォームにformControlnameの名称を設定します。テンプレート上ではキャメルケースで記述します。

ts
<label> 国の選択 </label>
<form [formGroup]="searchForm">
<select id="sel1" (change)="selectCountry()" formControlName="sel_country">
	<option value=''>-- 国名を選択 --</option>
	@for(item of countries; let i=$index; track i){
		<option [value]="item.ab">
			{{item.name}}
		</option>
	}
</select>
<label> エリアの選択</label>
<select id="sel2" (change)="selectState()" formControlName="sel_state">
	<option value=''>-- エリアを選択 --</option>
	@for(item of states; let i=$index; track i){
		<option [value]="item.code">
			{{item.name}}
		</option>
	}
</select>
	<br/>
<h4>検索文字を入力してください</h4>
<input type="text" id="word" (input)="searchWord()" [value]="this.searchForm.get('search_word').value|kana_change" formControlName="search_word" />
</form>

フォームの値を取得する

フォームの値を取得するには、オブジェクト上のプロパティから取得する方法と、get('フォーム名')を用いて明示的に取得する方法があります。

プロパティから取得

ts
	//エリアから該当する都市を絞り込み
	selectState(){
		this.f = this.searchForm.value //オブジェクト上のプロパティから取得
		const hits = this.cities.filter((item)=>{ return item.area == this.f.sel_state })
		this.hit_cities_by_state.set(hits)
	}

getから明示的に取得

ts
	//フリーワード検索
	searchWord(){
		const search_word = this.searchForm.get('search_word').value //getを使って明示的に取得
		const hit = this.cities.filter((item,key)=>{
			return item.name.includes(search_word) && search_word
		})
		this.hit_cities_by_word.set(hit)
	}

フォームのリセット

フォームの値をリセットしたい場合はreset()で消去します。引数の中にオブジェクトを指定することで、特定フォームを明示的に削除することもできます。

	clear(){
		this.searchForm.reset() //引数なしはグループの一括削除
	}

カスタムパイプ

パイプはAngularが用意しているテンプレート上の変数を加工するための部品です。パイプとは管のことで、Angularでもデータの流れを水のようにたとえており、そのネットワーク上を流れるデータに対し、中継的に処理するための装置と考えるといいでしょう。

したがって、フォームタグやテンプレート上の変数を加工したい場合はパイプを用いると効果的です。なお、このパイプですがRxJSオペレータのパイプと名称が紛らわしいのでカスタムパイプと呼ぶことが多いです。

今回は、入力文字の変換に対しカスタムパイプで処理しています。使用方法ですが、プロパティバインディングに対し、

[バインディング用のキー値]="変数|カスタムパイプの名前"

と記述して使用するカスタムパイプ名を設定します。

そして、カスタムパイプは@Pipeデコレータを用い、コンポーネントデコレータやディレクティブデコレータと同様に、パイプデコレータ用のアーキテクチャ内に具体的な処理を記述します。
ちなみにtransformはカスタムパイプが用意している関数です。

ts
import { Pipe,PipeTransform } from '@angular/core';

@Pipe({
  name: 'kana_change',
})
export class KanaChangePipe implements PipeTransform {
	transform( str : string){
		let kana = ""
		if(str != undefined){
			kana = str.replace(/[\u3041-\u3096]/g, (match)=>{
				let chr = match.charCodeAt(0) + 0x60;
				return String.fromCharCode(chr);
			});
		}
		return kana
	}
}

カスタムパイプの適用箇所

前述のディレクティブがプロパティに対し適用されるのに対し、カスタムパイプの適用対象はプロパティバインディングに設定している同期データそのものとなります。今回は[value]に適用していますが、[innerHTML]などに適用することもできます。フォームコントロールの値はパイプ適用できないので、代わりに[value]で値を取得しています。

<input type="text" id="word" (input)="searchWord()" [value]="this.searchForm.get('search_word').value|kana_change" formControlName="search_word" />

演習8リアクティブフォーム(Todoアプリ)

演習8では、今から簡易なTodoアプリを作っていきます(内容は別ページの記事をアレンジしたものです)。リアクティブフォームは演習7でも簡潔に述べていますが、これをしっかり覚えておくと複雑なフォーム制御も簡単に処理できます。

main.tsを設定する

スタンドアロンコンポーネントでは前述した通りapp.module.tsが不要です。

main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig)
  .catch((err) => console.error(err));

ルーティングの設定

ルーティング情報の設定ファイルはこのように作ります。Angular19ではapp.routes.tsという独立ファイルとなっているので、よりルーティング設定をしやすくなっています。

app.routes.ts
//ルーティングしたいコンポーネント
import {TodoComponent} from './views/todo/todo.component';
import {AddTodoComponent} from './views/addtodo/addtodo.component';
//コンポーネントをオブジェクト化する
export const routes: Routes = [
	{path: '',component:TodoComponent },
	{path: 'add',component: AddTodoComponent},
    /*コンポーネントを追加していく*/
];

ルーティングを結びつける

先ほど作成したルーティング用のオブジェクトはprovideRouterによってルーティングされます。また、今回もここでサービスファイルをインポートとしておきます。

app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import {TodoService} from './services/todo-service';
import {token} from './types/todo-types';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
	provideZoneChangeDetection({ eventCoalescing: true }),
	[{provide:token,useClass:TodoService}],
	provideRouter(routes)]
};

次からはプロジェクトにTodoアプリのファイルを作成していきますが、その前にもうひとつ準備をしておきましょう。

ライブラリで制御する

スタンドアロンコンポーネントはNgModuleが不要となっています。

ですが@ngModuleデコレータは廃止されていません。したがって、SPA作動に必要なモジュールだけをインポートだけして、各種コンポーネントに送り出すこともできます。その際にインポート先のコンポーネントで使用したいモジュールを必ずexportsで記述してください。これを忘れると外部のコンポーネントで各種モジュールを使用できません。この任意にカスタマイズしたモジュールファイルのことをAngularではライブラリと呼んでいるようです。

※各種コンポーネントをライブラリに記述することも可能ですが、従属関係が曖昧になるので今回はここにコンポーネントを設定しません。

custom.module.ts
import {NgModule} from '@angular/core';
import {RouterModule } from '@angular/router' //ルーティング制御に必要
import {ReactiveFormsModule} from '@angular/forms' //後述するリアクティブフォーム制御に必要

@NgModule({
	imports:[
    CommonModule, RouterModule, ReactiveFormsModule,
	],
    //外部で制御させたいモジュール
    exports:[
    CommonModule, RouterModule, ReactiveFormsModule,
    ],
})
export class CustomModule { }

これで使用したいコンポーネントでは、ライブラリだけインポートすれば冗長な記述を省略できます。これで準備ができたので、今からTodoアプリを作成していきます。

Todoアプリを作る

Todoアプリのディレクトリ構造はこうなっています。●はコンポーネントの一連ファイル(コンポーネント、テンプレート、スタイル、スペック)です。ちなみに、大本になったアプリは以下の記事(Vueで作成)のもので、これを簡略化して更にAngularに書き換えています。

main.ts
■app
    app-component.ts
    app-config.ts
    app-routes.ts
    ■services
        -todo-service.ts
    ■components
        -●todo-list //Todo各種のリスト(todo-itemの親コンポーネント)
        -●todo-item //Todoの各アイテム
    ■types
        -types-todo.ts //型定義とトークンを格納したファイル
    ■views
        -●add-todo //新規登録
        -●app //ルートコンポーネント
        -●detail-todo //詳細
        -●edit-todo //編集
        -●todo //トップ画面
custom.module.ts #ライブラリ
main.ts

ルートコンポーネント

全ての基準となるルートコンポーネントは以下のようになります。注意点としてテンプレートにルーティング先を示すrouter-outletタグを用いているので、RouterModuleも使用されています(先程作成したライブラリによって機能が内包されている)。

またルートコンポーネントはmain.tsに紐づいているのでセレクタ名指定は必須です。

app.component.ts
import { Component } from '@angular/core';
import { CustomModule } from '../custom.module' //先程設定したライブラリが内包される

@Component({
  selector: 'app-root', //main.tsに紐づいている
  imports: [CustomModule], //ここに追記する
  template: `<router-outlet/>`,
  styleUrls: ['./app.component.css'],
})
export class AppComponent{
}

トップページ

トップページはこのようになります。ここではTodosコンポーネントへの紐づけが必要なので、Todosコンポーネントをインポートしておくようにしておきます。これも、今までだと全てモジュールから制御しないといけませんでしたが、スタンドアロンコンポーネントによって、独立してコンポーネントを呼び出すことができるようになっています。したがって、コンポーネント同士の従属関係が非常に明白です。

また、この画面では新規登録画面への遷移用ボタンが実装されています。なので、ここも本来はRouterModuleが必要となります(これをインポートしておかないと画面遷移できません)。

todo.component.ts
import { Component } from '@angular/core';
import { CustomModule } from '../custom.module';
import { TodoListComponent } from '../todo/todo-list.component' //Todo一覧制御用のコンポーネント

@Component({
  imports: [CustomModule,TodoListComponent], //必要なモジュールとコンポーネント
  template:`
    <h2>TODO一覧</h2>
      <app-todolist/><!-- Todosコンポーネントに紐づけ -->
    <a routerLink="./add"><button>新規登録</button></a>
  `,
  styleUrls: ['./todo.component.css']
})
export class TodoComponent{}

型の定義ファイル

型の定義ファイルは以下のようになっています。ただし、Angularの仕様の都合上、メソッド制御の一部においてanyも使用しています。また、サービス注入用のトークンもここで発行しておきます。

※TSの型定義はバージョンで随時変わっています。

types_todo.ts
import { InjectionToken } from '@angular/core'
export const token = new InjectionToken<any>("token")

import { Observable } from 'rxjs'
//ベースとなる型(|でor条件を作成できる)
export type Status = 'waiting'|'working'|'completed'|'pending'

export type Data = {
  id?: number
  title?: string,
  description?: string,
  str_status?: Status, //上で定義した任意のパラメータ
}

//アレンジされた型
//任意のオブジェクトから独自の型定義を作成したい場合(ここではDataオブジェクトのうち、title、description、sutatusの3つのプロパティを用いたParams型を作成している)
export type Params = Pick<Data,'title'|'description'|'str_status'>
export interface Todos{
  todos: Data{}
}

//注入されたサービス用
export interface Services{
  sub: Observable<any>,
  addTodo: (data:Data)=> void,
  updTodo: (data:Data)=> void,
  delTodo:(data:Data)=> void,
  todoReducer: (mode:string,data:Data)=> void 
}

Todo一覧制御

Todo一覧の作成はtodosコンポーネントによって制御されます。また、このコンポーネントは各Todoを制御する子コンポーネントと親子関係に当たるのですが、これもスタンドアロンコンポーネントによって、コンポーネントの紐付けを簡明にしています(Vue、React、Svelteのように関係がわかりやすくなっている)。一方、このTodo一覧はトップページのtodoコンポーネントの被従属関係に当たるので、セレクタによる従属指定が必須です。

また、サービスから呼び出されるTodoデータはトークンを用いて取得し、ngOninitメソッド上で処理できます。そして、サービス上で登録、修正、削除の各種処理を実行することで、更新後のデータを検知し、それを購読できるようになっています。

todo-list.component.ts
import { Component, OnInit,inject } from '@angular/core'
import { CustomModule } from '../modules/custom.module'
import {TodoItemComponent } from './todo-item.component' //子コンポーネント 
import { Todos,Services,token } from '../types/types_todo'
@Component({
  selector: 'app-todos', //従属する親コンポーネントを紐づけるために必須
  imports: [CustomModule,TodoItemComponent],
  template: `
           <ul>
              @for(todo of todos; track todo.id){
                <app-todo-item [todo]="todo" />
              }@empty{
                 <p>データがありません</p>
              }
            </ul>
            `
  ,
  styleUrls: ['../todo-components/todoitem.component.css']
})
export class TodoListComponent implements OnInit{
  svs = inject<Services>(token)
  todos: Todos
  ngOnInit(){
    this.svs.sub.subscribe(v => this.todos = v) //更新を検知した値を購読
  }
}

各Todoを制御する

各Todoの制御は以下のようになっています。ここは親コンポーネントから値を受け取っているので、サービスの購読をしていません。また、ここもtodolistコンポーネントの子コンポーネントなので、セレクタ指定が必須となります。

todo-item.component.ts
import { Component, Input,inject } from '@angular/core';
import { Services,Data,token } from '../todo-types/types_todo'
import {Router } from '@angular/router'
import { CustomModule } from '../modules/custom.module'

@Component({
  selector: 'app-todo-item',
  imports: [CustomModule],
  templateUrl: './todo-item.component.html',
  styleUrls: ['./todo-item.component.css']
})
export class TodoItemComponent{
  svs = inject<Services>(token)
  @Input() todo: Data
  constructor(
    private rtr: Router,
  ) {}
  postcall(mode,data){
    this.svs.todoReducer(mode,data)
  }
  getId(mode,id){
    this.rtr.navigate([`${mode}/${id}`])
  }
}
todo-item.component.html
<!-- 各Todo子コンポーネント -->
<div class="card">
  <div>
    <a routerLink="/" (click)="getId('detail',todo.id)">
      <span class="title">{{todo.title}}</span>
    </a>
    <span class="status">{{ todo.str_status }}</span>
  </div>
  <div class="action">
    <a routerLink="/" (click)="getId('edit',todo.id)"><button>修正</button></a>
    <button (click)="postcall('DEL_TODO',todo.id)">削除</button>
  </div>
</div>

サービスファイル

サービスファイルはそこまで変化はないと思います。今回のケースはObservableが便利です。ただ、削除はspliceで制御しないと更新を検知できない(filterは非破壊的なので、制御しても変数の値は更新されない)ので注意しましょう。

※今回はsubscribeしているだけなので問題は発生しませんが、もしpipeで中継処理を行う場合はObservableだと期待どおりの動きをしなくなる場合があります。その場合はBehavior Subjectで定期的にnextメソッドを使用するといいでしょう。

todo-service.ts
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { Data } from '../todo-types/types_todo'

const todos = <any>[]

@Injectable({
  providedIn: 'root'
})


export class TodoService{
  sub: Observable<any>
  constructor(){
    this.sub = new Observable((ob)=>{
      ob.next(todos)
    })
  }
  //データ登録
  addTodo = (data)=>{
    todos.push(data)
  }
  //データ更新
  updTodo = (data)=>{
    const idx = todos.findIndex((item)=>item.id == data.id)
    todos[idx] = {...todos[idx],...data}
  }
  //データ削除
  delTodo = (id)=>{
    const idx = todos.findIndex((item)=>item.id == id)
    todos.splice(idx,1)
  }

  todoReducer =(mode,data)=>{
    switch(mode){
      case "ADD_TODO": this.addTodo(data)
      break
      case "UPD_TODO": this.updTodo(data)
      break
      case "DEL_TODO": this.delTodo(data)
      break
    }
  }
}

CRUDを制御する(リアクティブフォーム)

フォーム制御は演習7にも登場したリアクティブフォームを用います。

ここで、スタンドアロンコンポーネントの際の記述に注意点があります。スタンドアロンコンポーネントの場合、ReactiveFormModuleというモジュールを事前に取得しておく必要があります(今回の例ではカスタムコンポーネントに内包済)。また、FormGroup、FormControlの各種オブジェクトをインポートしておく必要があります。

※この各種CRUD用コンポーネントはそれぞれ独立している(従属関係を持たない)ので、セレクタ指定は不要です(あってもなくても動きます)。

それから登録画面はinjectでサービスを受け取っていますが、ここで使用しているのはsendDataメソッドというイベント転送メソッドだけなのでOnInitは使用しなくても大丈夫です。

Todoの新規登録

add-todo.component.ts
import { Component,inject } from '@angular/core';
import { CustomModule } from '../modules/custom.module';
import {Router } from '@angular/router'
import { Data,Todos,Status,Services,token } from '../todo-types/types_todo'
import {FormGroup,FormControl} from '@angular/forms'

@Component({
  imports: [CustomModule],
  templateUrl: './add-todo.component.html',
  styleUrls: ['./add-todo.component.css']
})
export class AddTodoComponent{
  svs = inject<Services>(token)
  data: Data
  constructor(
    private rtr:Router,
  ) {}
  //ダイレクトに変数を代入することもできる
  date = new Date()
  setid = this.date.getTime()
  todoForm = new FormGroup({
    id: new FormControl<number>(this.setid),
    title: new FormControl<string>(''),
    description: new FormControl<string>(''),
    str_status: new FormControl<Status>('waiting')
  })
  sendData(mode:string){
    const data = this.todoForm.value
    this.svs.todoReducer(mode,data)
    this.rtr.navigate(['/'])
  }
}

テンプレートの記述

テンプレートも今までのような双方向バインディングを使用しません。その代わりにformGroupというプロパティとformControlNameというプロパティが制御に必須となり、formControlNameプロパティ上の名称がformGroupに記載されたグループ名の所属フォーム名となります。そして、これらは先程のコンポーネントファイルで制御したフォーム設計に合致させておく必要があります。

具体的にいえば、フォームのグループ名todoFormに属する各種プロパティがtitle、description、そしてstr_statusとなります。

ただし、IDのようにreadonlyにする場合はプロパティバインディングを用います。

※繰り返しますが、コンポーネントはパスカルケース(FormHoge)なのに対し、プロパティはキャメルケース(formHoge)で記述するようになっています。注意しましょう。

add-todo.component.html
<h2>TODOの作成</h2>
<form [formGroup]="todoForm" (ngSubmit)="sendData('ADD_TODO')">
<div>
  <label for="title"> ID</label>
  <input type="text" id="title" [value]="setid" readonly />
</div>
<div>
  <label for="title"> タイトル</label>
  <input type="text" id="title" formControlName="title" />
</div>
<div>
  <label for="description"></label>
  <textarea id="description" formControlName="description" ></textarea>
  </div>
  <div>
  <label for="status">ステータス</label>
  <select id="status"  formControlName="str_status">
    <option value="waiting">waiting</option>
    <option value="working">working</option>
    <option value="completed">completed</option>
    <option value="pending">pending</option>
  </select>
</div>
<button type="submit">作成する</button>
</form>

入力したフォームの値を転送する

フォームの値転送は、formタグに埋め込まれたngSubmitディレクティブに記述されたsendDataによって転送されます。sendDataは以下のように記述しており、フォームグループのプロトタイプにあるvalueプロパティに格納されます。あとはこれを制御用のサービスファイルに送るだけです。

add-todo.component.ts
  sendData(mode){
    const data = this.todoForm.value //フォームに入力された値
    const id = this.setid //idの値
     this.svs.todoReducer(mode,{...data,id}) //サービス上のメソッドへ転送
    this.rtr.navigate(['/']) //TOP画面へ遷移
  }

修正画面

では、修正画面の場合はどうやって制御するのでしょうか。修正画面の場合は、選択された値をフォームに一旦返さないといけません、そこで、役立つメソッドがpatchValueで、対象フォームのプロパティに対して、更新処理を行ってくれます。

※setValueというメソッドもありますが、こちらは全プロパティを設定する必要があります。今回idは逐一、テンプレートに返さなくてもよいので、却ってsetValueを使用すると型不一致のエラーとなります。

また、今回は一旦サービスからデータを受け取る必要があるので、ngOnintを受け皿としてそこでpatchValueをかけています。その際の型はid以外のプロパティを抽出したParamsとなります。

edit.component.ts
import { Component,OnInit,inject } from '@angular/core'; //OnInitを忘れない
import { CustomModule } from '../modules/custom.module';
import { ActivatedRoute,Router } from '@angular/router'
import { Data,Params,Status,Todos,Services,token } from '../todo-types/types_todo'
import { FormGroup,FormControl} from '@angular/forms'

@Component({
  standalone: true,
  imports: [CustomModule],
  templateUrl: './edit-todo.component.html',
  styleUrls: ['./edit-todo.component.css']
})
export class EditTodoComponent implements OnInit{
  svs = inject<Services>(token)
  todos: Todos
  data: Data
  id: number
  todoForm = new FormGroup({
    id: new FormControl<number>(null),
    title: new FormControl<string>(''),
    description: new FormControl<string>(''),
    str_status: new FormControl<Status>('waiting')
  })
  constructor(
    private rt: ActivatedRoute,
    private rtr:Router,
  ) {}
  ngOnInit(){
    this.svs.sub.subscribe((v)=>{ this.todos = v})
    this.id = Number(this.rt.snapshot.params.id)
    this.data = this.todos.find((item:Data)=>item.id === this.id)
    this.todoForm.patchValue(<Params>this.data) //該当idのデータを設定する
  }
  //転送されたデータ
  sendData(mode: string){
    this.todoForm.patchValue({
      id: this.data.id
    })
    const data = this.todoForm.value
    this.svs.todoReducer(mode,data)
    this.rtr.navigate(['/'])
  }
  backto(){
    this.rtr.navigate(['/'])
  }
}

そして値の更新の際に、忘れずにidに対してもpatchValueをかけておきましょう。valueで入力されたフォームの値を取得するのはidを返してからの作業になります。

※TypeScriptの利点として、値を代入しなくても型を定義できるので次のような記述方法でも大丈夫です。フォームに返す値を逐一確認したい場合は前述の方法で、そこまでのフォームが存在しない場合は以下の方法でいいでしょう。

    this.todoForm.patchValue(<Params>this.data)

編集画面のテンプレート

編集用のテンプレートは登録用とそこまで変わりません。編集用だからといっても、フォームの値はリアクティブフォームが制御してくれるので、プロパティバインディングも必要なのはformGroupだけで済みます。

edit-component.html
<h2>TODOの編集</h2>
<form [formGroup]="todoForm" (ngSubmit)="sendData('UPD_TODO')">
<div>
  <label for="title"> タイトル</label>
  <input type="text" id="title" formControlName="title" />
</div>
<div>
  <label for="description"></label>
  <textarea id="description" formControlName="description" ></textarea>
  </div>
  <div>
  <label for="status">ステータス</label>
  <select id="status"  formControlName="str_status">
    <option value="waiting">waiting</option>
    <option value="working">working</option>
    <option value="completed">completed</option>
    <option value="pending">pending</option>
  </select>
</div>
<button type="submit">更新する</button>
</form>
<button (click)="backto()">戻る</button>

詳細画面(属性ディレクティブとカスタムパイプ)

詳細画面制御のコンポーネントは、フォーム制御不要なので記述もシンプルです。ただし、ここでは属性ディレクティブとカスタムパイプを使用しています。

そして、これらも同様にライブラリからインポートするだけで使用可能になります。

detail-todo.component.ts
import { Component,OnInit,inject } from '@angular/core';
import { CustomModule } from '../custom.module';
import {ActivatedRoute,Router } from '@angular/router'
import { Data,Todos,Services,token } from '../todo-types/types_todo'
import { TableDirective } from '../directives/table.directive' //属性ディレクティブ
import { SetBrPipe } from '../pipes/set.br.pipe' //カスタムパイプ

@Component({
  standalone: true,
  imports: [CustomModule,SetBrPipe,TableDirective], //使用するコンポーネント類を記述する
  templateUrl: './detail-todo.component.html',
  styleUrls: ['./detail-todo.component.css']
})
export class DetailTodoComponent implement OnInit{
  svs = inject<Services>(token)
  todos: Todos
  id: number
  data: Data
  constructor(
    private rt: ActivatedRoute,
    private rtr:Router,
  ) {}
  ngOnInit(){
    this.svs.sub.subscribe((v)=>{ this.todos = v})
    const id = Number(this.rt.snapshot.params.id)
    this.data = this.todos.find((item)=>item.id === id)
  }
  //ルートコンポーネントに戻る
  backto(){
    this.rtr.navigate(['/'])
  }
}

詳細画面のテンプレート

詳細画面はフォームとは異なり、ダイレクトに変数を返しています。

テーブルタグには属性ディレクティブ(bs_tableが任意のプロパティ)でBootstrapを制御しています。また、テキストエリアの改行が反映されるように、カスタムパイプで改行コードを置換した上でプロパティバインディングの[innerHTML]を使用しています。

detail-todo.component.html
	<h2>TODOの詳細</h2>
  <ng-template *ngIf="data.title ==''; then t else f"></ng-template>
  <ng-template #t>
	<div>ID:{{ data.id }}のTODOが見つかりませんでした</div> 
	</ng-template>
  <ng-template #f>
    <table bs_table>
      <thead>
        <tr>
        <th>タイトル</th>
        <th>説明</th>
        <th>ステータス</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>{{ data.title }}</td>
          <td [innerHTML]="data.description | setbr"></td>
          <td>{{ data.str_status }}</td>
        </tr>
      </tbody>
    </table>
  </ng-template>
  <button (click)="backto()">戻る</button>

属性ディレクティブ2(スタイル制御)

演習6で紹介したディレクティブはスタイル属性を制御することもでき、こちらの方が使用頻度が断然高いです。今回はHostBindingを利用し、それによってクラス属性にバインドしています。

table.directive.ts
import { Directive,HostBinding } from '@angular/core';

@Directive({
  selector: '[bs_table]', //タグに付与するプロパティの名称
})
export class TableDirective {
  @HostBinding("class") //class属性にバインド
  elementClass = "table table-bordered border-primary" //使用したいBootstrapのプロパティ
}

カスタムパイプを使用する

今回はカスタムパイプも使用しています。ここではテキストエリアに入力された値に対し、改行コードをbrタグに変換する処理をカスタムパイプ化しています。

set.br.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
  name: 'setbr',
})
export class SetBrPipe implements PipeTransform {
  transform( str : string){
    return str.replace(/\r?\n/g, '<br>') //テキストエリアの改行コードをbrタグに変換
  }
}

属性ディレクティブやカスタムパイプをライブラリに組み込む

前述したようにライブラリはディレクティブ、パイプ類も組み込むことができるので、利用頻度の高いディレクティブやパイプはライブラリから呼び出して一元管理した方がいい場合もあります。

custom.module.ts
import {NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import {RouterModule,Router } from '@angular/router'
import {ReactiveFormsModule } from '@angular/forms'
import {ButtonDirective} from '../directives/button.directive' //ボタンデザイン制御のディレクティブ

@NgModule({
	imports:[
    CommonModule,
    RouterModule,
    ReactiveFormsModule,
    ButtonDirective, //追加
	],
  exports: [
    CommonModule,
    RouterModule,
    ReactiveFormsModule,
    ButtonDirective, //追加
  ],
})
export class CustomModule {}

あとは使用したいコンポーネントで逐次インポートするだけです。

遅延ロード

AngularではNextやNuxtのような遅延ロードも可能となっています。ちなみに遅延ロードとは英語のlazy loadを直訳しただけで、lazyとは日本語にもなっているように怠け者、無精者という意味ですが、早い話、ルーティング元から呼び出された場合のみ、データの読み込みを行うローディング方法で、大幅にパフォーマンスを改善できます。

その遅延ロードの方法ですが、以下のように記述するだけです。

loadComponent: ()=> コンポーネントを呼び出す式

以下のようにコールバック関数を記述するだけでも大丈夫です。ここでは修正画面と詳細画面の制御コンポーネントを遅延ロードに書き換えてみました。

app-todo.ts
import { Routes } from "@angular/router";
//各種コンポーネント
import {TodoComponent} from '../todo-views/todo.component';
import {AddTodoComponent} from '../todo-views/add-todo.component';
const lazy_EditTodoComponent = import('../todo-views/edit-todo.component').then(c => c.EditTodoComponent)
const lazy_DetailTodoComponent = import('../todo-views/detail-todo.component').then(c => c.DetailTodoComponent)
export const Route: Routes = [
	{path: '',component: TodoComponent},
	{path: 'add',component: AddTodoComponent},
	{path: 'edit/:id',
    loadComponent: ()=> lazy_EditTodoComponent, //コールバック関数にしてもよい
    pathMatch: 'full'},
	{path: 'detail/:id',
    loadComponent: ()=> lazy_DetailTodoComponent, //コールバック関数にしてもよい
    pathMatch: 'full'},
]

※一般的には以下のように式を記述することが多いですが、これだと使用コンポーネントが散逸してしまって、個人的に分かりづらいと思ったからです。

app-todo.ts
loadComponent: import('../todo-views/detail-todo.component').then(c => c.DetailTodoComponent),

※loadChildrenを用いるとルーティングファイルごと子コンポーネント化させて、遅延ロードすることもできたりします。その際はパスが一致していることが条件となります。

effect

signalにはcomputedに対して、effectというものもあります。これはconstructor上で用いるもので、VueのライフサイクルフックonMountedのように、起動時に初期値や体裁を設定したい時に用いたりすると効果的です(起動後も監視された値に反応します)。また、effectは戻り値を設定できません

todolist.component.ts
import { Component,OnInit,inject,signal,effect } from '@angular/core';
import {TodoitemComponent } from '../todoitem/todoitem.component';
import { Todos,Services,token } from '../../types/types';
@Component({
  selector: 'app-todolist',
  imports: [TodoitemComponent],
  template: `
			<h2>一覧リスト{{ mes }}</h2>
			@for(todo of todos; track todo.id){
			<h4>{{ todo?.id }}</h4>
              <app-todoitem [todo]="todo"
              />
			}@empty{
				<p>リストに値はありません</p>
			}
			`
  ,
  styleUrl: './todolist.component.css'
})
export class TodoListComponent implements OnInit{
  svs = inject<Services>(token);
  todos = [];
  $cnt = signal(0);
  mes = "";
  //コンストラクタを設定し、中で用いる
  constructor(){
	effect(()=>{ this.mes = `(${this.$cnt()}件)`; }); //表示メッセージの体裁を設定
  }
  ngOnInit(){
    this.svs.sub.subscribe(v => this.todos = v); //更新を検知した値を購読
	this.$cnt.set(this.todos.length ); //件数の設定
  }
}
2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?