5

你不知道的Vue错误处理机制

 2 years ago
source link: https://developer.51cto.com/article/706063.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

阳光明媚的一天,面试官面了一个小伙子。小伙子在介绍项目时,说做了个错误上报机制,前端用的是Vue的错误捕获。这时面试官瞟了一眼简历,一行“熟悉Vue2源码”的字眼印入眼帘。待小伙介绍完后,面试官说不错不错,那你说说Vue的错误处理吧。小伙子双眼一瞪,心想这老铁不按常理出牌,说:这题不会!下一个~面试官:emmm... 行,那就下一个...

面试结束,小伙子立马打开Vue源码,决定一探究竟...

一、认识Vue错误处理

1. errorHandler

首先,可以看看Vue文档对其的介绍。这里不赘述太多,直接使用,一起看看打印结果。代码如下:

// main.js
Vue.config.errorHandler = function (err, vm, info) {
  console.log('全局捕获 err >>>', err)
  console.log('全局捕获 vm >>>', vm)
  console.log('全局捕获 info >>>', info)
}

// App.vue
...
created () {
  const obj = {}
  // 直接在App组件的created钩子中尝试错误操作,调用obj中不存在的fn
  obj.fn()
},
methods: {
  handleClick () {
    // 绑定一个click事件,点击时触发
    const obj = {}
    obj.fn()
  }
}
...

(1)created 的输出结果如下(文章结尾会以此进行 catch 的流程分析):

e18ce486810af2c860d508d8bf7399de664240.png

(2)handleClick 的输出结果如下(文章结尾会以此进行 catch 的流程分析)

89a6d1903f6b929ce9a31439e6ffc5edc904ad.png

由此可见:

  • err 可获取错误信息、堆栈信息
  • vm 可获取报错的vm实例(也就是对应的组件)
  • info created hook v-on handler

2. errorCaptured

老规矩,可以先看Vue文档的介绍,这里也是直接放上使用案例。代码如下:

// App.vue
<template>
  // 模版中引用子组件 HelloWorld
  <HelloWorld />
</template>
...
errorCaptured(err, vm, info) {
  // 添加errorCaptured钩子,其余跟上述案例一致
  console.log('父组件捕获 err >>>', err, vm, info)
}
...

// HelloWorld组件
...
created () {
  const child = {}
  // 直接在子组件的 created 中抛出错误,看看打印效果
  child.fn()
}
...

输出结果如下:

c3be4a396e732699bad5603a3c1c3f0363d320.png

可以看到, HelloWorld 组件中的报错既给App组件的 errorCaptured 捕获,也给全局的 errorHandler 所捕获。是不是有点类似我们事件中的 冒泡 呢?

一定要注意, errorCaptured 是捕获一个来自 后代组件 的错误时被调用,也就是说不能捕捉到自身的。可以做个实验验证一下,接着上述的案例稍作改造,在 HelloWorld 中加入 errorCaptured 钩子,并在 created 中打印 ‘子组件也用 errorCaptured 捕获错误’

...
created() {
  console.log('子组件也用 errorCaptured 捕获错误')
  const child = {}
  // 直接在子组件的 created 中抛出错误,看看打印效果
  child.fn()
},
errorCaptured(err, vm, info) {
  console.log('子组件捕获', err, vm, info)
}
...

22e856301471d380db97051f12460698bfbbb7.png

由此可知,除了多打印一行 created 中的输出,其他均无变化。

3. 一图总结Vue错误捕获机制

f7a145715eee832be742414b9f9b85dfd91b67.png

错误捕获.png

二、Vue错误捕获源码

源码分析的 Vue 版本是 v2.6.14 ,代码位于 src/core/util/error.js 。共四个方法: handleError 、 invokeWithErrorHandling 、 globalHandleError , logError ,接下来,我们一个一个的来认识他们~

1. handleError

Vue 中的错误统一处理函数,在此函数中实现向上通知 errorCaptured 直到全局 errorHandler 的功能。核心解读如下:

  • err vm info
  • pushTarget 、 popTarget 。源码中注释有写到,主要是避免处理错误时 组件 无限渲染
  • $parent 大Vue 大Vue $parent undefined
  • 获取 errorCaptured 。可能有小伙伴有疑问这里为什么是个 数组 ,因为 Vue 初始化的时候会对 hook 做 合并处理 。比如说我们用到 mixins 的时候,组件中可能会出现多个相同的 hook,初始化时会把这些 cb 都 合并 在一个 hook 的数组里,以便触发钩子的时候一一调用
  • capture 。如果为false的时候,直接 return,不会走到 globalHandleError 中

源码如下:

// 很明显,这个参数的就是我们熟悉的 err、vm、info
function handleError (err: Error, vm: any, info: string) {
  pushTarget()
  try {
    if (vm) {
      let cur = vm
      // 向上查找$parent,直到不存在
      // 注意了!一上来 cur 就赋值给 cur.$parent,所以 errorCaptured 不会在当前组件的错误捕获中执行
      while ((cur = cur.$parent)) {
        // 获取钩子errorCaptured
        const hooks = cur.$options.errorCaptured
        if (hooks) {
          for (let i = 0; i < hooks.length; i++) {
            try {
              // 执行errorCaptured
              const capture = hooks[i].call(cur, err, vm, info) === false
              // errorCaptured返回false,直接return,外层的globalHandleError不会执行
              if (capture) return
            } catch (e) {
              // 如果在执行errorCaptured的时候捕获到错误,会执行globalHandleError,此时的info为:errorCaptured hook
              globalHandleError(e, cur, 'errorCaptured hook')
            }
          }
        }
      }
    }
    // 外层,全局捕获,只要上面不return掉,就会执行
    globalHandleError(err, vm, info)
  } finally {
    popTarget()
  }
}

2. invokeWithErrorHandling

一个 包装函数 ,内部使用 try-catch 包裹传入的函数,且有更好的处理异步错误的能力。可处理 生命周期 、 事件 等回调函数的错误捕获。可处理返回值是Promise的异步错误捕获。捕获到错误后,统一派发给 handleError ,由它处理向上通知到全局的逻辑。核心解读如下:

  • 参数 handler 。传入的执行函数,在内部对其调用,并对其返回值进行Promise的判断
  • try-catch 。使用 try-catch 包裹并执行传入的函数,捕获错误后调用 handleError 。(是不是有点 高阶函数 那味呢~)
  • handleError 。捕获错误后也是调用 handleError 方法对错误进行向上通知
function invokeWithErrorHandling (
  handler: Function,
  context: any,
  args: null | any[],
  vm: any,
  info: string
) {
  let res
  try {
    // 处理handle的参数并调用
    res = args ? handler.apply(context, args) : handler.call(context)
    // 判断返回是否为Promise 且 未被catch(!res._handled)
    if (res && !res._isVue && isPromise(res) && !res._handled) {
      res.catch(e => handleError(e, vm, info + ` (Promise/async)`))
      // _handled标志置为true,避免嵌套调用时多次触发catch
      res._handled = true
    }
  } catch (e) {
    // 捕获错误后调用 handleError
    handleError(e, vm, info)
  }
  return res
}

3. globalHandleError

全局错误捕获。也就是我们在全局配置的 Vue.config.errorHandler 的触发函数

  • 内部用 try-catch 包裹 errorHandler 的执行。在这里就会执行我们全局的错误捕获函数~
  • 如果执行 errorHandler 中存在 错误 则被捕获后通过 logError 打印。( logError 在浏览器的生产环境的使用 console.error 打印)
  • 如果没有 errorHandler 。则会直接使用 logError 进行错误打印
function globalHandleError (err, vm, info) {
  if (config.errorHandler) {
    try {
      // 调用全局的 errorHandler 并return
      return config.errorHandler.call(null, err, vm, info)
    } catch (e) {
      // 翻译源码注释:如果用户故意在处理程序中抛出原始错误,不要记录两次      
      if (e !== err) {
        // 对在 globalHandleError 中的错误进行捕获,通过 logError 输出
        logError(e, null, 'config.errorHandler')
      }
    }
  }
  // 如果没有 errorHandler 全局捕获,则执行到这里,用 logError 错误
  logError(err, vm, info)
}

4. logError

实现对错误信息的打印(开发环境、线上会有所不同)

  • warn 。开发环境中会使用 warn 打印错误。以 [Vue warn]: 开头
  • console.error 。浏览器环境中使用 console.error 对捕获的错误进行输出
// logError源码实现
function logError (err, vm, info) {
  if (process.env.NODE_ENV !== 'production') {
    // 开发环境中使用 warn 对错误进行输出
    warn(`Error in ${info}: "${err.toString()}"`, vm)
  }
  /* istanbul ignore else */
  if ((inBrowser || inWeex) && typeof console !== 'undefined') {
    // 直接用 console.error 打印错误信息
    console.error(err)
  } else {
    throw err
  }
}

// 简单看看 warn 的实现
warn = (msg, vm) => {
  const trace = vm ? generateComponentTrace(vm) : ''
  if (config.warnHandler) {
    config.warnHandler.call(null, msg, vm, trace)
  } else if (hasConsole && (!config.silent)) {
    // 这就是我们平时常见的 Vue warn 打印报错的由来了!
    console.error(`[Vue warn]: ${msg}${trace}`)
  }
}

看看下图,如果我们不进行全局错误捕获,在开发环境的报错输出是否有点似曾相识呢?:point_down:

这里提个小问题:为什么 1 个错误打印 2 条报错信息?

f6a259d2645649ca73e2303d116acc2e0b80bb.png

哈哈哈~没错,其实就是 logError 函数的实现!!!这里再回顾一下, logError 先是调用 warn 打印 [Vue warn]: 开头的 Vue 包装过的错误提示信息,再通过 console.error 打印js的错误信息

