Fork me on GitHub

(译)从Angular谈一谈前端的错误处理

偶然在angular-realworld-example-app这个项目的issue中看到有人说此项目缺乏相应齐备和完善的错误处理,因此还是别叫realword了,因为根本跑不起来,同时还贴了一篇文章来说明一个Angular项目的错误处理应该是怎样的。

这篇文章名字叫做[Error Handling & Angular][link2],写的还真是非常好。我最近也经常思考如何才能完美的优雅又省力的处理前端框架中请求错误,数据错误,兼容错误等等各式各样的错误,尽量提高页面的适应性,保证错误发生时不至于损失太多的用户体验的同时又不会在项目中混杂太多的错误处理代码导致业务代码的bad smell

这篇文章,虽然讲的是Angular的错误处理,但其中展现出对前端错误的归纳方法和处理之道,无论使用什么框架,都会收获颇多。

因此翻译过来,与大家共享。

ps:本篇博客中使用的在线编辑器[StackBlitz][link3]需要翻墙才能访问,也就是说本篇博客的阅读最好在翻墙情况下进行……

再ps:我正在找不需要翻墙又比较好用的在线编辑器,等找到了就会把相应的Demo实例迁移过去,希望我能够早点找到……

如果你的年龄大于两岁的话,你肯定已经意识到,错误无论如何都会发生,当然,你的项目也不会例外。

当错误发生的时候,你可以无视它,任由它滋长,或者,你也可以通过做些什么,来消灭它,从而让这个世界更美好一点。

具体的,我们应该采取什么措施来处理错误呢?这取决于错误来自哪里:

外部错误

外部错误是更容易解决一些的错误,因为这类错误不是你造成的,你总可以怪罪到别的什么人头上。另一方面来说我们可以做的也并不是很多,我们只能被动的处理,并去告知这些外部错误的负责者,请求他去修复它们。

这类错误通常来自服务器,携带着一个表示错误分类的status错误状态码和表示错误原因的message,通过错误携带的这些信息我们可以知道发生了什么错误,做出相应的处理。

除了来自服务器的,也有一些其他的情况,例如网络连接失败,我们也是可以处理的。

但显然并不是所有的都行,像浏览器崩溃或者系统崩溃这类外部错误,我们明显的无能为力。

内部错误

内部错误就更复杂一些了,因为它要求我们去仔细的自我审查并在内心深处接受我们又一次搞砸了这个现实,然后去找到原因,修复它,被它好好的上一课。

我们可以根据谁发出的错误来对内部错误做一个分类:

服务器

服务器返回给我们的错误所包含的属性并没有什么固定的标准,至少我没找到过此类标准。

它大概会包含以下几个属性:

  • status (code) : 以4开头的三位错误状态码,目前共有 28 个错误状态码
  • name: 错误的名字,比如HttpErrorResponse
  • message: 解释错误原因的消息,形如Http failure response for...

客户端 (浏览器)

Javascript每当出错时,都会抛出一个使用Error构造函数创建的类型错误。

最常见的错误类型是ReferenceError(意指调用了不存在的变量)和 TypeError (表示你把变量当成了函数并尝试去执行它),还有其他的5种类型错误,点链接可以详细查看。

一个客户端错误通常会包含:

  • name: 例如 ReferenceError
  • message: 例如 X is not defined

在更现代的浏览器中,还会有错误发生所在的 fileName文件名,lineNumber行号和 columnNumber列号,及stack(错误发生时所在的函数堆栈)。

错误,异常和调用栈

Javascript中,每个函数在执行时都会被按执行顺序依次添加到栈中,当函数执行完毕,返回值后,再从栈中删除。

当一个错误发生时,一个异常会在错误所在栈被抛出,同时会删除当前栈中的所有函数调用,直到其被try/catch捕获。随后,控制权会移交给catch块中的代码。

如果没有任何try/catch块,则异常会移除所有嵌套的栈中的函数执行,使我们的应用彻底崩溃。

让我们来看一个例子

