来聊一聊JavaScript中的异常捕获

同步代码中的异常

我们先从简单的同步代码,来了解JavaScript中的异常是如何捕获和处理的

如何抛出异常

  1. 语法异常或使用了语言中缺少的功能

    1
    2
    3
    4
    5
    6
    7
    function test (a) {
    console.log(1)
    // 这里会抛出异常
    var b = a.slice(a)
    console.log(2)
    }
    test(1)

    上面代码中 console.log(2) 会无法得到执行,因为a.slice抛出异常并报错了

  2. 主动抛出异常

    1
    2
    3
    4
    5
    6
    7
    function test() {
    console.log(1)
    // 主动抛出异常
    throw new Error('throw error');
    console.log(2)
    }
    test(1)

    这里会主动抛出一个异常,也会导致console.log(2)无法得到执行

异常去哪了

由于上面所举的例子中,我们的异常都没有捕获,因此会在全局抛出。浏览器控制台会看到 Uncaught Error 的报错并可以展开堆栈信息。

捕获同步异常

如果想捕获同步代码中产生的异常,只需按照传统编程语言的思路,使用try catch即可解决

1
2
3
4
5
6
7
try {
console.log('before throw error');
throw new Error('throw error');
console.log('after throw error');
} catch (err) {
console.log(err.message);
}

这里会有如下输入:

1
2
before throw error
throw error

其中第二句是捕获到异常后主动打印出来的错误信息。

异步代码中的异常

下面这种代码是异步的

1
2
3
setTimeout(function () {
throw new Error('hello async error')
}, 3000)

异步代码的回调里产生的异常是无法被try catch捕获的

1
2
3
4
5
6
7
8
9
10
try {
// try catch 执行完的时候,异常代码还没有被触发
setTimeout(function () {
// 等到异常代码被触发时,try catch已经无效了
throw new Error('hello async error')
}, 3000)
}
catch(err) {
console.log('这里无法捕获到异常');
}

而,在异步回调执行的时候try catch,是可以捕获异常的(因为对于局部的代码来说是同步的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
try {
setTimeout(function () {
// 虽然外层的try catch无法捕获setTimeout。但是这里的try catch是能捕获里面代码错误的 (毕竟到这里的try catch 所有代码已经是同步执行了)
try {
throw new Error('hello async error')
}
catch(err) {
console.log('出错了', err)
}
}, 3000)
}
catch(err) {
console.log('这里的捕获无法打印出来')
}

异常是否会阻止代码继续执行?

在没有catch异常的情况下,异常抛出地方的后续代码肯定无法得到执行。

但是新的事件回调触发后,js还会执行吗? 比如你页面上某个按钮绑定了click事件:

1
2
3
document.querySelector('#testbtn').addEventListener('click', function () {
alert('hi')
}, false)

哪怕在用户点击这个按钮之前,你的页面js已经报错了。但这个在用户触发按钮的时候还是会执行这个click回调的哦。

一个典型的例子如下:

1
2
3
4
5
6
var a = 1;
setInterval(function () {
console.log(a++)
throw new Error('my error')
console.log(a++)
}, 3000)

设置一个间隔定时器,每隔3s都会打印一下数字a,并抛出一个错误。错误会导致后面的console无法执行。但是每隔3s钟setInterval函数还是会被触发(在浏览器中是可以再次触发的,但在 Node.js 中会导致进程退出无法再次触发)。

所以异常抛出会阻止后方同步代码的执行,但不会阻止异步任务和它的回调的执行(特指在浏览器中)。

wnidow.error 捕获未catch的异常

有没有办法监听到异步代码函数内的异常呢?

浏览器有一个机制: 不管同步代码还是异步代码,只要发生了异常且没有被捕获,那么会一直传递到全局抛出。所以我们可以在全局监听整个 JavaScript 运行过程的异常. window.onerror 就是做这个事情的。

看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @param {String} errorMessage 错误信息
* @param {String} scriptURI 出错的文件
* @param {Long} lineNumber 出错代码的行号
* @param {Long} columnNumber 出错代码的列号
* @param {Object} errorObj 错误的详细信息,Anything
*/
window.onerror = function(errorMessage, scriptURI, lineNumber,columnNumber,errorObj) {
console.log("错误信息:" , errorMessage);
console.log("出错文件:" , scriptURI);
console.log("出错行号:" , lineNumber);
console.log("出错列号:" , columnNumber);
console.log("错误详情:" , errorObj);
}
setTimeout(() => {
throw new Error("some message");
}, 0);

通过监听 window 的 error 事件 就能监听任何异常了。 (注意:如果你自己try catch捕获住了异常,并且没有继续向上抛出(即: 你吃掉了异常),那么全局是无法捕获到异常的)

注意我这里讲的是 监听,并不是 捕获。 因为 window.onerror 事实上 跟 try…catch 还是有很大的区别。window.error 只能在全局去监听到你代码内未捕获的异常(可以用作上报),但无法像 catch 一样 吃掉 这个异常。 可以说, window.error 发现错误的时候就代表这个错误已经抛到了全局,此时业务代码已经被异常中断了,此时已经为时已晚无法再做处理了。

Node.js 的异常处理

趁热打铁,我们来看看 Node.js 中的异常。上面说了那么多浏览器中 JavaScript 异常处理。实际上在 Node.js 中也是类似的,但是有个明显的区别是:

在浏览器中如果没有捕获异常,异常抛到全局,会被浏览器发现但不会导致浏览器进程退出; 然而 Node.js 来说,如果异常没有被 吃掉,被抛到全局的异常会直接导致进程退出。

例如我们写一个 web server 的例子(也可以用setTimeout来做例子):

1
2
3
4
5
6
7
8
9
const http = require('http')

const server = http.createServer((req, res) => {
console.log(1)
throw new Error('throwed error')
console.log(2)
})

server.listen(3001)

这段代码,只要来一个请求就会导致 web server 挂掉。原因就在于异步的异常无法捕获。

在 Node.js(也包括浏览器端的一些API) 的底层 API 中, 为了防止异步IO时抛出直接抛出异常导致 Node.js 应用挂掉,一般这些 API 都会采用 事件callback(err, data) 这样的机制来告诉 Node.js 应用出异常了。哪怕你看不做任何异常处理,也不会导致应用挂掉。所以 Node.js 中的代码一般是这样写的:

1
2
3
4
5
6
7
8
fs.mkdir('/dir', function (e) {
if (e) {
/*处理异常*/
console.log(e.message)
} else {
console.log('创建目录成功')
}
})

但实际上 Node.js 应用会肥非常复杂,不能排除自己业务代码中抛出的同步或异步的异常。在 Node.js 中也有类似于浏览器的全局捕获机制,Node.js 可以监听到进程内的全局所有异常做一些日志记录等工作

1
2
3
4
process.on('uncaughtException', function (e) {
/*处理异常*/
console.log(e.message)
});

跨源异常捕获

对于window.onerror方式的异常捕获方式,无法捕获跨源脚本爆出的错误。跨源的脚本报错,window.onerror只能收到一个 Script Error 的errorMessage信息,无法获取行号、错误对象等信息。

如果希望过滤掉跨域的错误,可以这样写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
window.onerror = function (msg, url, lineNo, columnNo, error) {
var string = msg.toLowerCase();
var substring = "script error";
if (string.indexOf(substring) > -1){
alert('Script Error: See Browser Console for Detail');
} else {
var message = [
'Message: ' + msg,
'URL: ' + url,
'Line: ' + lineNo,
'Column: ' + columnNo,
'Error object: ' + JSON.stringify(error)
].join(' - ');
alert(message);
}
return false;
};

要想可以捕获跨源的脚本错误,可以这样设置:

  1. 添加script的crossorigin属性

    1
    <script type="text/javascript" src="//xxx.domain.com/error.js" crossorigin></script>
  2. 配置一下服务器,设置静态资源Javascript的Response为Access-Control-Allow-Origin

    1
    2
    header('Access-Control-Allow-Origin:*');
    header('Content-type:text/javascript');

其实这里就是跨源资源共享的跨域机制了。

eval的异常

在使用localStorage或者动态加载远程js脚本的时候,通常采用的策略是: “用XMLHTTPRequest”把远程脚本请求回来,再把脚本内容放入 eval 函数执行,这会导致eval会把字符串内容放在eval自身所在位置的上下文里执行。

但eval执行代码有个问题,就是: 脚本中的错误异常会抛出,但不会被全局的window.onerror捕获. 因此,对于eval执行脚本的错误,如果希望被全局window.onerror函数捕获,则需要自己trycatch并自己抛出.

示例如下:

1
2
3
4
5
6
try {
eval('your code')
}
catch(err) {
throw err // 自己捕捉自己抛出
}

Promise中的异常

Promise改造了异步代码的回调书写方式,变成了then函数来注册回调的方式。

1
2
3
var p = Promise.resolve(5)
p.then()
console.log(6)

Promise中的异常,可以通过传入一个reject回调来捕获:

1
2
var p = Promise.resolve(5)
p.then(onResolved, onRejected)

由于Promise的错误如果没有被处理,则会一直往后面的Promise传递,所以你可以在链式调用的最后面去catch这个Promise错误:

1
2
3
4
5
6
7
sendRequest('test.html').then(function(data1) {
//do something
}).then(function (data2) {
//do something
}).catch(function (error) {
//处理前面三个Promise产生的错误
});

Promise 内报错或异常都会触发reject

在 Promise 内部如果抛出了错误,或代码语法错误,相当于会触发Promise的reject过程。

1
2
3
4
5
6
7
var promise = new Promise(function(resolve, reject) {
throw new Error('my error');
console.log('我是error之后的代码')
resolve(1)
});

promise.then(res=>{console.warn(res)}).catch(e=>{console.log(e)});

但是throw出来的异常错误会导致后面的代码 console.log 无法执行;而如果是手动 Promise.reject() 的异常,Promise内部的后方代码还会执行。例如打印”我是 error 之后的代码”

不catchPromise的错误会怎样?

如果不去捕获Promise的错误(例如reject调用,或者Promise内部发生了异常或语法错误),那么chrome浏览器会在控制台抛出这个错误,而据说safari和firefox没有任何反应。

这是个例子:

1
2
3
4
5
6
var promise = new Promise(function (resolve, reject) {
resolve(x);
});
promise.then(function (data) {
console.log(data);
});

异步之后的非reject异常无法捕获

1
2
3
4
5
6
7
8
9
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
throw 'Uncaught Exception!';
}, 1000);
});

promise.catch(function(e) {
console.log(e); //This is never called
});

这种异常只能被抛到外层window, try catch也无法捕获,Promise也无法捕获. 注意: 这里这个异常是非Promise异常了,可以被widow.onerror捕获

因此,如果要在Promise里抛出异常,最好使用reject的方式。reject无论在同步代码里还是在异步代码里都能让这个Promise抛出异常且能被后面的catch接住。

1
2
3
4
5
6
7
8
9
10
11
Promise.resolve()
.then(() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('throw error'));
}, 0);
});
})
.catch((err) => {
console.log(err);
});

全局捕获Promise异常

对于在Promise内部直接throw Error 或者 在 Promise内部异步代码回调里reject抛异常,这两种方式都是Promise异常。

promise最有争议的地方就是当一个promise失败但是没有rejection handler处理错误时静默失败。不过浏览器和Node.js都有相应的处理机制,两者大同小异,都是通过事件的方式监听. 有两个全局事件可以用来监听 Promise 异常:

  • unhandledRejection:当promise失败(rejected),但又没有处理时触发,event handler 有2个参数: reason,promise;
  • rejectionHandled: 当promise失败(rejected),被处理时触发,hanler 有1个参数: promise;

generator 的异常

generator 又是一个比较复杂的东东啦。它提供了一个 throw 方法可以向函数内抛出一个异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
function *gen() {
try {
yield 'a';
yield 'b';
} catch(e) {
console.log('inside:', e); // inside: [Error: error from outside]
}
}

var it = gen();
console.log(it.next());

console.log(it.throw(new Error('error from outside'))); // { value: undefined, done: true }

上述代码会输出:

