2022年7月21日

错误处理,"try...catch"

不管你多么精通编程,有时我们的脚本总还是会出现错误。可能是因为我们的编写出错,或是与预期不同的用户输入,或是错误的服务端响应以及其他数千种原因。

通常,如果发生错误,脚本就会“死亡”(立即停止),并在控制台将错误打印出来。

但是有一种语法结构 try...catch,它使我们可以“捕获(catch)”错误,因此脚本可以执行更合理的操作,而不是死掉。

“try…catch” 语法

try...catch 结构由两部分组成:trycatch

try {

  // 代码...

} catch (err) {

  // 错误捕获

}

它按照以下步骤执行:

  1. 首先,执行 try {...} 中的代码。
  2. 如果这里没有错误,则忽略 catch (err):执行到 try 的末尾并跳过 catch 继续执行。
  3. 如果这里出现错误,则 try 执行停止,控制流转向 catch (err) 的开头。变量 err(我们可以使用任何名称)将包含一个 error 对象,该对象包含了所发生事件的详细信息。

所以,try {...} 块内的 error 不会杀死脚本 —— 我们有机会在 catch 中处理它。

让我们来看一些例子。

  • 没有 error 的例子:显示 alert (1)(2)

    try {
    
      alert('开始执行 try 中的内容');  // (1) <--
    
      // ...这里没有 error
    
      alert('try 中的内容执行完毕');   // (2) <--
    
    } catch (err) {
    
      alert('catch 被忽略,因为没有 error'); // (3)
    
    }
  • 包含 error 的例子:显示 (1)(3) 行的 alert 中的内容:

    try {
    
      alert('开始执行 try 中的内容');  // (1) <--
    
      lalala; // error,变量未定义!
    
      alert('try 的末尾(未执行到此处)');  // (2)
    
    } catch (err) {
    
      alert(`出现了 error!`); // (3) <--
    
    }
try...catch 仅对运行时的 error 有效

要使得 try...catch 能工作,代码必须是可执行的。换句话说,它必须是有效的 JavaScript 代码。

如果代码包含语法错误,那么 try..catch 将无法正常工作,例如含有不匹配的花括号:

