详解JavaScript的BUG和错误

作者:拾光时间:2026-10-22 14:05:03浏览:140

本文深入解析JavaScript中的错误类型与调试策略。介绍严格模式如何帮助发现变量未声明和this绑定错误,探讨类型系统在JavaScript中的局限性及TypeScript的作用。重点讲解自动化测试、断言以及异常处理机制,包括try/catch/finally的使用、错误分类与自定义异常类,确保程序在出错时能优雅恢复。

JavaScript错误概述与调试基础

程序中的缺陷通常被称为bug。虽然程序员有时觉得这些只是无伤大雅的小瑕疵,但实际上它们是由开发者自己引入的。

如果将程序视为思想的具象化,错误大致可分为两类:源于思维混乱的逻辑错误,以及在将思维转化为代码时引入的实现错误。前者通常比后者更难诊断和修复。

语言特性对错误检测的影响

计算机能够自动指出许多错误,前提是它足够了解程序的意图。然而,JavaScript的宽松特性反而成了障碍。由于其绑定和属性概念模糊,拼写错误往往要等到实际运行程序时才会被发现。更糟糕的是,它允许执行一些不会报错但毫无意义的操作,例如计算true *'monkey'。

JavaScript并非对所有错误都视而不见。编写不符合语言语法的代码会立即触发报错;而调用非函数对象或访问未定义值的属性,则会在程序尝试执行相关操作时报告错误。

然而,在处理无意义计算时,JavaScript通常会返回NaN(非数字)或undefined。程序会认为代码执行无误并继续运行,直到后续函数使用这些无意义值时才出现问题。有时程序甚至不会报错,只是输出错误的结果。这类错误的源头极难追踪。

查找并修复程序中错误或bug的过程,被称为调试(debug)。

严格模式与类型系统

严格模式的作用

启用严格模式(strict mode)后,JavaScript在执行代码时会变得更加严格。只需在文件或函数体顶部添加字符串"use strict"即可启用。示例代码如下:


function canYouSpotTheProblem() {
 "use strict";
 for (counter = 0; counter < 10; counter++) {
 console.log("Happy happy");
 }
}

canYouSpotTheProblem();
// → ReferenceError: counter is not defined

通常,如果忘记在绑定前使用let(如示例中的counter),JavaScript会静默创建一个全局绑定并使用它。但在严格模式下,这会直接报错,非常有用。需要注意的是,如果该绑定已作为全局绑定存在,严格模式下的行为可能仍会覆盖其值。

严格模式的另一变化是,在非方法调用的函数中,this绑定为undefined。在非严格模式下,this引用全局作用域对象,其属性即为全局绑定。因此,如果在严格模式下错误地调用方法或构造器,JavaScript会在尝试从this读取内容时报错,而不是静默写入全局作用域。

例如,考虑以下代码,该代码在不使用new关键字的情况下调用构造器,导致this不引用新构造的对象:


function Person(name) { this.name = name; }
let ferdinand = Person("Ferdinand"); // oops
console.log(name);
// → Ferdinand

虽然在非严格模式下错误调用Person函数也能执行成功,但会返回undefined并创建名为name的全局绑定。而在严格模式下,结果截然不同。


"use strict";
function Person(name) { this.name = name; }
let ferdinand = Person("Ferdinand");
// → TypeError: Cannot set property 'name' of undefined

JavaScript会立即告知代码中存在错误,这一特性非常有用。

幸运的是,使用class语法创建的构造器,即使在不使用new调用的情况下,也会始终报错,即使在非严格模式下也不会产生静默失败的问题。

严格模式还做了更多事情,例如不允许函数拥有同名参数,并完全删除了某些有问题的语言特性(如with语句,本书不再讨论)。

简而言之,在程序顶部放置"use strict"几乎不会带来问题,反而有助于发现潜在错误。

类型系统的影响

有些语言在运行前就要求知道所有绑定和表达式的类型,并在类型使用不一致时立即报错。JavaScript仅在运行时考虑类型,尽管它经常尝试将值隐式转换为其预期的类型,因此这种机制帮助有限。

