上周,我们的线上系统出了一个诡异的Bug。

用户反馈,在某些情况下,页面会白屏,控制台报一个奇怪的错误。这个Bug只在生产环境出现,开发环境复现不了。我排查了整整一夜,终于找到了原因。

这篇文章,我想分享一下这次排查的过程,以及最终的解决方案。希望能给遇到类似问题的朋友一些参考。

Bug现象

先说说Bug的现象。

我们的系统是一个用Vue 3.4 + Vite构建的后台管理系统。用户反馈,在某些特定的操作路径下,页面会白屏,控制台报错:"Cannot read properties of undefined (reading 'value')"。

这个错误看起来很普通,就是访问了一个undefined对象的value属性。但奇怪的是,这个错误只在生产环境出现,开发环境完全正常。而且,不是所有用户都会遇到,只有某些特定的操作路径下才会出现。

更奇怪的是,这个Bug是在我们升级到Vue 3.4之后才出现的。之前用Vue 3.3的时候,一切正常。

我第一反应是,可能是Vue 3.4的某个新特性导致的兼容性问题。但具体是什么问题,还需要进一步排查。

初步排查

拿到Bug之后,我开始了初步排查。

第一步,尝试复现。我按照用户描述的操作路径,在测试环境试了很多次,但都没有复现。测试环境用的是生产构建的代码,和线上环境一样,但就是复现不了。

第二步,看错误堆栈。线上的错误堆栈是压缩后的,看不出具体是哪一行代码出的问题。我用source map还原了一下,发现错误出在一个Vue的内部函数里,不是我们的业务代码。

这就更奇怪了。Vue的内部函数报错,而且只在生产环境出现,这说明可能是Vue的生产构建和开发构建的行为不一致导致的。

第三步,对比Vue 3.3和3.4的变更。我翻了Vue 3.4的changelog,看看有哪些可能导致这个问题的变更。Vue 3.4主要的更新包括:更快的模板编译、改进的响应式系统、新的defineModel宏、改进的v-for解构等。

其中,有一个更新引起了我的注意:Vue 3.4改进了响应式系统的性能,对ref的解包做了优化。我怀疑,可能是这个优化导致了某些情况下的行为不一致。

但具体是怎么导致的,还需要更深入的排查。

深入排查

初步排查没有找到原因,我开始深入排查。

第一步,在本地用生产构建复现。我在本地用vite build构建了生产版本,然后用vite preview启动,按照用户的操作路径试了很多次,终于复现了这个Bug。

复现之后,我在浏览器的开发者工具里打断点,一步步调试。发现错误出现在一个组件的渲染过程中,访问一个ref的value属性的时候,这个ref是undefined。

但奇怪的是,这个ref在组件的setup函数里明明是定义了的,而且在开发环境里是有值的。为什么在生产环境里就变成undefined了呢?

第二步,对比开发构建和生产构建的代码。我把开发环境和生产环境的组件代码都打出来对比,发现生产构建的代码做了一些优化,其中一个优化是:如果一个ref只在模板中使用,不在JS中使用,生产构建会把它的.value访问优化掉,直接在模板中解包。

这个优化本身是没问题的,Vue的模板编译器会自动处理ref的解包。但问题出在,我们的代码里有一个特殊的用法:我们把一个ref通过props传给了子组件,然后在子组件的模板里直接使用了这个ref。

在Vue 3.3中,这种用法是可以正常工作的。但在Vue 3.4中,因为生产构建的优化,这个ref在某些情况下没有被正确解包,导致访问value的时候出错。

第三步,定位具体代码。我终于找到了出问题的代码。大概是这样的:

<!-- 父组件 -->
<template>
  <Child :data="someRef" />
</template>

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const someRef = ref(null)
// 一些异步操作后给someRef赋值
</script>

<!-- 子组件 -->
<template>
  <div>{{ data.value.name }}</div>
</template>

<script setup>
const props = defineProps(['data'])
</script>

问题出在子组件的模板里,我们写了data.value.name。在Vue 3.3中,因为data是一个prop,而且是一个ref,模板编译器不会自动解包,所以需要手动写.value。但在Vue 3.4中,生产构建的优化逻辑变了,它认为data是一个普通的prop,不需要.value,于是把.value优化掉了,导致运行时出错。

但为什么开发环境没问题呢?因为开发构建没有做这个优化,所以.data.value能正常访问。而生产构建做了优化,把.value去掉了,导致data变成了ref对象本身,而不是ref的值,然后访问.name的时候就出错了。

等等,这个分析好像不太对。让我再仔细想想。

实际上,问题更复杂一些。Vue 3.4对props的ref解包做了改进。在Vue 3.4中,如果一个prop是ref,模板中会自动解包。所以,子组件模板里应该写data.name而不是data.value.name

