跳到主要内容

错误处理与调试

JavaScript 一直以来被认为是最难调试的编程语言之一,因为它是动态的,且多年来没有适当的开发工具。错误经常会以令人迷惑的浏览器消息形式抛出,比如"object expected"。这样的消息没有上下文,因此很难理解。ECMAScript 第 3 版致力于改进这个方面,引入了 try/catchthrow 语句, 20 以及一些错误类型,以帮助开发者在出错时正确地处理它们。几年后,JavaScript 调试器和排错工具开始在浏览器中出现。到了 2008 年,大多数浏览器支持一些 JavaScript 调试能力。

有了适当的语言和开发工具,Web 开发者如今已可以实现适当的错误处理并找到问题的原因。

浏览器错误报告

所有主流桌面浏览器,包括 IE/Edge、Firefox、Safari、Chrome 和 Opera,都提供了向用户报告错误的机制。默认情况下,所有浏览器都会隐藏错误信息。一个原因是除了开发者之外这些信息对别人没什么用,另一个原因是网页在正常操作中报错的固有特性。

桌面控制台

所有现代桌面浏览器都会通过控制台暴露错误。这些错误可以显示在开发者工具内嵌的控制台中。在前面提到的所有浏览器中,访问开发者工具的路径是相似的。可能最简单的查看错误的方式就是在页面上单击鼠标右键,然后在上下文菜单中选择 Inspect(检查)或 Inspect Element(检查元素),然后再单击 Console(控制台)选项卡。

移动控制台

移动浏览器不会直接在设备上提供控制台界面。不过,还是有一些途径可以在移动设备中检查错误。

Chrome 移动版和 Safari 的 iOS 版内置了实用工具,支持将设备连接到宿主操作系统中相同的浏览器。然后,就可以在对应的桌面浏览器中查看错误了。这涉及设备之间的硬件连接,且要遵循不同的操作步骤,比如 Chrome 的操作步骤参见 Google Developers 网站的文章《Android 设备的远程调试入门》, Safari 的操作步骤参见 Apple Developer 网站的文章“Safari Web Inspector Guide”。

此外也可以使用第三方工具直接在移动设备上调试。Firefox 常用的调试工具是 Firebug Lite,这需要通过 JavaScript 的书签小工具向当前页面中加入 Firebug 脚本才可以。脚本运行后,就可以直接在移动浏览器上打开调试界面。Firebug Lite 也有面向其他浏览器(如 Chrome)的版本。

错误处理

错误处理在编程中的重要性毋庸置疑。所有主流 Web 应用程序都需要定义完善的错误处理协议, 大多数优秀的应用程序有自己的错误处理策略,尽管主要逻辑是放在服务器端的。事实上,服务器端团队通常会花很多精力根据错误类型、频率和其他重要指标来定义规范的错误日志机制。最终实现通过简单的数据库查询或报告生成脚本就可以了解应用程序的运行状态。

try/catch 语句

ECMA-262 第 3 版新增了 try/catch 语句,作为在 JavaScript 中处理异常的一种方式。基本的语法如下所示,跟 Java 中的 try/catch 语句一样:

try {
// 可能出错的代码
} catch (error) {
// 出错时要做什么
}

如果 try 块中有代码发生错误,代码会立即退出执行,并跳到 catch 块中。catch 块此时接收到一个对象,该对象包含发生错误的相关信息。与其他语言不同,即使在 catch 块中不使用错误对象, 也必须为它定义名称。错误对象中暴露的实际信息因浏览器而异,但至少包含保存错误消息的 message 属性。ECMA-262 也指定了定义错误类型的 name 属性,目前所有浏览器中都有这个属性。

try {
window.someNonexistentFunction();
} catch (error) {
console.log(error.message);
}

finally 子句

try/catch 语句中可选的 finally 子句始终运行。如果 try 块中的代码运行完,则接着执行 finally 块中的代码。如果出错并执行 catch 块中的代码,则 finally 块中的代码仍执行。try 或 catch 块无法阻止 finally 块执行,包括 return 语句。比如:

function testFinally() {
try {
return 0;
} catch (e) {
return 1;
} finally {
return 2;
}
}

console.log(testFinally()); // 2