尽管如此,类型为讨论程序提供了有用的框架。许多错误源于对进入或离开函数的值的类型感到困惑。如果将这些信息明确写下来,就不太容易感到困惑。

你可以在上一章的goalOrientedRobot函数上方添加如下注释来描述它的类型。


// (WorldState, Array) → {direction: string, memory: Array}
function goalOrientedRobot(state, memory) {
 // ...
}

有许多不同的约定用于标注JavaScript程序的类型。

类型系统需要引入复杂性才能描述足够有用的代码。例如,从数组中返回随机元素的randomPick函数的类型是什么?你需要引入一个绑定类型T,代表任何类型,从而赋予randomPick一个像([T])->T的类型(从T的数组到T的函数)。

当程序类型已知时,计算机可以为你检查它们,在运行前指出错误。有几种JavaScript语言为语言添加类型并检查它们。最流行的是TypeScript。如果你有兴趣为程序增加更多严谨性,建议尝试一下。

在本书中,我们将继续使用原始的、危险的、非类型化的JavaScript代码。

自动化测试与调试策略

测试的重要性

如果语言无法帮助我们发现错误,我们就必须通过运行程序并检查其执行是否正确来努力寻找它们。

反复手动操作是一个非常糟糕的主意。这不仅令人厌烦,而且往往无效,因为每次修改后都需要花费大量时间进行详尽测试。

计算机擅长重复性任务,测试是理想的重复性任务。自动化测试是编写用于测试另一个程序的程序的过程。虽然编写测试比手工测试更费功夫,但完成后你将获得一种超能力:只需几秒钟即可验证程序在你编写的所有测试用例中是否正常运行。当你破坏某些功能时,会立即注意到,而不是在稍后随机发现。

测试通常采用小型程序的形式来验证代码的某些方面。例如,一组(标准的、可能已由他人测试过的)toUpperCase方法的测试可能如下:


function test(label, body) {
 if (!body()) console.log(`Failed: ${label}`);
}

test("convert Latin text to uppercase", () => {
 return "hello".toUpperCase() == "HELLO";
});
test("convert Greek text to uppercase", () => {
 return "Χαίρετε".toUpperCase() == "ΧΑΊΡΕΤΕ";
});
test("don't convert case-less characters", () => {
 return "مرحبا".toUpperCase() == "مرحبا";
});

像这样编写测试往往会产生大量重复且笨拙的代码。幸运的是,有些软件通过提供适合表达测试的语言(以函数和方法的形式),并在测试失败时输出丰富信息,帮助你构建和运行测试集合(测试套件,test suite)。这些工具通常被称为测试运行器(test runner)。

有些代码比其他代码更容易测试。通常,代码与外部交互的对象越多,建立用于测试它的上下文就越困难。上一章中展示的编程风格,使用自包含的持久值而不是更改对象,通常更容易测试。

调试策略

当程序运行结果不符合预期或在运行过程中产生错误时,你便会注意到程序出现了问题,下一步就是推断问题出在哪里。

有时错误很明显。错误消息会指出错误出现在程序的哪一行,只要稍加阅读错误描述及出错的那行代码,你一般就知道如何修正错误了。

但情况并非总是如此。有时触发问题的行,只是第一个以无效方式使用其他地方产生的奇怪值的地方。如果你在前几章中已经解决了练习,你可能已经遇到过这种情况。

下面的示例代码尝试将一个整数转换成给定进制表示的字符串(十进制、二进制等),其原理是:不断循环取出最后一位数字,并将其除以基数(将最后一位数从数字中除去)。但该程序目前的输出表明程序中是存在bug的。


function numberToString(n, base = 10) {
 let result = "", sign = "";
 if (n < 0) {
 sign = "-";
 n = -n;
 }
 do {
 result = String(n % base) + result;
 n /= base;
 } while (n > 0);
 return sign + result;
}
console.log(numberToString(13, 10));
// → 1.5e-3231.3e-3221.3e-3211.3e-3201.3e-3191.3e-3181.3…

