接手一个跑了两年的 Vue 项目,最常遇到的两件事:一是页面进去要等一秒多,但不知道慢在哪个组件;二是某个操作偶尔白屏,控制台干干净净,window.onerror 里什么都没有。这两个问题都不是写业务代码能解决的,得靠 Vue 全局配置上那几个平时没人翻的 API。这篇把 config.performance、config.errorHandler、nextTick、watch 的 deep 和 immediate 这四个盲点逐个讲透,讲用法,也讲它们背后是怎么实现的、什么时候会失效。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 用
config.performance把每个组件的渲染耗时打到浏览器时间线上 - 为什么
window.onerror抓不到 Vue 里的报错,errorHandler又能覆盖到哪一层 nextTick到底在等什么,它的降级实现链和MutationObserver的关系watch的deep和immediate怎么用,以及它们各自的代价- 这四个 API 在 Vue 3 里对应的写法
# 一、用 performance 开启性能追踪
performance 是 Vue 全局配置里的一项,用来做组件级别的渲染耗时追踪。在入口文件里加上这段就开了:
if (process.env.NODE_ENV !== 'production') {
Vue.config.performance = true;
}
这个 API 是 2.2.0 新增的,只在开发模式并且浏览器支持 performance.mark 时生效。生产包里它是被直接编译掉的,所以不用担心线上开销,也别指望在生产环境靠它测数据。
开起来之后,浏览器时间线上就能看到每个组件在 init、compile、render、patch 各阶段花了多少毫秒。哪个组件的 patch 特别长,优化的靶子就有了。
原文这里推荐的是 Vue Performance Devtool 这个 Chrome 插件。它现在已经很久没更新了,我自己更习惯直接开 Chrome DevTools 的 Performance 面板录一段,Vue 打的这些标记会落在 Timings(有的版本叫 User Timing)轨道上,一目了然,也不用额外装东西。这条我只在 Chrome 上试过,Firefox 的面板叫法不太一样。
那 Vue 是怎么做到的?它用的就是浏览器原生的 window.performance,核心是两个方法:
performance.mark用于创建标记performance.measure用于记录两个标记之间的时间间隔
performance.mark('start'); // 创建 start 标记
performance.mark('end'); // 创建 end 标记
performance.measure('output', 'start', 'end'); // 计算两者时间间隔
performance.getEntriesByName('output'); // 获取标记,返回值是一个数组,包含了间隔时间数据
Vue 干的事情就是在每个组件的关键节点上按这个套路打点,标记名带上组件名,所以你在时间线上看到的是 <UserList> render 这种可读的条目,而不是一堆数字。
知道原理之后,这套打点你自己也能用。接口请求前后、一段复杂计算的首尾、一个大列表渲染完成的时刻,全都可以 mark 一下再 measure。比 console.time 强的地方在于,这些数据会进浏览器的性能时间线,能和网络、脚本执行、绘制放在同一张图上对齐着看。
除了上面这两个方法,原文还提到可以用 performance.timing 计算页面各个阶段的加载情况。这条现在要打个补丁:performance.timing 在规范里已经被标记为废弃,虽然浏览器还留着,但新代码建议改用 performance.getEntriesByType('navigation')[0] 拿到的 PerformanceNavigationTiming 对象,字段名基本能对上,而且时间基准更干净。
Vue 3 里这个开关搬到了应用实例上,写成 app.config.performance = true,语义和生效条件没变,还是只在开发模式起作用。
# 二、用 errorHandler 兜住框架吞掉的异常
浏览器里捕获异常,大家最熟的是 try...catch 和 window.onerror,这是原生 JavaScript 给的两条路。但在 Vue 2 项目里,你照着老经验挂一个 window.onerror,会发现组件里的报错一条都收不到。
原因是 Vue 自己在渲染和生命周期调用的外面包了一层 try...catch。它把错误接住了,走自己的错误处理流程,所以异常根本没冒泡到 window 上。想拿到这些信息,得用 errorHandler:
Vue.config.errorHandler = function (err, vm, info) {
let {
message, // 异常信息
name, // 异常名称
stack // 异常堆栈信息
} = err;
// vm 为抛出异常的 Vue 实例
// info 为 Vue 特定的错误信息,比如错误所在的生命周期钩子
}
三个参数里最有价值的其实是 info。它会告诉你错误发生在哪个钩子,比如 mounted hook 或者 render。做异常上报的时候把它一起带上,定位效率完全不一样。
errorHandler 的覆盖范围是随版本一点点扩的,这块很多人没注意到:2.2.0 起它开始捕获组件生命周期钩子里的错误,2.4.0 起支持捕获自定义事件处理函数内部的错误,2.6.0 起 v-on DOM 监听器里抛出的错误也会走到这里。所以你要是在一个 2.3 的老项目上验证「为什么自定义事件里的报错收不到」,别怀疑自己写错了,是版本不够。
<template>
<my-component @eventFn="doSomething"></my-component>
</template>
<script>
export default {
methods: {
doSomething() {
console.log(a); // a is not defined
}
}
}
</script>
上面这段就是 2.4.0 之后才能被 errorHandler 接住的典型场景。
这里有个坑要注意:errorHandler 管不到异步回调。setTimeout 里抛的错、Promise 链上没接 catch 的 reject,都跑到了 Vue 的调用栈之外,它接不到。真要做全量的前端异常监控,errorHandler 只是其中一块,还得配上 window.onerror 和 window.addEventListener('unhandledrejection'),三者各管一片。
另一个我自己踩过的问题是,errorHandler 里如果自己又抛了错,会形成套娃。上报函数里做了个 JSON.stringify 循环引用的对象,直接把这个兜底逻辑本身炸了,然后就是满屏刷不完的报错。所以这个函数体内部最好整个包一层 try...catch,并且保留一句 console.error(err),不然开发环境下所有错误都被你静悄悄吃掉了,调试体验会变得非常差。
组件级别的兜底还有 errorCaptured 这个钩子(2.5.0 新增),写在组件上,能拦住后代组件抛上来的错误,返回 false 可以阻止它继续往上冒。做局部降级 UI 的时候很好用,某个卡片崩了就渲染一个「加载失败」占位,不至于整页白屏。
Vue 3 里对应的是 app.config.errorHandler(err, instance, info) 和组合式 API 的 onErrorCaptured,思路一模一样,只是第二个参数从 vm 换成了组件实例,info 的字符串格式也做了调整。
# 三、nextTick 到底在等什么
先看一段一定会报错的代码:
<template>
<ul ref="box">
<li v-for="(item, index) in arr" :key="index"></li>
</ul>
</template>
<script>
export default {
data() {
return {
arr: []
}
},
mounted() {
this.getData();
},
methods: {
getData() {
this.arr = [1, 2, 3];
this.$refs.box.getElementsByTagName('li')[0].innerHTML = 'hello';
}
}
}
</script>
赋值完立刻去取 li,取到的是 undefined,再往上读 innerHTML 就抛错。第一直觉的解释是「数据还没赋上」,但你打个断点会发现 this.arr 明明已经是 [1, 2, 3] 了。
真正的原因是 Vue 的 DOM 更新是异步的。数据变了之后,Vue 不会立刻去改 DOM,而是把这个 watcher 推进一个队列,去重,然后在当前同步代码跑完之后统一 flush。这么设计是为了合并同一轮里的多次修改,你连着改十个字段,DOM 只重排一次。响应式这套依赖收集和队列调度的完整链路,我在 Vue响应式原理总结 里单独梳理过。
回到这块。既然更新排在队列后面,那我们把读 DOM 的操作也排到队列后面就行了,这就是 nextTick:
this.$nextTick(() => {
this.$refs.box.getElementsByTagName('li')[0].innerHTML = 'hello';
})
$nextTick 不传回调时会返回一个 Promise,所以也可以用 async/await 写平:
methods: {
async getData() {
this.arr = [1, 2, 3];
await this.$nextTick();
this.$refs.box.getElementsByTagName('li')[0].innerHTML = 'hello';
}
}
那 Vue 是靠什么实现这个「下一个 tick」的?源码里它并不是写死一种,而是按环境支持度做能力探测,依次降级:
Promise.then微任务MutationObserver监听 DOM 变化触发回调setImmediate(只有 IE 和 Node 有)setTimeout(fn, 0)宏任务兜底
前后两种大家都熟,都能把回调推到当前同步代码之后。中间那个 MutationObserver 是 HTML5 的特性,用一句话讲就是:创建一个观察者对象盯住某个 DOM 节点,它的 DOM 树一发生变化就执行你给的回调。Vue 用它的时候其实是取巧,创建一个游离的文本节点,改一下它的内容来触发回调,借的是这个回调跑在微任务队列里这一点。