Parcelを利用してReact × Typescriptの環境を用意

ちょっとしたモック作成や動作確認をしたいときに使ってみようかと思います。
Parcel v1.2.0を利用。

準備

Parcelをインストール

npm install -g parcel-bundler

tsconfig.jsonを用意

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "strict": true,
    "jsx": "react"
  }
}

Index.html

<html>
<body>
  <div id="root"></div>
  <script src="./index.tsx"></script>
</body>
</html>

Index.tsx

import * as React from 'react';
import * as ReactDOM from 'react-dom';

ReactDOM.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root')
);

起動

parcel index.html

参考

ローダーと通信エラーの実装

f:id:ryotah:20171214221323p:plainf:id:ryotah:20171214221325p:plain

Angularアプリのローダーと通信時エラーの実装について。
(Angular 5.0.1を利用)

ローディング

ローダーの種類

  • 通信時につねに表示するもの (A)
  • 通信時に画面をブロックしたいもの (B)

Aの実装方針

  • HTTP_INTERCEPTORSを利用して自動で実行させる

http-interceptor-loader.ts

@Injectable()
export class HttpInterceptorLoader implements HttpInterceptor {
  constructor(private store: Store<formRoot.AppState>) {}

  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {

    // 今回はngrxを利用
    // load開始時と終了時にカウントの増減をする
    this.store.dispatch(new LoadCountAdded());
    return next.handle(req).pipe(
      finalize(() => {
        setTimeout(() => this.store.dispatch(new LoadCountRemoved()), 0);
      })
    );
  }
}

actionとstate

/**
 * action
 */
export class LoadCountAdded implements Action {
  readonly type = LOAD_COUNT_ADDED;

  // `withBlocker`があるのは B タイプのローダーでも利用するため
  constructor(public withBlocker = false) { }
}

export class LoadCountRemoved implements Action {
  readonly type = LOAD_COUNT_REMOVED;
  constructor(public withBlocker = false) { }
}
/**
 * state
 */
const initialState: State = {
  loadCount: {
    withBlocker: 0,
    withoutBlocker: 0,
  },
};

stateをsubscribe

export class AppComponent {
  constructor(private store: Store<fromRoot.AppState>) {

    // http://ricostacruz.com/nprogress/を利用

    NProgress.configure({ showSpinner: false });
    this.store.select(fromRoot.getProgressNeeded)
      .subscribe(needed => {
        needed ? NProgress.start() : NProgress.done();
      }
    );
  }
}

Bの実装方針

  • 必要なタイミングでdispatchすればいい

利用例

 @Effect()
  load$: Observable<any> = this.actions$
    .ofType<actions.LoadData>(actions.LOAD_DATA)
    .map(action => action.payload)
    .switchMap(key => {

      // `withBlocker`フラグをtrueにする
      this.store.dispatch(new layout.LoadCountAdded(true));

      return this.service.getData(key)
        .catch(() => {
          // TODO
        })
        .finally(
          () => this.store.dispatch(new layout.LoadCountRemoved(true))
        );
    });

stateをsubscribe

// app-blockerというブロッカーコンポーネントの表示非表示をコントロールする
@Component({
  selector: 'app-root',
  template: `<router-outlet></router-outlet>
  <app-blocker *ngIf="blockerNeeded$ | async"></app-blocker>`,
  styles: [],
})
export class AppComponent {
  blockerNeeded$: Observable<boolean>;
  constructor(private store: Store<fromRoot.AppState>) {
    this.blockerNeeded$ = this.store.select(fromRoot.getBlockerNeeded);
  }
}

BlockerComponent

@Component({
  selector: 'app-blocker',
  template: `
  // bootstrap4のクラス
  // やっていること -> 中央にコンテンツを配置
  <div class="d-flex justify-content-center align-items-center h-100">
    <app-spinner size="lg"></app-spinner>
  </div>
  `,
  styles: [`
  :host {
    position: fixed;

    // 状況に応じて変更
    // z-index: 1000;

    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(255,255,255,.38);
  }`]
})
export class BlockerComponent {
}