finally 块的存在导致 try 块中的 return 语句被忽略。 因此,无论什么情况下调用该函数都会返回 2。如果去掉 finally 子句,该函数会返回 0。如果写出 finally 子句,catch 块就成了可选的(它们两者中只有一个是必需的)。

错误类型

代码执行过程中会发生各种类型的错误。每种类型都会对应一个错误发生时抛出的错误对象。 ECMA-262 定义了以下 8 种错误类型:

错误类型描述使用场景
Error所有错误类型的基类。通常用于自定义错误或表示通用错误。创建自定义错误对象,或者在不适用其他特定错误类型时使用。
InternalError表示 JavaScript 引擎内部错误,通常是内存问题或资源耗尽导致的。较少在常规代码中直接遇到,通常与实现相关。
EvalErroreval() 函数相关的错误。通常在禁用 eval() 时使用,但现代 JavaScript 很少使用。
RangeError数值超出其允许范围时抛出的错误,例如尝试创建长度为负数的数组。常见于数值计算或数组操作。
ReferenceError引用一个不存在的变量时抛出的错误。常见于访问未定义的变量或对象属性时。
SyntaxError代码中存在语法错误时抛出的错误。常见于代码解析阶段,例如 JSON.parse() 中的错误。
TypeError变量或参数不是预期类型时抛出的错误。常见于类型检查失败或对 nullundefined 进行操作时。
URIError全局 URI 处理函数(如 decodeURI)中使用不正确的 URI 时抛出的错误。常见于 URI 格式无效或编码错误时。

用法

try/catch 中发生错误时,浏览器会认为错误被处理了,因此就不会再使用本章前面提到的机制报告错误。如果应用程序的用户不懂技术,那么他们即使看到错误也看不懂,这是一个理想的结果。 使用 try/catch 可以针对特定错误类型实现自定义的错误处理。

try/catch 语句最好用在自己无法控制的错误上。例如,假设你的代码中使用了一个大型 JavaScript 库的某个函数,而该函数可能会有意或由于出错而抛出错误。因为不能修改这个库的代码,所以为防止这个函数报告错误,就有必要通过 try/catch 语句把该函数调用包装起来,对可能的错误进行处理。

如果你明确知道自己的代码会发生某种错误,那么就不适合使用 try/catch 语句。例如,如果给函数传入字符串而不是数值时就会失败,就应该检查该函数的参数类型并采取相应的操作。这种情况下, 没有必要使用 try/catch 语句。

抛出错误

try/catch 语句对应的一个机制是 throw 操作符,用于在任何时候抛出自定义错误。throw 操作符必须有一个值,但值的类型不限。下面这些代码都是有效的:

有效示例
throw 12345;
throw 'Hello world!';
throw true;
throw { name: 'JavaScript' };

使用 throw 操作符时,代码立即停止执行,除非 try/catch 语句捕获了抛出的值。

可以通过内置的错误类型来模拟浏览器错误。每种错误类型的构造函数都只接收一个参数,就是错误消息。

throw new Error('error message');

自定义错误常用的错误类型是 ErrorRangeErrorReferenceErrorTypeError 和其余4种错误类型:

throw new Error('error message');
throw new SyntaxError("I don't like your syntax.");
// InternalError 不是所有 JavaScript 环境中的标准错误类型,因此它可能不存在于你使用的环境中。
// throw new InternalError("I can't do that, Dave.");
throw new TypeError('What type of variable do you take me for?');
throw new RangeError("Sorry, you just don't have the range.");
throw new EvalError("That doesn't evaluate.");
throw new URIError('Uri, is that you?');
throw new ReferenceError("You didn't cite your references properly.");

自定义错误类型

如果你需要创建一个自定义的错误类型,可以继承自 Error 类来实现:

class CustomError extends Error {
constructor(message) {
super(message);
this.name = 'CustomError';
}
}

throw new CustomError("I can't do that, Dave.");

何时抛出错误

抛出自定义错误是解释函数为什么失败的有效方式。在出现已知函数无法正确执行的情况时就应该抛出错误。换句话说,浏览器会在给定条件下执行该函数时抛出错误。例如,下面的函数会在参数不是数组时抛出错误:

function process(values) {
values.sort();
for (let value of values) {
if (value > 100) {
return value;
}
}
return -1;
}

