文档结构  
翻译进度:已翻译     翻译赏金:10 元 (?)    ¥ 我要打赏

啊哈,在 JavaScript 中处理错误是个危险的事情。如果你相信墨菲定律,任何可能出错的事情就一定会出错。本文中,我会探讨 JavaScriopt 中的错误处理。内容涵盖陷阱、良好实践,以及处理异步代码和 Ajax 中的错误。

本文很受欢迎,它在 2017年6月8日针对读者的反馈进行了更新。具体地说,包括在代码段中添加文件名称,单元测试包含清理步骤,向 uglyHandler 引入了包装模式,加入了 CORS 和第三方错误处理的章节。

本作者的其它文章

第 1 段(可获 1.3 积分)

我觉得 JavaScript 的事件驱动模型让语言变得丰富。我喜欢把浏览器想象为这个事件驱动的机器,错误也没什么差别。当错误发生的时候,会在某个点上触发事件。理论上来说,可以认为错误是 JavaScript 中的简单事件。

如果你觉得这个说法很陌生,请准备好来这里加深印象。我在本文中只关注客户端的 JavaScript。

JavaScript中进行良好的异常处理中描述了一个概念,而本文正是建立在这个概念之上的。如果你还不是很熟悉这些基础,我建议你先去读一读。本文还假设阅读者拥有中级 JavaScript 知识水平。如果你正在想办法提升自己的水平,何不在 SitePoint 注册并观看我们的课程JavaScript:下一步。第一课是免费的。

 

第 2 段(可获 1.68 积分)

两种情况下我们的都是探索超出异常处理的基本需要。阅读本文会让你在下次遇到漂亮的 try...catch 块时反复思考。

演示

本文使用的演示已经放在GitHub上,它会展示下面这亲的页面:

Error Handling in JavaScript Demo

点击任何按钮都会引爆“炸弹”。炸弹就是通过抛出 TypeError 来模拟异常。下面是这个模块的定义:

// scripts/error.js

function error() {
  var foo = {};
  return foo.bar();
}

首先,这个函数申明了一个叫 foo 的空对象。注意并没有定义 bar()。现在用一个不错的单元测试来验证它会引爆炸弹。

第 3 段(可获 1.35 积分)
// tests/scripts/errorTest.js

it('throws a TypeError', function () {
  should.throws(error, TypeError);
});

这个单元测试是在 Mocha 中使用断言工具 Should.js 实现的。Mocha 是一个测试运行器,而 Should.js 是一个断言库。如果你对测试  API 还不熟悉,可以随意地去探索下。一个测试开始于 it('description'),而结束于通过/失败于 should。单元测试运行在 Node 上,不需要浏览器。我建议大家多关注测试,因为它们有证明普通 JavaScript 中的关键概念。

你 clone 了代码库并安装好依赖项之后,可以使用 npm t 来运行测试。另外,你也可以运行单个测试,像这样:./node_modules/mocha/bin/mocha tests/scripts/errorTest.js.

第 4 段(可获 1.26 积分)

如果上所示,error() 定义了一个空对象,然后尝试访问这个对象的一个方法。因为 bar() 并不存在于对象中,它会抛出异常。相信我,像 JavaScriopt 这样的动态语言中随时都在发生这种事情!

不好的做法

有一些错误处理并不好。我从上面按钮的实现中提取了处理函数,像这样:

// scripts/badHandler.js

function badHandler(fn) {
  try {
    return fn();
  } catch (e) { }
  return null;
}

这个处理函数接收 fn 回调作为参数。这个回调会在处理函数内部被调用。单元测试表明它如何有用:

 

第 5 段(可获 1.09 积分)
// tests/scripts/badHandlerTest.js

it('returns a value without errors', function() {
  var fn = function() {
    return 1;
  };

  var result = badHandler(fn);

  result.should.equal(1);
});

it('returns a null with errors', function() {
  var fn = function() {
    throw new Error('random error');
  };

  var result = badHandler(fn);

  should(result).equal(null);
});

正如你所看到的,这个错误的“错误处理器”在发生错误时返回 null。回调函数 fn() 将可能会指向一个合法的函数,或者可能是一个炸弹 —— 嘣 ~

下面的点击事件处理器继续我们的故事:

第 6 段(可获 0.44 积分)
// scripts/badHandlerDom.js

(function (handler, bomb) {
  var badButton = document.getElementById('bad');

  if (badButton) {
    badButton.addEventListener('click', function () {
      handler(bomb);
      console.log('Imagine, getting promoted for hiding mistakes');
    });
  }
}(badHandler, error));

糟糕的是我只得到一个 null。这让我完全搞不清楚哪里出了什么问题。这个隐藏失败的策略可能覆盖了从用户体验一直到数据异常。太让人伤心了,我花了几个小时来调试这个问题,却正好错过了 try-catch 块。这个可恶的处理程序吞下了代码中的错误,假装一次均好。对于那些不太关心代码质量的组织来说,可能是可以的。但是,隐藏错误会让你以后花数小时来进行调试。有具有深层调用栈的多层结构解决方案中,不太可能找到出错的地方。至于说错误处理,糟糕之极。

 