通信エラー

実装方針

  • エラー表示ロジックを実装したHttServiceをつくる
  • HttServiceではエラー表示ロジックのみ実装し、エラーの解決はしない
    • エラーの解決は利用状況により異なることが想定されるので、各Feature Serviceにまかせる
@Injectable()
export abstract class HttpService {
  protected apiUrl = environment.apiUrl;
  constructor(
    protected http: HttpClient,
    private injector: Injector,
  ) { }

  // resolve cyclic dependency
  get ngbModal(): NgbModal {
    return this.injector.get(NgbModal);
  }

  protected handleError(err: HttpErrorResponse | Error): ErrorObservable {
    const modalRef = this.ngbModal.open(NgbmAlertComponent, {
      backdrop: 'static'
    });

    // エラー処理
    if (err instanceof Error || err.error instanceof Error) {

      const msg = err instanceof Error ? err.message : err.error.message;

      // A client-side or network error occurred
      Object.assign(modalRef.componentInstance, {
        title: 'Error',
        message: `An error occurred: ${err.message}`
      });
    } else {

      // The backend returned an unsuccessful response code.
      Object.assign(modalRef.componentInstance, {
        title: 'Error',
        message: `Backend returned code ${err.status}, message was: ${err.message}`
      });
    }
    return Observable.throw(err);
  }
}

利用例

@Injectable()
export class ApiService extends HttpService {
  getHero(): Observable<Hero> {
    return this.http
      .get(`${this.apiUrl}/hero.json`)
      .pipe(
        map(json => /* ... */),
        catchError(err => this.handleError(err))
      );
  }
}

(WIP) createEmbeddedViewとcreateComponen

templateを利用して動的にコンポーネントを生成する

this.viewContainer.createEmbeddedView(this.templateRef);
  • 何が起きているか
    • Angularが生成した<ng-template>を利用して、embedded viewを作成。作成したembedded viewをホスト要素に隣接するview containerに差し込む
    • 隣接とは

ViewContainerRef

  • createEmbeddedView
    • templateRefからEmbeddedViewRefを生成してinsert
  • createComponent
    • ComponentFactoryからComponentRefを生成してinsert
  • insert(viewRef: ViewRef, index?: number): ViewRef
  • clear(): void

TemplateRef

  • 2つの利用方法
    • <ng-template>に配置されたディレクティブ(or directive prefixed with *)
      • TemplateRefがinjectされる
    • Query

EmbeddedViewRef

サンプル

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({
  selector: '[appUnless]'
})
export class UnlessDirective {
  private hasView = false;

  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainer: ViewContainerRef) { }

  @Input() set appUnless(condition: boolean) {
    if (!condition && !this.hasView) {

      // メモ: コンポーネントから生成する場合
      // this.viewContainer.createComponent(
      //   this.componentFactoryResolver.resolveComponentFactory(component)
      // );

      this.viewContainer.createEmbeddedView(this.templateRef);
      this.hasView = true;
    } else if (condition && this.hasView) {
      this.viewContainer.clear();
      this.hasView = false;
    }
  }
}

参考

ControlValueAccessorでRadioボタンを含むコンポーネントを実装するメモ

  • (change)を利用
  • ViewChildrenを利用
  • ref.nativeElement.checked = true;でViewを更新

RadioComponent

import { Component, ElementRef, QueryList, ViewChildren } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-radio',
  template: `
  <label *ngFor="let value of values">
    <input type="radio"
      (change)="onChange($event.target.value)"
      name="radio" [value]="value" #radio>{{value}}
  </label>
  <pre></pre>
 `,
  styles: [`:host {
    display: block;
    background-color: #fcc;
  }`],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: RadioComponent,
      multi: true
    }
  ]
})
export class RadioComponent implements ControlValueAccessor {
  @ViewChildren('radio') private radios: QueryList<ElementRef>;
  values = ['foo', 'bar', 'baz'];

  onChange: (value: string) => void;
  onTouched: () => void;