如果给这个函数传入字符串,调用 sort()函数就会失败。每种浏览器对此都会给出一个模棱两可的错误消息, 如 Chrome:对象名没有方法'sort';Firefox: values.sort() 不是函数。

如果是一个复杂的 Web 应用程序,有几千行 JavaScript 代码,想要找到错误的原因就会很难。这时候,使用适当的信息创建自定义错误可以有效提高代码的可维护性。

最佳实践
function process(values) {
if (!(values instanceof Array)) {
throw new Error('process(): Argument must be an array.');
}
values.sort();
for (let value of values) {
if (value > 100) {
return value;
}
}
return -1;
}

抛出错误与 try/catch

一个常见的问题是何时抛出错误,何时使用 try/catch 捕获错误。一般来说,错误要在应用程序架构的底层抛出,在这个层面上,人们对正在进行的流程知之甚少,因此无法真正地处理错误。如果你在编写一个可能用于很多应用程序的 JavaScript 库,或者一个会在应用程序的很多地方用到的实用函数, 那么应该认真考虑抛出带有详细信息的错误。然后捕获和处理错误交给应用程序就行了。

至于抛出错误与捕获错误的区别,可以这样想:应该只在确切知道接下来该做什么的时候捕获错误。捕获错误的目的是阻止浏览器以其默认方式响应;抛出错误的目的是为错误提供有关其发生原因的说明。

error 事件

任何没有被 try/catch 语句处理的错误都会在 window 对象上触发 error 事件。该事件是浏览器早期支持的事件,为保持向后兼容,很多浏览器保持了其格式不变。在 onerror 事件处理程序中,任何浏览器都不会传入 event 对象。相反,会传入 3 个参数:错误消息、发生错误的 URL 和行号。大多数情况下,只有错误消息有用,因为 URL 就是当前文档的地址,而行号可能指嵌入 JavaScript 或外部文件中的代码。另外,onerror 事件处理程序需要使用 DOM Level 0 技术来指定,因为它不遵循 DOM Level 2 Events 标准格式:

window.onerror = (message, url, line) => {
console.log(message);
};

在任何错误发生时,无论是否是浏览器生成的,都会触发 error 事件并执行这个事件处理程序。 然后,浏览器的默认行为就会生效,像往常一样显示这条错误消息。可以返回 false 来阻止浏览器默认报告错误的行为,如下所示:

window.onerror = (message, url, line) => {
console.log(message);
return false;
};

通过返回 false,这个函数实际上就变成了整个文档的 try/catch 语句,可以捕获所有未处理的 27 运行时错误。这个事件处理程序应该是处理浏览器报告错误的最后一道防线。理想情况下,最好永远不要用到。适当使用 try/catch 语句意味着不会有错误到达浏览器这个层次,因此也就不会触发 error 事件。

图片也支持 error 事件。任何时候,如果图片 src 属性中的 URL 没有返回可识别的图片格式,就会触发 error 事件。这个事件遵循 DOM 格式,返回一个以图片为目标的 event 对象。

const image = new Image();
image.addEventListener('load', event => {
console.log('Image loaded!');
});
image.addEventListener('error', event => {
console.log('Image not loaded!');
});

image.src = 'doesnotexist.gif'; // 不存在,资源会加载失败

错误处理策略

过去,Web 应用程序的错误处理策略基本上是在服务器上落地。错误处理策略涉及很多错误和错误处理考量,包括日志记录和监控系统。这些主要是为了分析模式,以期找到问题的根源并了解有多少用户会受错误影响。

在 Web 应用程序的 JavaScipt 层面落地错误处理策略同样重要。因为任何 JavaScript 错误都可能导致网页无法使用,所以理解这些错误会在什么情况下发生以及为什么会发生非常重要。绝大多数 Web 应用程序的用户不懂技术,在碰到页面出问题时通常会迷惑。为解决问题,他们可能会尝试刷新页面,也可能会直接放弃。作为开发者,应该非常清楚自己的代码在什么情况下会失败,以及失败会导致什么结果。另外,还要有一个系统跟踪这些问题。

识别错误

错误处理非常重要的部分是首先识别错误可能会在代码中的什么地方发生。因为 JavaScript 是松散类型的,不会验证函数参数,所以很多错误只有在代码真正运行起来时才会出现。通常,需要注意 3 类错误:

  • 类型转换错误
  • 数据类型错误
  • 通信错误