简单总结一下:

  1. handleErrorerrorCaptured errorCaptured
  2. invokeWithErrorHandling :包装函数,通过 高阶函数 的编程私思路,通过接收一个函数参数,并在内部使用 try-catch 包裹后 执行 传入的函数;还提供更好的 异步错误处理 ,当执行函数返回了一个Promise对象,会在此对其实现进行错误捕获,最后也是通知到 handleError 中(如果我们未自己对返回的Promise进行catch操作)
  3. globalHandleError :调用全局配置的 errorHandler 函数,如果在调用的过程中捕获到错误,则通过 logError 打印所捕获的错误,以 'config.errorHandler' 结尾
  4. logError 。实现对未捕获的错误信息进行打印输出。开发环境会打印 2种 错误信息~

三、错误捕获流程分析

看完了错误捕获的源码实现,不如具体看看 Vue是怎么捕获到错误的 ,以此来加深下理解。命中错误捕获的方式有很多,这里以 文章开头的代码案例 作为命中分支进行调试,带你看看Vue是怎么实现 错误捕获 的~

1. created 阶段的错误捕获

温习一下 Vue 的整个组件化流程(整个生命周期)做了什么,如下图:

67c7a9578aaf90ed653008490b462ac32479a5.png

created的触发阶段是在init阶段,如下图:

75ecb5887766b65a856862633a106b55f3a234.png

由此可见,触发created钩子的是 callHook 方法,接下来看下 callHook 的实现:

  • 遍历当前 vm 实例的 当前 hook 的所有 cb,并将其传入 invokeWithErrorHandling 函数中
  • invokeWithErrorHandling handleError 大Vue errorHandler
function callHook (vm, hook) {
  pushTarget();
  var handlers = vm.$options[hook];
  // info信息,这里是 created hook
  var info = hook + " hook";
  if (handlers) {
    for (var i = 0, j = handlers.length; i < j; i++) {
      // 直接调用invokeWithErrorHandling,传入对应的 cb
      invokeWithErrorHandling(handlers[i], vm, null, vm, info);
    }
  }
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook);
  }
  popTarget();
}

2. 点击事件的错误捕获

案例代码跟 一、认识Vue错误处理 中的 errorHandler 的 click是一样的,这里只是多一行console.log,方便大家看下打包后的代码加深理解。因为这部分会涉及到Vue源码中的另外一个点——事件。当然,这里不进行展开,大家大致了解即可。笔者会另外写一个篇章来介绍 Vue 的事件的源码解析~

// 模版代码
<template>
  <div id="app">
    <button @click="handleClick">click</button>
  </div>
</template>

// js代码
methods: {
  handleClick () {
    console.log('点击事件错误捕获')
    const obj = {}
    obj.fn()
  }
}

打包后代码长这样:

55cd821322a105ac9b9546fd4f986b006f7dcd.png

由此,在整个Vue初始化的过程中,会对我们绑定的click事件进行 updateDOMListeners 的处理,然后又会调用到 updateListeners 这个方法,我们来看下 updateListeners 核心的代码做了什么? 这里大家不用深究原因哈!!!知道这个流程的调用顺序即可,因为帖出来也是让你们理解得更清晰一点。如果感兴趣的话可以等笔者出一篇关于Vue事件的源码分析哈~

function updateListeners () {
  // 这里的 cur 就是我们写在 methods 中的 handleClick
  cur = on[name] = createFnInvoker(cur, vm);
}

可以知道,这里通过 createFnInvoker 对 我们的 handleClick 进行了一层包装再返回,而我们的错误捕获就是在包装的 createFnInvoker 中实现的。我们接着看看 createFnInvoker 做了什么

function createFnInvoker (fns, vm) {
  function invoker () {
    var arguments$1 = arguments;
    // 从 invoker 的静态属性 fns 获取方法
    var fns = invoker.fns;
    if (Array.isArray(fns)) {
      // 一个fns的新数组
      var cloned = fns.slice();
      for (var i = 0; i < cloned.length; i++) {
        // 对fns使用 invokeWithErrorHandling 进行包装
        invokeWithErrorHandling(cloned[i], null, arguments$1, vm, "v-on handler");
      }
    } else {
      // 这里也是一样的,只是对单一的fns使用 invokeWithErrorHandling 进行包装
      return invokeWithErrorHandling(fns, null, arguments, vm, "v-on handler")
    }
  }
  // 这里的fns,就是上面的cur,也就是我们的handleClick方法
  invoker.fns = fns;
  // 返回一个 invoker ,我们点击触发的其实是这个函数
  return invoker
}

总结一下:

  • 每当我们点击的时候,表面是触发了 handleClick ,其实是触发了一个装饰器 invoker
  • 再由 invoker 去调用 invokeWithErrorHandling ,并且传入保存在 invoker 的 静态属性 fns 中的函数(也就是我们用户编写的 handleClick 函数)
  • 如此一来,就跟 二、Vue错误捕获源码 中的 2. invokeWithErrorHandling 的执行一样了
  • 最终会通过 handleError 实现向上冒泡执行上层组件的错误钩子,直至全局的错误捕获 这也是我们 点击事件 的错误捕获流程了~

写在最后,怎么样,是不是非常的简单呢? 错误捕获 这个东西,不管是在框架层面,还是我们日常开发业务中都是比较重要的,但往往又被很多人忽略(比如我)。总览下来,其实这一块也不难,在 Vue 源码的实现中,大家只要看过都能懂。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK