前端 JavaScript 错误异常处理指北

0

在前端的 JavaScript 开发中,发现开发者对于错误异常的处理普遍都比较简单粗暴,如果应用程序中缺少有效的错误处理和容错机制,代码的健壮性就无从谈起。

本文整理出了一些常见的错误异常处理的场景,旨在为前端的 JavaScript 错误异常处理提供一些基础的指导。

Error 对象

先来简单介绍一下 JavaScript 中的 Error 对象,通常 Error 对象由重要的两部分组成,包含了 error.message 错误信息和 error.stack 错误追溯栈。

产生一个错误很简单,比如在 foo.js 中直接调用一个不存在的 callback 函数。

// foo.js
function foo () {
    callback();
}

foo();

此时通过 Chrome 浏览器的控制台会展示如下的信息。

Uncaught ReferenceError: callback is not defined
    at foo (foo.js:2)
    at foo.js:5

其中 Uncaught ReferenceError: callback is not defined 就是 error.message 错误信息,而剩下的 at xxx 就是具体的错误追溯栈,在 Chrome 的控制台中,对错误的展示进行了优化。

如果我们通过 window.onerror 来捕获到该错误后将 Error 对象直接输出到页面中会展示出更原始的数据。

<!-- 展示错误的容器 -->
<textarea id="error"></textarea>

// 输出错误
window.onerror = function (msg, url, line, col, err) {
    document.getElementById('error').textContent = err.message + '\n\n' + err.stack;
};

原始的错误数据中会展示出错误追溯栈中的 Source URL。

callback is not defined

