B-Teck!

お仕事からゲームまで幅広く

【TypeScript】CSVをパースする

csv-parseのインストール

$ npm install csv-parse

そのままだとエラーが発生してうまく動かないので対応する。

Warning: Module not found: Error: Can't resolve 'stream' の対応

$ npm i stream-browserify
"paths": {
  "stream": [
    "./node_modules/stream-browserify"
  ]
}

polyfills.tsの修正

この状態でビルドすると下記のようなエラーがいくつか出る。

  • Uncaught ReferenceError: global is not defined
  • Uncaught ReferenceError: Buffer is not defined
  • Uncaught ReferenceError: process is not defined
  • ERROR TypeError: process.nextTick is not a function

これらに対応するためにpolyfills.tsの末尾に下記を追加する

(window as any).global = window;
global.Buffer = global.Buffer || require('buffer').Buffer;
(window as any).process = {
    env: { DEBUG: undefined },
    nextTick: function() {
        return null;
    }
};

csv-parseの利用

import {parse} from 'csv-parse';
// readAsTextとかで読み込んだFileをparseの引数にする
// パース結果は `data` ハンドラで取得でき、1レコードごとに処理される
// 下記のコードの場合は1行ずつconsoleに出力される
parse(file).on('data', (data) => {
    console.log(data);
});

参考

[Angular] Angular 12 (Webpack 5)アップデート記念ビルドエラー対処メモ - Qiita Angular6で”global is not defined”が出た時の回避策 | DevelopersIO
javascript - Angular 6 - process is not defined when trying to serve application - Stack Overflow

【TypeScript/Angular】Formでファイルを読み込む

ReactiveFormsModuleの追加

imports: [
    environment.production ? [] : AkitaNgDevtools.forRoot(),
    BrowserModule,
    AppRoutingModule,
    ReactiveFormsModule,
]

Templateにファイル読み込み用のFormを追加

<form [formGroup]="form" (ngSubmit)="submit()">
  <div class="form-group">
    <label for="file">File</label>
    <input
      formControlName="file"
      id="file"
      type="file"
      class="form-control"
      (change)="onFileChange($event)">
>
<div*ngIf="f['file'].touched && f['file'].invalid" class="alert alert-danger">
      <div*ngIf="f['file'].errors?.['required']">File is required.</div>
    </div>
  </div>
  <button class="btn btn-primary" type="submit">Submit</button>
</form>
{{input}}

Componentに読み込む処理を追加

  • ファイルの選択時に、選択したファイルをeventオブジェクトから取り出す
  • FileReaderのread結果をTemplateに反映している
  • 今回は読み出したいCSVがSJISだったのでEncodeを指定している
import {Component, OnInit} from '@angular/core';
import {FormBuilder, Validators} from '@angular/forms';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
    form = this.fb.group({file: ['', [Validators.required]]});
    file: File | null = null;
    input = "";

    constructor(private fb: FormBuilder) {
    }

    get f() {
        return this.form.controls;
    }

    ngOnInit() {
    }

    onFileChange(event: any) {
        if (event.target.files.length > 0) {
            this.file = event.target.files[0];
        }
    }

    submit() {
        this.readAsText(this.file!).then(result => this.input = result);
    }

    private readAsText(file: File): Promise<string> {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = () => {
                resolve(reader.result as string);
            };
            reader.onerror = () => {
                reject(reader.error);
            };
            reader.readAsText(file, 'shift-jis'); // デフォルトのEncodeはUTF-8
        });
    }
}

【Angular】Angular + Akitaで単方向アーキテクチャ + Single State Streamパターンを実装するメモ

この記事の目的

の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>