Angularエラーハンドリング

先日 [ng-japan OnAir](https://twitter.com/hashtag/ng_jp_onair) を視聴していたところ「エラーハンドリングには ErrorHandler を実装する事が多いと思いますが、Router にもハンドラを渡す事ができるんですよ」なんて会話がありました。

なぬ？ えらーはんどらあ？？
シラナカッタ....。

という事でエラーハンドリングについて調べた事をメモしておきます。

# 環境

- angular 9.1
- node 13.9

# ランタイムのJSエラーをキャッチする

ErrorHandler を継承したクラスを実装します。

```typescript
// src/app/custom-error-handler.ts

import { ErrorHandler } from '@angular/core';

export class CustomErrorHandler implements ErrorHandler {
  handleError(e) {
    console.warn('👹ErrorHandler👹', e);
  }
}
```

クラスを AppModule の providers で提供します。

```typescript
import { ErrorHandler } from '@angular/core';
import { CustomErrorHandler } from './custom-error-handler';

@NgModule({
  declarations: [ AppComponent ],
  imports: [ BrowserModule ],
  providers: [
    {
      provide: ErrorHandler,
      useClass: CustomErrorHandler, // これ
    },
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }
```

ボタンを設置して、わざとランタイムエラーを発生させてみましょう。

```typescript
// app.component.html
<button (click)="test()">test</button>

// app.component.ts
export class AppComponent implements OnInit {
  test() {
    // TypeError: Cannot read property 'id' of undefined
    return [].find(_ => false).id;
  }
}
```

カスタムの ErrorHandler がランタイムの JS エラーをキャッチしている事が分かります。
<img width="700" src="https://user-images.githubusercontent.com/15980747/84593268-539efb00-ae86-11ea-8fb8-81af256f74ad.png">

# 500 ページに飛ばす

エラーをキャッチした後「予期しないエラーが発生しました」ページに飛ばしたい事もあるかもしれません。
その場合はこのように実装すれば良さそうです。

```typescript
// src/app/custom-error-handler.ts

// コンストラクタにインジェクトするため Injectable を宣言
@Injectable()
export class CustomErrorHandler implements ErrorHandler {
  constructor(
    private router: Router,
    private zone: NgZone,
  ) {}

  handleError(e) {
    console.warn('👹ErrorHandler👹', e);

    this.zone.run(() => {
      // URLに "/500" を表示せず画面遷移する
      this.router.navigate(['/500'], { skipLocationChange: true });
    });
  }
}
```

# HTTP エラーをキャッチする

HttpInterceptor を継承したクラスを実装します。

```typescript
// src/app/custom-interceptor.ts

import { HttpInterceptor } from '@angular/common/http';

@Injectable()
export class CustomInterceptor implements HttpInterceptor {
  intercept(
    req: HttpRequest<any>,
    next: HttpHandler,
  ): Observable<HttpEvent<any>> {
    return next.handle(req).pipe(
      catchError((e) => {
        console.warn('👹HttpInterceptor👹', e);
        
        // 呼び出し元で catchError() できるように例外処理
        return throwError(e);
      }),
    );
  }
}
```

クラスを AppModule の providers で提供します。

```typescript
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { CustomInterceptor } from './custom-interceptor.ts';

@NgModule({
  declarations: [ AppComponent ],
  imports: [ BrowserModule, HttpClientModule ],
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: CustomInterceptor, // これ
      multi: true,
    }
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }
```

ボタンを設置して、わざとエラーを発生させてみましょう。

```typescript
// app.component.html
<button (click)="test2()">test2</button>

// app.component.ts
export class AppComponent implements OnInit {
  test2() {
    // 存在しない URL へのリクエスト
    this.http.get(
      'http://dummy.restapiexample.com/api/v1/dummy-request',
    ).pipe(
      catchError(() => EMPTY)
    ).subscribe();
  }
}
```

カスタムの Intercepter が HTTP エラーをキャッチしている事が分かります。
<img width="700" src="https://user-images.githubusercontent.com/15980747/84593708-6c101500-ae88-11ea-847b-45c1ed882dd6.png">

# ルーターのエラーをキャッチする

Router の errorHandler を変更します。

```typescript
// app.component.ts

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  constructor(
    private router: Router,
  ) {}

  ngOnInit() {
    this.router.errorHandler = (e) => {
      console.warn('👹router ErrorHandler👹', e);
      this.router.navigate(['/403'], { skipLocationChange: true });
    };
  }
```

> ※ errorHandler をどこでセットするのが正しいのか、情報が見つからなかったためとりあえず AppComponent.ngOnInit で宣言しました。

アンカータグを設置して、わざと存在しないページへの遷移を発生させてみましょう。

```html
// app.component.html
<a routerLink="/path/to/invalid">invalid</a>
```

router.errorHandler が存在しないページへの遷移をキャッチしている事が分かります。
<img width="700" src="https://user-images.githubusercontent.com/15980747/84593893-afb74e80-ae89-11ea-9aff-0a575c920595.png">

なお、トップページにリダイレクトするようなフォールバックを指定している場合は、ルーターの errorHandler は実行されません。

```typescript
// app-router.module.ts

const routes: Routes = [
  { ... },
  { // リダイレクトによりエラーが発生しない
    path: '**',
    pathMatch: 'full',
    redirectTo: '',
  }
];

@NgModule({
  imports: [ RouterModule.forRoot(routes) ],
  exports: [ RouterModule ],
})
export class AppRouterModule {}
```

# おわり

Angular のエラーハンドリングを利用すれば簡単にエラーがキャッチできますね。サンプルのコードでは `console.warn` しているだけですが、実際の運用ではログ収集サービスにエラー情報を投げるなど、いろいろ有効活用できそうです。