第 7 段(可获 1.46 积分)

一套Fail-silent 策略会让你有种渴望去进行更好的错误处理。JavaScript对处理异常情况为我们提供了一种更灵活的方法。

丑陋的处理程序

是时候该调查一下丑陋的处理程序了。我将跳过耦合部分直接到DOM部分。这里和你看到的糟糕的处理程序大同小异而已。

// scripts/uglyHandler.js

function uglyHandler(fn) {
  try {
    return fn();
  } catch (e) {
    throw new Error('a new error');
  }
}

最重要的是它处理异常情况的方式,如下所示:

// tests/scripts/uglyHandlerTest.js

it('returns a new error with errors', function () {
  var fn = function () {
    throw new TypeError('type error');
  };

  should.throws(function () {
    uglyHandler(fn);
  }, Error);
});
第 8 段(可获 0.83 积分)

不好的处理程序需要改进。异常会在整个调用栈中冒泡出来。我现在很喜欢错误会展开调用栈这一点。如果发生异常,解释器会沿着调用栈向上查找另一个处理程序。这会产生很多机会在调用栈的顶部来处理错误。然而很不幸,因为这个丑陋的处理程序,我失去了原始的错误。因此,我不得不从调用栈往回查找原始异常。我至少应该知道你为什么会抛出一个异常。

 

第 9 段(可获 1.3 积分)

作为一种替代方案,可以用自定义的错误来终结这个丑陋的处理程序。如果你能添加更多详情在错误中,它就不再丑陋,而是有用的。关键在于添加关于错误的特定信息。

例如:

// scripts/specifiedError.js

// Create a custom error
var SpecifiedError = function SpecifiedError(message) {
  this.name = 'SpecifiedError';
  this.message = message || '';
  this.stack = (new Error()).stack;
};

SpecifiedError.prototype = new Error();
SpecifiedError.prototype.constructor = SpecifiedError;
// scripts/uglyHandlerImproved.js

function uglyHandlerImproved(fn) {
  try {
    return fn();
  } catch (e) {
    throw new SpecifiedError(e.message);
  }
}
第 10 段(可获 0.53 积分)
// tests/scripts/uglyHandlerImprovedTest.js

it('returns a specified error with errors', function () {
  var fn = function () {
    throw new TypeError('type error');
  };

  should.throws(function () {
    uglyHandlerImproved(fn);
  }, SpecifiedError);
});

这个特定的错误在保持原有错误信息的同时,添加了更多详情。有了这点改进,它就不再是一个丑陋的处理程序,而是清晰且有用的处理程序。

即时有了这些处理程序,我仍然得到一个未处理的异常。来看看浏览器是否有什么机制来处理这个问题。

展开调用栈

第 11 段(可获 0.66 积分)

从异常中解脱出来的方法之一就在调用栈的顶端放置 try...catch。

比如:

function main(bomb) {
  try {
    bomb();
  } catch (e) {
    // Handle all the error things
  }
}

不过记得我说过浏览器是事件驱动的?是的,JavaScript 的异常也不过是个事件而已。解释器会在执行环境中停止执行并跳出来。结果我们可以用全局的 onerror 事件处理程序

它就像这样:

// scripts/errorHandlerDom.js

window.addEventListener('error', function (e) {
  var error = e.error;
  console.log(error);
});
第 12 段(可获 0.84 积分)

事件处理程序会捕捉任何执行环境捕出的错误。错误事件会因错误不同而由不同的目标触发。把代码在这个事件中集中处理会非常彻底。像其它事件一样,你可以把处理程序串联起来用于处理特定的错误。如果你遵循 SOLID 原则,这种方式可以让错误处理函数目的单一。处理函数可以在任何时候注册。解释器会根据需要循环调用多个处理程序。代码从遍布各处的 try...catch 块解脱出来,变得易于调试。关键是在 JavaScript 中要把错误处理像事件处理一样看待。

 

第 13 段(可获 1.41 积分)

我们找到一种方法使用全局处理函数来探索调用栈,但是可以用它来做什么呢?

不管怎样,你都有调用栈可用。

捕捉调用栈

调用栈对排查问题非常有帮助。浏览器提供了这些信息,真是个好消息。stack 属性 并不是标准的一部分,但在所有最新的浏览器上都可用。

那么,下面的示例向你展示在服务器上记录错误

// scripts/errorAjaxHandlerDom.js

window.addEventListener('error', function (e) {
  var stack = e.error.stack;
  var message = e.error.toString();

  if (stack) {
    message += '\n' + stack;
  }

  var xhr = new XMLHttpRequest();
  xhr.open('POST', '/log', true);
  // Fire an Ajax request with error details
  xhr.send(message);
});
第 14 段(可获 1.04 积分)