上面这几种错误会在特定情况下,在没有对值进行充分检测时发生。

静态代码分析器

静态代码分析器要求使用类型、函数签名及其他指令来注解 JavaScript,以此描述程序如何在基本可执行代码之外运行。分析器会比较注解和 JavaScript 代码的各个部分,对在实际运行时可能出现的潜在不兼容问题给出提醒。

类型转换错误

类型转换错误的主要原因是使用了会自动改变某个值的数据类型的操作符或语言构造。使用等于 (==)或不等于(!=)操作符,以及在 if、for 或 while 等流控制语句中使用非布尔值,经常会导致类型转换错误。

相等和不相等操作符会自动把执行比较的两个不同类型的值转换为相同类型。在非动态语言中,符号之间是直接比较的,因此很多开发者在 JavaScript 中也会以相同方式来错误地比较值。大多数情况下,最好使用严格相等(===)和严格不相等(!==)操作符来避免类型转换。

严格相等比较类型
console.log(5 == '5'); // true
console.log(5 === '5'); // false
console.log(1 == true); // true
console.log(1 === true); // false

类型转换错误也会发生在流控制语句中。比如,if 语句会自动把条件表达式转换为布尔值,然后再决定下一步的走向。在实践中,if 语句是问题比较多的。来看下面的例子:

function concat(str1, str2, str3) {
let result = str1 + str2;
if (str3) {
// 不要!
result += str3;
}
return result;
}

事实上,第三个参数在没有传入时,默认赋值 undefined,在 if 语句会默认转换成 false,如果传入了 0 同样会转换成 false,为避免这类错误,需要始终坚持使用布尔值作为条件。

正确写法
function concat(str1, str2, str3) {
let result = str1 + str2;
if (typeof str3 === 'string') {
// 恰当的比较
result += str3;
}
return result;
}

数据类型错误

因为 JavaScript 是松散类型的,所以变量和函数参数都不能保证会使用正确的数据类型。开发者需要自己检查数据类型,确保不会发生错误。数据类型错误常发生在将意外值传给函数的时候。

通信错误

随着 Ajax 编程的出现,Web 应用程序在运行期间动态加载数据和功能成为常见的情形。JavaScript 和服务器之间的通信也会出现错误。

第一种错误是 URL 格式或发送数据的格式不正确。通常,在把数据发送到服务器之前没有用 encodeURIComponent() 编码,会导致这种错误。

把错误记录到服务器中

Web 应用程序开发中的一个常见做法是建立中心化的错误日志存储和跟踪系统。数据库和服务器错误正常写到日志中并按照常用 API 加以分类。对复杂的 Web 应用程序而言,最好也把 JavaScript 错误发 25 送回服务器记录下来。这样做可以把错误记录到与服务器相同的系统,只要把它们归类到前端错误即可。 使用相同的系统可以进行相同的分析,而不用考虑错误来源。

要建立 JavaScript 错误日志系统,首先需要在服务器上有页面或入口可以处理错误数据。该页面只 26 要从查询字符串中取得错误数据,然后把它们保存到错误日志中即可

记录错误函数示例
function logError(sev, msg) {
let img = new Image(),
encodedSev = encodeURIComponent(sev),
encodedMsg = encodeURIComponent(msg);
img.src = 'log.php?sev=${encodedSev}&msg=${encodedMsg}';
}

logError() 函数接收两个参数:严重程度和错误消息。严重程度可以是数值或字符串,具体取决于使用的日志系统。这里使用 Image 对象发送请求主要是从灵活性方面考虑的。

  • 所有浏览器都支持 Image 对象,即使不支持 XMLHttpRequest 对象也一样。
  • 不受跨域规则限制。通常,接收错误消息的应该是多个服务器中的一个,而 XMLHttpRequest此时就比较麻烦。
  • 记录错误的过程很少出错。大多数 Ajax 通信借助 JavaScript 库的包装来处理。如果这个库本身出错,而你又要利用它记录错误,那么显然错误消息永远不会发给服务器。

只要是使用 try/catch 语句的地方,都可以把相关错误记录下来。

