你不知道的Vue错误处理机制
2022-04-08 19:31:10来源:稀土掘金技术社区
阳光明媚的一天,面试官面了一个小伙子。小伙子在介绍项目时,说做了个错误上报机制,前端用的是Vue的错误捕获。这时面试官瞟了一眼简历,一行“熟悉Vue2源码”的字眼印入眼帘。待小伙介绍完后,面试官说不错不错,那你说说Vue的错误处理吧。小伙子双眼一瞪,心想这老铁不按常理出牌,说:这题不会!下一个~面试官:emmm... 行,那就下一个...
面试结束,小伙子立马打开Vue源码,决定一探究竟...
一、认识Vue错误处理1. errorHandler首先,可以看看Vue文档对其的介绍。这里不赘述太多,直接使用,一起看看打印结果。代码如下:
// main.jsVue.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的流程分析):
(2)handleClick的输出结果如下(文章结尾会以此进行catch的流程分析)
由此可见:
err可获取错误信息、堆栈信息vm可获取报错的vm实例(也就是对应的组件)infocreated hookv-on handler2. errorCaptured老规矩,可以先看Vue文档的介绍,这里也是直接放上使用案例。代码如下:
// App.vue // 模版中引用子组件 HelloWorld...errorCaptured(err, vm, info) { // 添加errorCaptured钩子,其余跟上述案例一致 console.log("父组件捕获 err >>>", err, vm, info)}...// HelloWorld组件...created () { const child = {} // 直接在子组件的 created 中抛出错误,看看打印效果 child.fn()}...
输出结果如下:
可以看到,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)}...
由此可知,除了多打印一行created中的输出,其他均无变化。
3. 一图总结Vue错误捕获机制错误捕获.png
二、Vue错误捕获源码源码分析的Vue版本是v2.6.14,代码位于src/core/util/error.js。共四个方法:handleError、invokeWithErrorHandling、globalHandleError,logError,接下来,我们一个一个的来认识他们~
1. handleErrorVue 中的错误统一处理函数,在此函数中实现向上通知errorCaptured直到全局errorHandler的功能。核心解读如下:
errvminfopushTarget、popTarget。源码中注释有写到,主要是避免处理错误时组件无限渲染$parent大Vue大Vue$parentundefined获取errorCaptured。可能有小伙伴有疑问这里为什么是个数组,因为 Vue 初始化的时候会对 hook 做合并处理。比如说我们用到mixins的时候,组件中可能会出现多个相同的 hook,初始化时会把这些cb都合并在一个 hook 的数组里,以便触发钩子的时候一一调用capture。如果为false的时候,直接 return,不会走到globalHandleError中源码如下:
// 很明显,这个参数的就是我们熟悉的 err、vm、infofunction 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 条报错信息?
哈哈哈~没错,其实就是logError函数的实现!!!这里再回顾一下,logError先是调用warn打印[Vue warn]:开头的Vue包装过的错误提示信息,再通过console.error打印js的错误信息
简单总结一下:handleErrorerrorCapturederrorCapturedinvokeWithErrorHandling:包装函数,通过高阶函数的编程私思路,通过接收一个函数参数,并在内部使用try-catch包裹后执行传入的函数;还提供更好的异步错误处理,当执行函数返回了一个Promise对象,会在此对其实现进行错误捕获,最后也是通知到handleError中(如果我们未自己对返回的Promise进行catch操作)globalHandleError:调用全局配置的 errorHandler 函数,如果在调用的过程中捕获到错误,则通过logError打印所捕获的错误,以 "config.errorHandler" 结尾logError。实现对未捕获的错误信息进行打印输出。开发环境会打印2种错误信息~三、错误捕获流程分析看完了错误捕获的源码实现,不如具体看看Vue是怎么捕获到错误的,以此来加深下理解。命中错误捕获的方式有很多,这里以文章开头的代码案例作为命中分支进行调试,带你看看Vue是怎么实现错误捕获的~
1.created阶段的错误捕获温习一下 Vue 的整个组件化流程(整个生命周期)做了什么,如下图:
created的触发阶段是在init阶段,如下图:
由此可见,触发created钩子的是callHook方法,接下来看下callHook的实现:
遍历当前 vm 实例的当前hook 的所有 cb,并将其传入invokeWithErrorHandling函数中invokeWithErrorHandlinghandleError大VueerrorHandlerfunction 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 的事件的源码解析~
// 模版代码// js代码methods: { handleClick () { console.log("点击事件错误捕获") const obj = {} obj.fn() }}
打包后代码长这样:
由此,在整个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源码的实现中,大家只要看过都能懂。