实际例子

  • 打开例子编辑器中的console命令行
  • 打开app>app.components.ts文件
  • 点击触发错误按钮,因为引用错误ReferenceError没有被捕获,这个错误清除了所有的函数调用栈,应用崩溃了。
  • 刷新页面
  • 点击触发错误并捕获按钮,查看错误被捕获了的情况。

你可以在console命令行中看到,try/catch捕获了错误,并继续执行了catch块中的内容,避免了应用崩溃。

如何全局处理错误

默认的,angular有自己的一个ErrorHandle用来拦截所有内部发生的错误并打印到控制台。在上面的例子中,为了演示错误的发生,我禁用了它。

我们可以基于ErrorHandle创建一个新的错误处理类来修改它的默认行为。

1
2
3
4
5
6
7
8
9
10
11
// errors-handler.ts
import { ErrorHandler, Injectable} from '@angular/core';

@Injectable()
export class ErrorsHandler implements ErrorHandler {
handleError(error: Error) {
// 在这里进行自定义的错误处理
// 例如将错误发送到服务器或者打印到console命令行
console.error('发生错误: ', error);
}
}

在定义了一个错误处理的新的类(它是一个服务)之后,我们需要告诉Angular使用我们自己定义的类处理全局错误。

1
2
3
4
5
6
7
8
9
10
11
// errors.module.ts
@NgModule({
imports: [ ... ],
declarations: [ ... ],
providers: [
{
provide: ErrorHandler,
useClass: ErrorsHandler,
}
]
})

现在,每次当新的错误发生时,Angular都会打印一条发生错误并跟随着具体错误信息的消息到console控制台中。

如何识别错误

ErrorHandler中,我们可以识别错误属于哪种类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// errors-handler.ts
import { ErrorHandler, Injectable} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';

@Injectable()
export class ErrorsHandler implements ErrorHandler {
handleError(error: Error | HttpErrorResponse) {
if (error instanceof HttpErrorResponse) {
// 服务器或者连接错误
if (!navigator.onLine) {
// 网络连接错误
} else {
// Http 错误 (error.status === 403, 404...)
}
} else {
// 客户端错误 (Angular Error, ReferenceError...)
}
// 总是输出错误到控制台
console.error('发生错误: ', error);
}

如何分别处理错误

server端错误

server端和网络连接的错误会影响到我们的应用和服务器的通信。当我们的应用离线时,可能发生访问地址不存在(404),无权限访问(403)等错误。AngularHttpClient会给出对应的错误信息提示,不过我们的应用并不会崩溃。但我们仍然必须去处理它,从而避免我们的应用处于数据可能会受损或者丢失的风险中。

一般来说,对于这种错误,我们需要进行一个通知:一个清晰明了的解释信息来告诉用户发生了什么,应该怎么做。

为此我们需要在应用中引入一个notification服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// errors-handler.ts
import { ErrorHandler, Injectable} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';

@Injectable()
export class ErrorsHandler implements ErrorHandler {

constructor(
// 因为 ErrorHandler 的创建在服务的构建引入之前,
// 所以我们需要通过使用服务的注入器`injector`来手动获取服务
private injector: Injector
){}

const notificationService = this.injector.get(NotificationService)

handleError(error: Error | HttpErrorResponse) {
if (error instanceof HttpErrorResponse) {
// 服务器或者连接错误
if (!navigator.onLine) {
// 网络连接错误
return notificationService.notify('无网络连接');
} else {
// Http 错误 (error.status === 403, 404...)
return notificationService.notify(`${error.status} - ${error.message}`);
}
} else {
// 客户端错误 (Angular Error, ReferenceError...)
}
// 总是输出错误到控制台
console.error('发生错误: ', error);
}

我们还可以对某些类型的错误进行专门的处理,例如当 403 错误发生时,我们可以通知用户并重定向页面到登录页

最好的错误是永远不发生的错误。我们可以通过Http拦截器 HttpInterceptor改进我们的错误处理。通过HttpInterceptor,我们拦截所有的服务器响应,并在请求发生错误时重试若干次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// server-errors.interceptor.ts
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpErrorResponse
} from '@angular/common/http';

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/retry';


@Injectable()
export class ServerErrorsInterceptor implements HttpInterceptor {

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// 如果请求发生错误,重试5次仍然错误时再抛出
return next.handle(request).retry(5);
}
}

