この記事の目的
の2つの記事を踏まえて、AngularのアプリケーションでAkitaを導入し、単一のStateに依存するComponentを作成する流れのメモ。
流れに沿って実装すれば以下画像のデータフローの状態になるが、各ブロックに細かい説明は書いていない。
実装を直接見たい場合は Sandbox-Frontend/Angular-Architecture-Sample at master · beatdjam/Sandbox-Frontend · GitHub を参照。
環境構築
ブログ用サンプル 環境構築 · beatdjam/Sandbox-Frontend@015f59e · GitHub
Angular CLIインストール
$ npm install -g @angular/cli
Akita CLIインストール
$ npm install @datorama/akita-cli -g
プロジェクトの初期設定
プロジェクト作成
$ ng new <name>
Akita導入
$ npm install @datorama/akita
iniのバージョンが古くて怒られるけどコードジェネレータでの利用のみということで特に対応されていないっぽい
ini 1.3.5 - dependency vulnerability in npm audit · Issue #698 · datorama/akita · GitHub
package.jsonに下記を追記するとAkita CLIで作成されるファイルがAngularプロジェクト用になる(TypeScript、Angularのアノテーションがつくなど)
"akitaCli": { "customFolderName": "true", "template": "angular", "basePath": "./src/app" },
(任意)akita-ngdevtoolsインストール
$ npm i @datorama/akita-ngdevtools --save-dev
Redux DevTools - Chrome ウェブストア でStoreの状態を確認できるようにする
app.module.tsのimportsに AkitaNgDevtools.forRoot()
の記述を追加する
imports: [ environment.production ? [] : AkitaNgDevtools.forRoot(), BrowserModule, BrowserAnimationsModule ],
実装
ユーザー一覧を表示する
ブログ用サンプル 画面表示実装 · beatdjam/Sandbox-Frontend@8c77299 · GitHub
CLIでcomponent作成
$ ng g component
app.component.htmlから作成したcomponentを呼び出す
<app-user-list></app-user-list>
app.module.tsのimportsに HttpClientModuleを追加
imports: [ environment.production ? [] : AkitaNgDevtools.forRoot(), BrowserModule, AppRoutingModule, HttpClientModule, ]
レスポンスを扱うModelを作成
export interface User { id: string; first_name: string; last_name: string; email: string; avatar: string; }
ユーザー一覧APIのリクエスト部分を作成
$ ng g service
Reqres - A hosted REST-API ready to respond to your AJAX requests を使ってAPI取得部分を作成
import { Injectable } from '@angular/core'; import {HttpClient} from "@angular/common/http"; import {User} from "../model/user"; import {map, Observable} from "rxjs"; const apiHost = 'https://reqres.in/api'; interface ApiResponse<T> { data: T; } @Injectable({ providedIn: 'root' }) export class UsersService { constructor(private http: HttpClient) { } get users$(): Observable<User[]> { return this.http .get<ApiResponse<User[]>>(`${apiHost}/users`) .pipe(map(resp => resp.data)) } }
ComponentからAPIを呼び出す部分を作成
import { Component, OnInit } from '@angular/core'; import {UsersService} from "../../repository/users.service"; @Component({ selector: 'app-user-list', templateUrl: './user-list.component.html', styleUrls: ['./user-list.component.scss'] }) export class UserListComponent implements OnInit { users$ = this.service.users$; constructor(private service: UsersService) { } ngOnInit(): void { } }
取得したデータを画面に表示するテンプレートを作成
<ul> <li *ngFor="let user of users$ | async"> #{{user.id}} {{user.first_name}} {{user.last_name}} </li> </ul>
処理の分離と単方向データフローの適用
ブログ用サンプル Store経由の単方向データフローにする · beatdjam/Sandbox-Frontend@122880a · GitHub
Store, Queryの作成
$ akita
Akita CLIで作る場合のオプションに出てくるHttp Entity Serviceは今回利用していない。
import{Injectable}from '@angular/core'; import{EntityState, EntityStore, StoreConfig}from '@datorama/akita'; export class UserListItem{ constructor( public id: string, public first_name: string, public last_name: string ) {} } export interface UserListState extends EntityState<UserListItem> {} @Injectable({providedIn: 'root'}) @StoreConfig({ name: 'UserList', idKey: 'id' }) export class UserListStore extends EntityStore<UserListState> { constructor() { super(); } }
import{Injectable}from '@angular/core'; import{QueryEntity}from '@datorama/akita'; import{UserListStore, UserListState}from './user-list.store'; @Injectable({providedIn: 'root'}) export class UserListQuery extends QueryEntity<UserListState> { constructor(protected override store: UserListStore) { super(store); } }
Usecase作成
$ ng g service
Serviceと同じように使うのでserviceで作成する
import { Injectable } from '@angular/core'; import {UsersService} from "../../repository/users.service"; import {UserListItem, UserListStore} from "./user-list.store"; import {tap} from "rxjs"; @Injectable({ providedIn: 'root' }) export class UserListUsecaseService { constructor(private service: UsersService, private store: UserListStore) { } fetchUsers() { this.store.setLoading(true); this.service.getAllUsers().pipe( tap(res => { const users = res.map(user => { return new UserListItem(user.id, user.first_name,user.last_name); }); this.store.add(users); }) ).subscribe(() => this.store.setLoading(false)); } }
ComponentからQueryの購読
import { Component, OnInit } from '@angular/core'; import {UserListUsecaseService} from "./user-list-usecase.service"; import {UserListQuery} from "./user-list.query"; @Component({ selector: 'app-user-list', templateUrl: './user-list.component.html', styleUrls: ['./user-list.component.scss'] }) export class UserListComponent implements OnInit { users$ = this.query.selectAll(); constructor(private usecase: UserListUsecaseService, private query: UserListQuery) { } ngOnInit(): void { this.usecase.fetchUsers(); } }
Single State Streamパターンを適用する
ブログ用サンプル Single State Streamパターン適用 · beatdjam/Sandbox-Frontend@c9739c4 · GitHub
Single State Streamの説明は下記を参照
第2章 Effective RxJS - コンポーネントにおけるObservableの購読
要約すると、同じObservableにAsyncパイプを適用したり、いくつものObservableを同期的に処理する必要があるとき、それらを単一のStreamに合成して扱うことでテンプレートで扱う状態を単純化できるというもの。
Stateを合成する
import { Component, OnInit } from '@angular/core'; import {UserListUsecaseService} from "./user-list-usecase.service"; import {UserListQuery} from "./user-list.query"; import {map, Observable} from "rxjs"; import {combineQueries} from "@datorama/akita"; import {UserListItem} from "./user-list.store"; @Component({ selector: 'app-user-list', templateUrl: './user-list.component.html', styleUrls: ['./user-list.component.scss'] }) export class UserListComponent implements OnInit { readonly state$: Observable<UserStateViewModel>; constructor(private usecase: UserListUsecaseService, private query: UserListQuery) { this.state$ = combineQueries([this.query.selectAll()]).pipe( map(([users]) => new UserStateViewModel(users)) ); } ngOnInit(): void { this.usecase.fetchUsers(); } } class UserStateViewModel { constructor(public users: UserListItem[]) { } }
合成したStateをテンプレートから利用する
<ng-container *ngIf="state$ | async as state"> <ul> <li *ngFor="let user of state.users"> #{{user.id}} {{user.first_name}} {{user.last_name}} </li> </ul> </ng-container>