前記事の続編をそれぞれのフレームワーク毎に分類し、時流の変化を追いやすくしたものです。
今回は、前回で補完できなかった、コンポーネント周りのもっと突っ込んだ操作を含めた解説ができたらと思います。また、前回までは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 { Component, OnInit,signal } from '@angular/core';
これはAngularという外部ファイルをAngularという名称で利用する、かつComponent,onInit,signalというオブジェクトを利用するという命令になります。
演習5 コンポーネント制御(電卓)
ここでは子コンポーネントを用いて、親コンポーネントに簡易な電卓を作成していきます。それにはプッシュキーとそれを押下したタイミングでの制御が必要となりますが、その際にプッシュキーの部品を子コンポーネント化することで、効率よくシステムを構築することができます。
その前に、なぜ親子のコンポーネントに分割、階層化するかですが、結論からいえば冗長な記述を回避するためです。
◆Angularでコンポーネント制御する
Angularは以前、複雑な構造だったのですが、スタンドアロンコンポーネントになってからは、非常に明快な構造になっています。
◆コンポーネントの構造
Angularの場合はindexファイルの中にあるがアプリケーションコンポーネントとなります。
<!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></app-root>
</body>
</html>
このmain.tsが従来の設定ファイルですが、今はどちらかというと機能分離を繰り返した結果、Angular19現在、メインとしてアプリケーションを紐づける部分となっています。
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; //設定ファイルの呼び出し
import { AppComponent } from './app/app.component'; //アプリケーションこんぽーねんt
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));
Angular18からは設定ファイルも独立しているので、ルーティングなどの設定も容易になりました。
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)]
};
またルーティングファイルもデフォルトで用意されているので、ルーティング設定も簡単です。
export const routes: Routes = []; //空オブジェクトだけ準備してくれている
アプリケーションコンポーネント
アプリケーションコンポーネントファイルは以下のようになっています。ここから、演習用コンポーネントを作成していきます。注意点として@Componentデコレータからのインポート指定を忘れないことです。また、app-rootはindex.htmlとを紐づける大事なセレクタなので、これもうっかり消去しないようにしましょう。
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 {
}
◆アプリケーションの作成
Angularは基本、ngというコマンドを用いてアプリケーションやモジュール、コンポーネントの作成を行っていきます。したがって、Angularでアプリケーションを作成したい場合は、以下のコマンドだけで作成可能です。
また、任意のコンポーネントファイルを作成するコマンドが以下になります(gはgenertateの省略コマンド)。
app # ng g component hoge
このコマンドを実行すると、新規にディレクトリが作成され、以下の4ファイルがワンセットで構築されます。
■Hoge
- hoge.component.css //CSS制御用
- hoge.component.html //テンプレートファイル用
- hoge.component.spec.ts
- hoge.component.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])
}
}
signal
signalはAngular16から登場した、リアクティブ制御のためのメソッドで、非常にルールが明快です(Angularは他のJSフレームワークのようにproxy制限しないので)。リアクティブに制御させたい変数に対しSignalで設定すると変数はコールバック関数となります。その関数に対し、
state = signal() //stateは任意の変数(コールバック関数)
state.set(任意の値を代入したい場合)
state.update((arg)=> argを更に再計算したい場合)
このような制御ができます(後になればcomputed、effectedも登場します)。
また、テンプレート上に展開したい場合は
{{ state() }}
とします。SolidJSのSignalと似ているようですが、こちらの方がずっと単純です。
◆テンプレートファイル
もし、テンプレートを外部ファイルから呼び出したい場合は、プロパティをtemplateからtemplateURLに変更します。
@Component({
selector: 'Calc', //appコンポーネントから紐づけているコンポーネント名
imports:[SetkeysComponent],
templateURL: "./calc.component.html"
})
<div>
<Setkeys [pushkeys]='pushkeys()' (ev)="onReceive($event)" />
<div>
<p>打ち込んだ文字:{{str()}}</p>
<p>合計:{{sum()}}</p>
</div>
</div>
◆親コンポーネントから子コンポーネントへの値の受け渡し
ここでおさらいですが、Angularではプロパティバインディング[dataFromParent]='data'を用います。この記述によって、変数を子コンポーネントに受け渡すことが可能となります。
◆子コンポーネントから親コンポーネントへの値の受け取り
また、子コンポーネントから親コンポーネントへの値の受け取りは、Angularではイベントバインディング(ev)="onReceive($event)"
を用いることで、値の受け取りが可能となります。
また、受け取り用のメソッドは
onReceive(data){
this.data = data
}
となります。なお、コンポーネントファイルでのコンポーネント名指定はモジュール上で定義しているので不要です。
◆変数dataの定義用ファイル
変数dataはオブジェクトなので、TypeScriptの場合はそれぞれ、メンバに対しても細かく型指定が必要となります。そこで、定義ファイルを作っておきます。
※TypeScript自身がある程度、自動で変数の型を判別してくれるようになった)なので、今は単純に定義ファイルを作れます
export type Data
{
lnum: number;
cnum: number;
sign: string;
}
◆子コンポーネントの制御
子コンポーネントは以下のようになっています。
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
}
}
◆親子コンポーネント間でデータをやりとりする
◆2種類のデコーダとイベントエミッター
Angularで親コンポーネントから子コンポーネント、子コンポーネントから親コンポーネントへの値の受け渡しを行うには、それぞれInputデコーダとOutputデコーダという二種類のデコーダをモジュールから呼び出す必要があります。また、子コンポーネントから親コンポーネントへ値を転送する際には、EventEmitter(イベントエミッター)が必須となるので、それぞれ事前に定義しておきましょう。
◆親コンポーネントから子コンポーネントへ値を受け渡す
親コンポーネントから子コンポーネントへ値を受け渡すにはInputデコーダを用いて、プロパティバインディングを設定するだけです。また、メソッドに値を呼び出すにはthis.xxxxとすれば値を取得できます。
@Input() dataFromParent; //親コンポーネントのテンプレートに記載したプロパティバインディングの値
◆子コンポーネントから親コンポーネントへ値を受け渡す
子コンポーネントから親コンポーネントへ値を受け渡すにはOutputデコーダを用いて、イベントバインディングの値を設定してから、EventEmitterで具体的な値を転送することになります。
◆Outputデコーダ
Outputデコーダは子コンポーネントから親コンポーネントへ値を受け渡すためのイベントを設定するためのもので、以下のように記述します。
@Output() ev = new EventEmitter<any>();
ここでevは親コンポーネントのテンプレートに記述されたイベントバインディングの値となり、EventEmitterの後に<転送する値の型>
となります。ところが、TypeScriptの性格上Object型というのは存在しないため、ここではあらゆる型に対応しているAnyを用いています(転送する変数dataFromParentは親コンポーネントで定義しているので、これで問題はありません)。
◆emitメソッド
emitメソッドはEventEmitter内のメソッドなのでOutputデコーダで定義したプロトタイプから呼び出し、以下のように記述します。
this.ev.emit(data)
変数dataは制御を行った変数であり、これが親コンポーネントのテンプレート内に記述されたイベントバインディングと対応しているため、onReceiveメソッドで受け取ることができます。
ビルトインコントロールフロー(ループ文)
Angular17からは脱ngテンプレートを目指しており、SvelteやSolidJSのようなビルトインコントロールフローを用いることができます。今回はループ処理の@forを用いています(インデックスを付与したい場合はlet idx=$indexとして、それで一意の値を作り出すこともできます)。
@for( 展開したい値 of 元のデータオブジェクト; track キーとなる一意の値){
}
@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はループが一意であることを示すためのもので、必須です。後述しますが、データが存在しない場合は@emptyという例外処理も同時に記述することができます。
@for(){
//データの展開
}@empty{
//データがない場合の処理
}
※ビルトインコントロールフローは他に@if、@switchなどもあります。
演習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に書き換えておく必要があります。
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という共通モジュールが必須でしたが、スタンドアロンコンポーネントでは、このモジュールが不要となりました。ですが、逐一必要なモジュールを各コンポーネントに呼び出すのも面倒なので、custom.module.tsという任意設定モジュール(公式でライブラリと命名しているものです)を設定しておきます。
import {NgModule} from '@angular/core';
import {RouterModule } from '@angular/router' //ルーティング制御に必要
@NgModule({
imports:[
RouterModule,
],
//外部で制御させたいモジュール
exports:[
RouterModule,
],
})
export class CustomModule { }
※このモジュールですが、サービスを分配することはできません。
ルーティング設定
ルーティング設定はアプリケーションフォルダ(app)内にあるapp.routes.tsから設定しておきます。
各コンポーネントオブジェクトの記述ルールは以下の通りです。
{path: 任意のディレクトリ(トップの場合は''とする。),component: インポートしたコンポーネント名}
※このコンポーネント名は各コンポーネントファイル内のオブジェクト名と一致しています。
export class HogeComponent(
)
ルーティング設定をアプリケーションに紐づける
ルーティング設定をアプリケーションに紐づけるためのファイルがapp.config.tsとなります。ここのprovideRouter(routes)がルーティング設定の記述になります。
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はキーワード)。
import {CartService} from './services/cart-service'; //サービスを分配する
import {token} from './types/cart-types';
export const appConfig: ApplicationConfig = {
providers: [
[{provide:token,useClass:CartService}], //useClassでサービスクラスを注入できる
provideRouter(routes)]
};
ルーティング先のリンクと表示
ルーティング先のリンクと表示は以下のようになります。リンクはaタグを用い、routerLinkプロパティで遷移先のパスを指定します。
対するリンク先はrouter-outletタグに表示されます。
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にしておいた方が利便性も高いです。また、サービス受取用のパスワードトークンもここに記しておきます。
型指定のファイル
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,
}
データファイル
データファイルは以下のようになっています。
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でデータをやりとりする場合は、AppService
という共通処理用のサービスから値をインポートすると一番効率が良さそうです。共通処理用のサービスは基本app.service.tsというファイルで定義され、今回はcart-service.tsという名称となっています。
そして、ここでAngular敬遠の遠因ともいわれていたRxJSが必須となります。RxJSは非常に複雑で、これ一つで書籍が出ているぐらい奥が深いのですが、簡潔にいえば、Angularにおいてネットワーク内で自在にデータをやりとりするためのライブラリです。これを用いることで、ルーティング先の全コンポーネントでデータをやりとりすることが円滑になります。
※RxJSとはReactive extensions for JavaScriptの略称です。そしてこのRxJSの部分が、一番Angularで頻繁に仕様が更新され、記述が簡略(シンタックスシュガー)化してきています。とりわけ、Angular6以降はだいぶ書き方がスリム化しており、簡単にネットワーク監視・更新用のプロトタイプを作成できるので、今までのように敬遠してしまうことも少なくなるのではないかと思っています。
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は同期データの最終更新処理を行うためのメソッドで、これで処理されたデータは更新後情報として、各コンポーネントを経由してテンプレート上に展開することができます。
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は観察、監視という和訳の通り、データの動きを監視するものです。したがって、最初から設定したデータの値をリアルタイムで監視させることで、変更を検知することができます。なので、reducerメソッドのnext設定も不要なので、不定期のタイミングでデータ更新を行いたい場合は便利です。
※以前はコンストラクタ内しか指定できませんでしたが、今はダイレクトに記述が可能です。
export class CartService{
//クラス内に直接記述が可能
ob = new Observable((sb)=>{
sb.next(store) //同期を取りたいデータを送信
})
}
※obは監視用のオブジェクト、subは記述用のオブジェクトとなります。
ちなみに、subscribeで受け取ることを購読(subscribeをそのまま和訳したものらしいです)と言います。
◆コンポーネント側の記述
一方、購読データを受け取るコンポーネント側の制御は以下のようになっています。コンストラクタに使用するコンポーネントを定義させておき、ngOnInit内から先程のメソッドから変数storageを呼び出すことで、モジュールに紐付けられたルーティング内でデータを共有することができます。
this.appService.sub.subscribe(v => this.storage = v)
スタンドアロンコンポーネントではinjectメソッドからサービスを受け取ることができるので、そのサービスを購読すれば同期を取ることができます。トップコンポーネントの場合、二種類のデータを購読しているので以下のようになります。
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) //個数のカウントを購読
}
}
イベントの転送
サービスは、逆にイベントを転送することもできます。商品一覧ファイルでは、各商品に対し、「買い物かごに入れる」ボタンがあり、それと紐づいています。また、Angular17からはテンプレート内に、ダイレクトに変数を埋め込んで記述することも可能です。
転送するイベントの中身はこのようになっており、サービスクラス内のdispatchメソッドを呼び出します。
<button (click)="this.svs.dispatch({mode:'add',dif:product})">かごに入れる</button>
import { Component,OnInit,inject,signal } 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>
<div >
<strong>{{ product.title }}</strong> - {{ product.price}}円
@if(product.stock > 0 ){【残り{{product.stock}}個】}
</div>
<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) //更新を検知した値を購読
}
}
◆更新されたデータを加工する(ofとpipe)
では、今までは更新データをそのままテンプレートに受け渡すだけでしたが、それらを加工してテンプレートに渡すこともできます。それがofやpipeという機能です。では、このpipeを使って、買い物かごの合計値を税込料金と内税料金に分けてみます。
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で対応することもできます。
tapというオペレーターもあり、これは値に関係なく、処理途中の値を参照できます。
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
という分岐用もあります。
@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を記述します。
const Route: Routes = [
{path: '',component: ProductsComponent,data{title: "Angularでルーティング"}},
ActivatedRouteオブジェクトを呼び出し、それをコンストラクタで定義してください。ただし、モジュールのdataプロパティから取得する場合はsubscribeメソッドではなく、snapshotを使って簡単にパラメータを取得できます。
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の場合もテンプレート上にパラメータを埋め込む場合は以下のようにします。
ルーティングファイルにパスの設定
まずは、詳細ページのコンポーネントと対応パスの準備をします(アプリケーションの仕組みは演習8で復習します)。
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]"[任意のパス,対象のパラメータ]"=>
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のインポートとコンストラクタへの設定が必要です。
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]="クエリ送信対象のプロパティ">
※モジュールはパスだけを記述しておきます。
const Route: Routes = [
{path: '',component: ProductsComponent},
{path: 'detail',component: DetailComponent},
受け取り側のコンポーネントは以下のように記述します。
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()となります。
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()) //一致するアイテムを取得
})
}
}
ディレクティブ
ルーティング先から前ページに戻る場合はLocationオブジェクトのbackメソッドを用います。いろいろな方法があるのですが、今回はディレクティブを実装してみます。
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>
`,
})
/*後略*/
戻るボタンの実装
戻るボタン実装のように、任意のイベントコマンドに特定の処理を紐づけるものです。記述方法は以下の通りになります。ちなみにAngular16からは複数のディレクティブを紐づけることが可能です。
※必ずディレクティブの制御はコンポーネントの上で行って下さい。また、コンポーネント側からはimportで呼び出す必要があります。
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 が対象のディレクティブ -->
`,
})
※ディレクティブはモジュールに紐づけることも可能です。また、似たような機能にカスタムパイプというものもあり、これは特定のデータに対し追加処理などができます(演習7で紹介)。
演習7 スタイル制御(写真検索システム)
JSフレームワークの魅力はスタイル属性もリアルタイムに制御できることです。そこで、Angularで先程とは大きく書き直した写真検索システムにfont-awesomeのアイコンを使って気に入った画像を「いいね!」(ハートアイコンを赤に着色)できるようにしてみました。
なお、font-awesomeを使用する場合は、予めプロジェクトにインストールしておく必要があります。また、現行バージョンではFontAwesomeModuleをインポートして使用することを公式が推奨しています。
# npm install @fortawesome/free-solid-svg-icons
# npm install @fortawesome/angular-fontawesome
◆Angularでスタイルを制御する
では、次はAngularで制御してみますが、Angularでのクラス制御は非常に簡単です。方法は色々ありますが、一番よく使われるやり方がプロパティバインディング
を利用した方法で、styleプロパティにメンバを代入させることで、その値を適用させることができます。
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となり、ジェネレータで作成した場合のデフォルト設定を活用します。
<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>
◆AngularのconstructorとngOnInitについて
ここでconstructorとngOnInitについて軽く説明しておきます。
constructorはTypescriptに実装されているメソッドで、Angularの読込前に処理を行うことができるメソッドで、DOM生成前に事前処理を実行できます。今回はcountriesというオブジェクトとjsonから取得したstatesとcitiesを格納しています。
対するngOnitはライフサイクルフックの一種で、DOM生成後に初期化処理を行うメソッドです。こが、このngOnInitに記述しておくことで、メソッド読込後すぐにデータ利用できます(演習6を参照)。
リアクティブフォーム
Angular13から試験実装され、14から正規実装となったフォームデータ取得のためのモジュールです。今回のようにsubmitを行わない場合でも、このリアクティブフォームモジュールを活用できます。
searchForm = new FormGroup({
sel_country: new FormControl<string>(null),
sel_state: new FormControl<string>(null),
search_word: new FormControl<string>(null),
})
テンプレート上のフォームと紐づける
テンプレート上のフォームを紐づける場合は、formタグ上にformGroupの名称を、また各種フォームにformControlnameの名称を設定します。テンプレート上ではキャメルケースで記述します。
<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('フォーム名')を用いて明示的に取得する方法があります。
プロパティから取得
//エリアから該当する都市を絞り込み
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から明示的に取得
//フリーワード検索
searchWord(){
const search_word = this.searchForm.get('search_word').value
const hit = this.cities.filter((item,key)=>{
return item.name.includes(search_word) && search_word
})
this.hit_cities_by_word.set(hit)
}
※submitによるフォーム転送は演習8で実践します
フォームのリセット
フォームの値をリセットしたい場合はreset()で消去します。引数の中にオブジェクトを指定することで、特定フォームを明示的に削除することもできます。
clear(){
this.searchForm.reset() //引数なしはグループの一括削除
}
カスタムパイプ
フォームやテンプレート上の値を加工したい場合は、Pipeを用いる場合もあります。RxJSオペレータのパイプと名称が紛らわしいので、カスタムパイプと呼ぶことが多いです。
今回は、入力文字の変換に対し、カスタムパイプで処理しています。
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アプリを作っていきます(内容は別ページの記事をアレンジしたものです)
main.tsを設定する
スタンドアロンコンポーネントでは前述した通りapp.module.tsが不要です。そしてmain.tsが重要な役割を担うことになり、ここにルーティングも制御していきます。また任意のルーティングファイルを作っておけば、簡単に接続したいアプリケーションを切り替えられるので、分業も非常に楽になります。
それから今回はサービスも全体に分配したいので、main.tsから設定しておくといいでしょう。このAppComponentがapp.comonent.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という独立ファイルとなっているので、よりルーティング設定をしやすくなっています。
//ルーティングしたいコンポーネント
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によってルーティングされます。また、今回もここでサービスファイルをインポートとしておきます。
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:CartService}],
provideRouter(routes)]
};
次からはプロジェクトにTodoアプリのファイルを作成していきますが、その前にもうひとつ準備をしておきましょう。
ライブラリで制御する
スタンドアロンコンポーネントはNgModuleが不要となっていますが、逆に、SPA作動に必要なモジュールだけをインポートだけして、ライブラリとして各種コンポーネントに送り出すこともできます。その際にインポート先のコンポーネントで使用したいモジュールを必ずexportsで記述してください。これを忘れると外部のコンポーネントで各種モジュールを使用できません。
※各種コンポーネントをライブラリに記述することも可能ですが、従属関係が曖昧になるので今回はここにコンポーネントを設定しません。
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に紐づいているのでセレクタ名指定は必須です。
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が必要となります(これをインポートしておかないと画面遷移できません)。
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の型定義はバージョンで随時変わっています。
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メソッド上で処理できます。そして、サービス上で登録、修正、削除の各種処理を実行することで、更新後のデータを検知し、それを購読できるようになっています。
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コンポーネントの子コンポーネントなので、セレクタ指定が必須となります。
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子コンポーネント -->
<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メソッドを使用するといいでしょう。
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の新規登録
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)で記述するようになっています。注意しましょう。
<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プロパティに格納されます。あとはこれを制御用のサービスファイルに送るだけです。
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となります。
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)
}
//転送されたデータ
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だけで済みます。
<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>
詳細画面(属性ディレクティブとカスタムパイプ)
詳細画面制御のコンポーネントはフォーム制御は不要なので、記述もシンプルです。ただし、ここでは属性ディレクティブとカスタムパイプを使用しているのですが、それぞれスタンドアロン宣言が必要になります。
そして、これらも同様にimportするだけで使用可能になります。
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]を使用しています。
<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>
ディレクティブでスタイルを制御する
ディレクティブは属性を制御することもできます。今回はHostBindingを利用し、それによってクラス属性にバインドしています。
import { Directive,HostBinding } from '@angular/core';
@Directive({
selector: '[bs_table]', //タグに付与するプロパティの名称
standalone: true
})
export class TableDirective {
@HostBinding("class") //class属性にバインド
elementClass = "table table-bordered border-primary" //使用したいBootstrapのプロパティ
}
ディレクティブ作成のコマンド
ディレクティブを使用するときも同様にstandalone宣言が必要です。dはディレクティブの省略形です。また--flatを付与しないと余分なdirectivesフォルダが作成されることになります。
# cd directives
# ng g d 任意のディレクティブ名 --flat --standalone
カスタムパイプを使用する
今回はカスタムパイプも使用しています。ここではテキストエリアに入力された値に対し、改行コードをbrタグに変換する処理をカスタムパイプ化しています。
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'setbr',
standalone: true
})
export class SetBrPipe implements PipeTransform {
transform( str : string){
return str.replace(/\r?\n/g, '<br>') //テキストエリアの改行コードをbrタグに変換
}
}
カスタムパイプ作成のコマンド
カスタムパイプを作成するときも同様にstandalone宣言が必要です。pはカスタムパイプの省略形です。また--flatを付与しないと、余分なpipesフォルダが作成されることになります。
# cd pipes
# ng g p 任意のパイプ名 --flat --standalone
属性ディレクティブやカスタムパイプをライブラリに組み込む
前述したようにライブラリはディレクティブ、パイプ類も組み込むことができるので、利用頻度の高いディレクティブやパイプは一元管理した方がいい場合もあります。
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のような遅延ロード(リンクされた時だけ読み込みを行うローディング方法)もできます。それには以下のように記述するだけです。コールバック関数を記述するだけでも大丈夫です。修正画面と詳細画面の制御コンポーネントを遅延ロードに書き換えてみました。
loadComponent: ()=> コンポーネントを呼び出す式またはコールバック関数
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'},
]
一般的には以下のように式を記述することが多いですが、これだと使用コンポーネントが散逸してしまって、個人的に分かりづらいと思ったからです。
loadComponent: import('../todo-views/detail-todo.component').then(c => c.DetailTodoComponent),
※loadChildrenを用いるとルーティングファイルごと子コンポーネント化させて、遅延ロードすることもできたりします。その際はパスが一致していることが条件となります。
effect
signalにはcomputedに対して、effectというものもあります。これはconstructor上で用いる
もので、VueのライフサイクルフックonMountedのように、起動時に初期値や体裁を設定したい時に用いたりすると効果的です(起動後も監視された値に反応します)。また、effectは戻り値を設定できません。
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 ); //件数の設定
}
}