try {
  {{{{{{{{{{{{
} catch (err) {
  alert("引擎无法理解这段代码,它是无效的");
}

JavaScript 引擎首先会读取代码,然后运行它。在读取阶段发生的错误被称为“解析时间(parse-time)”错误,并且无法恢复(从该代码内部)。这是因为引擎无法理解该代码。

所以,try...catch 只能处理有效代码中出现的错误。这类错误被称为“运行时的错误(runtime errors)”,有时被称为“异常(exceptions)”。

try...catch 同步执行

如果在“计划的(scheduled)”代码中发生异常,例如在 setTimeout 中,则 try...catch 不会捕获到异常:

try {
  setTimeout(function() {
    noSuchVariable; // 脚本将在这里停止运行
  }, 1000);
} catch (err) {
  alert( "不工作" );
}

因为 try...catch 包裹了计划要执行的函数,该函数本身要稍后才执行,这时引擎已经离开了 try...catch 结构。

为了捕获到计划的(scheduled)函数中的异常,那么 try...catch 必须在这个函数内:

setTimeout(function() {
  try {
    noSuchVariable; // try...catch 处理 error 了!
  } catch {
    alert( "error 被在这里捕获了!" );
  }
}, 1000);

Error 对象

发生错误时,JavaScript 会生成一个包含有关此 error 详细信息的对象。然后将该对象作为参数传递给 catch

try {
  // ...
} catch (err) { // <-- “error 对象”,也可以用其他参数名代替 err
  // ...
}

对于所有内建的 error,error 对象具有两个主要属性:

name
Error 名称。例如,对于一个未定义的变量,名称是 "ReferenceError"
message
关于 error 的详细文字描述。

还有其他非标准的属性在大多数环境中可用。其中被最广泛使用和支持的是:

stack
当前的调用栈:用于调试目的的一个字符串,其中包含有关导致 error 的嵌套调用序列的信息。

例如:

try {
  lalala; // error, variable is not defined!
} catch (err) {
  alert(err.name); // ReferenceError
  alert(err.message); // lalala is not defined
  alert(err.stack); // ReferenceError: lalala is not defined at (...call stack)

  // 也可以将一个 error 作为整体显示出来
  // error 信息被转换为像 "name: message" 这样的字符串
  alert(err); // ReferenceError: lalala is not defined
}

可选的 “catch” 绑定

最近新增的特性
这是一个最近添加到 JavaScript 的特性。 旧式浏览器可能需要 polyfills.

如果我们不需要 error 的详细信息,catch 也可以忽略它:

try {
  // ...
} catch { // <-- 没有 (err)
  // ...
}

使用 “try…catch”

让我们一起探究一下真实场景中 try...catch 的用例。

正如我们所知道的,JavaScript 支持 JSON.parse(str) 方法来解析 JSON 编码的值。

通常,它被用来解析从网络、服务器或是其他来源接收到的数据。

我们收到数据后,然后像下面这样调用 JSON.parse

let json = '{"name":"John", "age": 30}'; // 来自服务器的数据

let user = JSON.parse(json); // 将文本表示转换成 JavaScript 对象

// 现在 user 是一个解析自 json 字符串的有自己属性的对象
alert( user.name ); // John
alert( user.age );  // 30

你可以在 JSON 方法,toJSON 一章中找到更多关于 JSON 的详细内容。

如果 json 格式错误,JSON.parse 就会生成一个 error,因此脚本就会“死亡”。

我们对此满意吗?当然不!

如果这样做,当拿到的数据出了问题,那么访问者永远都不会知道原因(除非他们打开开发者控制台)。代码执行失败却没有提示信息,这真的是很糟糕的用户体验。

让我们用 try...catch 来处理这个 error:

let json = "{ bad json }";

try {

  let user = JSON.parse(json); // <-- 当出现 error 时...
  alert( user.name ); // 不工作

} catch (err) {
  // ...执行会跳转到这里并继续执行
  alert( "很抱歉,数据有错误,我们会尝试再请求一次。" );
  alert( err.name );
  alert( err.message );
}

在这儿,我们将 catch 块仅仅用于显示信息,但我们可以做更多的事:发送一个新的网络请求,向访问者建议一个替代方案,将有关错误的信息发送给记录日志的设备,……。所有这些都比代码“死掉”好得多。

抛出我们自定义的 error

如果这个 json 在语法上是正确的,但是没有所必须的 name 属性该怎么办?

像这样:

let json = '{ "age": 30 }'; // 不完整的数据

try {

  let user = JSON.parse(json); // <-- 没有 error
  alert( user.name ); // 没有 name!

} catch (err) {
  alert( "doesn't execute" );
}

这里 JSON.parse 正常执行,但缺少 name 属性对我们来说确实是个 error。

为了统一进行 error 处理,我们将使用 throw 操作符。

“throw” 操作符

throw 操作符会生成一个 error 对象。

语法如下:

throw <error object>

技术上讲,我们可以将任何东西用作 error 对象。甚至可以是一个原始类型数据,例如数字或字符串,但最好使用对象,最好使用具有 namemessage 属性的对象(某种程度上保持与内建 error 的兼容性)。

JavaScript 中有很多内建的标准 error 的构造器:ErrorSyntaxErrorReferenceErrorTypeError 等。我们也可以使用它们来创建 error 对象。

它们的语法是:

let error = new Error(message);
// 或
let error = new SyntaxError(message);
let error = new ReferenceError(message);
// ...

对于内建的 error(不是对于其他任何对象,仅仅是对于 error),name 属性刚好就是构造器的名字。message 则来自于参数(argument)。

例如:

let error = new Error("Things happen o_O");

alert(error.name); // Error
alert(error.message); // Things happen o_O

让我们来看看 JSON.parse 会生成什么样的 error:

try {
  JSON.parse("{ bad json o_O }");
} catch(err) {
  alert(err.name); // SyntaxError
  alert(err.message); // Unexpected token b in JSON at position 2
}

正如我们所看到的, 那是一个 SyntaxError

在我们的示例中,缺少 name 属性就是一个 error,因为用户必须有一个 name

所以,让我们抛出这个 error。

let json = '{ "age": 30 }'; // 不完整的数据

try {

  let user = JSON.parse(json); // <-- 没有 error

  if (!user.name) {
    throw new SyntaxError("数据不全:没有 name"); // (*)
  }

  alert( user.name );

} catch(err) {
  alert( "JSON Error: " + err.message ); // JSON Error: 数据不全:没有 name
}

(*) 标记的这一行,throw 操作符生成了包含着我们所给定的 messageSyntaxError,与 JavaScript 自己生成的方式相同。try 的执行立即停止,控制流转向 catch 块。

现在,catch 成为了所有 error 处理的唯一场所:对于 JSON.parse 和其他情况都适用。

再次抛出(Rethrowing)

在上面的例子中,我们使用 try...catch 来处理不正确的数据。但是在 try {...} 块中是否可能发生 另一个预料之外的 error?例如编程错误(未定义变量)或其他错误,而不仅仅是这种“不正确的数据”。

例如:

let json = '{ "age": 30 }'; // 不完整的数据

try {
  user = JSON.parse(json); // <-- 忘记在 user 前放置 "let"

  // ...
} catch (err) {
  alert("JSON Error: " + err); // JSON Error: ReferenceError: user is not defined
  // (实际上并没有 JSON Error)
}

当然,一切皆有可能!程序员也会犯错。即使是被数百万人使用了几十年的开源项目中,也可能突然被发现了一个漏洞,并导致可怕的黑客入侵。

在我们的例子中,try...catch 旨在捕获“数据不正确”的 error。但实际上,catch 会捕获到 所有 来自于 try 的 error。在这儿,它捕获到了一个预料之外的 error,但仍然抛出的是同样的 "JSON Error" 信息。这是不正确的,并且也会使代码变得更难以调试。

为了避免此类问题,我们可以采用“重新抛出”技术。规则很简单:

catch 应该只处理它知道的 error,并“抛出”所有其他 error。

“再次抛出(rethrowing)”技术可以被更详细地解释为:

  1. Catch 捕获所有 error。
  2. catch (err) {...} 块中,我们对 error 对象 err 进行分析。
  3. 如果我们不知道如何处理它,那我们就 throw err

通常,我们可以使用 instanceof 操作符判断错误类型:

try {
  user = { /*...*/ };
} catch (err) {
  if (err instanceof ReferenceError) {
    alert('ReferenceError'); // 访问一个未定义(undefined)的变量产生了 "ReferenceError"
  }
}

我们还可以从 err.name 属性中获取错误的类名。所有原生的错误都有这个属性。另一种方式是读取 err.constructor.name

在下面的代码中,我们使用“再次抛出”,以达到在 catch 中只处理 SyntaxError 的目的:

let json = '{ "age": 30 }'; // 不完整的数据
try {

  let user = JSON.parse(json);

  if (!user.name) {
    throw new SyntaxError("数据不全:没有 name");
  }

  blabla(); // 预料之外的 error

  alert( user.name );

} catch (err) {

  if (err instanceof SyntaxError) {
    alert( "JSON Error: " + err.message );
  } else {
    throw err; // 再次抛出 (*)
  }

}

如果 (*) 标记的这行 catch 块中的 error 从 try...catch 中“掉了出来”,那么它也可以被外部的 try...catch 结构(如果存在)捕获到,如果外部不存在这种结构,那么脚本就会被杀死。

所以,catch 块实际上只处理它知道该如何处理的 error,并“跳过”所有其他的 error。

下面这个示例演示了这种类型的 error 是如何被另外一级 try...catch 捕获的:

function readData() {
  let json = '{ "age": 30 }';

  try {
    // ...
    blabla(); // error!
  } catch (err) {
    // ...
    if (!(err instanceof SyntaxError)) {
      throw err; // 再次抛出(不知道如何处理它)
    }
  }
}

try {
  readData();
} catch (err) {
  alert( "External catch got: " + err ); // 捕获了它!
}

上面这个例子中的 readData 只知道如何处理 SyntaxError,而外部的 try...catch 知道如何处理任意的 error。

try…catch…finally

等一下,以上并不是所有内容。

try...catch 结构可能还有一个代码子句(clause):finally

如果它存在,它在所有情况下都会被执行:

  • try 之后,如果没有 error,
  • catch 之后,如果有 error。

该扩展语法如下所示:

try {
   ... 尝试执行的代码 ...
} catch (err) {
   ... 处理 error ...
} finally {
   ... 总是会执行的代码 ...
}

试试运行这段代码:

try {
  alert( 'try' );
  if (confirm('Make an error?')) BAD_CODE();
} catch (err) {
  alert( 'catch' );
} finally {
  alert( 'finally' );
}

这段代码有两种执行方式:

  1. 如果你对于 “Make an error?” 的回答是 “Yes”,那么执行 try -> catch -> finally
  2. 如果你的回答是 “No”,那么执行 try -> finally

finally 子句(clause)通常用在:当我们开始做某事的时候,希望无论出现什么情况都要完成完成某个任务。

例如,我们想要测量一个斐波那契数字函数 fib(n) 执行所需要花费的时间。通常,我们可以在运行它之前开始测量,并在运行完成时结束测量。但是,如果在该函数调用期间出现 error 该怎么办?特别是,下面这段 fib(n) 的实现代码在遇到负数或非整数数字时会返回一个 error。

无论如何,finally 子句都是一个结束测量的好地方。

在这儿,finally 能够保证在两种情况下都能正确地测量时间 —— 成功执行 fib 以及 fib 中出现 error 时:

let num = +prompt("输入一个正整数?", 35)

let diff, result;

function fib(n) {
  if (n < 0 || Math.trunc(n) != n) {
    throw new Error("不能是负数,并且必须是整数。");
  }
  return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}

let start = Date.now();

try {
  result = fib(num);
} catch (err) {
  result = 0;
} finally {
  diff = Date.now() - start;
}

alert(result || "出现了 error");

alert( `执行花费了 ${diff}ms` );

你可以通过运行上面这段代码并在 prompt 弹窗中输入 35 来进行检查 —— 代码运行正常,先执行 try 然后是 finally。如果你输入的是 -1 —— 将立即出现 error,执行将只花费 0ms。以上两种情况下的时间测量都正确地完成了。

换句话说,函数 fibreturn 还是 throw 完成都无关紧要。在这两种情况下都会执行 finally 子句。

变量和 try...catch...finally 中的局部变量

请注意,上面代码中的 resultdiff 变量都是在 try...catch 之前 声明的。

否则,如果我们使用 lettry 块中声明变量,那么该变量将只在 try 块中可见。

finallyreturn

finally 子句适用于 try...catch任何 出口。这包括显式的 return

在下面这个例子中,在 try 中有一个 return。在这种情况下,finally 会在控制转向外部代码前被执行。

function func() {

  try {
    return 1;

  } catch (err) {
    /* ... */
  } finally {
    alert( 'finally' );
  }
}

alert( func() ); // 先执行 finally 中的 alert,然后执行这个 alert
try...finally

没有 catch 子句的 try...finally 结构也很有用。当我们不想在这儿处理 error(让它们 fall through),但是需要确保我们启动的处理需要被完成。

function func() {
  // 开始执行需要被完成的操作(比如测量)
  try {
    // ...
  } finally {
    // 完成前面我们需要完成的那件事,即使 try 中的执行失败了
  }
}

上面的代码中,由于没有 catch,所以 try 中的 error 总是会使代码执行跳转至函数 func() 外。但是,在跳出之前需要执行 finally 中的代码。

全局 catch

环境特定

这个部分的内容并不是 JavaScript 核心的一部分。

设想一下,在 try...catch 结构外有一个致命的 error,然后脚本死亡了。这个 error 就像编程错误或其他可怕的事儿那样。

有什么办法可以用来应对这种情况吗?我们可能想要记录这个 error,并向用户显示某些内容(通常用户看不到错误信息)等。

规范中没有相关内容,但是代码的执行环境一般会提供这种机制,因为它确实很有用。例如,Node.JS 有 process.on("uncaughtException")。在浏览器中,我们可以将一个函数赋值给特殊的 window.onerror 属性,该函数将在发生未捕获的 error 时执行。

语法如下:

window.onerror = function(message, url, line, col, error) {
  // ...
};
message
error 信息。
url
发生 error 的脚本的 URL。
linecol
发生 error 处的代码的行号和列号。
error
error 对象。

例如:

<script>
  window.onerror = function(message, url, line, col, error) {
    alert(`${message}\n At ${line}:${col} of ${url}`);
  };

  function readData() {
    badFunc(); // 啊,出问题了!
  }

  readData();
</script>

全局错误处理程序 window.onerror 的作用通常不是恢复脚本的执行 —— 如果发生编程错误,恢复脚本的执行几乎是不可能的,它的作用是将错误信息发送给开发者。

也有针对这种情况提供 error 日志的 Web 服务,例如 https://errorception.comhttp://www.muscula.com

它们会像这样运行:

  1. 我们注册该服务,并拿到一段 JavaScript 代码(或脚本的 URL),然后插入到页面中。
  2. 该 JavaScript 脚本设置了自定义的 window.onerror 函数。
  3. 当发生 error 时,它会发送一个此 error 相关的网络请求到服务提供方。
  4. 我们可以登录到服务方的 Web 界面来查看这些 error。

总结

try...catch 结构允许我们处理执行过程中出现的 error。从字面上看,它允许“尝试”运行代码并“捕获”其中可能发生的 error。

语法如下:

try {
  // 执行此处代码
} catch (err) {
  // 如果发生 error,跳转至此处
  // err 是一个 error 对象
} finally {
  // 无论怎样都会在 try/catch 之后执行
}

这儿可能会没有 catch 或者没有 finally,所以 try...catchtry...finally 都是可用的。

Error 对象包含下列属性:

  • message _ 人类可读的 error 信息。
  • name —— 具有 error 名称的字符串(Error 构造器的名称)。
  • stack(没有标准,但得到了很好的支持)—— Error 发生时的调用栈。

如果我们不需要 error 对象,我们可以通过使用 catch { 而不是 catch (err) { 来省略它。

我们也可以使用 throw 操作符来生成自定义的 error。从技术上讲,throw 的参数可以是任何东西,但通常是继承自内建的 Error 类的 error 对象。下一章我们会详细介绍扩展 error。

再次抛出(rethrowing)是一种错误处理的重要模式:catch 块通常期望并知道如何处理特定的 error 类型,因此它应该再次抛出它不知道的 error。

即使我们没有 try...catch,大多数执行环境也允许我们设置“全局” error 处理程序来捕获“掉出(fall out)”的 error。在浏览器中,就是 window.onerror

任务

重要程度: 5

比较下面两个代码片段。

  1. 第一个代码片段,使用 finallytry..catch 之后执行代码:

    try {
      // 工作
    } catch (err) {
      // 处理 error
    } finally {
      // 清理工作空间
    }
  2. 第二个代码片段,将清空工作空间的代码放在了 try...catch 之后:

    try {
      // 工作
    } catch (err) {
      // 处理 error
    }
    
    // 清理工作空间

我们肯定需要在工作后进行清理,无论工作过程中是否有 error 都不影响。

在这儿使用 finally 更有优势,还是说两个代码片段效果一样?如果在这有这样的优势,如果需要,请举例说明。

当我们看函数中的代码时,差异就变得很明显了。

如果在这有“跳出” try..catch 的行为,那么这两种方式的表现就不同了。

例如,当 try...catch 中有 return 时。finally 子句会在 try...catch任意 出口处起作用,即使是通过 return 语句退出的也是如此:在 try...catch 刚刚执行完成后,但在调用代码获得控制权之前。

function f() {
  try {
    alert('start');
    return "result";
  } catch (err) {
    /// ...
  } finally {
    alert('cleanup!');
  }
}

f(); // cleanup!

……或者当有 throw 时,如下所示:

function f() {
  try {
    alert('start');
    throw new Error("一个 error");
  } catch (err) {
    // ...
    if("无法处理此 error") {
      throw err;
    }

  } finally {
    alert('cleanup!')
  }
}

f(); // cleanup!

正是这里的 finally 保证了 cleanup。如果我们只是将代码放在函数 f 的末尾,则在这些情况下它不会运行。

教程路线图