同样我们需要告诉Angular使用我们自定义的ServerErrorsInterceptor类来拦截每个Http请求。

1
2
3
4
5
6
7
8
9
10
11
12
// errors.module.ts
@NgModule({
imports: [ ... ],
declarations: [ ... ],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: ServerErrorsInterceptor,
multi: true,
},
]
})

客户端错误

客户端错误看起来对我们更危险一些。它可能导致我们的应用完全崩溃,或者导致错误的数据被存储,甚至让用户花费很多时间处理的数据无法保存。

我认为在这种情况下,我们需要进行更严格的错误响应:当我们的应用程序出现问题时,我们应该停止应用,并将页面重定向到包含所有错误信息的错误专用页面,然后尽快修复错误并通知用户应用已恢复正常。

为了实现上面说的,我们需要使用一个可以从路由参数中获取错误信息并展示的错误专用组件,在错误发生时重定向到它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// errors-handler.ts
import { ErrorHandler, Injectable} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';

@Injectable()
export class ErrorsHandler implements ErrorHandler {

constructor(
// 因为 ErrorHandler 的创建在服务的构建引入之前,
// 所以我们需要通过使用服务的注入器`injector`来手动获取服务
private injector: Injector
){}

const notificationService = this.injector.get(NotificationService)
const router = this.injector.get(Router)

handleError(error: Error | HttpErrorResponse) {
if (error instanceof HttpErrorResponse) {
// 服务器或者连接错误
if (!navigator.onLine) {
// 网络连接错误
return notificationService.notify('无网络连接');
} else {
// Http 错误 (error.status === 403, 404...)
return notificationService.notify(`${error.status} - ${error.message}`);
}
} else {
// 客户端错误 (Angular Error, ReferenceError...)
router.navigate(['/errors'], { queryParams: {error: error}})
}
// 总是输出错误到控制台
console.error('发生错误: ', error);
}

实际例子

  • 点击Client Error触发一个客户端错误,观察错误的处理方法
  • 点击Server Error 触发一个服务端错误,观察错误的处理方法
  • 打开app>core>errors 文件夹查看用于错误处理的ErrorsComponent,ErrorsHandler,ServerErrorsInterceptorerrors-routing的细节。

如何追踪记录错误

眼不见的心不烦…..,如果我们不知道在我们的应用中发生了什么错误,也就不会有进一步的优化和改进。

我们可以使用一个ErrorsService错误服务来将与错误有关的上下文信息发送到我们的服务器从而记录错误发生的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//errors.service.ts

import { ErrorHandler, Injectable, Injector} from '@angular/core';
import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';

// stacktrackjs是一个十分好用的追踪记录错误的库: https://www.stacktracejs.com
import * as StackTraceParser from 'error-stack-parser';

@Injectable()
export class ErrorsService {
constructor(
private injector: Injector,
) {}
log(error) {
// 将错误打印到控制台
console.error(error);
// 发送错误到服务器
const errorToSend = this.addContextInfo(error);
return fakeHttpService.post(errorToSend);
}
addContextInfo(error) {
// 所有你需要的错误上下文信息详情(它们有的来自其他的`service`服务或者常量,用户服务等)
const name = error.name || null;
const appId = 'shthppnsApp';
const user = 'ShthppnsUser';
const time = new Date().getTime();
const id = `${appId}-${user}-${time}`;
const location = this.injector.get(LocationStrategy);
const url = location instanceof PathLocationStrategy ? location.path() : '';
const status = error.status || null;
const message = error.message || error.toString();
const stack = error instanceof HttpErrorResponse ? null : StackTraceParser.parse(error);
const errorToSend = {name, appId, user, time, id, url, status, message, stack};
return errorToSend;
}
}

然后,更改我们的错误处理类ErrorsHandler来使用我们全新的ErrorsService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { ErrorHandler, Injectable, Injector} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';

import * as StackTraceParser from 'error-stack-parser';
import { ErrorsService } from '../errors-service/errors.service';
import { NotificationService } from '../../services/notification/notification.service';

@Injectable()
export class ErrorsHandler implements ErrorHandler {
constructor(
private injector: Injector,
) {}

handleError(error: Error | HttpErrorResponse) {
const notificationService = this.injector.get(NotificationService);
const errorsService = this.injector.get(ErrorsService);
const router = this.injector.get(Router);
if (error instanceof HttpErrorResponse) {
//服务器或者连接错误
if (!navigator.onLine) {
// 网络连接错误
return notificationService.notify('No Internet Connection');
}
// Http 错误
// 发送到服务器
errorsService
.log(error)
.subscribe();
// 并向用户显示通知消息
return notificationService.notify(`${error.status} - ${error.message}`);
} else {
// 客户端错误
// 发送错误到服务器并将客户重定向到包含所有错误信息的错误页面
errorsService
.log(error)
.subscribe(errorWithContextInfo => {
router.navigate(['/error'], { queryParams: errorWithContextInfo });
});
}
}
}

现在,我们对发生的错误拥有了许多的上下文信息并将它们发送到了服务器,从而便于我们的记录和追踪,同时这也让我们的应用能够给予客户更好的体验和反馈。

实际例子