你可能已经发现程序运行结果不对了,不过先暂时装作不知道。我们知道程序运行出了问题,试图找出其原因。

这是一个地方,你必须抵制随机更改代码来查看它是否变得更好的冲动。相反,要思考。分析正在发生的事情,并提出为什么可能发生的理论。然后,再做一些观察来检验这个理论 - 或者,如果你还没有理论,可以进一步观察来帮助你想出一个理论。

有目的地在程序中使用console.log来查看程序当前的运行状态,是一种不错的获取额外信息的方法。在本例中,我们希望n的值依次变为 13,1,然后是 0。让我们先在循环起始处输出n的值。


13
1.3
0.13
0.013
…
1.5e-323

没错。13 除以 10 并不会产生整数。我们不应该使用n/=base,而应该使用n=Math.floor(n/base),使数字“右移”,这才是我们实际想要的结果。

使用console.log来查看程序行为的替代方法,是使用浏览器的调试器(debugger)功能。浏览器可以在代码的特定行上设置断点(breakpoint)。当程序执行到带有断点的行时,它会暂停,并且你可以检查该点的绑定值。我不会详细讨论,因为调试器在不同浏览器上有所不同,但请查看浏览器的开发人员工具或在 Web 上搜索来获取更多信息。

设置断点的另一种方法,是在程序中包含一个debugger语句(仅由该关键字组成)。如果你的浏览器的开发人员工具是激活的,则只要程序达到这个语句,程序就会暂停。

错误传播与异常处理机制

错误传播策略

不幸的是,程序员不可能避免所有问题。如果你的程序以任何方式与外部世界进行通信,则可能会导致输入格式错误、工作负荷过重或网络故障。

如果你只为自己编程,那么你就可以忽略这些问题直到它们发生。但是如果你创建了一些将被其他人使用的东西,你通常希望程序比只是崩溃做得更好。有时候,正确的做法是不择手段地继续运行。在其他情况下,最好向用户报告出了什么问题然后放弃。但无论在哪种情况下,该程序都必须积极采取措施来回应问题。

假设你有一个函数promptInteger,要求用户输入一个整数并返回它。如果用户输入"orange",它应该返回什么?

一种办法是返回一个特殊值,通常会使用null,undefined或 -1。


function promptNumber(question) {
 let result = Number(prompt(question, ""));
 if (Number.isNaN(result)) return null;
 else return result;
}

console.log(promptNumber("How many trees do you see?"));

现在,调用promptNumber的任何代码都必须检查是否实际读取了数字,否则必须以某种方式恢复 - 也许再次询问或填充默认值。或者它可能会再次向它的调用者返回一个特殊值,表示它未能完成所要求的操作。

在很多情况下,当错误很常见并且调用者应该明确地考虑它们时,返回特殊值是表示错误的好方法。但它确实有其不利之处。首先,如果函数已经可能返回每一种可能的值呢?在这样的函数中,你必须做一些事情,比如将结果包装在一个对象中,以便能够区分成功与失败。


function lastElement(array) {
 if (array.length == 0) {
 return {failed: true};
 } else {
 return {element: array[array.length - 1]};
 }
}

返回特殊值的第二个问题是它可能产生非常笨拙的代码。如果一段代码调用promptNumber 10 次,则必须检查是否返回null 10 次。如果它对null的回应是简单地返回null本身,函数的调用者将不得不去检查它,以此类推。

异常处理机制

当函数无法正常工作时,我们只希望停止当前任务,并立即跳转到负责处理问题的位置。这就是异常处理的功能。

异常是一种当代码执行中遇到问题时,可以触发(或抛出)异常的机制,异常只是一个普通的值。触发异常类似于从函数中强制返回:异常不只跳出到当前函数中,还会跳出函数调用方,直到当前执行流初次调用函数的位置。这种方式被称为“堆栈展开(Unwinding the Stack)”。你可能还记得我们在第3章中介绍的函数调用栈,异常会减小堆栈的尺寸,并丢弃所有在缩减程序栈尺寸过程中遇到的函数调用上下文。