但我们的代码是从Vue 3.2时代写过来的,那时候props的ref不会自动解包,所以我们写了data.value.name。在Vue 3.3中,这种写法还能工作。但在Vue 3.4中,因为新的解包逻辑,这种写法在生产构建中出了问题。

根因分析

找到了出问题的代码之后,我开始分析根因。

根本原因是:Vue 3.4对模板中ref的解包逻辑做了改进,和Vue 3.3不完全兼容。

具体来说,Vue 3.4引入了一个新的编译优化:在模板中,如果一个变量是ref,编译器会自动插入解包逻辑,不需要手动写.value。这个优化在开发环境和生产环境都有,但生产环境的优化更激进。

问题在于,当一个ref通过props传递给子组件时,Vue 3.4的编译器对这种情况的处理和Vue 3.3不一样。在Vue 3.3中,props里的ref不会自动解包,需要手动写.value。但在Vue 3.4中,编译器会尝试自动解包props里的ref。

如果你的代码里写了data.value.name,Vue 3.4的编译器会认为你已经手动解包了,就不会再自动解包。但在生产构建中,因为某些优化,这个手动解包的逻辑被错误地处理了,导致运行时出错。

更准确地说,这是Vue 3.4的一个编译器Bug。在特定的代码结构下,生产构建的模板编译器会错误地优化掉ref的解包逻辑,导致运行时访问undefined的value属性。

这个Bug已经在Vue 3.4的后续小版本中修复了,但我们当时用的是较早的3.4版本,所以遇到了这个问题。

解决方案

找到了根因之后,解决方案就很简单了。

第一个方案是升级Vue版本。我们把Vue从3.4.0升级到了3.4.21(当时的最新版本),这个Bug已经被修复了。升级之后,问题就消失了。

第二个方案是修改代码。把data.value.name改成data.name,让Vue的模板编译器自动处理ref的解包。这种写法更符合Vue 3.4的最佳实践,也更简洁。

我们最终采用了两个方案:升级Vue版本,同时修改代码。因为升级版本能解决根本问题,修改代码能让代码更规范,避免以后再遇到类似问题。

修改代码的时候,我们还做了一个全面的检查,把所有类似的写法都找出来,统一改成了新的写法。这个过程花了一些时间,但避免了以后再踩坑。

排查经验总结

这次排查,花了我整整一夜的时间。总结一下经验:

第一,线上Bug要先想办法复现。复现是排查的第一步,如果不能复现,就很难定位问题。对于只在生产环境出现的Bug,可以在本地用生产构建来复现。

第二,善用source map。生产环境的代码是压缩后的,没有source map根本看不懂。确保生产构建生成了source map,并且能正确映射到源代码。

第三,关注框架版本变更。升级框架版本的时候,一定要仔细看changelog,了解有哪些变更可能影响现有代码。尤其是大版本或者有破坏性更新的版本,更要仔细测试。

第四,不要忽略编译器的优化。现代前端框架的编译器做了很多优化,这些优化在大部分情况下是好的,但在某些边界情况下可能出问题。遇到奇怪的Bug时,要考虑编译器优化的可能性。

第五,写代码要遵循最佳实践。Vue 3.4推荐在模板中直接使用ref,不需要手动写.value。遵循最佳实践的代码,在框架升级时更不容易出问题。

第六,要有完善的测试。如果我们有完善的端到端测试,覆盖了那个出问题的操作路径,这个Bug在测试阶段就能发现,不会流到线上。

后续改进

这次Bug之后,我们做了一些改进,避免类似问题再次发生。

第一,完善了测试流程。增加了更多的端到端测试,覆盖了更多的操作路径。每次升级框架版本或者做大型重构之后,都会跑一遍完整的测试。

第二,建立了框架版本升级的规范。升级框架版本之前,要仔细阅读changelog,评估可能的影响。升级之后,要做全面的回归测试,确保没有引入新的问题。

第三,做了代码规范的检查。用ESLint和自定义规则,检查代码中是否有不符合最佳实践的写法,比如在模板中手动写ref.value。

第四,建立了线上Bug的快速响应机制。线上出现Bug之后,有明确的排查流程和责任人,能快速定位和解决问题。

写在最后

这次排查Vue 3.4新特性Bug的经历,让我学到了很多。

前端开发发展到今天,框架和工具已经非常复杂了。编译器做了大量的优化,让我们的代码运行得更快、更高效。但这些优化也带来了一些复杂性,在某些边界情况下可能出问题。

作为开发者,我们需要理解框架的工作原理,遵循最佳实践,同时保持对新技术的好奇心和警惕心。遇到问题的时候,不要慌,一步步排查,总能找到原因。

希望这篇文章能给遇到类似问题的朋友一些帮助。如果你也有类似的排查经历,欢迎在评论区分享。