ReferenceError: callback is not defined
    at foo (http://example.com/js-error/foo.js:2:5)
    at http://example.com/js-error/foo.js:5:1

有了错误追溯栈,就能通过发生错误的文件 Source URL 和错误在代码中的具体位置来快速定位到错误。

看起来好像很简单,但实际的开发中如何有效的捕获错误,如何有效的抛出错误都有一些需要注意的点,下面逐个的来讲解。

window.onerror

前端在捕获错误时都会通过绑定 window.onerror 事件来捕获全局的 JavaScript 执行错误,标准的浏览器在响应该事件时会依次提供 5 个参数。

window.onerror = function(message, source, lineno, colno, error) { ... }
  1. message 错误信息
  2. source 错误发生时的页面 URL
  3. lineno 错误发生时的 JS 文件行数
  4. colno 错误发生时的 JS 文件列数
  5. error 错误发生时抛出的标准 Error 对象

使用 window.addEventListener 也能绑定 error 事件,但是该事件函数的参数是一个 ErrorEvent 对象。

绑定 window.onerror 事件时,事件处理函数的第 5 个参数在低版本浏览中或 JS 资源跨域场景下可能不是 Error 对象。

在 Chrome 浏览器中如果页面加载的 JS 资源文件中存在跨域的 script 标签,在发生错误时会提示 Script error 而缺乏错误追溯栈。

window.onerror 在响应跨域 JavaScript 错误时缺乏错误追溯栈时的 arguments 对象如下:

[
    'Script error.',
    '',
    0,
    0,
    null
]

为了正常的捕获到跨域 JS 资源文件的错误,需要具备两个条件: 1. 为 JS 资源文件增加 CORS 响应头。 2. 通过 script 引用该 JS 文件时增加 crossorigin="anonymous" 的属性,如果是动态加载的 JS,可以写作 script.crossOrigin = true

window.onerror 能捕获一些全局的 JavaScript 错误,但还有不少场景在全局是捕获不到的。

try/catch

window.onerror 能捕获全局场景下的错误,如果已知一些程序的场景中可能会出现错误,这个时候一般会使用 try/catch 来进行捕获。

但是在使用 try/catch 块时无法捕获异步错误,例如块中使用了 setTimeout

try {
    setTimeout(function () {
        callTimeout();  // callTimeout 未定义,会抛错
    }, 1000);
}
catch (err) {
    console.log('catch the error', err); // 不会被执行
}

try/catch 在处理 setTimeout 这类异步场景时是无效的,执行时仍会抛错,catch 中的代码不会被执行。

虽然在 try/catch 中没有捕获到,此时如果有绑定 window.onerror 则会被全局捕获。

由此可见,try/catch 应该是只能捕获 JS Event Loop 中同步的任务。

如果想正确的捕获 setTimeout 中的错误,需要将 try/catch 块写到 setTimeout 的函数中。

setTimeout(function () {
    try {
        callTimeout(); // callTimeout 未定义,不会抛错
    }
    catch (err) {
        console.log('catch the error', err); // 将会被执行
    }
}, 1000);

Promise

Promise 有自己的错误处理机制,通常 Promise 函数中的错误无法被全局捕获。

var promise = new Promise(executor);
promise.then(onFulfilled, onRejected);

比较容易遗漏错误处理的地方有 executoronFulfilled,在这些函数中如果发生错误都不能被全局捕获。

正确的捕获 Promise 的错误,应该使用 Promise.prototype.catch 方法,意外的错误和使用 reject 主动捕获的错误都会触发 catch 方法。

catch 方法中通常会接收到一个 Error 对象,但是当调用 reject 函数时传入的是一个非 Error 对象时,catch 方法也会接收到一个非 Error 对象,这里的 reject 和 throw 的表现是一样的,所以在使用 reject 时,最好是传入一个 Error 对象。

reject(
    new Error('this is reject message')
);

值得注意的是,如果 Promise 的 executor 中存在 setTimeout 语句时,setTimeout 的报错会被全局捕获。

Async Function

Async Function 和 Promise 一样,发生错误不会被全局的 window.onerror 捕获,所以在使用时如果有报错,需要手动增加 try/catch 语句。

匿名函数

匿名函数的使用在 JavaScript 中很常见,但是当出现匿名函数的报错时,在错误追溯栈中会以 anonymous 来标识错误,为了排查错误方便,可以将函数进行命名,或者使用函数的 displayName 属性。

函数如果有 displayName 属性,在错误栈中会展示该属性值,如果用于命名重要的业务逻辑属性,将有效帮助排查错误。

Fetch API

在使用 Fetch API 来进行数据请求时,普通的异常信息如状态码、状态结果信息等会包含在 Fetch 的 Response 对象中。

fetch('/data').then((response) => {
    if (!res.ok) {
        console.log('status', response.status);
        console.log('statusText', response.statusText);
        console.log('url', response.url);
    }
});

如在请求的时候发生 500 错误,那么 status 的值就是 500,而 statusText 的值就是 Internal Server Error

上面说的是后端服务器能响应的情况下返回的异常信息,如果服务器没有响应,就要使用 catch 方法来进行错误捕获,此时错误信息会统一返回 TypeError: Failed to fetch

fetch('/data').then((response) => {
    if (!res.ok) {
        console.log('status', response.status);
        console.log('statusText', response.statusText);
        console.log('url', response.url);
    }
})
.catch((error) => {
    console.log('catch the error');
    console.log(error);  // => TypeError: Failed to fetch
});

请求错误进入 catch 语句块的关键点在于 服务器没有响应。

服务器返回了 404、500 等错误状态码是服务器有响应,但是响应出现了异常,返回的不是预期的结果。

在请求和响应的过程中发生了网络错误,此时才意味着服务器没有响应。比如浏览器端发起的请求需要经过 Nginx 的反向代理才达到实际的 Node.js 的服务器,那么此时 Node.js 服务在未运行的情况下会正常的返回 502 错误,这个返回是 Nginx 服务的返回,Fetch 时不会进入 catch 语句块。如果没有 Nginx,而是直接请求 Node.js 的服务器,如果 Node.js 服务在未运行的情况下,此时是没有任何服务器能响应这个请求的,在这种场景下就会进入到 catch 语句块。

如果在请求发生异常的时候发生了跨域,此时也会进入到 catch 语句块,如果要正确的捕获到异常需要在服务端正确的设置 CORS。

throw error

上面说了很多错误捕获的注意点,如果要主动的抛错,都会使用 throw 来抛错,常见的几种抛错方法如下:

throw new Error('Problem description.')  // 方法 1
throw Error('Problem description.')      // 方法 2
throw 'Problem description.'             // 方法 3
throw null                               // 方法 4

其中方法 1 和方法 2 的效果一样,浏览器都能正确的展示错误追溯栈。方法 3 和方法 4 不推荐,虽然能抛错,但是在抛错的时候不能展示错误追溯栈。

try/catchthrow,一个用来捕获错误,一个用来抛出错误,如果两个结合起来用通常等于脱了裤子放屁多此一举,唯一有点用的是可以对错误信息进行再加工。

可以在 Chrome 控制台中模拟出一个结合使用的实际场景。

try {
    foo();
}
catch (err) {
    err.message = 'Catch the error: ' + err.message;
    throw Error(err);
}

由于在 catch 块中又抛出了错误,所以该错误没有被捕获到,但此时错误信息经过了二次封装。

Uncaught Error: ReferenceError: Catch the error: foo is not defined

通过对错误信息的二次封装,可以增加一些有利于快速定位错误的额外信息。

2019.12.11 新增了 Fetch API 部分。

本文基于 JavaScript Errors Handbook 做了实际的测试,对内容进行了删减和简化,有兴趣的可以前往阅读。