for (let mod of mods) {
try {
mod.init();
} catch (ex) {
logError('nonfatal', 'Module init failed: ${ex.message}');
}
}

调试技术

在 JavaScript 调试器出现以前,开发者必须使用创造性的方法调试代码。结果就出现了各种各样专门为输出调试信息而设计的代码。其中最为常用的调试技术是在相关代码中插入 alert(),这种方式既费事(调试完之后还得清理)又麻烦(如果有漏洞的警告框出现在产品环境中,会给用户造成不便)。 已不再推荐将警告框用于调试,因为有其他更好的解决方案。

把消息记录到控制台

所有主流浏览器都有 JavaScript 控制台,该控制台可用于查询 JavaScript 错误。另外,这些浏览器都支持通过 console 对象直接把 JavaScript 消息写入控制台,这个对象包含如下方法。

  • error(message):在控制台中记录错误消息。
  • info(message):在控制台中记录信息性内容。
  • log(message):在控制台记录常规消息。
  • warn(message):在控制台中记录警告消息。
提示

相比于使用警告框,打印日志消息是更好的调试方法。这是因为警告框会阻塞代码执行,从而影响对异步操作的计时,进而影响代码的结果。打印日志也可以随意输出任意多个参数并检查对象实例

理解控制台运行时

浏览器控制台是个读取-求值-打印-循环(REPL,read-eval-print-loop),与页面的 JavaScript 运行时并发。这个运行时就像浏览器对新出现在 DOM 中的 <script> 标签求值一样。在控制台中执行的命 19 令可以像页面级 JavaScript 一样访问全局和各种 API。控制台中可以执行任意数量的代码,与它可能会阻塞的任何页面级代码一样。修改、对象和回调都会保留在 DOM 和运行时中。

JavaScript 运行时会限制不同窗口可以访问哪些内容,因而在所有主流浏览器中都可以选择在哪个窗口中执行 JavaScript 控制台输入。你所执行的代码不会有特权提升,仍会受跨源限制和其他浏览器施加的控制规则约束。

控制台运行时也会集成开发者工具,提供常规 JavaScript 开发中所没有的上下文调试工具。其中一个非常有用的工具是最后点击选择器,所有主流浏览器都会提供。在开发者工具的 Element(元素)标签页内,单击 DOM 树中一个节点,就可以在 Console(控制台)标签页中使用 $0 引用该节点的 JavaScript 实例。它就跟普通的 JavaScript 实例一样,因此可以读取属性(如$0.scrollWidth),或者调用成员方法(如$0.remove())。

使用 JavaScript 调试器

在所有主流浏览器中都可以使用的还有 JavaScript 调试器。ECMAScript 5.1 规范定义了 debugger 关键字,用于调用可能存在的调试功能。如果没有相关的功能,这条语句会被简单地跳过。可以像下面 24 这样使用 debugger 关键字:

function pauseExecution() {
console.log('Will print before breakpoint');
debugger;
console.log('Will not print until breakpoint continues');
}

在运行时碰到这个关键字时,所有主流浏览器都会打开开发者工具面板,并在指定位置显示断点。 然后,可以通过单独的浏览器控制台在断点所在的特定词法作用域中执行代码。此外,还可以执行标准的代码调试器操作(单步进入、单步跳过、继续,等等)。

浏览器也支持在开发者工具的源代码标签页中选择希望设置断点的代码行来手动设置断点(不使用 debugger 关键字)。这样设置的断点与使用 debugger 关键字设置的一样,只是不会在不同浏览器会话之间保持。

在页面中打印消息

另一种常见的打印调试消息的方式是把消息写到页面中指定的区域。这个区域可以是所有页面中都包含的元素,但仅用于调试目的;也可以是在需要时临时创建的元素。例如,可以定义这样 log() 函数:

function log(message) {
// 这个函数的词法作用域会使用这个实例
// 而不是window.console
const console = document.getElementById('debuginfo');
if (console === null) {
console = document.createElement('div');
console.id = 'debuginfo';
console.style.background = '#dedede';
console.style.border = '1px solid silver';
console.style.padding = '5px';
console.style.width = '400px';
console.style.position = 'absolute';
console.style.right = '0px';
console.style.top = '0px';
document.body.appendChild(console);
}
console.innerHTML += '<p> ${message}</p>';
}