异常指的是在程序运行过程中发生的异常事件,通常是由外部问题(如硬件错误、输入错误)所导致的。在 Java、JavaScript 等编程语言的异常处理机制中,异常这一术语所描述的通常是一种数据结构,这种数据结构可以存储与某种异常情况相关的信息。
TODO: 错误分类
参考文献
异常处理是对异常的确定,是编程语言或计算机硬件里的一种机制,用于处理软件或信息系统中出现的异常状况(即超出程序正常执行流程的某些特殊条件)。通过异常处理,我们可以对用户在程序中的非法输入进行控制和提示,以防程序崩溃。现代编程语言提供了异常处理机制来处理异常,基本思路是:程序运行时如果发生错误,就“抛出”一个异常,而系统能够“捕获”这个异常并执行特定的异常处理代码。
理论上异常处理划分为两个模型(终止模型与继续模型),但实际使用方面我们对终止模型用的比较多,这个模型比较实用,而继续模型则不是那么的应用普遍。
- 终止模型:假设错误非常关键,将以致于程序无法返回到异常发生的地方继续执行.一旦异常被抛出,就表明错误已无法挽回,也不能回来继续执行。
- 恢复模型:意思是异常处理程序的工作是修正错误,然后重新尝试调动出问题的方法,并认为第二次能成功。
参考文献
早期的 C 语言的“异常处理机制”(C 语言一般认为是不支持异常处理的),通常是我们人为的对返回结果加一些标志来进行判定,比如发生错误返回什么标志,正常情况下我们又是返回什么标记,而这些都不是语言本身所赋予我们的,这种机制的问题在于,C 语言的异常处理机制全是我们人为的定义,这样就会造成业务逻辑的主线受到异常处理的牵制,或者说是我们难免会将注意力转移,并且造成业务逻辑与异常处理之间有很大程度上的缠绕。
现代编程语言的异常处理机制是代替日渐衰落的 error code 方法的新法,提供 error code 所未能具体的优势。异常处理分离了接收和处理错误代码。这个功能理清了编程者的思绪,也帮助代码增强了可读性,方便了维护者的阅读和理解。
ps:可以这么认为,现代编程语言的异常处理机制提供了 try...catch 控制流(沿着函数调用栈的函数调用逆向搜索,直到遇到异常处理代码为止),让我们更简单和易度方式处理异常。虽然 C 语言一般认为是不支持异常处理,但也可以通过 goto 等语法间接的实现“异常处理机制”,从而将业务逻辑和错误处理分离(参考 C语言中的异常处理机制)。
示例:下面是一个新增用户的示例,在新建用户时需要检查用户名是否已经存在,如果不存在则可以创建该用户。注意示例中 getUserByName
的参数和返回值,createUser
内的实现逻辑。
-
错误标识:使用错误标识需要回调返回值里携带错误代码,调用函数的业务逻辑需要加上对错误代码的处理,这样的实现业务逻辑与异常处理缠绕在一起,降低了代码可读性。
// 错误标识 function getUserByName(name: string, callback: (result: { code: number, data: { id: string, name: string } }) => void): void { // ...一系列异步操作 callback({ code: 0, data: { id: 'a', name: '某某某', }, }); } function createUser(name: string, callback: (code: number) => void) { getUserByName(name, function (result) { const { code, data } = result; if (code === 0) { // 需要判断错误代码 // 查询成功 if (data === null) { // 新建用户 callback(0); } else { callback(1); // 表示用户已经存在 } } else { callback(code); // 向外传 code } }) } createUser('某某某', function (code) { if (code === 0) { // 创建成功 } else { // 创建失败 } });
-
错误对象:Nodejs 写法,确定了统一的函数回调返回值写法,返回值的错误标识和业务对象也分离开来了。
// 错误对象 function getUserByName(name: string, callback: (error: Error | null, data: { id: string, name: string }) => void): void { // ...一系列异步操作 callback( null, { id: 'a', name: '某某某', }, ); } function createUser(name: string, callback: (error: Error | null) => void) { getUserByName(name, function (error, data) { if (error === null) { // 查询成功 if (data === null) { // 新建用户 callback(null); } else { callback(new Error('用户已经存在')); } } else { callback(error); } }); } createUser('某某某', function (error: Error | null) { if (error === null) { // 创建成功 } else { // 创建失败 } });
-
Promsie:基于 Promise 的异常处理机制,让代码变得更加优雅,但还不是原生的异常处理机制。
// Promise function getUserByName(name: string): Promise<{ id: string, name: string }> { return new Promise(function (resolve, reject) { // ...一系列异步操作 resolve({ id: 'a', name: '某某某', }); }); } function createUser(name: string): Promise < null > { // 只需要关心 getUserByName 返回的业务数据,需要处理异常(交给最外层处理) return getUserByName(name) .then(function (data) { if (data === null) { // 新建用户 return null; } else { return Promise.reject(new Error('用户已经存在')); } }); } createUser('某某某') .then(function () { // 创建成功 }) .catch(function () { // 创建失败 });
-
异常处理:基于 async/await 语法,可以使用原生的异常处理机制(try...catch)来处理异常,帮助代码增强了可读性,方便了维护者的阅读和理解。
// Async function todo() { // 一系列异步操作 } async function getUserByName(name: string): Promise<{ id: string, name: string }> { await todo(); return { id: 'a', name: '某某某', }; } async function createUser(name: string): Promise<null> { const data = await getUserByName(name); if (data === null) { // 新建用户 return null; } else { throw new Error('用户已经存在'); } } (async function () { try { await createUser('某某某'); // 创建成功 } catch (error) { // 创建失败 } }());
思考:从上文的例子来看,异常确实让代码变得更加清晰和优雅,但是异常处理机制真的要比错误代码要好吗(可以从错误对象,错误传播,错误处理和错误安全等角度去对比思考)?
VS | 异常 | 错误代码 |
---|---|---|
错误对象 | 异常可以携带更多信息 | 通常错误码在不同的库中有不同的定义,必须手动转换成统一的形式 |
错误传播 | 不需要额外的措施就能传播到上层 | 手动传播,当函数层次比较深时,会产生很多 if 语句,同时,每一个错误代码都需要一个 if 语句,if 语句会增加分支数,增加测试成本。 |
错误处理 | 异常则集中于一个地方,容易管理 | 错误代码的处理散布各处,伸缩性较差。 |
优缺点
-
错误代码
- 跨系统/语言边界
- 资源消耗少
-
异常
- 优雅性:异常不会侵入接口,使得正常情况下,接口更加优雅
- 清晰性:代码逻辑更加清晰,没有多余的分支,算法更加透明
参考文献
- 错误码到底比异常优越在哪?
- 「杂谈」异常处理 vs 错误处理
- 如何用C语言实现异常/状况处理机制?
- 如何设计一门语言(六)——exception和error code
- 为什么不建议用 try catch?
- 各种编程语言中的「错误/异常处理」有哪些成熟的,优雅的或是热门的机制/思想?
- Go 语言的错误处理机制是一个优秀的设计吗?
- C、C++、Java语言中异常处理机制浅析
- 运行时异常处理程序是如何实现的?
- 关于异常的争论
- Suggestion:
throws
clause and typed catch clause
JavaScript 解析或运行时,一旦发生错误,引擎就会抛出一个错误对象。JavaScript 原生提供 Error 构造函数,所有抛出的错误都是这个构造函数的实例(Error 对象也可用于用户自定义的异常的基础对象)。
创建一个 Error 对象示例:new Error([message[, fileName[,lineNumber]]])
var err = new Error('出错了'); // 也可以当做函数调用 Error('出错了'),效果和 new 语法一样
err.name // 错误名称,初始值是 `"Error"`
err.message // 错误信息 “错误了”
err.stack // 错误发生时的堆栈
-
name 和 message 是 ES 标准属性;
-
stack 虽然是非标准属性,但现代浏览器都支持该属性;
-
不同浏览器的错误堆栈格式不一致,参考 Error.prototype.stack;
-
匿名函数在错误栈中没有名字,表现为空字符串要么就是Anonymous function(根据不同浏览器会有区别);
-
将函数赋值给一个变量时,浏览器通常也会使用匿名函数赋值给的变量作为函数名,在追溯帧中出现;
-
除了 IE11,函数名的展现也可以通过给函数定义一个 displayName 属性,displayName 会出现在浏览器的 devtools debugger 中,虽然关于 displayName 还没有官方的标准,但是该属性已经在主要的浏览器中实现了。
-
当抛出一个错误但又没有追溯栈的时候(通过下面的内容了解更多),我们可以通过一些编程的手段来捕获追溯栈。
-
在 Chrome 中,可以简单的调用 Error.captureStackTrace API 来获取到追溯栈,关于该API的使用可以通过如下链接了
function ignoreThisFunctionInStackTrace() { var err = new Error(); Error.captureStackTrace(err, ignoreThisFunctionInStackTrace); return err.stack; }
-
在其它浏览器中,追溯栈也可以通过生成一个错误,然后通过 stack 属性来获取追溯栈。
var err = new Error('');return err.stack;
-
如果上面的方法都起作用时,我们可以通过 arguments.callee.caller 对象来粗糙的获取一个没有行数和列数的追溯栈,但是这种方法在ES5严格模式下不起作用,因此这种方法也不是一种推荐的做法。
-
-
异步追溯栈:在 JavaScript 代码中异步代码是非常常见的。比如 setTimeout 的使用,或者 Promise 对象的使用,这些异步调用入口往往会给追溯栈带来问题,因为异步代码会生成一个新的执行上下文,而追溯栈又会重新形成追溯帧。Chrome DevTools 已经支持了异步追溯栈,换句话说,追溯栈在追溯一个错误的时候也会显示引入异步调用的那一调用帧。目前,异步追溯栈只有 Chrome DevTools 支持,而且只有在 DevTools 打开的情况下才会捕获,在代码中通过Error对象不会获取到异步追溯栈。
-
-
fileName、lineNumber 和 columnNumber 是非标准属性,参考浏览器的兼容性,只有 Firefox 支持。
-
cause
Power up your Node.js debugging and error handling with the new Error cause feature
参考文献
- MDN of Error
- Specification of Error
- JavaScript Errors 指南 - 错误剖析
- Debugging Asynchronous JavaScript with Chrome DevTools
Error 实例对象是最一般的错误类型,在它的基础上,JavaScript 还定义了其他 6 种错误对象。也就是说,存在 Error 的6个派生对象。
- SyntaxError
- ReferenceError
- RangeError
- TypeError
- URIError
- EvalError
参考文献
除了 JavaScript 原生提供的七种错误对象,还可以定义自己的错误对象。
// prototype
function UserError(message) {
var instance = new Error(message);
instance.name = 'UserError';
Object.setPrototypeOf(instance, Object.getPrototypeOf(this));
if (Error.captureStackTrace) {
Error.captureStackTrace(instance, UserError);
}
return instance;
}
UserError.prototype = Object.create(Error.prototype, {
constructor: {
value: Error,
enumerable: false,
writable: true,
configurable: true
}
});
if (Object.setPrototypeOf){
Object.setPrototypeOf(UserError, Error);
} else {
UserError.__proto__ = Error;
}
// class
class UserError extends Error {
constructor(message) {
super(message);
this.name = 'UserError';
}
}
参考文献
throw 语句用来抛出一个用户自定义的异常,当前函数的执行将被停止(throw 之后的语句将不会执行),并且控制将被传递到调用堆栈中的第一个 catch 块。如果调用者函数中没有catch块,程序将会终止。
语法:
throw expression;
expression 可以是任何类型的值,如下所示:
throw "Error2"; // 抛出了一个值为字符串的异常
throw 42; // 抛出了一个值为整数42的异常
throw true; // 抛出了一个值为true的异常
虽然可以这么做,但通常建议这样,因为非错误对象示例是无法追踪异常调用栈的(要 Error 对象示例采用 stack 属性)。
一旦发生错误,程序就中止执行了。JavaScript 提供了 try...catch 结构,允许对错误进行处理,选择是否往下执行。
try {
try_statements // 需要被执行的语句。
}
[catch (exception_var_1 if condition_1) { // non-standard
catch_statements_1
}]
...
[catch (exception_var_2) { // 用于保存关联catch子句的异常对象的标识符。
catch_statements_2 // 如果在try块里有异常被抛出时执行的语句。
}]
[finally {
finally_statements // 在try语句块之后执行的语句块。无论是否有异常抛出或捕获这些语句都将执行。
}]
要求:try 语句包含了由一个或者多个语句组成的 try 块, 和至少一个 catch 子句或者一个 finally 子句的其中一个,或者两个兼有, 下面是三种形式的 try 声明:
try...catch
try...finally
try...catch...finally
条件 catch 子句:JavaScript 标准不支持条件 catch 语法,但可以在 catch 子句内部自行判断处理,例如:
try {
myroutine(); // may throw three types of exceptions
} catch (e) {
if (e instanceof TypeError) {
// statements to handle TypeError exceptions
} else if (e instanceof RangeError) {
// statements to handle RangeError exceptions
} else if (e instanceof EvalError) {
// statements to handle EvalError exceptions
} else {
// statements to handle any unspecified exceptions
logMyErrors(e); // pass exception object to error handler
}
}
finally 子句包含的语句,是在 try 块和 catch 子句之后,但在 try..catch..finally 块之后的语句之前执行。请注意,无论是否抛出异常finally 子句都会执行。此外,如果抛出异常,即使没有 catch 子句处理异常,finally 子句中的语句也会执行。finnaly 通常用来释放资源,例如:
openMyFile()
try {
// tie up a resource
writeMyFile(theData);
}
finally {
closeMyFile(); // always close the resource
}
注意点:如果从 finally 块中返回一个值,那么这个值将会成为整个 try-catch-finally 的返回值,无论是否有 return 语句在 try 和 catch 中。这包括在catch块里抛出的异常。例如:
function f() {
try {
throw '出错了!';
} catch(e) {
console.log('捕捉到内部错误');
throw e; // 这句原本会等到 finally 结束再执行
} finally {
return false; // 直接返回
}
}
try {
f();
} catch(e) {
// 此处不会执行
console.log('caught outer "bogus"');
}
TODO: 性能问题
思考:捕获到错误时要做什么样的处理?
try...catch
无法捕获 setTimeout 等异步任务中的错误,那其中的原因是什么。以及异步代码在 js 中是特别常见的,我们该怎么做?
// 这段代码中,setTimeout 的回调函数抛出一个错误,并不会在 catch 中捕获,会导致程序直接报错崩掉。
const task = () => {
setTimeout(() => {
throw new Error('async error');
}, 1000)
}
// 主任务
function main() {
try {
task();
} catch(err) {
console.log(err)
}
}
-
宏任务的回调函数中的错误无法捕获
这种情况下 main 是无法 catch error 的,这跟浏览器的执行机制有关。异步任务由 eventloop 加入任务队列,并取出入栈(js 主进程)执行,而当 task 取出执行的时候, main 的栈已经退出了,也就是上下文环境已经改变,所以 main 无法捕获 task 的错误。
-
微任务(promise)的回调
// 返回一个 promise 对象 const task = () => new Promise((reslove) => { throw new Error('async error'); }); function main() { try { // 回调函数里抛出错误 task().then(() => { // ... }); } catch(e) { console.log(err); } }
promise 的任务,抛出错误同样也无法 catch。因为微任务队列是在两个 task 之间清空的,所以 then 入栈的时候,main 函数也已经出栈了。
参考文献
- try..catch为什么不能捕获异步的错误
- JS 异步错误捕获二三事
- Javascript 异常处理的演进
- Callback Promise Generator Async-Await 和异常处理的演进
要获取异步任务执行时抛出的错误对象通常会采用回调的方式获取,不同的实现采用了不同的规范,Node.js 采用 error-first 风格的回调函数,有些前端库采用成功回调 + 失败回调的方案,例如 Geolocation.getCurrentPosition。
const task = (callback) => {
setTimeout(() => {
callback(new Error('async error'))
}, 1000)
}
function main() {
task((error, data) => {
if (error) {
console.log(error);
} else {
// ...
}
});
}
或者
const task = (success, failure) => {
setTimeout(() => {
failure(new Error('async error'))
}, 1000)
}
function main() {
task(
data => {
// ...
},
error => {
console.log(error);
}
);
}
采用回调方式开发的人都知道回调最大的问题是“回调地狱”,Promise 是一种编写异步代码的方法,基于它实现的异步代码仍然表现为以自上而下的方式执行。
const task = () => {
new Promise((reslove) => {
throw new Error('async error');
});
}
// 主任务
function main() {
task()
.then(() => {
// ...
})
.catch((err) => {
console.log(err);
});
}
const task = async () => {
await xxx(); // 异步任务
throw new Error('async error');
}
// 主任务
async function main() {
try {
await task();
// ...
} catch(error) {
console.log(error);
}
}
更复杂的情形是,当你在做一个可能会产生多个错误或多个结果的复杂操作的时候,调用者需要监听这个对象的 error 事件。例如 <XMLHttpRequest>
在请求过程中可能触发各种事件:abort
、error
和 timeout
等。
我们知道 try...catch 不是万能的,它只能捕获到同步的运行时错误,对语法和异步错误却无能为力。除了支持 Promsie API,aync/await 语法外,JavaScript 的运行环境还提供了一些适用于自身使用场景的 API 来捕获异常。例如:浏览器端的 onerror
。
前面异步错误中有提到,在复杂的情形下做一个产生多个错误或多个结果的复杂操作的时候,调用者需要监听这个对象的 error 事件(观察者模式)。
实现思路:
- 借助 DOM 事件处理模型来实现自定义错误事件 创建和触发 events。
- 使用自定义的事件处理模型,例如 es-event-emitter。
全局错误捕获 - onerror
浏览器运行前端代码就是一个很复杂的情形,在运行中可能会产生各种错误,例如:语法错误、未处理的异常和资源加载失败等。而浏览器的全局对象 window
就提供了监听全局错误的方式。
作用机制:
- JavaScript 运行时错误(包括语法错误和一些未捕获的异常)会在
window
对象上触发一个基于ErrorEvent
的错误事件,并调用window.onerror
。 - 当某个资源(例如:
<img>
、<script>
等)加载失败时,会在对应元素对象上触发一个基于Error
的错误事件,并调用该元素绑定的的onerror
函数。和运行时错误不同的是该事件不会冒泡到window
对象上,但是可以通过事件捕获来监听该事件。
使用方法:
-
window.onerror = function(message, source, lineno, colno, error) { ... }
- message:错误信息(字符串)。可用于HTML onerror=""处理程序中的event。
- source:发生错误的脚本URL(字符串)
- lineno:发生错误的行号(数字)
- colno:发生错误的列号(数字)
- error:Error对象(对象)
ps:
- 若该函数返回true,则阻止执行默认事件处理函数(即不会在开发者工具里打印错误信息)。
- onerror 最好写在所有 JS 脚本的前面,否则在绑定之前的错误无法捕获;
-
window.addEventListener('error')
:等同于window.onerror
window.addEventListener('error', function(event: ErrorEvent) { ... })
ErrorEvent 包含同
window.onerror
一样的错误信息ErrorEvent.message
ErrorEvent.filename
ErrorEvent.lineno
ErrorEvent.lineno
ErrorEvent.error
-
element.onerror = function(event: Event) { ... }
或者
window.addEventListener('error', function(event: Event) { ... }, true)
由于网络请求异常不会事件冒泡,因此必须在捕获阶段将其捕捉到才行,但是这种方式虽然可以捕捉到网络请求的异常,但是无法判断 HTTP 的状态是 404 还是其他比如 500 等等,所以还需要配合服务端日志才进行排查分析才可以。
TODO: 跨域脚本 可能无法正常获取异常调用堆栈,可以通过跨域响应头或者改写 EventTarget.prototype.addEventListener
来解决。
参考文献
- GlobalEventHandlers.onerror
- How to catch JavaScript Errors with window.onerror (even on Chrome and Firefox)
在 promise 中使用 catch 可以非常方便的捕获到异步 error,这个很简单。但没有写 catch 的 Promise 中抛出的错误无法被 onerror 或 try-catch 捕获到,所以我们务必要在 Promise 中不要忘记写 catch 处理抛出的异常。为了防止有漏掉的 Promise 异常,建议在全局增加一个对 unhandledrejection 的监听,用来全局监听Uncaught Promise Error。使用方式:
window.addEventListener("unhandledrejection", function(e){
console.log(e.promise);
console.log(e.reason);
});
TODO: unhandledrejection 的兼容性怎么样?如果不兼容要怎么处理(改写 Promise.prototype.then
)?
参考文献
- Promise rejection events
- Window: unhandledrejection event
- Window: rejectionhandled event
- PromiseRejectionEvent
- [译] NodeJS 错误处理最佳实践
- 域模块已死
- 剑走偏锋!domain模块居然还能这样用!
- Node.js Domain 模块
- Node.js 异步异常的处理与domain模块解析
- Error Handling in Node.js
- Node.js内部是如何捕获异步错误的?
虽然可以避免回调地狱,并使用 try...catch 来捕获异常,但这么做会影响性能,不建议使用。
- node-error-first-callback
- The Node.js Way - Understanding Error-First Callbacks
- Why does node prefer error-first callback?
- Node.js的Error-first回调模式
- Why does node prefer error-first callback?
虽然 Node.js 规范了回调函数的参数格式,但是还是无法解决回调地狱问题,可以借助一些第三方库来封装 Node.js API 来实现 Promsie 话,例如:Bluebird's promisification。
Node.js 内置了 EventEmitter 模块,可以轻松的实现自定义的异步操作事件处理机制。
域模块已死,不再介绍。
类似浏览器端的全局对象 window
,Node.js 环境也有自己的全局对象用来监听未处理的异常和 Promise rejection。
componentDidCatch(error, info) {
console.log(error, info);
}
Vue.config.errorHandler = (err, vm, info) => {
console.error(err);
console.error(vm);
console.error(info);
}
TODO
app.use(function (err, req, res, next) {
console.error(err);
res.status(500).send('Something broke!');
});
Rollbar 是一个异常监控平台,Rollbar 于 2018 年基于数千个项目的错误日志数据统计了前端项目中 Top10 的错误类型。。
这个开源项目 —— JavaScript 异常档案 收集了前端的异常处理的相关敬仰。
Rollbar 的统计中有 7 个是类型错误(TypeError),一个 ReferenceError,一个 RangeError,还有一个脚本跨域加载错误,怎么在开发中尽早发现这些问题?Rollbar 给的建议是 TypeScript 来避免这些问题,即使不用 TypeScript,也可以使用 lint 工具来提升代码质量。
A good static type checking system like Typescript could help you avoid them if you use the strict compiler option. It can warn you if a type is expected but has not been defined. Even without Typescript, it helps to use guard clauses to check whether objects are undefined before using them.
TODO: 即使是技术水平再高的开发团队,即使采用了最佳实践,生产中也会出现意外错误。了解影响用户的错误并获得快速解决问题的好工具非常重要(前端错误监控系统)。
- 异常和异常处理是什么
- 编程语言为什么需要异常处理机制
- JavaScript 异常错误对象和怎么抛出、捕获和处理异常
- 异步任务的异常处理机制
- 特定运行环境下的异常处理
- 特定开发框架下的异常处理
思考:知道了这些我要做什么?
- 注意查阅原生 API 和第三方库的接口文档:是否存在异常,以及异常的处理方式;
ps:避免忽略这些错误,例如:很容易忽略 JSON 解析和 URI 编码抛出的异常,不处理这些异常就是程序 bug,导致程序直接终止,用户得不到任何反馈。
- 统一接口规范,制定一致的异常处理方式(回调、Promise 或者 Async/Await);
- 使用全局错误捕获机制来收集和上报日志(快速定位线上问题,完善前端方案,前端监控系统)。
- 前端开发中的Error以及异常捕获
- Top 10 JavaScript errors from 1000+ projects (and how to avoid them) / https://rollbar.com/blog/top-10-javascript-errors/
- 【第1701期】 JavaScript Errors 指南
- Introduction to JavaScript Source Maps
- Debugging Asynchronous JavaScript with Chrome DevTools
- 异常处理,"try..catch"
- JavaScript 错误处理权威指南
- 如何优雅处理前端异常?