如果异常总是会将堆栈尺寸缩减到栈底,那么异常也就毫无用处了。它只不过是换了一种方式来彻底破坏你的程序罢了。异常真正强大的地方在于你可以在堆栈上设置一个“障碍物”,当异常缩减堆栈到达这个位置时会被捕获。一旦发现异常,你可以使用它来解决问题,然后继续运行该程序。


function promptDirection(question) {
 let result = prompt(question, "");
 if (result.toLowerCase() == "left") return "L";
 if (result.toLowerCase() == "right") return "R";
 throw new Error("Invalid direction: " + result);
}

function look() {
 if (promptDirection("Which way?") == "L") {
 return "a house";
 } else {
 return "two angry bears";
 }
}

try {
 console.log("You see", look());
} catch (error) {
 console.log("Something went wrong: " + error);
}

throw关键字用于引发异常。异常的捕获通过将一段代码包装在一个try块中,后跟关键字catch来完成。当try块中的代码引发异常时,将求值catch块,并将括号中的名称绑定到异常值。在catch块结束之后,或者try块结束并且没有问题时,程序在整个try / catch语句的下面继续执行。

在本例中,我们使用Error构造器来创建异常值。这是一个标准的 JavaScript 构造器,用于创建一个对象,包含message属性。在多数 JavaScript 环境中,构造器实例也会收集异常创建时的调用栈信息,即堆栈跟踪信息(Stack Trace)。该信息存储在stack属性中,对于调用问题有很大的帮助,我们可以从堆栈跟踪信息中得知问题发生的精确位置,即问题具体出现在哪个函数中,以及执行失败为止调用的其他函数链。

需要注意的是现在look函数可以完全忽略promptDirection出错的可能性。这就是使用异常的优势:只有在错误触发且必须处理的位置才需要错误处理代码。其间的函数可以忽略异常处理。

嗯,我们要讲解的理论知识差不多就这些了。

异常后的清理工作

异常的效果是另一种控制流。每个可能导致异常的操作(几乎每个函数调用和属性访问)都可能导致控制流突然离开你的代码。

这意味着当代码有几个副作用时,即使它的“常规”控制流看起来像它们总是会发生,但异常可能会阻止其中一些发生。

这是一些非常糟糕的银行代码。


const accounts = {
 a: 100,
 b: 0,
 c: 20
};

function getAccount() {
 let accountName = prompt("Enter an account name");
 if (!accounts.hasOwnProperty(accountName)) {
 throw new Error(`No such account: ${accountName}`);
 }
 return accountName;
}

function transfer(from, amount) {
 if (accounts[from] < amount) return;
 accounts[from] -= amount;
 accounts[getAccount()] += amount;
}

transfer函数将一笔钱从一个给定的账户转移到另一个账户,在此过程中询问另一个账户的名称。如果给定一个无效的帐户名称,getAccount将引发异常。

但是transfer首先从帐户中删除资金,之后调用getAccount,之后将其添加到另一个帐户。如果它在那个时候由异常中断,它就会让钱消失。

这段代码本来可以更智能一些,例如在开始转移资金之前调用getAccount。但这样的问题往往以更微妙的方式出现。即使是那些看起来不像是会抛出异常的函数,在特殊情况下,或者当他们包含程序员的错误时,也可能会这样。

解决这个问题的一个方法是使用更少的副作用。同样,计算新值而不是改变现有数据的编程风格有所帮助。如果一段代码在创建新值时停止运行,没有人会看到这个完成一半的值,并且没有问题。

但这并不总是实际的。所以try语句具有另一个特性。它们可能会跟着一个finally块,而不是catch块,也不是在它后面。finally块会说“不管发生什么事,在尝试运行try块中的代码后,一定会运行这个代码。”


function transfer(from, amount) {
 if (accounts[from] < amount) return;
 let progress = 0;
 try {
 accounts[from] -= amount;
 progress = 1;
 accounts[getAccount()] += amount;
 progress = 2;
 } finally {
 if (progress == 1) {
  accounts[from] += amount;
 }
 }
}

