前記事の続編をそれぞれのフレームワーク毎に分類し、時流の変化を追いやすくしたものです。
今回は、前回で補完できなかった、コンポーネント周りのもっと突っ込んだ操作を含めた解説ができたらと思います。また、前回まではAngularの具体的なモジュールやコンポーネントの具体的な働きは見ていませんでしたが、実践的な力を身に付けていくために、その辺りも掘り下げられたらと思っています。
動作検証を兼ねた記事の内容は現時点でAngular12時点のもので、情報はある程度汎用性を持たせるようにしています。Angular13からはレンダリングエンジンの高速化(Ivy採用)とリアクティブフォームの試験実装、Angular14からはリアクティブフォームの正規実装に加えてスタンドアロンコンポーネントという画期的な機能(従来のモジュールが不要になる)が搭載されたので、第8章ではそれに特化した記事を作成しています。
※今回学習する内容は
- 5章 コンポーネント制御(電卓)
- 6章 ルーティング制御(買い物かご)
- 7章 スタイル制御(写真検索システム)
- 8章 スタンドアロンコンポーネントとリアクティブフォーム(Todoアプリ) ※Angular14使用
となります。
import構文の共通ルール
その前に、JavaScriptのimport構文のルールを把握しておく必要があるはずです。
- A:import Fuga from './Hoge'
- B:import {Fuga} from './Hoge'
この2つの違いを把握しておかないと後々エラーを多発することになります。AはHogeという外部ファイルをFugaという名称で定義するという意味です(敢えて別名にする必要はないので、普通はインポートファイル名と同一です)。対してBはHogeという外部ファイルの中から定義されたFugaというオブジェクトを利用するという意味です。なので、次の例でいえば
import React,{useState,useEffect,useContext} from 'React'
これはReactという外部ファイルをReactという名称で利用する、かつuseState、useEffect、useContextというオブジェクトを利用するという命令になります。
演習5 コンポーネント制御(電卓)
ここでは子コンポーネントを用いて、親コンポーネントに簡易な電卓を作成していきます。それにはプッシュキーとそれを押下したタイミングでの制御が必要となりますが、その際にプッシュキーの部品を子コンポーネント化することで、効率よくシステムを構築することができます。
その前に、なぜ親子のコンポーネントに分割、階層化するかですが、結論からいえば冗長な記述を回避するためです。
◆Angularでコンポーネント制御する
Angularでコンポーネント制御する場合は、ちょっと複雑です。
app-rootによって制御されたAngular.component.tsファイルから親コンポーネントを紐付けないといけません。そして、そこから親コンポーネントから子コンポーネント、子コンポーネントから親コンポーネントへの値のやりとりを行うことになります。
また、子コンポーネントに値を定義することは一応、可能ですが、結局は親から子、子から親への同期をとるために双方の処理が必要になってくるので、親コンポーネントに値を定義しておくのが望ましいでしょう。
◆アプリケーションの作成
Angularは基本、ngというコマンドを用いてアプリケーションやモジュール、コンポーネントの作成を行っていきます。したがって、Angularでアプリケーションを作成したい場合は、以下のコマンドだけで作成可能です。
html # ng new 任意のアプリケーション名
※Angularのアプリケーションは容量があるので、VueやReactの感覚で複数プロジェクトを作成しない方が無難です。もし、学習用のテスト環境を作成するなら、単一のアプリケーションに対し、後述の親コンポーネントやモジュールを随時変更していくといいでしょう。
◆モジュールを呼び出し
Angular-cliはこのような流れで、コンポーネントを呼び出しています。スクリプトからモジュールを呼び出し、そのモジュールからコンポーネントを呼び出すという動きなので、次章のルーティング制御など任意のモジュールを作成して制御したい場合はここを調整しますが、基本はappモジュールのままの方がいいでしょう。
main.ts → app.module.ts → app.component.ts
したがって、任意のモジュールファイル(app.module.ts)を紐づけたい場合は、main.tsを以下のように書き換えます。
import { AppModule } from './app/app.module'; //ここで任意のモジュールファイルを呼び出す
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule) //ここのモジュールを書き換える
.catch(err => console.log(err));
※任意のモジュールを作成するngコマンドは以下の通りです。
app # ng g module hoge --flat --module=hoge
◆モジュールにコンポーネントを紐づけ
Angularでコンポーネントを使用するためには、モジュール制御ファイル(app.module.ts)に**使用するコンポーネントを紐づけておく必要があります。なお、テンプレートはコンポーネントファイルから呼び出します。
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CalcComponent } from './calc.component'; //親コンポーネントのアーキテクチャー
import { SetkeyComponent } from './setkey.component'; //子コンポーネントのアーキテクチャー
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
@NgModule({
declarations: [
CalcComponent, //使用するコンポーネントを宣言しておく
SetkeyComponent, //使用するコンポーネントを宣言しておく
],
imports: [
BrowserModule,
NgbModule,
FormsModule,
FontAwesomeModule,
],
providers: [],
bootstrap: [ CalcComponent, SetkeyComponent ] //bootstrapも設定しておく
})
export class AppModule { }
また、任意のコンポーネントファイルを作成するコマンドが以下になります(gはgenertateの省略コマンド)。
app # ng g component hoge
このコマンドを実行すると、新規にディレクトリが作成され、以下の4ファイルがワンセットで構築されます。
■Hoge
- hoge.component.css //CSS制御用
- hoge.component.html //テンプレートファイル用
- hoge.component.spec.ts
- hoge.component.ts //コンポーネント制御用
もし、同一ディレクトリに作成したい場合は以下のコマンドを付記します。こうすれば、同一ディレクトリ直下に4ファイルが生成されます(module指定は、単一アプリケーションにモジュールが複数存在している場合、必須となります)。
app # ng g component hoge --flat --module=hoge.module
◆親コンポーネントとapp-root
親コンポーネントファイルと紐づかせたテンプレートファイルは以下のようになっています。そして、よく観察してみればVueのOptions APIと似たような構造になっているのがわかると思います。
※また、親コンポーネントのセレクタはapp-rootのままにしておいて下さい(app-rootテンプレートはsrcディレクトリ直下のindex.htmlに紐づいており、ここを親コンポーネントとして呼び出しているようです)。こうやって紐づけておかないと、app-rootテンプレートが見つからないという警告が表示されます。
stack overflowより
テンプレートの呼び出しは以下の部分となります。
@component({
selector: 'app-root', //親テンプレートの場合はapp-rootとしておく
templateUrl: './calc.component.html', //テンプレートファイルの呼び出し
styleUrls: ['./calc.component.css'] //テンプレートに適用するcss
})
import { Component, OnInit } from '@angular/core';
import { Data } from '../class/calc'; // 追加
let data= {
lnum: null,
cnum: 0 ,
sum: 0 ,
str: '',
sign: '',
}
let vals=[
[['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','+']]
]
@Component({
selector: 'app-root', //indexに紐づいたrootコンポーネント
templateUrl: './calc.component.html', //app-rootに表示させたい親コンポーネント
styleUrls: ['./calc.component.css']
})
export class CalcComponent implements OnInit{
public data = data
public vals = vals
constructor() {}
ngOnInit() {}
//子コンポーネントから値の受け取り
onReceive(data){
this.data = data
}
}
◆テンプレートファイル
対して親コンポーネントファイルに紐づいているテンプレートファイルはこのようになっており、*ngForディレクティブによって子コンポーネントapp-setkeyをループ制御によってプッシュキーを作成することになります。
<div>
<div *ngFor="let val of vals">
<app-setkey [dataFromParent]='data' [vByP]='v' (ev)="onReceive($event)" *ngFor="let v of val"></app-setkey>
</div>
<div>
<p>打ち込んだ文字:{{data.str}}</p>
<p>合計:{{data.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;
sum: number;
str: string;
sign: string;
}
◆子コンポーネントの制御
子コンポーネントはテンプレートを外部呼び出しではなく、敢えてtemplateプロパティで記述してみました。その際には`<任意のhtml>`とバッククォートで囲むようにしましょう。
また、app-setkeyテンプレートが先程の親コンポーネントのテンプレートに記述したコンポーネントになります。
import { Component, OnInit,Input,Output,EventEmitter } from '@angular/core';
@Component({
selector: 'app-setkey', //親のテンプレートで定義した子コンポーネントのセレクタ
template:
`<button type="button" [value]="vByP[0]" (click)="getChar(vByP[0],vByP[1])">
{{vByP[1]}}
</button>`
})
export class SetkeyComponent implements OnInit{
@Input() dataFromParent;
@Input() vByP: string;
@Output() ev = new EventEmitter<any>();
public data = []
constructor(){}
ngOnInit(){}
getChar(chr: string,strtmp:string){
let data = this.dataFromParent
let lnum = data.lnum
let cnum = data.cnum
let sum = data.sum
let sign = data.sign
let str = data.str + strtmp
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 = ''
}
data.lnum = lnum
data.cnum = cnum
data.sum = sum
data.str = str
data.sign = sign
this.ev.emit(data) //子コンポーネントからの転送処理
}
calc(mode: string,lnum: number,cnum: number){
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; //dataFromParentは親コンポーネントのテンプレートに記載したプロパティバインディングの値
@Input() vByP: string; //vByPはngForディレクティブでループさせたvals内の値
◆子コンポーネントから親コンポーネントへ値を受け渡す
子コンポーネントから親コンポーネントへ値を受け渡すには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メソッドで受け取ることができます。
◆注意点
なお、Inputデコーダですが、app-rootのapp.componentに設定した親コンポーネントしか制御できません。
<app-calc></app-calc>
うかつにindex.htmlから制御しようとしても、全く動きませんので注意してください(自分はこれで1日費やしました)
演習5のまとめ
このようにコンポーネントの分割の目的は冗長なエレメントを集約し、テンプレート化することで無駄な記述を回避するためです。また、それにあたって変数の受け渡しの処理が必要になります。
要約するとこうなります。
- 事前に使用する全コンポーネントをモジュールに追記する。
- 親コンポーネントのセレクタにはapp-rootを定義しておく。
- 変数定義はどちらのコンポーネントに定義しても動くが、なるべく親コンポーネントに記述するのが望ましい。
- 親から子を呼び出す場合は、moduleファイルに必要なコンポーネントを全部記述する。
- 親から子への値の受け渡しは、親コンポーネントのテンプレートにプロパティバインディングを設定し、子コンポーネントにInputデコーダを用い、値を受け取る。
- 子から親への値の受け渡しは、Outputデコーダを設定し、受け渡したい変数にEventEmitterのemitメソッドを用い、親コンポーネントのテンプレートに設定したイベントバインディングを用いて、親コンポーネントから任意のメソッドで受け取る。
演習6 ルーティング(買い物かご)
今までは親子コンポーネントの説明はしていますが、あくまで単一のページのみの制御でした。ですが、世の中のWEBページやアプリケーションは複数のページを自在に行き来できます。それを制御しているのがルーティングという機能です。
フレームワークにおけるルーティングとはフレームワークのように基本となるリンク元のコンポーネントがあって、パスの指定によってコンポーネントを切替できるというものです。もっと専門的な言葉を用いれば、SPA(SINGLE PAGE APPLICATION)というものに対し、URIを振り分けて各種コンポーネントファイルによって紐付けられたインターフェースを表示するというものです。
Angularはルーティング機能が標準実装されているので、モジュールにインポート構文を追記します。フレームワークにリンクタグとリンク先を表示するタグが存在し、toというパス記述用のプロパティが存在しています。また、それぞれにデータ転送用、データ受取用のライブラリが用意されており、それらを受け渡しと受け取っていきます。
◆ Angularでルーティング制御する
Angularのルーティングは、テンプレートに記述するのはリンクパスのみで、あとは全部モジュールに記述するようになっています。
Angularの場合でのファイル構造は以下のようになっています(最低限、処理に必要なものだけを抜粋)。
■app
- ■main-navigation
- main-navigation.component.ts //子コンポーネント(ナビ部分のアーキテクチャー)
- main-navigation.component.html //テンプレート(ここにルーティング用のリンクを記述)
- ■pages
- products.component.ts //商品一覧(ここに商品を入れるボタンがある)
- products.component.html //商品一覧のテンプレート
- cart.component.ts //買い物かご(ここに商品差し戻し、購入のボタンがある)
- cart.component.html / /買い物かご(ここに商品差し戻し、購入のボタンがある)
- app.component.ts //親コンポーネント
- app.component.html //親コンポーネント
- app.service.ts //共通の処理制御用(サービス)
- app.module.ts //ルーティング制御用
- shop-context.ts //商品情報の格納
※■はディレクトリ
では、今までと同じように商品ページのproductsと買い物かごページのcartを使って、順番に見ていきます。
大事なポイントはルーティングを制御するために
import {RouterModule,Routes} from '@angular/router';
このルーターモジュールを呼び出しておきましょう。今度は、Routesオブジェクトを用いて、ルーティングを定義していきます。
const Hoge: Routes = []
このようにオブジェクトを定義し、それぞれ採用するルーティング先に対し、pathプロパティ(親ディレクトリの場合は''となります)、そして使用コンポーネントを定義するcomponentプロパティを記述します。
const Route: Routes = [
{path: '',component: ProductsComponent},
{path: '/cart',component: CartComponent},
]
このルーティング設定をモジュールのimportsプロパティにセットしておきます。こうすることで、ルーターにrouteの設定をすぐに適用することができるみたいです。
RouterModule.forRoot(Route)
また、exportsプロパティにもRouterModuleを記述しておきます。
import { BrowserModule } from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {RouterModule,Routes} from '@angular/router'; //ルーティングを制御するためのモジュール
import {AppComponent } from './app.component';
import {MainNavigationComponent } from './main-navigation/main-navigation.component'; //ナビ
import {ProductsComponent} from './pages/products.component'; //商品ページ
import {CartComponent} from './pages/cart.component'; //買い物かごページ
//ルーティングの定義
const Route: Routes = [
{path: '',component: ProductsComponent},
{path: 'pages/cart',component: CartComponent},
]
@NgModule({
declarations:[
AppComponent,
MainNavigationComponent,
ProductsComponent,
CartComponent,
],
imports:[
BrowserModule,
RouterModule.forRoot(Route) //この引数に先程定義したルーティング名を代入
],
exports:[RouterModule], //ルーター設定に必要
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
◆テンプレートの記述
テンプレートは以下のようになっており、Vueのリンクタグとブラウザタグとよく似ています。aタグに記述されたrouterLinkプロパティにパスを記述してリンクを制御し、そのルーティング先がrouter-outletタグで制御されます。
<div class="main-navigation">
<ul>
<li><a routerLink="/">Products</a></li>
<li><a routerLink="./cart">Cart({{storage.cart.length}})</a></li>
</ul>
</div>
<!-- ルーティング先で反映される領域 -->
<router-outlet></router-outlet>
◆SPAでデータをやりとりする(RxJS)
Angularで親子関係にないコンポーネント間同士でデータをやりとりする場合は、AppService
という共通処理用のサービスから値をインポートすると一番効率が良さそうです。共通処理用のサービスはapp.service.tsというファイルで定義しています。
そして、ここでAngular敬遠の遠因ともいわれていたRxJSが必須となります。RxJSは非常に複雑で、これ一つで書籍が出ているぐらい奥が深いのですが、簡潔にいえば、Angularにおいてネットワーク内で自在にデータをやりとりするためのライブラリです。これを用いることで、ルーティング先の全コンポーネントでデータをやりとりすることが円滑になります。
※RxJSとはReactive extensions for JavaScriptの略称です。そしてこのRxJSの部分が、一番Angularで頻繁に仕様が更新され、記述が簡略(シンタックスシュガー)化してきています。とりわけ、Angular6以降はだいぶ書き方がスリム化しており、簡単にネットワーク監視・更新用のプロトタイプを作成できるので、今までのように敬遠してしまうことも少なくなるのではないかと思っています。
◆一定のタイミングでデータを更新する(BehaviorSubject)
まずは比較的理屈が単純で、定期的なタイミングで更新したデータを転送させるBehaviorSubjectを用います。BehaviorSubjectを使用すると効率的なのは、前述したように一定のタイミングで更新処理を行いたい場合です。流れとしては商品個数を格納する変数cntを準備しておき、それを
public sub = new BehaviorSubject(cnt) 《cntは初期設定のデータ》
として変数sub(名前は任意)にプロトタイプを作成しておきます。そのプロトタイプsubに対し、共通で呼び出した変数内にnextという、ルーティング情報を送信するメソッドを活用します。
this.sub.next(cnt2) 《cnt2は更新後のデータ》
このようにプロトタイプに紐づいたnextメソッドに同期を取りたい更新された値を代入することで、後は自動で同期を取ってくれるようになります。なお、nextは同期データの最終更新処理を行うためのメソッドで、これで処理されたデータは更新後情報として、各コンポーネントを経由してテンプレート上に展開することができます。
※Injectableメソッドはこのservice.tsは他のどのコンポーネントファイルでも使用可能であることを指示したもので、provider:rootと指定することで、どのアプリケーションからも利用可能だということを意味しています。要は、これを記述することで、モジュール内に紐づいたどのファイルにも自在にデータを受け渡し、同期を取ることが可能になるわけです。
import { Injectable, OnInit } from '@angular/core'
import { BehaviorSubject,Observable } from 'rxjs'
import Storage,{Product} from './shop-context'
const STORAGE_KEY = "storage_key"
export type CntT = {
"cart": number,
"article": number,
}
const cnt = <CntT>{"cart":0,"article":0} //ナビに表示する個数
@Injectable({
providedIn: 'root'
})
//export class AppService implements OnInit{
export class AppService{
public state =[]
public storage = []
public sub = new BehaviorSubject<any>(cnt) //同期を取りたいデータの設定
su2b: Observable<any>
constructor(){
const storage = Storage()
this.sub2 = new Observable((ob)=>{
ob.next(storage)
})
}
//買い物かごの調整
addProductToCart(product:Product,state){
let cartIndex = null
const stat = state
//買い物かごの調整
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 //加算対象のインデックス
}
stat.cart = updatedCart
//商品在庫の調整
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
stat.products = updatedProducts
//合計金額の調整
const total = stat.total
const sum = this.getSummary(updatedCart,total)
stat.total = sum
state = {...state,stat}
this.state = state
}
//カートから商品の返却
removeProductFromCart(productId: string,state){
const stat = state
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
state = {...state,stat}
this.state = state
}
//購入手続き
buyIt(articles,state){
const stat = state
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
state = {...state,stat}
this.state = state
}
getSummary(cart,total){
const sum = cart.reduce((total,{price = 0,quantity})=> total + price * quantity,0)
return sum
}
cartItemNumber = (ctx)=>{
return ctx.cart.reduce((count,curItem)=>{
return count + curItem.quantity //買い物かごの個数
},0)
}
reducer(mode,selected,storage){
let value = []
switch(mode){
case "add": this.addProductToCart(selected,storage)
break
case "remove": this.removeProductFromCart(selected,storage)
break
case "buy": this.buyIt(selected,storage)
break
}
let cnt_tmp = cnt
cnt_tmp.cart = this.cartItemNumber(storage)
this.sub.next(cnt) //処理を更新する
localStorage.setItem(STORAGE_KEY,JSON.stringify(this.state))
}
}
※Angularでも最後の変数を返す部分だけ書き加えるだけで、Vue、Reactで使用したメソッドを使い回すことができます(前記事では、敢えて分割代入を使いませんでしたが、Angularで分割代入しても何も支障ありません)。
◆リアルタイムでデータを更新する(Observable)
RxJSには同じぐらい使用頻度が高いものとしてObservableというものもあり、商品情報はこちらで制御しています。先程のBehaviorSubjectはデータ更新を反映させるものでしたが、今度のObservableは観察、監視という和訳の通り、データの動きを監視するものです。したがって、最初から設定したデータの値をリアルタイムで監視させることで、変更を検知することができます。なので、reducerメソッドのnext設定も不要なので、不定期のタイミングでデータ更新を行いたい場合は便利です。
ただし、Observableでデータを監視する場合はconstructor()に設定してください。constructorについては第7章で触れていますが、AngularでのDOM生成前に動作するものなので、事前に設定しないと、データ類が処理できないというエラーが発生することになります。
※テンプレートへの記述は同じです
import { Observable } from 'rxjs' //Observable使用を明記する
export class AppService{
public state =<any>[]
sub2: Observable<any> //各種コンポーネントへの展開用
constructor(){
const storage = Storage()
const this.sub2 = new Observable((ob)=>{
ob.next(storage) //同期を取りたいデータを送信
})
}
/*略*/
}
※ここで注意する必要があるのはobは監視用のオブジェクト、sub2は描写用のオブジェクトです。したがって、上記のAppService.tsでデータを反映させるnextメソッドを用いる場合は、this.ob.nextとなります(BehaviorSubjectは更新情報の転送のみ)
ちなみに、subscribeで受け取ることを購読(subscribeをそのまま和訳したものらしいです)と言います。
◆コンポーネント側の記述
一方、購読データを受け取るコンポーネント側の制御は以下のようになっています。コンストラクタに使用するコンポーネントを定義させておき、ngOnInit内から先程のメソッドから変数storageを呼び出すことで、モジュールに紐付けられたルーティング内でデータを共有することができます。
this.appService.sub.subscribe(v => this.storage = v)
ここが一番の肝で、appServiceは先程の共通ファイル、subは同期処理用のプロトタイプになります。そこからsubscibeメソッドで展開した変数vをthis.storageとしてテンプレートに返す流れとなります。また、postcall関数が各種テンプレートに記載された買い物かごに入れる、買い物かごから除去する、購入するといった各処理を行う共通メソッドreducerへの橋渡し用のメソッドとなります。
import { Component, OnInit } from '@angular/core';
import { AppService } from "../appservice" //共通制御用のメソッド
@Component({
templateUrl: './products.component.html',
styleUrls: ['./products.component.css']
})
export class ProductsComponent implements OnInit {
public storage: []
constructor(private appService: AppService) {}
ngOnInit(){
this.appService.sub2.subscribe(v => this.storage = v) //テンプレートに変数を返す
}
//app.serviceに記述されたappService.reducerへの橋渡し
postcall(mode,sel,storage){
this.appService.reducer(mode,sel,storage) //処理振り分け用のメソッド
}
}
◆テンプレートの記述
テンプレートには共通処理用のイベントバインディングpostcallを記述します。postcallというメソッドがAppServiceコンポーネントに記された処理分岐用メソッドreducerへの橋渡しとなります。
※*ngモジュールはモジュールファイルで事前に定義しておかないと起動しないので注意。
<section class="products">
<ul>
<li *ngFor="let product of storage.products; index as i">
<div >
<strong>{{ product.title }}</strong> - {{ product.price}}円
<ng-template *ngIf="product.stock > 0; then rest"></ng-template>
<ng-template #rest>【残り{{product.stock}}個】 </ng-template>
</div>
<div>
<button (click)="postcall('add',product,storage)">かごに入れる</button>
</div>
</li>
</ul>
</section>
◆更新されたデータを加工する(ofとpipe)
では、今までは更新データをそのままテンプレートに受け渡すだけでしたが、それらを加工してテンプレートに渡すこともできます。それがofやpipeという機能です。では、このpipeを使って、買い物かごの合計値を税込料金と内税料金に分けてみます。
import { of,pipe } from 'rxjs' //追記
import { map } from 'rxjs/operators' //追記
/*中略*/
export class CartComponent{
storage:Storage
totaltax: number
constructor(private appService:AppService)
{
const totaltax = this.totaltax //事前に代入する変数を取得
this.appService.sub2.subscribe(v => this.storage = v)//購読されたデータ(サービスからの更新データ)
//ofはループ制御のメソッド
of(this.storage.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など本来汎用データ上に持つべきではない変数をローカル上で制御することが可能になります。
<section class="cart" >
<p *ngIf="storage.cart.length <= 0">No Item in the Cart!</p>
<ul>
<li *ngFor="let cartitem of storage.cart ; index as i">
<div>
<strong>{{ cartitem.title }}</strong> - {{ cartitem.price }}円
({{ cartitem.quantity }})
</div>
<div>
<button (click)="postcall('remove',cartitem.id,storage)">買い物かごから戻す(1個ずつ)</button>
</div>
</li>
</ul>
<h3>合計: {{ totaltax }}円(内税{{ storage.total }}円)</h3>
<h3>所持金: {{storage.money}}円</h3>
<button (click)="postcall('buy',storage.articles,storage)"
*ngIf="storage.cart.length >0 && storage.money - storage.total >= 0">購入</button>
</section>
ちなみに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です。
参考にした開発系ブログ
◆※モジュールからデータを取得する方法
モジュールからデータを受け渡す方法もあります。先程の変数データは任意のサービスからの転送でしたが、ルーティングからのデータ受け取りはActivatedRouteオブジェクトを用います。
まずはモジュールに転送したいdataを記述します。
const Route: Routes = [
{path: '',component: ProductsComponent,data{title: "Angularでルーティング"}},
ActivatedRouteオブジェクトを呼び出し、それをコンストラクタで定義してください。ただし、モジュールのdataプロパティから取得する場合はsubscribeメソッドではなく、snapshotメソッドが使える(Angular7以降)ので手軽にデータを取得できます。
import { Component, OnInit } from '@angular/core';
import {ActivatedRoute} from '@angular/router' //ActivatedRouteオブジェクトを使用
import { AppService } from "../appservice"
@Component({
templateUrl: './products.component.html',
styleUrls: ['./products.component.css']
})
export class ProductsComponent implements OnInit {
public storage: []
public title: ''
constructor(private route: ActivatedRoute,private appService: AppService) {} //コンストラクタに定義しておく
ngOnInit(){
this.title = this.route.snapshot.data.title //モジュールから受け渡されたデータ(title)
this.appService.sub.subscribe(v => this.storage = v) //app.serviceから受け渡されたデータ(同期処理用のプロトタイプsub)
}
postcall(mode,sel,storage){
this.appService.reducer(mode,sel,storage)
}
}
あとはテンプレート内に{{title}}と代入すれば、そこにモジュールから受け渡された値が代入されます。
◆詳細ページを表示(パラメータの送受信)
このモジュールからのパラメータを受け取り方を履修したところで、任意のパラメータを受け渡し、受け取りしてみます。このAngularの場合もテンプレート上にパラメータを埋め込む場合は、そのままURLに送り込めないので、やはりイベント制御からパラメータを埋め込む作業が必要になり、AngularではRouterオブジェクトのnavigateメソッドが必須となります。
◆動的なパラメータを受け渡す
前述したとおり、toプロパティに変数を埋め込んでもうまくいきませんので、クリックイベントgetIdにパラメータを送信するようにします。
<div >
<a routerLink="/" (click)="getId(product.id)"><strong>{{ product.title }}</strong></a>
- {{ product.price}}円
<ng-template *ngIf="product.stock > 0; then rest"></ng-template>
<ng-template #rest>【残り{{product.stock}}個】 </ng-template>
</div>
引き続いてコンポーネント側です。navigateメソッドの使い方は単純で、いろいろなパラメータを送れるようですが、シンプルにパラメータを埋め込みたい場合は、moduleに設定したリンク先と同じパスを埋め込んでおくだけです。
router.navigate([埋め込みたいパス])
import {ActivatedRoute,Router} from '@angular/router' //Routerを追記する
export class ProductsComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private router: Router, //これを追記する
private appService: AppService
) {}
/*略*/
getId(id:string){
id = id.replace("p","")
this.router.navigate([`detail/${id}`]) //routerからnavigateを呼び出す
}
/*略*/
}
◆モジュールに受け渡されたパラメータ
モジュールに受け渡されたパラメータ:idとなっており、idという値で取得します。
import {DetailComponent} from './pages/detail.component' //追記
const Route: Routes = [
{path: '',component: ProductsComponent},
{path: 'detail/:id',component: DetailComponent}, //追記
{path: 'cart',component: CartComponent,pathMatch: 'full'},
]
@NgModule({
declarations:[
AppComponent,
MainNavigationComponent,
ProductsComponent,
CartComponent,
DetailComponent, //追記
],
/*略*/
})
◆詳細ページで受け取る
詳細ページで受け取るには、前述したsnapshotメソッドが役立ちます。今度受け取る側はrouterではなくrouteなので注意(VueのuseRouteとuseRouterの関係と同じです)。
import { Component, OnInit } from '@angular/core';
import {ActivatedRoute} from '@angular/router' //ActivatedRouteオブジェクトを使用
import { AppService } from "../appservice"
@Component({
selector: 'app-detail',
templateUrl: './detail.component.html',
styleUrls: ['./detail.component.css']
})
export class DetailComponent implements OnInit {
public storage:any = []
public item: any = []
constructor(private route: ActivatedRoute,private appService: AppService) { }
ngOnInit(){
const id = this.route.snapshot.params.id //先程転送したパラメータ
const selid = `p${id}` //検索条件に引っかかるように値を合わせる
this.appService.sub2.subscribe(v => this.storage = v) //サービスから受け取ったデータ
const item = this.storage.products.find((item)=>item.id === selid) //一致するアイテムを取得
this.item = item //テンプレートに返す
}
}
◆クエリパラメータを受け渡す場合(修正済)
では、Angularでもクエリからデータを受け渡してみます。Angularのnavigateは?
という文字が使えないので、以下の記述が必須となります。
router.navigate([遷移先のパスルート,{queryParams:{パラメータ名:パラメータの値}})
getId(id:string){
this.router.navigate(["detail",{queryParams:{'id':id}})
}
モジュールはパスだけを記述しておきます。
const Route: Routes = [
{path: '',component: ProductsComponent},
{path: 'detail',component: DetailComponent},
受け取り側のコンポーネントは以下のように記述するのが一番手軽です。以下の記述で、一発で任意のクエリパラメータを取得できます。
route.snapshot.queryParams.任意のパラメータ
ngOnInit(){
const selid = this.route.snapshot.queryParams.id
this.appService.sub2.subscribe(v => this.storage = v)
const item = this.storage.products.find((item)=>item.id === selid)
this.item = item
}
●戻るボタンを実装する
ルーティング先から前ページに戻る場合はLocationオブジェクトのbackメソッドを用い、イベントバインディングで紐づけます。
import { Component, OnInit } from '@angular/core';
import { Location } from '@angular/common'; //追記する
import {ActivatedRoute} from '@angular/router' //ActivatedRouteオブジェクトを使用
import { AppService } from "../appservice"
@Component({
selector: 'app-detail',
templateUrl: './detail.component.html',
styleUrls: ['./detail.component.css']
})
export class DetailComponent implements OnInit {
public storage:any = []
public item: any = []
constructor(
private route: ActivatedRoute,
private appService: AppService,
private location: Location, //追記する
) { }
/*略*/
btBackTo(){
this.location.back() //戻る制御
}
}
<ul>
<li>{{item.title}}</li>
</ul>
<button (click)="btBackTo()">戻る</button>
演習6のまとめ
■ルーティングのまとめ
- リンクは<a routerLink="パス">でリンク元を、<router-outlet>でリンク先を表示する。
- ルーティング情報はモジュールに記述する。
- 兄弟コンポーネントへのデータのやりとりはサービスで制御し、その際にRxJSのBehaviorSubject(定期更新)かObservable(リアルタイム更新)を用いて、nextでデータ更新を送信する。更新データは前述のオブジェクトからsubscribeで受け取る。
- パラメータを送信するにはRouterオブジェクトのnavigateを使用し、パラメータを受け取るにはActiveRouteオブジェクトのsnapshotから受け取る。
- 一つ前の画面に戻る場合はangular/coreライブラリのLocationオブジェクトを使用し、backメソッドを使用する。イベントはイベントバインディングで任意のメソッドを紐づける。
演習7 スタイル制御(写真検索システム)
JSフレームワークの魅力はスタイル属性もリアルタイムに制御できることです。そこで、Angularで先程とは大きく書き直した写真検索システムにfont-awesomeのアイコンを使って気に入った画像を「いいね!」(ハートアイコンを赤に着色)できるようにしてみました。
なお、font-awesomeを使用する場合は、予めプロジェクトにインストールしておく必要があります。
◆Angularでスタイルを制御する
では、次はAngularで制御してみますが、Angularでのクラス制御は非常に簡単です。方法は色々ありますが、一番よく使われるやり方がプロパティバインディング
を利用した方法で、styleプロパティにメンバを代入させることで、その値を適用させることができます。
<ul class="ulDatas" >
<li class="liData" *ngFor="let item of hits">
<label class="lbHit">{{ item.name }}
<label (click)="colorSet(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>
また、clickイベントのcolorSetの中身はこのように制御します。
const active = '' //アイコン塗りつぶし対象のカラー
/*中略*/
export class WorldComponent{
/*中略*/
public active = "red"
public selecteditem = []
//スタイル制御
colorSet(id: number){
let cities = [...this.cities]
let active = this.active
let selecteditem = cities.find(function(item,idx){
return item.id == id
})
cities.filter(function(item,idx){
if(item.id == id){
if(selecteditem.act !== active){
selecteditem.act = active
}else{
selecteditem.act = ''
}
}
})
this.cities = cities
}
}
第4章で説明したとおり、Angularはオブジェクトの再生成が行われないので、通常のJSと同じ代入方法で同期処理が可能ですが、理屈に慣れれば分割代入の方が記述が楽です。
◆AngularのconstructorとngOnInitについて
説明が遅れましたが、constructorとngOnInitについて軽く説明しておきます。
constructorはTypescriptに実装されているメソッドで、Angularの読込前に処理を行うことができるメソッドで、DOM生成前に事前処理を実行できます。
対するngOnitはライフサイクルフックの一種で、DOM生成後に初期化処理を行うメソッドです。ここではjsonから取得したstatesとcitiesを格納しておりますが、このngOnInitに記述しておくことで、メソッド読込後すぐにデータ利用できます。
※監視プロパティにあたるもの
Angularでイベントを監視する監視プロパティにあたるものは一般的に存在しないようです。ですが、Angularは他のJSフレームワークと違って、ビューとアーキテクチャでIOのやりとりをしているものなので、メソッドを仕込むだけで似たようなことができます。また、RxJSを使う方法もあるみたいなので、また解説できればと思います。
import { Component,OnInit } from '@angular/core';
import * as lodash from 'lodash';
import { faHeart } from '@fortawesome/free-solid-svg-icons';
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"},
]
const states:any = []
const cities:any = []
const hit_cities:any = []; //選択でヒット
const hit_cities_by_state:any = []; //エリア検索でヒットした都市を絞り込み
const hit_cities_by_word:any = []; //文字検索でヒットした都市を絞り込み
const sel_country:string = '' //選択した国
const sel_state:string = '' //選択したエリア
const word:string = ''; //検索文字
const active:string = '' //アイコン塗りつぶし対象のカラー
@Component({
selector: 'app-world',
templateUrl: './world.component.html',
styleUrls: ['./world.component.css']
})
export class WorldComponent implements OnInit{
public faHeart = faHeart;
public countries = []
public states = []
public cities = []
public sel_country = sel_country
public sel_state = sel_state
public word = ''
public hit_cities = []
public hit_cities_by_state = []
public hit_cities_by_word = []
public active = "red"
public selecteditem = []
constructor(){}
ngOnInit(){
this.countries = countries
this.states = state_json
this.cities = city_json
}
//スタイル制御
colorSet(id: number){
let cities = [...this.cities]
let active = this.active
let selecteditem = cities.find(function(item,idx){
return item.id == id
})
cities.filter(function(item,idx){
if(item.id == id){
if(selecteditem.act !== active){
selecteditem.act = active
}else{
selecteditem.act = ''
}
}
})
this.cities = cities
}
//国から該当するエリアを絞り込み
selectCountry(sel_country){
const states = this.states
const opt_states = states.filter((item,key)=>{ return item.country == sel_country })
this.states = opt_states
}
//エリアから該当する都市を絞り込み
selectState(sel_state){
const hit_cities_by_state = this.cities.filter((item)=>{ return item.state == sel_state })
this.hit_cities_by_state = hit_cities_by_state
this.watcher()
}
//フリーワード検索
searchWord(word){
const hit_cities_by_word = this.cities.filter((item,key)=>{
item = item.name.replace(/[A-Z]/g, function(ch) {return String.fromCharCode(ch.charCodeAt(0) | 32);})
return item.includes(word) && word
})
this.word = word
this.hit_cities_by_word = hit_cities_by_word
this.watcher()
}
//論理積を求める(ただのメソッド)
watcher(){
const hit_cities_by_state = this.hit_cities_by_state
const hit_cities_by_word = this.hit_cities_by_word
const len_state = hit_cities_by_state.length
const len_word = hit_cities_by_word.length
let hits = []
if(len_state > 0 && len_word > 0 ){
hits = lodash.intersection(hit_cities_by_state, hit_cities_by_word)
}else if(len_state > 0){
hits = hit_cities_by_state
}else if(len_word > 0){
hits = hit_cities_by_word
}else{
hits = []
}
this.hit_cities = hits
}
clear(){
this.sel_state = ''
this.sel_country = ''
this.word = ''
this.hit_cities = []
}
}
<label> 国の選択 </label>
<select id="sel1" [(ngModel)]="sel_country"(change)="selectCountry(sel_country)">
<option value=''>-- 国名を選択 --</option>
<option *ngFor="let item of countries" [value]="item.ab">
{{item.name}}
</option>
</select>
<label> エリアの選択</label>
<select id="sel2" [(ngModel)]="sel_state" (change)="selectState(sel_state)" >
<option value=''>-- エリアを選択 --</option>
<option [value]="item.code" *ngFor="let item of states">
{{item.name}}
</option>
</select>
<br/>
<h4>検索文字を入力してください</h4>
<input type="text" id="word" [(ngModel)]="word" (input)="searchWord(word)" />
<button type="button" (click)="clear()">clear</button>
<div>ヒット数:{{ hit_cities.length }}件</div>
<ul class="ulDatas" >
<li class="liData" *ngFor="let item of hit_cities">
<label class="lbHit">{{ item.name }}
<label (click)="colorSet(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>
演習8 スタンドアロンコンポーネントとリアクティブフォーム(Todoアプリ)
◆スタンドアロンコンポーネント
Angular14からはスタンドアロンコンポーネントという、今までのAngularの常識をくつがえすような、React、Vue、新勢力のSvelteなどに対抗しうる画期的な機能が搭載されました。このスタンドアロンコンポーネントを用いれば、様々なメリットがあります。
- モジュールに依存しないので、アプリケーションを大幅に軽量化できる。つまり、小規模プロジェクトにも適したプロジェクトやコンポーネントを作成できる。
- app.module.tsが不要になるので、作業の分担やアプリケーションの切換が簡単になる
- ngModule、FormsModule、BrowserModuleは不要になる。
- また、使用したいモジュールだけをカスタマイズできる
- 任意のコンポーネントの従属関係を明確化できる
- ルーティング制御を簡略化できる
- 従属コンポーネントが存在しない場合はセレクタを省略できる
- ローカル上の変数はダイレクトに返すことができる
いいことずくめです。また、スタンドアロンコンポーネントは任意に使用が可能なので、スタンドアロン化したくない場合は従来のコンポーネントを併存させることもできます。
詳しくは独立記事を参照してください。
ちなみに、このページは以下の技術系サイト(英文)を参考にしています。色々巡回したのですが、欲しい情報をほぼ全部入手できたのはこのページだけでした。
補足(Angular14の起動や更新が遅い場合)
Angular.jsonで起動や更新が遅い場合は以下のようにチューニングするといいようです。Angular12の記事ですが、ng updateを用いてアップデートした場合、12の設定を引き継いでいたようです。
起動速度はそれまでの30秒ぐらいが、3秒程度まで短縮されました。