原文链接:http://javascript.info/custom-errors,translate with ❤️ by zhangbao.

在开发的时候,我们经常需要定义我们自己的错误类型,方便反映发生在项目里的具体错误类型。对于网络操作错误我们可能需要 HttpError,数据操作错误用 DbError,查找错误使用 NotFoundError 等等。

我们的自定义应该支持基本的属性,像 messagename,嗯,最好还有 stack。但是也可以有他们自己的一个属性,比如 HttpError 对象可以有 statusCode 属性,其属性值可能为 404403 或者 500

JavaScript 允许 throw 后面跟任何类型值,所以从技术上讲,自定义错误对象不必非要继承自 Error。如果我们继承了的话,就可以使用 obj instanceof Error 去识别错误对象。说一说,最好继承自 Error

当构建项目时,我们的自定义错误通常也有继承关系。比如,HttpTimeoutError 继承 HttpError 等等。

扩展 Error

作为一个例子,我们考虑函数 readUser(json) 用来读取 JSON 格式的用户数据。

这里是一个有效 JSON 数据格式的例子:

  1. let json = `{ "name": "John", "age": 30 }`;

在内部,我们使用 JSON.parse 解析 JSON 格式文本,如果是无效 JSON 格式,就会抛出 SyntaxError 错误。

但即使 json 没有语法错误,也并不意味着这是一个有效的用户,对吗?它可能会缺少必要字段。例如,可能没有 name 或者 age 属性。

我们的函数 readUser(json) 不仅仅读取 JSON,而且会检查(“验证”)数据。如果缺少了必要的字段,或是格式错误的话,都会产生错误。就是说,错误不是只有 SyntaxError 类型的,因为数据可能是语法有效的,产生的是其他类型的错误。我们叫它 ValidationError 并且用类创建它,这种错误包含有关违规字段信息。

我们的 Validator 类应该继承内置的 Error 类。

这个类是内置的,但是我们应该看看它的伪代码,以了解我们正在扩展的内容。

  1. class Error {
  2. constructor (message) {
  3. this.message = message;
  4. this.name = 'Error'; // 不同的内置错误对象有不同的 name 属性值
  5. this.stack = <nested calls>; // 非标准属性,但是许多环境支持它
  6. }
  7. }

现在我们继续,用 Validator 继承它:

  1. class ValidatorError extends Error {
  2. constructor (message) {
  3. super(message); // (1)
  4. this.name = 'ValidatorError'; // (2)
  5. }
  6. }
  7. function test() {
  8. throw new ValidationError('Whoops!');
  9. }
  10. try {
  11. test();
  12. } catch(err) {
  13. alert(err.message); // Whoops!
  14. alert(err.name); // ValidationError
  15. alert(err.stack); // a list of nested calls with line numbers for each
  16. }

请看一下构造函数:

  1. (1) 处,我们调用了父级构造器。JavaScript 引擎要求我们要在子类构造器中调用 super,这是必须的。父类构造器设置了 message 属性。

  2. 父类构造器也会设置 name 属性值为“Error”,因此 (2) 行我们将其重置为正确的值。

我们在 readUser(json) 使用下:

  1. class ValidationError extends Error {
  2. constructor(message) {
  3. super(message);
  4. this.name = "ValidationError";
  5. }
  6. }
  7. // 使用
  8. function readUser(json) {
  9. let user = JSON.parse(json);
  10. if (!user.age) {
  11. throw new ValidationError("No field: age");
  12. }
  13. if (!user.name) {
  14. throw new ValidationError("No field: name");
  15. }
  16. return user;
  17. }
  18. // 在 try..catch 中使用
  19. try {
  20. let user = readUser('{ "age": 25 }');
  21. } catch (err) {
  22. if (err instanceof ValidationError) {
  23. alert("Invalid data: " + err.message); // Invalid data: No field: name
  24. } else if (err instanceof SyntaxError) { // (*)
  25. alert("JSON Syntax Error: " + err.message);
  26. } else {
  27. throw err; // unknown error, rethrow it (**)
  28. }
  29. }

try..catch 代码块在这里处理了两种错误类型:一个是 Validator 错误;一个是由内置方法 JSON.parse 触发的 SyntaxError错误。

请注意,我们在 (*) 处使用 instanceof 操作符检查错误类型。