这个版本的函数跟踪其进度,如果它在离开时注意到,它中止在创建不一致的程序状态的位置,则修复它造成的损害。

请注意,即使finally代码在异常退出try块时运行,它也不会影响异常。finally块运行后,堆栈继续展开。

即使异常出现在意外的地方,编写可靠运行的程序也非常困难。很多人根本就不关心,而且由于异常通常针对异常情况而保留,因此问题可能很少发生,甚至从未被发现。这是一件好事还是一件糟糕的事情,取决于软件执行失败时会造成多大的损害。

选择性捕获与自定义异常

选择性捕获异常

当程序出现异常且异常未被捕获时,异常就会直接回退到栈顶,并由 JavaScript 环境来处理。其处理方式会根据环境的不同而不同。在浏览器中,错误描述通常会写入 JavaScript 控制台中(可以使用浏览器工具或开发者菜单来访问控制台)。我们将在第 20 章中讨论的,无浏览器的 JavaScript 环境 Node.js 对数据损坏更加谨慎。当发生未处理的异常时,它会中止整个过程。

对于程序员的错误,让错误通行通常是最好的。未处理的异常是表示糟糕的程序的合理方式,而在现代浏览器上,JavaScript 控制台为你提供了一些信息,有关在发生问题时堆栈上调用了哪些函数的。

对于在日常使用中发生的预期问题,因未处理的异常而崩溃是一种糟糕的策略。

语言的非法使用方式,比如引用一个不存在的绑定,在null中查询属性,或调用的对象不是函数最终都会引发异常。你可以像自己的异常一样捕获这些异常。

进入catch语句块时,我们只知道try体中引发了异常,但不知道引发了哪一类或哪一个异常。

JavaScript(很明显的疏漏)并未对选择性捕获异常提供良好的支持,要不捕获所有异常,要不什么都不捕获。这让你很容易假设,你得到的异常就是你在写catch时所考虑的异常。

但它也可能不是。可能会违反其他假设,或者你可能引入了导致异常的 bug。这是一个例子,它尝试持续调用promptDirection,直到它得到一个有效的答案:


for (;;) {
 try {
 let dir = promtDirection("Where?"); // ← typo!
 console.log("You chose ", dir);
 break;
 } catch (e) {
 console.log("Not a valid direction. Try again.");
 }
}

我们可以使用for (;;)循环体来创建一个无限循环,其自身永远不会停止运行。我们在用户给出有效的方向之后会跳出循环。但我们拼写错了promptDirection,因此会引发一个“未定义值”错误。由于catch块完全忽略了异常值,假定其知道问题所在,错将绑定错误信息当成错误输入。这样不仅会引发无限循环,而且会掩盖掉真正的错误消息——绑定名拼写错误。

一般而言,只有将抛出的异常重定位到其他地方进行处理时,我们才会捕获所有异常。比如说通过网络传输通知其他系统当前应用程序的崩溃信息。即便如此,我们也要注意编写的代码是否会将错误信息掩盖起来。

因此,我们转而会去捕获那些特殊类型的异常。我们可以在catch代码块中判断捕获到的异常是否就是我们期望处理的异常,如果不是则将其重新抛出。那么我们该如何辨别抛出异常的类型呢?

我们可以将它的message属性与我们所期望的错误信息进行比较。但是,这是一种不稳定的编写代码的方式 - 我们将使用供人类使用的信息来做出程序化决策。只要有人更改(或翻译)该消息,代码就会停止工作。

我们不如定义一个新的错误类型,并使用instanceof来识别异常。


class InputError extends Error {}

function promptDirection(question) {
 let result = prompt(question);
 if (result.toLowerCase() == "left") return "L";
 if (result.toLowerCase() == "right") return "R";
 throw new InputError("Invalid direction: " + result);
}

新的错误类扩展了Error。它没有定义它自己的构造器,这意味着它继承了Error构造器,它需要一个字符串消息作为参数。事实上,它根本没有定义任何东西 - 这个类是空的。InputError对象的行为与Error对象相似,只是它们的类不同,我们可以通过类来识别它们。

