Carpe Diem

備忘録。https://github.com/jun06t

TypeScriptのthisでハマった話

概要

AngularでエラーハンドリングをまとめようとしたらTypeScriptのthisの扱いにハマった話です。

環境

  • TypeScript 2.5.3
  • Angular 4.4.6

どんな問題が起きていたか

APIコールのロジックでエラーが発生した時のハンドリングをまとめることができるよう、共通のErrorServiceを持っていました。
ただ時々ErrorServiceでCannot read property 'navigateByUrl' of undefinedというエラーが出てきていました。

共通のエラーハンドラー

以下のように共通のロジックを持っていました。

@Injectable()
export class ErrorService {
  constructor(
    private router: Router,
  ) { }

  handleError(error: HttpErrorResponse): Observable<any> {
    if (error.status === 503) {
      this.router.navigateByUrl('/maintenance');
    }
    if (error.status === 401) {
      Store.removeItem('auth');
      this.router.navigateByUrl('/login');
    }
    if (/^4[0-9]{2}$/.test('' + error.status) === false) {
      Raven.captureException(error);
    }
    return Observable.throw(error);
  }
}
  • 503エラーの時はメンテナンスページに飛ばす
  • 401エラーの時は認証に失敗したので、セッション情報を消して強制的にログアウトさせる
  • それ以外で4xx出ないエラーであれば、Exceptionを検知ツールへ通知する

というロジックです。

APIコールでの利用

以下のようにObservableのcatchでエラーを掴んだら、上で定義していたErrorServiceを呼ぶようにしていました。

@Injectable()
export class StatusService {
  constructor(
    private http: HttpClient,
    private errorService: ErrorService
  ) {}

  fetch(): Observable<Status> {
     return this.http.get<Status>(environment.api + '/status')
       .catch(this.errorService.handleError);
  }
}

ErrorServiceは問題なくDIできているのに、なぜかhandleError()内のthis.routerundefinedと怒られることがあったのです。

原因と解決方法

同じ問題の人がいて、その解決方法に則ると以下の通りでした。

stackoverflow.com

.catch(this.errorService.handleError);

という呼び出しを

.catch(error => this.errorService.handleError(error));

という形にする必要がありました。何故でしょうか?

TypeScriptのthisの扱い

TypeScriptのgithub wikiには以下のように書いています。

When a function is invoked in JavaScript, you can follow these steps to determine what this will be (these rules are in priority order):

  • If the function was the result of a call to function#bind, this will be the argument given to bind
  • If the function was invoked in the form foo.func(), this will be foo
  • If in strict mode, this will be undefined
  • Otherwise, this will be the global object (window in a browser)

ref: 'this' in TypeScript

訳すと以下です。

  • bindを使ってたら、thisは引数を指す
  • foo.func()という呼び出し方であれば、thisfooを指す
  • strict modeであれば、thisはundefinedになる
  • それ以外はthisはグローバルオブジェクトになる

この流れでいくと、以下のコードのthisが何を指しているかすんなり理解できます。

class Foo {
  x = 3;
  print() {
    console.log('x is ' + this.x);
  }
}

var f = new Foo();
f.print(); // Prints 'x is 3' as expected

// Use the class method in an object literal
var z = { x: 10, p: f.print };
z.p(); // Prints 'x is 10'

var p = z.p;
p(); // Prints 'x is undefined'

今回の問題に照らし合わせる

.catch(this.errorService.handleError);

という渡し方は、関数を直接渡しています。上の例で言うと

var p = z.p;

と同じですね。ということは前述したthisの区別で言うと、それ以外はthisはグローバルオブジェクトになるに当てはまります。
したがってhandleError()内のthisはグローバルオブジェクトを指すため、それはrouterなんてプロパティを持っておらずundefinedというエラーが発生したわけです。

一方解決方法の

.catch(error => this.errorService.handleError(error));

の方は、foo.func()という呼び出し方であれば、thisfooを指すに当てはまるため、handleError()内のthisはthis.errorServiceを指します。なので問題なくrouterというプロパティにアクセスできます。

まとめ

普通は

var p = z.p;

みたいに関数を別の変数に渡すことなんてしないので気にならないthisの法則でしたが、Observableって直接関数渡せるから短縮して書いちゃおみたいな横着をするとこんな問題にぶつかる事があります。
きちんと理解して使いましょう。

ソース