我们也可以检查 err.name,像这样:

  1. // ...
  2. // 不使用 err instanceof SyntaxError
  3. } else if (err.name === 'SyntaxError') { // (*)
  4. // ...

使用 instanceof 版本代码会更好一点,因为在未来我们可能会扩展 ValidatorError,从中扩展出子类,像 PropertyRequiredError。而 instanceof 检查对新子类错误类型检查仍是 OK 的。它不会过时的。

同样重要的是,如果 catch 遇到一个未知错误,那么它就会在 (**) 处重新抛出。catch 只知道如何处理语法和有效性错误,其他类型(由于代码中的一个输入错误)不会处理,而是交由外部代码。

进一步继承

ValidationError 类太通用了,可能导致许多事情可能不对。属性可能不存在,也可能是错误的格式(比如给 age 字段一个字符串值)。让我们做一个更具体的类 PropertyRequiredError,携带关于丢失的属性的附加信息。

  1. class ValidationError extends Error {
  2. constructor(message) {
  3. super(message);
  4. this.name = "ValidationError";
  5. }
  6. }
  7. class PropertyRequiredError extends ValidationError {
  8. constructor(property) {
  9. super("No property: " + property);
  10. this.name = "PropertyRequiredError";
  11. this.property = property;
  12. }
  13. }
  14. // 使用
  15. function readUser(json) {
  16. let user = JSON.parse(json);
  17. if (!user.age) {
  18. throw new PropertyRequiredError("age");
  19. }
  20. if (!user.name) {
  21. throw new PropertyRequiredError("name");
  22. }
  23. return user;
  24. }
  25. // 在 try..catch 中使用
  26. try {
  27. let user = readUser('{ "age": 25 }');
  28. } catch (err) {
  29. if (err instanceof ValidationError) {
  30. alert("Invalid data: " + err.message); // Invalid data: No property: name
  31. alert(err.name); // PropertyRequiredError
  32. alert(err.property); // name
  33. } else if (err instanceof SyntaxError) {
  34. alert("JSON Syntax Error: " + err.message);
  35. } else {
  36. throw err; // unknown error, rethrow it
  37. }
  38. }

新类型 PropertyRequiredError 很好使用:我们只需要通过 new PropertyRequiredError(property) 方式传递属性名。人类可读的 message 信息会由构造函数生成。

请注意,PropertyRequiredError 构造器里再一次为 this.name 手工赋值了。这可能会变得有点麻烦——在创建每个自定义错误时都要分配 this.name=<class name>。但有一条出路,我们可以创建自己的“基本错误”类,通过在构造函数中使用 this.constructor.name 来消除我们肩上的负担,然后后来的子类都继承它。

我们可以假设现叫这个基础类叫 MyError

下面是 MyError 和其他自定义错误类的代码,简化了:

  1. class MyError extends Error {
  2. constructor (message) {
  3. super(message);
  4. this.name = this.constructor.name;
  5. }
  6. }
  7. class ValidatorError extends MyError {
  8. constructor (property) {
  9. super('No property: ' + property);
  10. this.property = property;
  11. }
  12. }
  13. // name 属性值是正确的
  14. alert( new PropertyRequiredError("field").name ); // PropertyRequiredError

现在的自定义错误要短得多,特别是 ValidationError,因为我们在构造函数中去掉了 this.name = ... 这一行内容。

包装异常

上面的代码中 readUser 函数的目的是“读取用户数据”,对吗?在这个过程中可能会出现不同类型的错误。现在我们有了 SyntaxErrorValidationError,但是在将来,readUser 函数可能会增长:新的代码可能会产生其他类型的错误。

调用 readUser 的代码应该处理这些错误。现在,它在 catch 块中使用多个 if 来检查不同的错误类型,并重新抛出未知的错误类型。但是如果 readUser 函数产生了几种类型的错误——那么我们应该问自己:我们真的想要在每一个调用 readUser 的代码中逐一检查所有的错误类型吗?

通常答案是“不”:外部代码想要“高于一切”。它想要有某种“数据读取错误”。为什么会发生这种情况——通常是不相关的(错误消息描述了它)。或者,如果有一种方法可以得到错误细节,那就更好了,但前提是我们需要。

因此,让我们创建一个新的类 ReadError 来表示这些错误。如果在 readUser 中发生了错误,我们将在那里捕获并产生 ReadError。我们还将在其 cause 属性中保留对原始错误的引用。然后,外部代码只需要检查 ReadError

下面是定义 ReadError 的代码,并在 readUser 中的 try..catch 中演示它的用法:

  1. class ReadError extends Error {
  2. constructor(message, cause) {
  3. super(message);
  4. this.cause = cause;
  5. this.name = 'ReadError';
  6. }
  7. }
  8. class ValidationError extends Error { /*...*/ }
  9. class PropertyRequiredError extends ValidationError { /* ... */ }
  10. function validateUser(user) {
  11. if (!user.age) {
  12. throw new PropertyRequiredError("age");
  13. }
  14. if (!user.name) {
  15. throw new PropertyRequiredError("name");
  16. }
  17. }
  18. function readUser(json) {
  19. let user;
  20. try {
  21. user = JSON.parse(json);
  22. } catch (err) {
  23. if (err instanceof SyntaxError) {
  24. throw new ReadError("Syntax Error", err);
  25. } else {
  26. throw err;
  27. }
  28. }
  29. try {
  30. validateUser(user);
  31. } catch (err) {
  32. if (err instanceof ValidationError) {
  33. throw new ReadError("Validation Error", err);
  34. } else {
  35. throw err;
  36. }
  37. }
  38. }
  39. try {
  40. readUser('{bad json}');
  41. } catch (e) {
  42. if (e instanceof ReadError) {
  43. alert(e);
  44. // Original error: SyntaxError: Unexpected token b in JSON at position 1
  45. alert("Original error: " + e.cause);
  46. } else {
  47. throw e;
  48. }
  49. }

在上面的代码中,readUser 的工作方式与所描述的完全一样——捕获语法和验证错误,并抛出 ReadError 错误(未知的错误像往常一样被重新抛出)。

所以外部代码检查了 ReadError 的实例,就是这样。不需要列出可能的所有错误类型。

这种方法被称为“包装异常”,因为我们将“低级别异常”“包装”到 ReadError 中,这对于调用代码来说更加抽象和方便,它在面向对象编程中得到了广泛的应用。

总结

  • 我们可以继承 Error 和其他内置错误类型,只需要注意 name 属性,也不要忘记调用 super

  • 大多数情况下,我们应该使用 instanceof 来检查特定的错误,它也会检查原型链。但是有时候我们有一个来自第三方库的错误对象,没有简单的方法来得到这个类。然后,此类情况适合使用 name 属性检查。

  • 包装异常是一种广泛的技术,用函数来处理低层异常,并用一个更高层对象来报告错误。低层异常成为高层对象得一个属性(比如上面例子里得 err.cause),但这并不是严格要求的。

(完)