现在循环可以更仔细地捕捉它们。


for (;;) {
 try {
 let dir = promptDirection("Where?");
 console.log("You chose ", dir);
 break;
 } catch (e) {
 if (e instanceof InputError) {
  console.log("Not a valid direction. Try again.");
 } else {
  throw e;
 }
 }
}

这里的catch代码只会捕获InputError类型的异常,而其他类型的异常则不会在这里进行处理。如果又输入了不正确的值,那么系统会向用户准确报告错误——“绑定未定义”。

断言与习题练习

断言的使用

断言(assertions)是程序内部的检查,用于验证某个东西是它应该是的方式。它们并不是用于处理正常操作中可能出现的情况,而是发现程序员的错误。

例如,如果firstElement被描述为一个函数,永远不会在空数组上调用,我们可以这样写:


function firstElement(array) {
 if (array.length == 0) {
 throw new Error("firstElement called with []");
 }
 return array[0];
}

现在,它不会默默地返回未定义值(当你读取一个不存在的数组属性的时候),而是在你滥用它时立即干掉你的程序。这使得这种错误不太可能被忽视,并且当它们发生时更容易找到它们的原因。

我不建议尝试为每种可能的不良输入编写断言。这将是很多工作,并会产生非常杂乱的代码。你会希望为很容易犯(或者你发现自己做过)的错误保留他们。

本章小结

错误和无效的输入十分常见。编程的一个重要部分是发现,诊断和修复错误。如果你拥有自动化测试套件或向程序添加断言,则问题会变得更容易被注意。

我们常常需要使用优雅的方式来处理程序可控范围外的问题。如果问题可以就地解决,那么返回一个特殊的值来跟踪错误就是一个不错的解决方案。或者,异常也可能是可行的。

抛出异常会引发堆栈展开,直到遇到下一个封闭的try/catch块,或堆栈底部为止。catch块捕获异常后,会将异常值赋予catch块,catch块中应该验证异常是否是实际希望处理的异常,然后进行处理。为了有助于解决由于异常引起的不可预测的执行流,可以使用finally块来确保执行try块之后的代码。

习题

重试机制

假设有一个函数primitiveMultiply,在 20% 的情况下将两个数相乘,在另外 80% 的情况下会触发MultiplicatorUnitFailure类型的异常。编写一个函数,调用这个容易出错的函数,不断尝试直到调用成功并返回结果为止。

确保只处理你期望的异常。


class MultiplicatorUnitFailure extends Error {}

function primitiveMultiply(a, b) {
 if (Math.random() < 0.2) {
 return a * b;
 } else {
 throw new MultiplicatorUnitFailure();
 }
}

function reliableMultiply(a, b) {
 // Your code here.
}

console.log(reliableMultiply(8, 8));
// → 64

上锁的箱子

考虑以下这个编写好的对象:


const box = {
 locked: true,
 unlock() { this.locked = false; },
 lock() { this.locked = true; },
 _content: [],
 get content() {
 if (this.locked) throw new Error("Locked!");
 return this._content;
 }
};

这是一个带锁的箱子。其中有一个数组,但只有在箱子被解锁时,才可以访问数组。不允许直接访问_content属性。

编写一个名为withBoxUnlocked的函数,接受一个函数类型的参数,其作用是解锁箱子,执行该函数,无论是正常返回还是抛出异常,在withBoxUnlocked函数返回前都必须锁上箱子。


const box = {
 locked: true,
 unlock() { this.locked = false; },
 lock() { this.locked = true; },
 _content: [],
 get content() {
 if (this.locked) throw new Error("Locked!");
 return this._content;
 }
};

function withBoxUnlocked(body) {
 // Your code here.
}

withBoxUnlocked(function() {
 box.content.push("gold piece");
});

try {
 withBoxUnlocked(function() {
 throw new Error("Pirates on the horizon! Abort!");
 });
} catch (e) {
 console.log("Error raised:", e);
}
console.log(box.locked);
// → true

作者最新文章