前端异常处理

前言

为什么要处理前端异常,有以下几方面的原因:

  1. 提高代码健壮性:对于开发人员来说,这点很重要,代码的健壮性越好,系统越不容易崩溃;
  2. 提升系统稳定性:异常会导致正常流程无法进行、页面样式错乱、崩溃甚至白屏等问题,严重的会给业务造成损失;
  3. 增强用户体验:代码的错误不应该影响页面的正常显示和用户交互,出错时我们需要使用拖底方案或者给用户反馈;
  4. 便于定位问题:只有知道了如何处理异常,我们才能将异常正常上报给前端监控系统,及时发现并定位问题。

本文分为以下三个部分:
第一部分:介绍 Error 对象及 Error 的类型;
第二部分:介绍捕获异常的方式有哪些,包含通用、Vue和React项目、iframe中的捕获以及页面崩溃异常的获取;
第三部分:结合工作中的场景,总结各自对应的异常处理方式。

这篇文章的前两部分我尽量都提供了对应的示例,希望这些示例对你们有用。另外,由于这篇文章是做汇总用的,会比较长,各位可以按自己的需要去看对应的部分。

Error 及 Error 类型

说到异常,我们需要先从 Error 对象讲起。当 JavaScript 运行时,如果发生了错误,浏览器就会抛出 Error 的实例对象。

Error 对象

Error 是 JavaScript 中的错误类,它同时也是一个构造函数,可以用来创建一个错误对象。创建Error 实例对象的方法如下:

  new Error([message[, fileName[,lineNumber]]]);

此外,Error 可以像函数一样使用,如果没有 new,它将返回一个 Error 实例对象。所以, 仅仅调用 Error 产生的结果与通过 new 关键字构造 Error 实例对象生成的结果相同。

Error类型

参照MDN的文档, 还有以下错误类型都继承自 Error 对象:

  • SyntaxError
  • RangeError
  • ReferenceError
  • TypeError
  • URIError
  • EvalError
  • InternalError
  • AggregateError

接下来我将按顺序介绍上述错误类型的含义,并尽量举出对应的例子。

  1. SyntaxError