1
2
3
4
5
6
7
8
9
10
11
12
{ value: 'a', done: false }
inside: Error: error from outside
at Object.<anonymous> (E:\study\tsw\generator\koa1\test.js:13:24)
at Module._compile (module.js:624:30)
at Object.Module._extensions..js (module.js:635:10)
at Module.load (module.js:545:32)
at tryModuleLoad (module.js:508:12)
at Function.Module._load (module.js:500:3)
at Function.Module.runMain (module.js:665:10)
at startup (bootstrap_node.js:201:16)
at bootstrap_node.js:626:3
{ value: undefined, done: true }

由于第一次执行next后,函数执行会暂停在 yield a 这一句。 虽然a已经返回,但这一句还没有执行完,等下次调用 next 或 throw 时才会继续执行完成 赋值 才标志着本句结束。由于第二次执行时并不是 next, 而是一个 throw, 所以yield b 无法得到执行,然后catch后面已经没有其他代码,所以本次 throw 会返回 { value: undefined, done: true }, 表示generator已经执行完毕。

其实 generator 跟异步没有关系,抛开执行流程的特殊性,就把他当做普通函数来看就好了。这种函数体内捕获错误的机制,大大方便了对错误的处理。多个yield表达式,可以只用一个try…catch代码块来捕获错误。如果使用回调函数的写法,想要捕获多个错误,就不得不为每个函数内部写一个错误处理语句,现在只在 Generator 函数内部写一次catch语句就可以了。

除了函数体外触发异常被函数体内捕获之外, 函数体内触发的异常也可以被函数体外捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* foo() {
var x = yield 3;
var y = x.toUpperCase();
yield y;
}

var it = foo();

it.next(); // { value:3, done:false }

try {
it.next(42);
} catch (err) {
console.log(err);
}

一旦 Generator 执行过程中抛出错误,且没有被内部捕获,就不会再执行下去了。如果此后还调用next方法,将返回一个value属性等于undefined、done属性等于true的对象,即 JavaScript 引擎认为这个 Generator 已经运行结束了。

为什么throw error可以导致后面代码无法执行?

在 JavaScript 函数中,只有 return / yield / throw 会中断函数的执行,其他的都无法阻止其运行到结束的

浏览器全局优雅地处理异常

对于在代码中强制开发者处理好自己的异常是不太客观的,因此可以在全局去监听异常(包括让开发者主动抛出的异常),然后做出相应的处理。对于需要提示用户的就提示用户,对于意外的异常,可以打印或上报。贴一个网上的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import EnsureError from './ensureError.js';
import ToastError from './toastError.js';
import DevError from './devError.js';
import EnsurePopup from './ensurePopup.js';
import ToastPopup from './toastPopup.js';

function errorHandler(err) {
if (err instanceof EnsureError) {
EnsurePopup(err.message);
} else if (err instanceof ToastError) {
ToastPopup(err.message);
}else if( err instanceof DevError){
DevError(err.message);
}else{
error.message += ` https://stackoverflow.com/questions?q=${encodeURI(error.message)}`
console.error(err.message);
}
}

window.onerror = (msg, url, line, col, err) => {
errorHandler(err);
}

window.onunhandledrejection = event =>{
errorHandler(event.reason);
};

export default errorHandler;

几个错误类的实现在这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function EnsureError(message = 'Default Message') {
this.name = 'EnsureError';
this.message = message;
this.stack = (new Error()).stack;
}
EnsureError.prototype = Object.create(Error.prototype);
EnsureError.prototype.constructor = EnsureError;

export default EnsureError;


function ToastError(message = 'Default Message') {
this.name = 'ToastError';
this.message = message;
this.stack = (new Error()).stack;
}
ToastError.prototype = Object.create(Error.prototype);
ToastError.prototype.constructor = ToastError;

export default ToastError;


function DevError(message = 'Default Message') {
this.name = 'ToastError';
this.message = message;
this.stack = (new Error()).stack;
}
DevError.prototype = Object.create(Error.prototype);
DevError.prototype.constructor = DevError;

export default DevError;

总结

看文本文你学会JavaScript里的异常了吗。可以做做这个Promise的题目试试: https://www.zhihu.com/question/39780175

refer

用window.onerror捕获并上报Js错误
初探Promise
Callback Promise Generator Async-Await 和异常处理的演进