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然后在模板里用name和age,修改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的道路上,越走越顺,少踩坑,多成长。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录