  // ControlValueAccessor
  writeValue(value: string) {
    if (value) {
      const target = this.radios.find(radio =>
        radio.nativeElement.value
      );
      if (target) {
        toChecked(target);
      }
    }
  }
  registerOnChange(fn: any) { 
    this.onChange = fn;
  }
  registerOnTouched(fn: any) {
    this.onTouched = fn;
  }
}

function toChecked(ref: ElementRef) {
  ref.nativeElement.checked = true;
}

利用例

<app-radio name="radio" [ngModel]="(form$ | async).radio"></app-radio>

11月メモ・リンク集

  • 11月に調べたことのメモ

ngrx関連

各Feature Module(Page Module)のステートを、画面遷移時にリセットする

  • meta reducerを使って以下のように実装
  • LEAVE時にFeatureAのステートをundefinedにする -> 各reducersでinitial stateを設定する

feature-a.module

// ...

StoreModule.forFeature(
  'feature-a',
  fromFeatureA.reducers,
  {  metaReducers: [fromFeatureA.resetReducer]  }
),

reducers/index.ts

// ...

export function resetReducer(reducer: ActionReducer<FeatureAState, routeActions.Actions>) {
  return function (state, action) {
    if (action.type === routeActions.LEAVE) {
      return reducer(undefined, action);
    } else {
      return reducer(state, action);
    }
  };
}

Angular関連

Component/Directive関連

アプリケーションに依存するデータを表示するコンポーネントのインターフェース(@Input)について

コンポーネントで必要とするデータと、実データ(e.g. アプリ上で利用しているデータ型)の違いがある場合。柔軟なインターフェースにしたほうがいい場合。

  • @Inputに渡すデータの型をどうするか
    • データの形式を固定してしまうと、いつか苦労する
      • APIによってサーバから送られるデータが違う場合
      • サーバからもらうデータと、クライアントで作成したデータが同じ構造になるとは限らない
  • @Inputの数 = 更新タイミングの数
    • 不要に増やしたくはない

show-data.component.ts

 export interface IShowDataProps {
  a: number;
  b: numebr;
  c?: string;
  d?: string;
  e?: boolean;
}

// ...

export class ShowDataComponent {
  /**
   * `appData`か`props`のどちらかを受け取り
   * `_props`をつくる
   */
  _props: IShowDataProps;
  @Input() set params(
    params: { appData?: AppData, props?: IShowDataProps, options?: any }
  ) {
    if (params.appData) {
      this._props = this.toProps(params.appData);
    } else if (params.props) {
      this._props = params.props;
    }
  }
  get props() { return this._props; }

  // ...

}

page.html

<show-data [params]="{ appData: appData }"></show-data>
<show-data [params]="{ appData: appData, options: {...} }"></show-data>
<show-data [params]="{
  props: {
    a: 100,
    b: 1000',
    c: 'c',
    e: true,
  }
}">

Form関連

CSS関連

AngularのForm(応用)

f:id:ryotah:20171202160340p:plainf:id:ryotah:20171202160319p:plain

これは何か

AngularConnect 2017のセッション「Angular Forms - Advanced Topics」を自分用にまとめたものです。 セッションではCustom Form Controls、ControlValueAccessor、Formを入れ子にする場合のベストプラクティスなどについて話されています。

Angular v5.0.2で動作確認しています。

Custom Form Controls

  • 自作のフォームパーツをつくる
  • ControlValueAccessorを利用する
  • バリデーションが必要な場合はValidatorも利用する
    • 外部から(利用するテンプレートから)requiredなど既存のバリデーションを付与することも可能だが、コンポーネントの機能として必須な条件があれば、内部で定義した方がいい

NG_VALUE_ACCESSORを利用する

@Component({
  // ...
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: FooComponent,
      multi: true
    },
    {
      provide: NG_VALIDATORS,
      useExisting: FooComponent,
      multi: true
    }
  ]
})
export class FooComponent implements ControlValueAccessor, Validator {

  // ControlValueAccessor
  writeValue(obj: any) {
  }
  registerOnChange(fn: any) { 
  }
  registerOnTouched(fn: any) {
  }
  setDisabledState(disabled: boolean) {
  }