  • 点击Client Error按钮,查看相关的文件,你会明白我们是如何向用户提供错误的详细信息。同时我们还向客户提供了一个错误ID。

当我们修复这个错误时,我们可以联系客户通知关于这个特定 ID 错误的具体情况,简直太酷了。

当然,如果你不想自己来记录和追踪管理所有错误的话,有很多现成的第三方错误追踪服务可供你选用。例如sentryrollbar,或者 jsnlog

404错误

404错误是十分常见和典型的错误,它会在你请求一个服务器不存在的页面时发生。但在使用单页面应用的项目(SPA)中,页面已经在客户端,不需要通过网络请求获取,网络请求通常只被用来请求用于填充页面的数据

所以,我们应该在什么时候展示一个404错误页呢?那就是在我们请求一个新页面填充所需的数据时,换句话说,当路由改变,新页面开始渲染,但新页面所需的数据并不可用时。这主要分为两种情况:

  • 路由URL改变,但新URL是未定义或者不可用的。
  • 路由守卫解析失败(对应路由所需的数据请求失败)

对于第一种情况,导航向一个不存在的URL,我们可以声明一个通配符(\*)*来匹配所有不存在的路由导航。

1
2
3
4
5
6
7
8
9
10
11
12
13
// errors-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ErrorsComponent } from '../errors-component/errors.component';
const routes: Routes = [
{ path: 'error', component: ErrorsComponent },
{ path: '**', component: ErrorsComponent, data: { error: 404 } },
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ErrorRoutingModule { }

通配符将匹配到的路由定向到我们上面创建的错误组件并传递一个404错误给它。

对于第二种情况,当导航的路由守卫解析失败时,我们可以监听路由的导航错误NavigationError

我们在上面创建的ErrorsService中进行NavigationError错误的订阅。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// errors.service.ts
@Injectable()
export class ErrorsService {
constructor(
private injector: Injector,
private router: Router,
) {
// 监听 navigation 错误
this.router
.events
.subscribe((event: Event) => {
// 重定向到 ErrorComponent 组件
if (event instanceof NavigationError) {
if (!navigator.onLine) { return; }
this.log(event.error)
.subscribe((errorWithContext) => {
this.router.navigate(['/error'], { queryParams: errorWithContext });
});
}
});
}
...
}

现在,当我们解析路由守卫失败时,将会携带着错误信息作为查询参数重定向到ErrorComponent组件。你可以点击上面实际例子中的Go To Page按钮来看看实际效果。

我差点忘了NavigationErrorErrorHandler中同样也会被捕获,这对我来说很有意义,但现在Angular在数据请求失败时会抛出一个Uncaught(in Promise)错误(而不是错误的具体信息,因为具体错误已经被ErrorsService捕获了)

OK,这篇博客就是这些了。你看到了错误是如何发生的,那么,当错误出现时会发生什么,将由你来决定了。

感谢阅读!

----本文结束感谢阅读----