这个例子可能不太明显,但对以前的例子来说是显而易见的。每个错误处理函数都应该只有一个目的,以保持代码DRY

 

在浏览器中,事件处理函数会被添加到DOM上。也就是说,如果你在建设一个第三方库,你的事件将与客户端代码并存。 window.addEventListener() 为帮你处理好这个事情,它不会删除掉已经原有的事件。

下面截图中的日志看起来像是在服务器上:

Ajax Log Request to Node Server

这个日志输出到命令行,是的,它运行在 Windows 上。

 

第 15 段(可获 1.21 积分)

这个消息来自 Firebox Developer Edition 54。注意,适当的错误处理程序能清晰的指出问题所在。不需要隐藏错误,这样一眼就能看出来什么地方抛出了什么异常。这种透明度对高度前端代码非常有帮助。你可以分析日志,了解哪些条件下会触发什么样的错误。

对于高度来说,调用栈也很有用,千万不要忽视它的力量。

还有一个问题在于,如果你有一段跨域的脚本,允许 CORS的情况下你不会看到任何错误详情。这通常会发生在你将代码放在 CDN 上的时候,比如,为了放开每个域名只能有6个请求的限制。发生问题时 e.message 会只显示 "Script error"。在 JavaScript 中,错误信息只在单个域内有效。

 

第 16 段(可获 1.78 积分)

一种解决方案是在抛出错误时带上其错误信息:

try {
  return fn();
} catch (e) {
  throw new Error(e.message);
}

 一旦你抛出错误后,全局的错误处理程序就会帮你完成剩下的工作,不过需要确保你的错误处理程序是在同一个域中。你甚至可以将一些特定的错误信息封装在一个自定义的错误处理里。这将保存着原始的信息、堆栈、和自定义的错误对象。

异步处理

啊,危险的异步。 JavaScript将异步代码从执行上下文中分离出来。这意味着像以下的异常处理程序就会有问题:

第 17 段(可获 1.14 积分)
// scripts/asyncHandler.js

function asyncHandler(fn) {
  try {
    // This rips the potential bomb from the current context
    setTimeout(function () {
      fn();
    }, 1);
  } catch (e) { }
}

单元测试讲述了故事的剩余部分:

// tests/scripts/asyncHandlerTest.js

it('does not catch exceptions with errors', function () {
  // 抛出
  var fn = function () {
    throw new TypeError('type error');
  };

  // 检测异常是否被捕获
  should.doesNotThrow(function () {
    asyncHandler(fn);
  });
});

这个异常不会被捕获,且我可以通过这个单元测试来验证它。 注意有一个未处理的异常出现,尽管我已经在这段代码外用 try...catch 包围起来。 没错,try…catch 语句只能在一个单一的执行上下文生效。当这个异常被抛出时,解释器已经从 try...catch 跑开了。 同样的行为也会发生在 Ajax 调用。

第 18 段(可获 0.88 积分)

因此,可以异步回调内部捕捉异常:

setTimeout(function () {
  try {
    fn();
  } catch (e) {
    // Handle this async error
  }
}, 1);

这个方法会生效,但改善的空间还很大。首先,try ... catch 块会到处都是。实际上,上世纪70年代糟糕的程序就进行了这样的调用,以达到回到代码中的目的。另外,V8 引擎不鼓励在函数内部使用 try ... catch 块。V8 是 Chrome 浏览器和 Node 使用的 JavaScriopt 引擎。还有一种想法是将代码块移到调用栈的顶部,可惜这种方式不适用于异步代码。

 

第 19 段(可获 1.14 积分)

那么,它会把我们带到哪里去?我曾提到过一个原因,说全局错误处理程序可以在任何执行环境中运行。在 window 对象上添加了一个错误处理程序就是这样!这是很好的做法,符合 DRY 和 SOLID 原则。全局错误处理程序会让你的异步函数整洁而漂亮。

下面是这个异常处理程序在服务器端报告的错误信息。注意如果你跟踪下去,会发现它会随着你使用的浏览器不同而不同。

Async Error Report on the Server

这个处理程序甚至可以告诉我错误来自异步代码。它说它来自 setTimeout() 函数。真是酷!

 

第 20 段(可获 1.41 积分)

小结

在错误处理领域至少存在两种方法。一是保持沉默,即在代码中忽略错误。而另一种是快速失败并退回的方法,即错误会中断程序并抛出。我倾向于哪一种方法是显而易见的。我认为:不要隐藏问题。没人会因为程序中发生的问题而责备你。用户可以接受停止,退回并再次尝试。

在这个并不完美的世界,拥有第二次机会相当重要。错误是不可避免的,所以你对它们的处理才会有价值。

 

第 21 段(可获 1.44 积分)

文章评论