  // Validator
  validate(ctrl: AbstractControl) {
  }
}

NgControlを利用する

  • コンポーネント内でcontrolにアクセスしたい場合(control.errorsを利用したい)
    • [control]=dir.control #dir="ngModel"[control]=form.get('foo') formControlName="foo"でもできるが、毎回書くのは煩わしい
    • NgModelFormControlNameは利用しないでNgControlを利用する(Template-driven、Reactive両方で利用できるようにするため)
  • provide: ...は不要になる
  • @Selfにする意味
export class FooNgControlComponent implements ControlValueAccessor {

  constructor(@Self() public controlDir: NgControl) {
    controlDir.valueAccessor = this;
  }

  ngOnInit() {
    const control = this.controlDir.control;
    const validators = control.validator
      ? [control.validator, Validators.required]
      : Validators.required;
    control.setValidators(validators);
    control.updateValueAndValidity();
  }

  // ControlValueAccessor
  writeValue(obj: any) {
  }
  registerOnChange(fn: any) { 
  }
  registerOnTouched(fn: any) {
  }
  setDisabledState(disabled: boolean) {
  }
}

サンプルコード

メモ

フォームの入れ子

ControlValueAccessorを利用

  • FormGroupの値を利用して、データの受け渡しをする
  • Template-drivenとReactive両方で利用できる
@Component({
  selector: 'app-address',
  template: `
  <div [formGroup]="form">
    <input type="text" formControlName="street">
    <input type="text" formControlName="city">
  </div>`,
  providers: [
    { provide: NG_VALUE_ACCESSOR, /* ... */ },
    { provide: NG_VALIDATORS, /* ... */ },
  ]
})
export class AddressComponent  implements OnInit, ControlValueAccessor {
  form: FormGroup;
  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    this.form = this.fb.group({
      street: '',
      city: ''
    });
  }

  // ControlValueAccessor
  writeValue(obj: any) {
    if (obj) {
      this.form.setValue(obj, { emitEvent: false });
    }
  }
  registerOnChange(fn: any) {
    this.form.valueChanges.subscribe(fn);
  }
  registerOnTouched(fn: any) {
  }
}

Template-driven専用

@Component({
  selector: 'app-address-template',
  template: `
  <div ngModelGroup="address">
    <input type="text" name="street" ngModel>
    <input type="text" name="city" ngModel>
  </div>
    `,
  viewProviders: [
    {
      provide: ControlContainer,
      useExisting: NgForm
    }
  ]
})
export class AddressTemplateComponent {
}

Reactive専用

  • シンプルですぐ実装可能
  • FormGroupに子コンポーネントが自分でコントローラを登録する
    • ちょっと奇妙な書き方に感じる
@Component({
  selector: 'app-address-reactive',
  template: `
  <div formGroupName="address">
    <input type="text" formControlName="street">
    <input type="text" formControlName="city">
  </div>
    `,
  viewProviders: [
    {
      provide: ControlContainer,
      useExisting: FormGroupDirective
    }
  ]
})
export class AddressReactiveComponent {

  constructor(private parent: FormGroupDirective) {
  }

  ngOnInit() {
    // FormGroupを取得
    const form = this.parent.form;

    // 自身で、親のFormGroupにコントローラを登録する
    form.addControl('address', new FormGroup({
      street: new FormControl(''),
      city: new FormControl(''),
    }));
  }
}

サンプルコード

  • angular-form-nested-forms - StackBlitz
    • AddressComponent
      • ControlValueAccessorを利用
      • Template-driven、Reactive両方で利用可能
    • AddressTemplateComponent
      • Template-drivenで利用可能
    • AddressReactiveComponent
      • Reactive両方で利用可能

メモ

Error Aggregator

  • フォーム内の各コントローラのエラーを集約して表示させたい場合
  • formをprojectionするのはさせよう
    • Template-driven、Reactive、どちらかでしか利用できないような設計になる
    • 難しくなるし、バグが発生しやすくなる
    • もうすこし良い設計があるはず