SyntaxError 是代码不符合 Javascript 语法规范产生的错误。

  // 变量名错误
  let 1name // Uncaught SyntaxError: Invalid or unexpected token

  // 缺少括号
  console.log('test' // Uncaught SyntaxError: missing ) after argument list

  // 字符串没有加引号
  a string // Uncaught SyntaxError: Unexpected identifier
  1. RangeError

RangeError 是当一个值不在允许的范围或者集合中时的错误。

  // 传递一个不合法的length值作为Array构造器的参数创建数组
  new Array(-1) // Uncaught RangeError: Invalid array length

  // 传递错误值到数值计算方法
  var number = 10
  number.toFixed(-1) // Uncaught RangeError: toFixed() digits argument must be between 0 and 100
  1. ReferenceError

ReferenceError 是引用一个不存在的变量或者给不能赋值的对象赋值时发生的错误。

  // 变量名未定义
  undefinedVariable // Uncaught ReferenceError: unknowName is not defined

  // 方法名未定义
  undefinedFunction() // Uncaught ReferenceError: undefinedFunction is not defined

  // 等号左侧不能赋值 //todo: 为啥
  console.log() = 1 // Uncaught SyntaxError: Invalid left-hand side in assignment

  // 等号左侧不能赋值 //todo: 为啥
  if(a === 1 || b = 2) {
      console.log('a === 1 || b = 2')
  } // Invalid left-hand side in assignment

  // this对象不能手动赋值
  this = 1 // Uncaught SyntaxError: Invalid left-hand side in assignment
  1. TypeError

TypeError 是变量或参数的类型不是预期类型时发生的错误。

  // new命令的参数不是构造函数
  new 123 // Uncaught TypeError: 123 is not a constructor

  // 使用的方法不是function
  let functionName = 'functionName'
  functionName() // Uncaught TypeError: functionName is not a function

  // undefined或null没有对应的属性或方法
  undefined.value // Uncaught TypeError: Cannot read property 'value' of undefined
  1. URIError

URIError 是 URI 相关函数的参数不正确时抛出的错误。

  decodeURI('%') // Uncaught URIError: URI malformed
  1. EvalError

EvalError 表示 eval 函数没有被正确执行时发生的错误。需要注意的是此异常不再会被JavaScript 抛出,但是 EvalError 对象仍然保持兼容性。

  // 没有报EvalError而是对应执行js时的SyntaxError
  eval('a string') // Uncaught SyntaxError: Unexpected identifier

永远不要使用 eval!
eval() 是一个危险的函数, 它使用与调用者相同的权限执行代码。如果你用 eval() 运行的字符串代码被恶意方(不怀好意的人)修改,您最终可能会在您的网页/扩展程序的权限下,在用户计算机上运行恶意代码。
eval() 通常比其他替代方法更慢,因为它必须调用 JS 解释器,而许多其他结构则可被现代 JS 引擎进行优化。

  1. InternalError

InternalError 表示出现在 JavaScript 引擎内部的错误。

示例场景通常为某些成分过大,例如:

  • "too many switch cases"(过多case子句);
  • "too many parentheses in regular expression"(正则表达式中括号过多);
  • "array initializer too large"(数组初始化器过大);
  • "too much recursion"(递归过深)。
  1. AggregateError

AggregateError 是用于把多个错误集合在一起。需要注意的是这是一个实验中的功能,尚未被所有的浏览器支持(下面例子中用到的 Promise.any 也是实验中的功能)。

  Promise.any([
    Promise.reject(new Error("some error"))
  ]) // Uncaught (in promise) AggregateError: All promises were rejected

Promise.any() 接收一个 Promise 可迭代对象,只要其中的一个 promise 成功,就返回那个已经成功的 promise。如果可迭代对象中没有一个 promise 成功(即所有的 promises 都失败/拒绝),就返回一个失败的 promise 和 AggregateError 类型的实例。

我们还可以基于 Error 自定义异常类型,或者用 throw 方法抛出任意类型的异常,但我们本文的目标在于捕获并处理浏览器抛出的异常,这里对自定义的异常和手动 throw 的异常不做过多说明。

捕获异常

在了解了浏览器会抛出哪些异常后,我们现在来进一步了解在代码层面我们可以做些什么来捕获这些异常,从而协助我们提升代码的健壮性。

通用方式

try-catch

try-catch 语句标记要尝试的语句块,并指定一个出现异常时抛出的响应。try 语句包含了由一个或者多个语句组成的 try 块,catch 子句包含 try 块中抛出异常时要执行的语句。如果在 try 块中有任何一个语句(或者从 try 块中调用的函数)抛出异常,控制立即转向 catch 子句。如果在 try 块中没有异常抛出,会跳过 catch 子句。

  try {
    const person = {};
    console.log(person.info.name);
  } catch (err) {
    console.log(err);
  }

  // TypeError: Cannot read property 'name' of undefined at :3:27

上面的例子中,我们试图获取一个 undefined 对象的属性值,这个异常被 catch 捕获并输出在控制台。

任何给定的异常只会被离它最近的封闭 catch 块捕获一次。

有时候,我们代码中也会出现 try-catch 嵌套的情况,如果内层没有 catch 事件,则会被外层 catch 捕获:

  try {
    try {
      throw new Error('error');
    }
    finally {
      console.log('finally');
    }
  }
  catch (err) {
    console.log('outer', err);
  }

  // finally
  // VM1360:10 outer Error: error at :3:11

如果在内层抛出新异常,这个新异常会被外层 catch 捕获:

  try {
    try {
      throw new Error('error');
    }
    catch (err) {
      console.log('inner', err);
      throw err; // 抛出新异常,没有被内层捕获过
    }
  }
  catch (err) {
    console.log('outer', err);
  }

  // inner Error: error at :3:11
  // outer Error: error at :3:11

try-catch 适用于知道某段代码可能出现问题的情况,只能捕获同步的运行时错误,不能捕获语法错误和异步错误:

  1. 语法错误:语法错误,try-catch 没有正确执行。
  try {
    let 1a = 'a';
    console.log(1a);
  } catch (err) {
    console.log('catch syntax error');
  }

  // Uncaught SyntaxError: Invalid or unexpected token
  1. 异步错误:因为异步事件已经放入异步事件队列中,无法捕捉到。
  try {
    setTimeout(() => {
      console.log(a)
    }, 1000);
  } catch (err) {
    console.log('catch async error');
  }

  // Uncaught ReferenceError: a is not defined at :3:17

GlobalEventHandlers.onerror

从 GlobalEventHandlers.onerror 字面本身就可以看出,这个 onerror 用于处理全局的错误。我们先来看下 MDN 上对它的解释:

混合事件 GlobalEventHandlers 的 onerror 属性用于处理 error 的事件。

  • 当 JavaScript 运行时错误(包括语法错误)发生时,window 会触发一个 ErrorEvent 接口的 error 事件,并执行 window.onerror()。
  • 当一项资源(图片或 JavaScript文件)加载失败,加载资源的元素会触发一个 Event 接口的 error 事件,并执行该元素上的 onerror() 处理函数。这些 error 事件不会向上冒泡到 window,不过(至少在 Firefox 中)能被单一的 window.addEventListener 捕获。

从上面的文字,我们可以得出以下的结论:

  1. 代码发生运行时错误(包括语法错误)时,会触发 window 的 error 事件,我们可以通 window.onerror 和 window.addEventListener('error', function(event) { ... })来捕获;
  2. 静态资源加载失败时,会触发加载资源的元素上的 onerror 事件,由于该事件不会冒泡到 winow,因此 window.onerror 是不会捕获到静态资源加载失败的错误的;
  3. 如果要使用全局方法捕获静态资源加载失败的错误,可以使用 window.addEventListener。

我们还是来通过具体的例子来验证一下,先定义下 window.onerror 和 window.addEventListener 这两个方法(需要写在所有 JavaScript 脚本的前面,否则有可能捕获不到错误):

  window.onerror = function(message, source, lineno, colno, error) {
    console.log('window.onerror catch error:', message);
  }
  window.addEventListener('error', function(event) {
    console.log('window.addEventListener catch error:', event.message)
  });
  1. 语法错误
  let 1a = 'a';
  console.log(1a);

  // window.onerror catch error: Uncaught SyntaxError: Invalid or unexpected token
  // window.addEventListener catch error: Uncaught SyntaxError: Invalid or unexpected token
  1. 静态资源加载错误

要捕获静态资源加载失败的错误,我们可以在静态资源上添加 onerror 事件:

  

  // script load onerror

如果要全局捕获静态资源加载的错误,需要给 addEventListener 方法增加第三个参数,即设置useCapture 为 ture:

  window.addEventListener('error', function(event) {
    console.log('window.addEventListener catch error:', event.message)
  }, true); 

加载一个错误的JavaSctipt文件:

  

  // window.addEventListener catch error: 
  1. 异步错误
  setTimeout(() => {
    console.log(a)
  }, 1000);
  
  // window.onerror catch error: Uncaught ReferenceError: a is not defined
  // window.addEventListener catch error: Uncaught ReferenceError: a is not defined

从上面的例子可以看出,GlobalEventHandlers.onerror 适用于需要捕获全局的异常的情况。另外,同 try-catch 相比,window.onerror 和 window.addEventListener 可以捕获语法错误和异步错误,element.onerror 和 window.addEventListener 可以捕获静态资源加载失败的错误。

尽管 window.onerror 和 window.addEventListener 可以处理异步错误,但是对于 Promise 的异步错误,是捕获不到的。

  new Promise((resolve, reject) => {
      console.log(a)
  })

  // Uncaught (in promise) ReferenceError: a is not defined

promise-catch

Promise 的错误需要使用 promise-catch 来捕获,这些错误可以是代码运行时的错误,也可以是我们处理业务逻辑时 reject 的错误。

  1. 代码错误
  new Promise((resolve, reject) => {
      console.log(a)
  }).catch(err => {
      console.log('promise catch error:', err.message)
  })

  // promise catch error: a is not defined
  1. reject的错误
  new Promise((resolve, reject) => {
      reject(new Error('error rejected!'))
  }).catch(err => {
      console.log('promise catch error:', err.message)
  })

  // promise catch error: error rejected!

promise-catch 的适用范围很明确,就是处理 Promise 的异常。但是这里有例外,async/await 虽然本质上还是 Promise 语法,但是可以被 try-catch 捕获。(因此我们提倡使用 async/await 来代替纯 Promise,这样子可以更方便的被捕获,如果你还是使用 Promise,要记得添加 catch事件,或者依赖全局捕获错误的方法。)

  function fn() {
      return new Promise((resolve, reject) => {
          console.log(a);
          resolve();
      })
  }
  async function test() {
      try {
          await fn();
      } catch (err) {
          console.log('try-catch error:', err.message);
      }
  }
  test();

  // try-catch error: a is not defined

unhandledrejection

我们开发的时候,如果有些 Promise 异常没有被处理,可以使用全局的方法来捕获,这里用到了 unhandledrejection 事件。

  window.onunhandledrejection = function(err) {
    console.log('window.onunhandledrejection catch error:', err.reason);
  }
  window.addEventListener('unhandledrejection', function(event) {
    console.log('window.addEventListener unhandledrejection catch error:', event.reason);
  });

  // window.onunhandledrejection catch error: ReferenceError: a is not defined
  // window.addEventListener unhandledrejection catch error: ReferenceError: a is not defined

我们在写前端项目的时候一般都是使用框架的,除了上面的通用的捕获异常的方法,框架本身还提供了一些方法供我们使用。

Vue 中捕获异常

Vue 的官方文档没有专门的章节来介绍异常的处理。总的来说,在生产环境有以下几种方式(开发环境的错误通过控制台就可以看到,这里不再铺开,详见 Vue 官网中的 warnHandler 及 renderError):

  • errorHandler
  • errorCaptured

errorHandler

errorHandler 在 Vue 中用于捕获全局的错误:

  Vue.config.errorHandler = function (err, vm, info) {
    console.log('vue errorHandler: ' + err);
  }

errorHandler 可以捕获的异常包含以下方面:

  1. 组件的渲染和观察期间未捕获的错误

需要注意的是 template 中如果引用一个不存在的变量的话是不会被 errorHandler 捕获的,这个错误需要使用 errorHandler 捕获。

  

  

  // 没有捕获到异常

稍微修改一下,在 data 中加入 currentTime 变量,但是赋值错误:

  

  

  // vue errorHandler: ReferenceError: currentTime is not defined
  1. 捕获组件生命周期钩子里的错误(版本>=2.2.0)
  

  

  // vue errorHandler: ReferenceError: currentTime is not defined
  1. 自定义事件处理函数内部的错误(版本>=2.4.0)

我们假设子组件使用 $emit 方法触发了 change 事件:

  

  

  // vue errorHandler: ReferenceError: changedValue is not defined
  1. v-on DOM 监听器内部抛出的错误(版本>=2.6.0)
  

  

  // vue errorHandler: ReferenceError: target is not defined
  1. 如果任何被覆盖的钩子或处理函数返回一个 Promise 链 (例如 async 函数),则来自其 Promise 链的错误也会被处理。(版本>=2.6.0)
  

  

  // vue errorHandler: ReferenceError: target is not defined

errorCaptured

errorCaptured 是 Vue 在 2.5.0 新增加的钩子函数,用于捕获来自子组件的错误。现在,我们依然假设子组件抛出了一个错误(这里依然保留上一节提到的 errorHandler 方法):

  

  

  // vue errorCaptured: ReferenceError: current is not defined
  // vue errorHandler: ReferenceError: current is not defined

上面的例子显示,errorCaptured 先于 errorHandler 捕获了错误,如果不想再次被上级捕获,可以在钩子函数中返回 false 。附上Vue官网给出的错误传播规则:

  • 默认情况下,如果全局的 config.errorHandler 被定义,所有的错误仍会发送它,因此这些错误仍然会向单一的分析服务的地方进行汇报。
  • 如果一个组件的继承或父级从属链路中存在多个 errorCaptured 钩子,则它们将会被相同的错误逐个唤起。
  • 如果此 errorCaptured 钩子自身抛出了一个错误,则这个新错误和原本被捕获的错误都会发送给全局的 config.errorHandler。
  • 一个 errorCaptured 钩子能够返回 false 以阻止错误继续向上传播。本质上是说“这个错误已经被搞定了且应该被忽略”。它会阻止其它任何会被这个错误唤起的 errorCaptured 钩子和全局的 config.errorHandler。

React 中捕获异常

React官网中有专门的章节介绍异常的章节——错误边界。

错误边界

错误边界的概念是 React 在 React 16 引入的概念,是为了解决部分 UI 的 JavaScript 错误引起的应用崩溃问题。

错误边界是一种 React 组件,这种组件可以捕获并打印发生在其子组件树任何位置的 JavaScript 错误,并且,它会渲染出备用 UI,而不是渲染那些崩溃了的子组件树。错误边界在渲染期间、生命周期方法和整个组件树的构造函数中捕获错误。
如果一个 class 组件中定义了 static getDerivedStateFromError() 或 componentDidCatch() 这两个生命周期方法中的任意一个(或两个)时,那么它就变成一个错误边界。
只有 class 组件才可以成为错误边界组件。

基于上面的说明,我们的错误边界的组件可以这样写:

  import React from 'react'

  import Default from '/default'
  import { uploadError } from '../utils/error'

  class ErrorBoundary extends React.Component {
    constructor (props) {
      super(props)
      this.state = { hasError: false }
    }

    static getDerivedStateFromError (err) {
      // 发生错误,显示降级后的UI
      return { hasError: true }
    }

    componentDidCatch (err, info) {
      // 可以将错误日志上报给服务器
      uploadError(err)
    }

    render () {
      if (this.state.hasError) {
        return 
      }
      return this.props.children
    }
  }

  export default ErrorBoundary

  // 使用:
  
    
  

错误边界的工作方式类似于 JavaScript 的 catch {},不同的地方在于错误边界只针对 React 组件。错误边界无法捕获的错误有下面几个方面,这些异常需要使用 try-catch 等捕获:

  • 事件处理
  • 异步代码
  • 服务端渲染
  • 它自身抛出来的错误(并非它的子组件)

iframe 异常

当我们的页面引用了 iframe 的时候,也可以使用 onerror 方法捕获 iframe 的异常,但这种形式仅限于你自己的页面和 iframe 的页面同域名的情况:

  
  

  // iframe error: Uncaught ReferenceError: a is not defined

页面崩溃

页面崩溃和上面提到的异常捕获的情况是不一样的,页面崩溃时,JavaScript 代码已经不执行了。但还是有办法来监控到页面崩溃的,目前有两种:一个是load 和 beforeunload 结合, 另外一个是基于 Service Worker。

load 和 beforeunload 事件

我们先来看下代码:

  window.addEventListener('load', function () {
    sessionStorage.setItem('good_exit', 'pending');
    setInterval(function () {
      sessionStorage.setItem('time_before_crash', new Date().toString());
    }, 1000);
  });

  window.addEventListener('beforeunload', function () {
    sessionStorage.setItem('good_exit', 'true');
  });

  if(sessionStorage.getItem('good_exit') &&
    sessionStorage.getItem('good_exit') !== 'true') {
    /*
        insert crash logging code here
    */
    alert('Hey, welcome back from your crash, looks like you crashed on: ' + sessionStorage.getItem('time_before_crash'));
  }

从上面的代码来看,这个方法其实是利用了页面崩溃时无法触发 beforeunload 事件来实现的。页面加载完成后,在 sessionStorage 中存储 good_exit 的值为 pending。如果页面正常关闭, 会触发 beforeunload 事件,在 beforeunload 事件中,我们将 good_exit 的值重置为 true。如果页面崩溃了,刷新页面时,从 sessionStorage 中读取到的值就是 pending 而不是 true。

用上面的方式处理有以下问题:

  1. 由于是 sessionStorage 存储的值,页面崩溃后如果用户关闭页面或重新打开浏览器,sessionStorage 中存储的 good_exit 值我们是获取不到的;
  2. 如果前进或后退,页面会从缓存中加载,有时候是不会触发 load 事件的。

即使存在上面的问题,但这个方法对我们依然有借鉴意义。页面崩溃时,JavaScript 不会执了,DOM 也卸载了,我们对页面的渲染是无能为力的。但我们可以在用户再次刷新页面时捕获到上次的崩溃信息,并将崩溃上报到监控系统。如果监控系统收到大量的崩溃信息,就说明我们的页面出现了严重的问题了,这时候我们就需要想办法复现或者从代码逻辑层面找到崩溃原因了。

基于 Service Worker

基于 Service Worker 的方案其实也是利用了页面崩溃时无法触发 beforeunload 事件来实现的,与 load 和 beforeunload 的区别是 Service Worker 相对于驱动应用的主 JavaScript 线程,它运行在其他线程中,即使网页崩溃了,Service Worker 一般情况下也不会崩溃。所以,我们不需要等到用户再次刷新页面才能获取上次的崩溃信息了。

// 页面 JavaScript 代码
if (navigator.serviceWorker.controller !== null) {
  let HEARTBEAT_INTERVAL = 5 * 1000; // 每五秒发一次心跳
  let sessionId = uuid();
  let heartbeat = function () {
    navigator.serviceWorker.controller.postMessage({
      type: 'heartbeat',
      id: sessionId,
      data: {} // 附加信息,如果页面 crash,上报的附加数据,比如页面地址等
    });
  }
  window.addEventListener("beforeunload", function() {
    navigator.serviceWorker.controller.postMessage({
      type: 'unload',
      id: sessionId
    });
  });
  setInterval(heartbeat, HEARTBEAT_INTERVAL);
  heartbeat();
}

// Service Worker
const CHECK_CRASH_INTERVAL = 10 * 1000; // 每 10s 检查一次
const CRASH_THRESHOLD = 15 * 1000; // 15s 超过15s没有心跳则认为已经 crash
const pages = {}
let timer
function checkCrash() {
  const now = Date.now()
  for (var id in pages) {
    let page = pages[id]
    if ((now - page.t) > CRASH_THRESHOLD) {
      // 上报 crash
      delete pages[id]
    }
  }
  if (Object.keys(pages).length == 0) {
    clearInterval(timer)
    timer = null
  }
}

worker.addEventListener('message', (e) => {
  const data = e.data;
  if (data.type === 'heartbeat') {
    pages[data.id] = {
      t: Date.now()
    }
    if (!timer) {
      timer = setInterval(function () {
        checkCrash()
      }, CHECK_CRASH_INTERVAL)
    }
  } else if (data.type === 'unload') {
    delete pages[data.id]
  }
})

上面代码的思路是:

  1. 网页加载后,通过 postMessage API 每 5s 给 sw 发送一个心跳,表示自己的在线,sw 将在线的网页登记下来,更新登记时间;
  2. 网页在 beforeunload 时,通过 postMessage API 告知自己已经正常关闭,sw 将登记的网页清除;
  3. 如果网页在运行的过程中 crash 了,sw 中的 running 状态将不会被清除,更新时间停留在奔溃前的最后一次心跳;
  4. Service Worker 每 10s 查看一遍登记中的网页,发现登记时间已经超出了一定时间(比如 15s)即可判定该网页 crash 了。

同样的,Service Worker捕获的错误对前端监控是很有用的。

总结

具体到实际工作中,我们要处理的异常分为以下几种:

  1. 语法错误及代码异常:对可疑区域增加 try-catch,全局增加 window.onerror;
  2. 数据请求异常:使用 promise-catch 处理 Promise 异常,使用 unhandledrejection 处理未捕获的Promise异常,使用 try-catch 处理 async/await 异常;
  3. 静态资源加载异常:在元素上添加 onerror,全局增加 window.addEventListener;
  4. 白屏:Vue 使用 errorHandler, React 使用 componentDidCatch,渲染备用UI;
  5. iframe异常:同域条件下使用 onerror。
  6. 页面崩溃:load 和 beforeunload 结合或者使用 Service Worker。

原文地址:https://yolkpie.net/2021/01/28/%E5%89%8D%E7%AB%AF%E5%BC%82%E5%B8%B8%E5%A4%84%E7%90%86/

你可能感兴趣的:(前端异常处理)