toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
21 stars 1 forks source link

啊,似乎没有真正理解 try...catch...finally! #286

Open toFrankie opened 1 year ago

toFrankie commented 1 year ago

配图源自 Freepik

写了那么久的 JavaScript,似乎真的没有很认真地去了解 try...catch...finally 的各种用法,真是惭愧了!Anyway,不懂就学...

一、错误与异常

错误,在程序中是很常见的。它可以是 JS 引擎在执行代码时内部抛出的,也可以是代码开发人员针对一些不合法的输入而主动抛出的,或者是网络断开连接导致的错误等等...

可能很多人会认为,「错误」和「异常」是同一回事,其实不然,一个错误对象只有在被抛出时才成为异常。

1.1 错误

在 JavaScript 中,错误通常是指 Error 实例对象或 Error 的派生类实例对象(比如 TypeErrorReferenceErrorSyntaxError 等等)。创建 Error 实例对象很简单,如下:

const error = new Error('oops') // 等价于 Error('oops')
const typeError = new TypeError('oops')
// ...

虽然 Error 及其派生类是构造函数,但是当作函数调用也是允许的(即省略 new 关键字),同样会返回一个错误实例对象。

一个错误实例对象,包含以下属性和方法:

const errorInstance = {
  name: String, // 标准属性,所有浏览器均支持(默认值为构造方法名称)
  message: String, // 标准属性,所有浏览器均支持(默认值为空字符串,实例化时传入的第一个参数可修改其属性值)
  stack: String, // 非标准属性,但所有浏览器均支持(栈属性,可以追踪发生错误的具体信息)

  columnNumber: Number, // 非标准属性,仅 Firefox 浏览器支持(列号)
  lineNumber: Number, // 非标准属性,仅 Firefox 浏览器支持(行号)
  fileName: String, // 非标准属性,仅 Firefox 浏览器支持(文件路径)

  column: Number, // 非标准属性,仅 Safari 浏览器支持(同上述三个属性)
  line: Number, // 非标准属性,仅 Safari 浏览器支持
  sourceURL: String, // 非标准属性,仅 Safari 浏览器支持

  toString: Function, // 标准方法(其返回值是 name 和 message 属性的字符串表示)
}

我们写个最简单的示例,打印看下各大浏览器的情况:

try {
  throw new TypeError('oops')
} catch (e) {
  console.log(e.toString())
  console.dir(e)
}

插个题外话:

不知道有人没有对此有疑惑的,为什么 console.log() 一个 Error 对象,打印出来的是字符串,而不是一个对象呢?

const err = new Error('wrong')
console.log(err) // "Error: wrong"
console.log(typeof err) // "object"

那么,如果想打印出 Error 对象,使用 console.dir() 即可。

前面 console.log() 打印结果为字符串的原因其实很简单,那就是 console.log() 内部「偷偷地」做了一件事,当传入的实参为 Error 对象(或其派生类错误对象),它会先调用 Error 对象的 Error.prototype.toString() 方法,然后将其结果输出到控制台,所以我们看到的打印结果为字符串。

其实现如下:

// polyfill
Error.prototype.toString = function () {
  'use strict'

  var obj = Object(this)
  if (obj !== this) throw new TypeError()

  var name = this.name
  name = name === undefined ? 'Error' : String(name)

  var msg = this.message
  msg = msg === undefined ? '' : String(msg)

  if (name === '') return msg
  if (msg === '') return name

  return name + ': ' + msg
}

细心的同学会发现,在不同浏览器下,其打印结果可能会不相同(但不重要)。原因也非常简单,console 并不是 ECMAScript 标准,而是浏览器 BOM 对象提供的一个接口,其标准由 WHATWG 机构制定,虽然标准是统一的,但实现的是各浏览器厂商大爷们,它们有可能不会严格遵守规范去实现,因而产生差异化。比如,此前写过一篇文章是关于不同宿主环境下 async/await 和 promise 执行顺序的差异,就因为 JS 引擎实现差异导致的。

1.2 异常

前面提到,当错误被抛出时就会成为异常。

假设我们编写的代码存在语法错误,那么在编译阶段的语法分析过程就会被聪明的 JS 引擎发现,因而在编译阶段便会抛出 SyntaxError。

假设我们代码没有语法错误,但错误地引用了一个不存在的变量,那么在执行阶段的执行上下文过程(代码执行之前的一个过程),聪明的 JS 引擎发现在其作用域链上找不到该变量,那么就会抛出 ReferenceError。

假设即不存在语法错误,也没有引用错误,但我们对一个变量做了“不合法”的操作,比如 null.name'str'.push('ing'),那么 JS 引擎就会抛出 TypeError。

还有很多很多,就不举例了。

前面都是 JS 引擎主动抛出的错误,那么,我们开发者则可通过 throw 关键字来抛出错误,语法很简单:

// throw expression
throw 123
throw 'abc'
throw { name: 'Frankie' }
// ...

