Vue 3.5发布之后,我们团队把几个项目升级到了新版本。

升级的过程中,遇到了很多问题,有些是Vue 3.5新特性带来的,有些是我们自己的代码不规范导致的,还有一些是第三方库的兼容性问题。这些问题,让我熬了好几个夜,才一一解决。

这篇文章我想记录一下使用Vue 3.5+过程中遇到的各种坑和解决方案。从响应式、生命周期、性能到构建部署,聊聊那些让我熬夜排查的问题。如果你也在用Vue 3.5+,或者打算升级,希望这篇文章能帮你少走一些弯路。

先说明一下,本文基于Vue 3.5及以上版本,部分问题在更早的版本中可能不存在,或者表现不同。另外,本文记录的是我个人遇到的问题,不一定全面,仅供参考。

坑一:ref和reactive的响应式丢失

第一个坑,也是最常见的坑,就是ref和reactive的响应式丢失。

我们项目里有一个场景,需要把一个reactive对象解构出来,然后在模板里使用。结果发现,解构之后的值,不再是响应式的了,修改之后页面不会更新。

代码大概是这样的:

const state = reactive({
  name: '张三',
  age: 25
})

const { name, age } = state

然后在模板里用nameage,修改state.name的时候,页面不会更新。

这个问题的原因是,reactive对象解构之后,得到的是普通的值,不再是响应式的了。因为reactive的响应式是通过Proxy实现的,解构之后,就脱离了Proxy的代理,失去了响应式。

解决方案有几个:

第一,用toRefs把reactive对象转成ref对象,然后再解构。toRefs会把reactive对象的每个属性都转成ref,这样解构之后还是响应式的。

import { reactive, toRefs } from 'vue'

const state = reactive({
  name: '张三',
  age: 25
})

const { name, age } = toRefs(state)

第二,直接在模板里用state.name,不解构。这样虽然写起来麻烦一点,但不会丢失响应式。

第三,用ref而不是reactive。如果对象的属性不多,可以用ref来定义,ref解构之后还是响应式的(因为ref本身就是一个响应式引用)。

这个坑看起来简单,但很多人都会犯,特别是从Vue 2转过来的开发者,习惯了data里的属性直接用,到了Vue 3里就容易踩这个坑。我自己就踩过好几次,每次都是排查半天才发现是解构导致的响应式丢失。

坑二:watch的immediate和deep属性

第二个坑,是关于watch的immediate和deep属性。

我们项目里有一个场景,需要监听一个对象的变化,并且在初始化的时候就执行一次。代码大概是这样的:

const state = reactive({
  user: {
    name: '张三',
    age: 25
  }
})

watch(() => state.user, (newVal, oldVal) => {
  console.log('user changed', newVal)
}, { immediate: true })

结果发现,修改state.user.name的时候,watch不会触发。而且immediate执行的时候,newVal和oldVal是一样的,都是初始值。

这个问题的原因是,watch默认是浅监听,只监听对象的引用变化,不监听对象内部属性的变化。修改state.user.name的时候,state.user的引用没有变,所以watch不会触发。

解决方案是,加上deep: true,开启深度监听。

watch(() => state.user, (newVal, oldVal) => {
  console.log('user changed', newVal)
}, { immediate: true, deep: true })

这样,修改对象内部的属性,watch也会触发了。

但这里还有一个坑,就是开启deep之后,immediate执行的时候,oldVal是undefined,而不是初始值。这是因为深度监听的时候,第一次执行还没有旧值。如果需要用oldVal,需要判断一下是否为undefined。

另外,还有一个坑,就是watch监听ref的时候,不需要写函数,直接写ref就行。如果写了函数,反而可能出问题。

// 正确
watch(name, (newVal, oldVal) => {
  console.log('name changed', newVal)
})

// 也可以,但没必要
watch(() => name.value, (newVal, oldVal) => {
  console.log('name changed', newVal)
})

这些watch的细节,看起来简单,但很容易踩坑。我自己就因为deep的问题,排查了好几个小时,最后发现只是少写了一个deep: true。

坑三:computed的缓存和副作用

第三个坑,是关于computed的缓存和副作用。

我们项目里有一个场景,需要根据几个响应式变量计算一个值,并且在计算的时候有一些副作用,比如调用一个函数。代码大概是这样的:

const count = ref(0)

const doubleCount = computed(() => {
  console.log('computed executed')
  doSomething()
  return count.value * 2
})

结果发现,doSomething()只执行了一次,后面修改count的时候,虽然doubleCount的值变了,但doSomething()没有执行。

这个问题的原因是,computed是有缓存的。computed的值会被缓存,只有当依赖的响应式变量变化的时候,才会重新计算。而且,computed的重新计算,是惰性的,只有当你访问computed的值的时候,才会重新计算。

所以,如果你在computed里写了副作用,这些副作用只会在computed重新计算的时候执行,而且只有当你访问computed的值的时候才会执行。这可能会导致副作用不执行,或者执行时机不对。

解决方案是,不要在computed里写副作用。computed应该是纯函数,只用来计算值,不应该有副作用。如果需要副作用,应该用watch或者watchEffect。

const count = ref(0)

const doubleCount = computed(() => {
  return count.value * 2
})

watch(count, (newVal) => {
  console.log('count changed', newVal)
  doSomething()
})

这样,computed只负责计算值,watch负责处理副作用,职责清晰,也不会出问题。

另外,还有一个坑,就是computed的值是只读的,不能直接修改。如果尝试修改computed的值,会报错。如果需要修改,应该用可写的computed,提供get和set函数。

const firstName = ref('张')
const lastName = ref('三')

const fullName = computed({
  get: () => `${firstName.value}${lastName.value}`,
  set: (val) => {
    firstName.value = val[0]
    lastName.value = val.slice(1)
  }
})

这些computed的细节,很容易被忽略,导致一些奇怪的问题。我自己就因为在computed里写了副作用,排查了很久才发现问题。

坑四:生命周期钩子的使用时机

第四个坑,是关于生命周期钩子的使用时机。

Vue 3的组合式API里,生命周期钩子需要在setup函数里调用,或者在组件的顶层调用。如果在异步函数里调用,或者在条件判断里调用,就会出问题。

我们项目里有一个场景,需要在异步获取数据之后,注册一个生命周期钩子。代码大概是这样的:

import { onMounted } from 'vue'

async function fetchData() {
  const data = await api.getData()
  onMounted(() => {
    console.log('mounted', data)
  })
}

fetchData()

结果发现,onMounted里的回调函数没有执行,而且控制台报了一个警告,说onMounted只能在setup函数里调用。

这个问题的原因是,Vue 3的生命周期钩子,是通过当前组件实例来注册的。在setup函数执行的时候,当前组件实例是有效的,所以可以注册生命周期钩子。但在异步函数里,setup函数已经执行完了,当前组件实例已经失效了,这时候调用生命周期钩子,就会失败。

解决方案是,把生命周期钩子的调用放在setup函数的顶层,不要放在异步函数里。如果需要在生命周期钩子里用异步获取的数据,可以把数据定义在外面,在钩子里访问。

import { ref, onMounted } from 'vue'

const data = ref(null)

onMounted(async () => {
  data.value = await api.getData()
  console.log('mounted', data.value)
})

或者,如果确实需要在异步之后执行某些逻辑,可以不用生命周期钩子,直接在异步函数里执行,只要确保执行时机是对的。

另外,还有一个坑,就是onUnmounted钩子,在组件卸载的时候执行。如果在组件卸载之后,还有异步操作在执行,可能会导致内存泄漏或者报错。所以,在onUnmounted里,要清理掉定时器、事件监听、异步请求等。

这些生命周期的细节,很容易被忽略,特别是从Vue 2转过来的开发者,习惯了在选项式API里用生命周期,到了组合式API里就容易踩坑。

坑五:v-for和v-if的优先级

第五个坑,是关于v-for和v-if的优先级。

在Vue 2里,v-for的优先级比v-if高,所以可以在同一个元素上同时用v-for和v-if,v-if会在每次循环的时候执行。

但在Vue 3里,v-if的优先级比v-for高。所以,如果在同一个元素上同时用v-for和v-if,v-if会先执行,这时候v-for的循环变量还没有定义,会报错。

我们项目里就有这样的代码:

<div v-for="item in list" v-if="item.visible">
  {{ item.name }}
</div>

升级到Vue 3之后,这段代码报错了,说item没有定义。

解决方案是,不要在同一个元素上同时用v-for和v-if。可以把v-if放在外层元素上,或者用computed先过滤列表。

<!-- 方案一:外层包一个元素 -->
<template v-for="item in list">
  <div v-if="item.visible">
    {{ item.name }}
  </div>
</template>

<!-- 方案二:用computed过滤 -->
<div v-for="item in visibleList">
  {{ item.name }}
</div>
const visibleList = computed(() => list.value.filter(item => item.visible))

这个坑,是Vue 2升级到Vue 3的时候最常见的坑之一。很多老项目升级的时候,都会遇到这个问题。我自己就因为这个问题,改了好几个组件的代码。

另外,还有一个相关的坑,就是v-for的key属性。在Vue 3里,v-for的key应该放在template上,而不是内部的元素上。如果用了template标签做循环,key要放在template上。

<!-- 正确 -->
<template v-for="item in list" :key="item.id">
  <div>{{ item.name }}</div>
</template>

<!-- 错误,key放在了div上 -->
<template v-for="item in list">
  <div :key="item.id">{{ item.name }}</div>
</template>

这些模板语法的变化,虽然不大,但很容易踩坑。

坑六:props的解构和默认值

第六个坑,是关于props的解构和默认值。

在Vue 3.5之前,defineProps返回的props对象,是不能解构的,解构之后会丢失响应式。而且,默认值的设置也比较麻烦,需要用withDefaults。

Vue 3.5引入了响应式props解构,可以直接解构props,而且还是响应式的。但这个特性,也带来了一些新的坑。

我们项目里有一个组件,代码大概是这样的:

const props = defineProps({
  title: {
    type: String,
    default: '默认标题'
  },
  visible: {
    type: Boolean,
    default: false
  }
})

const { title, visible } = props

升级到Vue 3.5之后,这段代码看起来没问题,但实际上有一个坑:解构之后的props,虽然是响应式的,但如果在解构的时候给了默认值,默认值的行为和预期不一样。

比如:

const { title = '默认标题' } = props

这里的默认值,只有在props.title是undefined的时候才会生效。但如果父组件传了null或者空字符串,默认值不会生效。这和我们预期的可能不一样。

另外,还有一个坑,就是解构之后的props,不能直接修改,因为props是只读的。如果尝试修改,会报错。如果需要修改,应该用本地的ref或者computed。

const props = defineProps({
  title: String
})

const localTitle = ref(props.title)

// 或者用computed
const computedTitle = computed({
  get: () => props.title,
  set: (val) => emit('update:title', val)
})

还有一个坑,就是defineProps的类型声明。在Vue 3.5里,支持用TypeScript的类型声明来定义props,而且可以直接给默认值。

const { title = '默认标题', visible = false } = defineProps<{
  title?: string
  visible?: boolean
}>()

这种方式写起来更简洁,但也有一些限制,比如不能用运行时的验证。如果需要运行时验证,还是要用对象声明的方式。

这些props的细节,虽然不大,但很容易踩坑。我自己就因为解构props的默认值问题,排查了很久。

坑七:emit的事件名和v-model

第七个坑,是关于emit的事件名和v-model。

在Vue 3里,自定义事件的命名,推荐用camelCase,而不是kebab-case。而且,v-model的用法也和Vue 2不一样。

我们项目里有一个组件,需要实现双向绑定。代码大概是这样的:

const props = defineProps({
  modelValue: String
})

const emit = defineEmits(['update:modelValue'])

function handleChange(val) {
  emit('update:modelValue', val)
}

父组件里用:

<MyComponent v-model="name" />

这看起来没问题,但实际上有一个坑:在Vue 3.5里,v-model的参数名变了。以前是modelValue,现在可以用任意名字,而且默认的v-model参数名也可以配置。

比如:

<MyComponent v-model:title="name" />

这时候,组件里的props应该是title,emit的事件是update:title。

const props = defineProps({
  title: String
})

const emit = defineEmits(['update:title'])

这个变化,让v-model的用法更灵活了,但也容易混淆。特别是从Vue 2转过来的开发者,习惯了value和input的写法,到了Vue 3里就容易搞混。

另外,还有一个坑,就是emit的事件名,在模板里监听的时候,要用kebab-case。比如,emit的事件名是update:modelValue,在模板里监听的时候,要用@update:model-value。

<MyComponent @update:model-value="handleUpdate" />

虽然Vue 3也支持camelCase的事件监听,但推荐用kebab-case,因为HTML是不区分大小写的。

还有一个坑,就是defineEmits的类型声明。在Vue 3.5里,可以用TypeScript的类型声明来定义emits。

const emit = defineEmits<{
  (e: 'update:title', val: string): void
  (e: 'change', val: string): void
}>()

这种方式写起来更规范,有类型提示,但也有一些限制。

这些emit和v-model的细节,很容易踩坑,特别是在做组件库的时候。我自己就因为v-model的参数名问题,改了好几个组件。

坑八:性能优化的几个细节

第八个坑,是关于性能优化的几个细节。

Vue 3.5在性能方面做了很多优化,但如果代码写得不好,还是会有性能问题。我们项目里就遇到了几个性能相关的坑。

第一个是v-for的性能问题。如果列表很大,而且每一项都很复杂,v-for的渲染会很慢。解决方案是用虚拟滚动,只渲染可见区域的列表项。Vue 3.5里,可以用vue-virtual-scroller等第三方库来实现虚拟滚动。

另外,v-for的key也很重要。不要用index作为key,要用唯一的id作为key。用index作为key,在列表排序或者删除的时候,会导致组件重新渲染,影响性能。

第二个是computed和watch的滥用。computed和watch虽然好用,但如果用得太多,也会影响性能。特别是深度监听的watch,会遍历对象的所有属性,开销很大。如果不需要深度监听,就不要开deep。

另外,computed的依赖也不要太多。如果一个computed依赖了很多响应式变量,任何一个变化都会导致重新计算,影响性能。可以把复杂的computed拆成几个简单的computed。

第三个是组件的不必要渲染。在Vue 3里,组件的渲染是基于响应式的。如果一个组件的props没有变化,就不会重新渲染。但如果父组件重新渲染了,子组件默认也会重新渲染,即使props没有变化。

解决方案是用shallowRef或者shallowReactive,减少响应式的深度。或者用memo(Vue 3.5新增的API),缓存组件的渲染结果,只有props变化的时候才重新渲染。

import { memo } from 'vue'

const MyComponent = memo(() => {
  return h('div', 'hello')
})

第四个是大对象的响应式开销。如果一个对象很大,而且嵌套很深,用reactive包裹之后,响应式的开销会很大。因为reactive会递归地把对象的所有属性都变成响应式的。

解决方案是用shallowReactive,只把对象的第一层属性变成响应式的,内部的属性不做响应式。或者用ref,把整个对象作为一个ref,修改的时候替换整个对象。

// 用shallowReactive
const state = shallowReactive({
  user: {
    name: '张三',
    age: 25
  }
})

// 用ref
const state = ref({
  user: {
    name: '张三',
    age: 25
  }
})

// 修改的时候替换整个对象
state.value = {
  ...state.value,
  user: {
    ...state.value.user,
    name: '李四'
  }
}

这些性能优化的细节,在小项目里可能感觉不到,但在大项目里,影响会很大。我自己就因为大对象的响应式开销,排查了很久的性能问题。

坑九:构建和部署的问题

第九个坑,是关于构建和部署的问题。

Vue 3.5的构建,一般用Vite。Vite的构建速度很快,但也有一些坑。

第一个是依赖预构建的问题。Vite在开发的时候,会预构建第三方依赖。如果第三方依赖有问题,或者预构建的缓存有问题,会导致开发服务器启动失败,或者页面报错。

解决方案是删除node_modules/.vite目录,重新启动开发服务器,强制重新预构建。或者在vite.config.js里配置optimizeDeps,把有问题的依赖排除掉,或者强制包含。

第二个是环境变量的问题。Vite的环境变量,需要以VITE开头,才会暴露给客户端代码。如果不以VITE开头,只能在配置文件里用。很多人踩过这个坑,定义了环境变量,但在代码里访问不到。

另外,环境变量的加载顺序也很重要。.env.[mode]会覆盖.env里的同名变量。如果有多个环境变量文件,要注意加载顺序。

第三个是打包体积的问题。Vite默认的打包,已经做了很多优化,但如果项目很大,打包体积还是会很大。解决方案是代码分割、懒加载、gzip压缩、CDN等。

另外,还要注意第三方依赖的体积。有些第三方依赖很大,比如moment.js、lodash等,可以用更小的替代库,或者按需引入。

第四个是部署的问题。Vue项目部署到服务器之后,刷新页面会404。这是因为Vue是单页应用,所有路由都在前端处理,服务器上没有对应的文件。解决方案是配置服务器的重写规则,把所有请求都重写到index.html。

比如,Nginx的配置:

location / {
  try_files $uri $uri/ /index.html;
}

Apache的配置,在.htaccess里:

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>

这些构建和部署的问题,虽然不是Vue本身的问题,但在实际项目中经常遇到。我自己就因为部署后刷新404的问题,折腾了很久。

写在最后

以上就是我使用Vue 3.5+过程中遇到的一些坑和解决方案。

Vue 3.5确实是一个很好的版本,性能更好,功能更强,开发体验也更好。但任何技术都有学习曲线,都有一些容易踩坑的地方。只有踩过坑,解决过问题,才能真正掌握它。

这篇文章记录的只是我遇到的一部分问题,Vue 3.5还有很多其他的坑和细节,需要在实际使用中慢慢探索。如果你也在用Vue 3.5+,或者打算升级,希望这篇文章能帮你少走一些弯路。

当然,技术是不断发展的,Vue也在不断更新。今天的坑,可能明天就被修复了;今天的最佳实践,可能明天就过时了。所以,最重要的不是记住这些坑,而是学会排查问题的方法,学会看文档,学会看源码,学会自己解决问题。

最后用一句话来结束这篇文章:"踩坑不可怕,可怕的是踩了坑不总结。"

愿你在Vue的道路上,越走越顺,少踩坑,多成长。