@Component({
  selector: 'error-aggregator',
  template: `
  <div *ngFor="let error of errors">{{error | json}}</div>`,
})
export class ErrorAggregatorComponent implements OnInit {
  @Input() form: FormGroup;
  errors;
  constructor() { }
  ngOnInit() {
    // NgFormを間違って渡していないか確認
    if (this.form instanceof FormGroup) {
      this.form.statusChanges.subscribe(val => {
        this.errors = this.reduceErrors();
      });
    }
  }  
  private reduceErrors() {
    return pickErrors(this.form);
  }
}

// for demo
function pickErrors(formGroup: FormGroup) {
  debugger;
  const keys = Object.keys(formGroup.controls);
  return keys.map(key => {
    const control = formGroup.controls[key];
    if (control instanceof FormControl) {
      return control.errors;
    } else if (control instanceof FormGroup) {
      return pickErrors(control);
    }
  });
}

サンプルコード

参考

メモ

  • 以前のコード
// ...
@Input() model?: string;
@Input() form: NgForm;
@ViewChild('ngModel') ngModel: NgModel;
ngOnInit() {
  this.form.addControl(this.ngModel);
}
// ...

10月メモ・リンク集

これは何か

  • 10月に調べたことのメモ
  • 基本形式は、「参考URLとそれに関するメモとコメント」

ngrx関連

ngrxとフォーム

Introducing @ngrx/entity – @ngrx – Medium

状態のリセット

// foo.module.ts
...
StoreModule.forFeature(
  'foo',
  fromCalendar.reducers,
  {
    metaReducers: [fromFoo.resetReducer]
  }
),
...

// reducers/index.ts
export function resetReducer(reducer: ActionReducer<FooState, reset.Actions>) {
  return function (state, action) {
    if (action.type === RESET) {
      return reducer(undefined, action);
    } else {
      return reducer(state, action);
    }
  };
}

How to share state between reducers best practice · Issue #159 · ngrx/example-app · GitHub

Rx関連

unsubscribe

その他

Angular関連

view操作

ngOnInit() {
  const componentFactory = this.componentFactoryResolver.resolveComponentFactory(SpinnerComponent);
  const componentRef = componentFactory.create(this.viewContainer.injector);
  this.renderer.appendChild(
    this.viewContainer.element.nativeElement,
    componentRef.location.nativeElement
  );
}

フォーム

export class ChildComponent implements OnInit {
  ...
  @ViewChild("model") ngModel: NgModel;

  ngOnInit() {
    this.parentForm.addControl(this.ngModel); // <== add this
    this.parentForm.valueChanges.subscribe(changes => {
      console.log(JSON.stringify(changes));
    });
  }
}

その他

null vs undefined

TypeScript

スニペット

Bootstrap4, デスクトップファースト

// Media of at most the maximum breakpoint width. No query for the largest breakpoint.
// Makes the @content apply to the given breakpoint and narrower.
@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {
  $max: breakpoint-max($name, $breakpoints);
  @if $max {
    @media (max-width: $max) {
      @content;
    }
  } @else {
    @content;
  }
}

Htmlの<base href>を取得

// <base href="/ja/">
const bases = document.getElementsByTagName('base');
if (bases.length > 0) {
  const baseHref = bases[0].href;
  // -> 'https://foo.com/ja/'
}

Bugsnag

import { ErrorHandler, Injectable } from '@angular/core';
import { environment } from '../../environments/environment';

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  constructor() {
    Bugsnag.releaseStage = environment.stage;
  }
  handleError(error) {
    Bugsnag.notifyException(error);
    throw error;
  }
}

ローダーのオンオフ、簡易版

function disable() {
  const elm: HTMLElement | null = document.getElementById('blocker');
  if (elm) {
    elm.classList.add('active');
  }
}

function enable() {
  const elm: HTMLElement | null = document.getElementById('blocker');
  if (elm) {
    elm.classList.remove('active');
  }
}

Clickメモ

Array.apply(null, document.querySelectorAll('input[type=radio][value="3"]')).forEach(elm => elm.click());