请注意,在 JavaScript 中 throw 关键字和 returnbreakcontinue 等关键字一样,会受到 ASI(Automatic Semicolon Insertion)规则的影响,它不能在 throwexpression 之间插入任意换行符,否则可能得不到预期结果。

语法很简单,但通常项目中「不建议」直接抛出一个字面量,而是抛出 Error 对象或其派生类对象,应该这样:

throw new Error('oops')
throw new TypeError('arguments must be a number.')
// ...

原因是 Error 对象会记录引发此错误的文件的路径、行号、列号等信息,这应该是排除错误最有效的信息。在 ESLint 中的 no-throw-literal 规则,正是用来约束上述直接抛出字面量的写法的。

除了 throw 关键字之外,ES6 中强大的 Generator 函数也提供了一个可抛出异常的方法:Generator.prototype.throw()。它可以在函数体外抛出异常,然后在函数体内捕获异常。

function* genFn() {
  try {
    yield 1
  } catch (e) {
    console.log('inner -->', e)
  }
}

try {
  const gen = genFn()
  gen.next()
  gen.throw(new Error('oops'))
} catch (e) {
  console.log('outer -->', e)
}

打印结果是 inner --> Error: oops。如果生成器函数体内没有 try...catch 去捕获异常,那么它所抛出的异常可以被外部的 try...catch 语句捕获到。

当生成器「未开始执行之前」或者「执行结束之后」,调用生成器的 throw() 方法。它的异常只会被生成器函数外部的 try...catch 捕获到。若外部没有 try...catch 语句,则会报错且代码就会停止执行。详看

需要注意的是,生成器函数虽然是一个很强大的异步编程的解决方案,但它本身是同步的,而且执行生成器函数并不会立刻执行函数体的逻辑,它需要主动调用生成器实例对象的 next()return()throw() 方法去执行函数体内的代码。当然,你也可以通过 for...of、解构等语法去遍历它,因为生成器本身就是一个可迭代对象。

二、try...catch

对于可能存在异常的代码,我们通常会使用 try...catch...finally 去处理一些可预见或不可预见的错误。语法有以下三种形式:

  • try...catch
  • try...finally
  • try...catch...finally

且必须至少存在一个 catch 块或 finally 块。

try {
  throw new Error('oops')
} catch (e) {
  // some statements...
}

以上这些语法,写过 JavaScript 相信都懂。

曾经 Firefox 59 及以下版本的浏览器,有一种 Conditional catch-blocks 的「条件 catch 子句」的语法(请注意,其他浏览器并不支持该语法,即便是远古神器 IE5,因此知道有这回事就行了)。它的语法如下:

try {
  // may throw three types of exceptions
  willThrowError()
} catch (e if e instanceof TypeError) {
  // statements to handle TypeError exceptions
} catch (e if e instanceof RangeError) {
  // statements to handle RangeError exceptions
} catch (e if e instanceof EvalError) {
  // statements to handle EvalError exceptions
} catch (e) {
  // statements to handle any unspecified exceptions
}

那么符合 ECMAScript 标准的「条件 catch 子句」应该这样写:

try {
  // may throw three types of exceptions
  willThrowError()
} 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
  }
}

请注意,try...catch 只能以「同步」的形式处理异常,因此对于 XHR、Fetch API、Promise 等异步处理是无法捕获其错误的,究其原因就是 Event Loop 嘛。当然实际中可能结合 async/await 来控制会更多一些。

2.1 catch子句

我们知道,若 try 块中抛出异常时,会立即转至 catch 子句执行。若 try 块中没有异常抛出,会跳过 catch 子句。

try {
  // try statements
} catch (exception_var) {
  // catch statements
}

其中 exception_var 表示异常标识符(如 catch(e) 中的 e),它是「可选」的,因此可以这样编写 try { ... } catch { ... }。通过该标识符我们可以获取关于被抛出异常的信息。

请注意,该标识符的「作用域」仅在 catch 块中有效。当进入 catch 子句时,它被创建,当 catch 子句执行完毕,此标识符将不可再用。也可以理解为(在 ES6 以前)异常标识符是 JavaScript 中含有“块级作用域”的变量。

2.2 finally 子句

finally 子句在 try 块和 catch 块之后执行,但在下一个 try 声明之前执行。无论是否异常抛出,finally 子句总是会执行。

如果从 finally 块中返回一个值,那么这个值将成为整个 try...catch...finally 的返回值,无论是否有 return 语句在 trycatch 块中(即使 catch 块中抛出了异常)。

对于这个我表示很无语,可能整个前端圈子就我还不知道吧,原来 finally 还能 return 一个值,在做项目的过程中,确实没写过和见过在 finallyreturn 某个值的,让您见笑了,实在惭愧。

但请注意,若要在 try...catch...finally 中使用 return,它只能在函数中运行,否则是不允许的,会抛出语法错误。

