来聊一聊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
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
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
4
5
6
7
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 中会导致进程退出无法再次触发)。

所以异常抛出会阻止后方同步代码的执行(可以理解为执行栈碰到错误,就会清空执行栈),于是 js 线程空闲,从而有机会去执行其他的任务。所以报错并不会阻止执行底层线程中的异步任务,也不会阻止异步回调的执行(特指在浏览器中)。

wnidow.error 捕获未 catch 的异常

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

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

看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @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) 这样的机制—通过一个自定义 err 对象参数来告诉 Node.js 应用出异常了。即,他没有真的 throw error,所以哪怕你不做任何异常处理,也不会导致应用挂掉。所以 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
    2
    3
    4
    5
    <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
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
8
9
10
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
8
9
10
11
12
13
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
29
30
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 和异常处理的演进