try {
  doSomething()
} catch (e) {
  console.warn(e)
  throw e
} finally {
  return 'completed' // SyntaxError: Illegal return statement
}

2.3 执行顺序

在平常的项目中,一般的 try...catch 写法是在 try 块中 returncatch 块则作相应的异常处理,少数情况也会在 catch 块中 return。因此,大家对这种常规写法的执行顺序应该没什么问题。

先来个谁都会的示例:

function foo() {
  try {
    console.log('try statement')
    throw new Error('oops')
  } catch (e) {
    console.log('catch statement')
    return 'fail'
  }
}

foo()
// 以上,先后打印 "try statement"、"catch statement",foo 函数返回一个 "fail" 值

接着再看,它打印什么,函数又返回什么呢?

function foo() {
  try {
    console.log('try statement')
    throw new Error('oops')
  } catch (e) {
    console.log('catch statement')
    return 'fail'
  } finally {
    console.log('finally statement')
    return 'complete'
  }
}

foo()
// 先后打印:"try statement"、"catch statement"、"finally statement"
// foo 函数返回值是 "complete"

前面提到,如果 finally 块中含有 return 语句,那么它的 return 值将作为当前函数的返回值,因此 foo() 结果为 "complete"

然后我们再稍微改动一下,在 try 块中 return 一个值,看下结果又有什么不同?

function foo() {
  try {
    console.log('try statement')
    return 'success'
  } catch (e) {
    console.log('catch statement')
    return 'fail'
  } finally {
    console.log('finally statement')
    return 'complete'
  }
}

foo()
// 先后打印:"try statement"、"finally statement"
// foo 函数返回值是 "complete"

由于 try 块中没有抛出异常,因此 catch 块会被跳过,不执行,但是 finally 块还是会执行的,而且它里面返回了 "complete",因此这个值也就作为 foo 函数的返回值了。

因此,我们大致可以得出一个结论,finally 块的代码总会在 return 之前执行,不管 return 是存在于 trycatch 还是 finally 块中。

但是,这就完了吗?

还没有,我们再看一个示例,看看里面这个 bar() 函数是惰性求值?还是怎样?

function foo() {
  try {
    console.log('try statement')
    throw new Error('oops')
  } catch (e) {
    console.log('catch statement')
    return bar()
  } finally {
    console.log('finally statement')
    return 'complete'
  }
}

function bar() {
  console.log('bar statement')
  return 'something'
}

foo()

以上示例,打印顺序和结果是什么呢?

// 打印顺序,依次是:
"try statement"
"catch statement"
"bar statement"
"finally statement"

// 结果是 "complete"

假设 catch 块中的 return bar() 换成 throw bar() 呢,结果又有什么变化呢?如果换成这个你就犹豫了,说明你理解得不够深刻,因此这里我不给出答案,你自己去试试,效果更佳!

综上所述,finally 块的执行时机如下:

在所有 try 块和 catch 块(如果有,且触发进入的话)执行完之后,即便此时 try 块或 catch 块中存在 returnthrow 语句,它们将会被 Hold 住先不返回或抛出异常,继续执行 finally 块中的代码:

  • 如果 finally 中存在 return 语句,其返回值将作为整个函数的返回值(前面 try 块或 catch 中的 returnthrow 都会被忽略,可以理解为没有了 returnthrow 关键字一样)。
  • 如果 finally 中存在 throw 语句,前面 try 块或 catch 中的 returnthrow 同样会被忽略,最后整个函数将会抛出 finally 块中的异常。

2.4 嵌套使用

它是可以嵌套使用的,当内部的 try...catch...finally 中抛出异常,它会被离它最近的 catch 块捕获到。

function foo() {
  try {
    try {
      return 'success'
    } finally {
      throw new Error('inner oops') // 它将会被外层的 catch 块所捕获到
    }
  } catch (e) {
    console.log(e) // Error: inner oops
  }
}

foo()

注意,本节内容所述都是同步代码,而不存在任何异步代码。

到此,已彻底弄懂 try...catch...finally 语句了,再也不慌了!

三、异常有哪些?

在 Web 中,主要有以下几种异常类型:

3.1 JavaScript 异常

try...catch 可以捕获同步任务导致的异常,也可以捕获 async/await 中的异常。

Promise 中抛出的异常,则可通过 Promise.prototype.catch()Promise.prototype.then(onResolved, onRejected) 捕获。

3.2 DOM Exception

在调用 DOM API 时发生的,都属于 DOM Exception。比如:

<!DOCTYPE html>
<html>
  <body>
    <video id="video" controls src="https://dl.ifanr.cn/hydrogen/landing-page/ifanr-products-introduce-v1.1.mp4"></video>
    <script>
      window.onload = function () {
        const video = document.querySelector('#video')
        video.play() // Uncaught (in promise) DOMException: play() failed because the user didn't interact with the document first.
      }
    </script>
  </body>
</html>

未完待续...