Vue3 watch inject怎么正确监听?注入依赖响应式失效怎么办?
为什么watch inject会成为Vue3开发的常见问题?
很多刚从Vue2过渡或者刚学Vue3组合式API的朋友,都会遇到这个场景:父组件provide了一个状态,子组件inject下来想监听变化,要么是watch直接报错说不是响应式源,要么是改了父组件的状态,子组件的watch毫无反应,其实这主要是Vue3响应式系统和provide/inject的设计区别导致的——Vue2的provide/inject默认是非响应式的,需要用特定的对象包裹或者监听整个根对象属性,但Vue3虽然提供了响应式依赖注入的方案,可组合式API下watch的绑定逻辑又和Vue2的watch有细微不同,稍不注意就踩坑。
Vue3 watch inject的三种正确基础操作
首先得明确一点:Vue3中,不管是provide还是watch的正确监听,前提都是inject接收的是一个合法的响应式源——也就是ref、reactive对象的根属性(直接reactive.inject不行,要包ref或者provide computed)、readonly包裹的ref/reactive,下面分最常用的场景讲:
场景1:父组件provide的是ref,子组件直接watch这个ref
这是最稳妥、最简单的基础操作,假设父组件有个用户输入的搜索关键词,要传给所有子组件的搜索栏、结果页,结果页需要监听关键词变化重新请求数据:
<!-- 父组件 Parent.vue -->
<script setup>
import { ref, provide } from 'vue'
const searchKeyword = ref('')
// 这里直接provide ref本身!
provide('searchKw', searchKeyword)
</script>
<template>
<input v-model="searchKeyword" placeholder="搜索商品..." />
<SearchBar />
<SearchResult />
</template>
<!-- 子组件 SearchResult.vue -->
<script setup>
import { inject, watch } from 'vue'
// 接收时可以给个默认值,防止父组件没provide时报错
const searchKeyword = inject('searchKw', ref(''))
// 直接watch ref,不需要加.value,因为watch会自动解包ref
watch(searchKeyword, (newVal, oldVal) => {
if (!newVal.trim()) return
console.log(`搜索关键词从 ${oldVal} 变成 ${newVal},现在去请求数据`)
// 模拟请求
// fetchGoods(newVal)
})
</script>
这里要注意两个细节:一是provide时传ref,不是传ref.value(传值的话就是普通非响应式的字符串/数字了);二是inject接收的ref如果给默认值,默认值必须是ref或者reactive这样的响应式,否则默认情况是非响应式的,watch会绑定失败。
场景2:父组件provide的是reactive对象,子组件watch对象的具体属性
有时候父组件要传多个相关的状态,比如用户的登录信息(token、userId、username),这时候用reactive更方便,但不能直接watch整个reactive对象注入的值的根吗?可以,但更推荐watch具体属性,因为watch整个对象可能会触发不必要的回调(比如改了不相关的其他属性)。
<!-- 父组件 Parent.vue -->
<script setup>
import { reactive, provide } from 'vue'
const userInfo = reactive({
token: '',
userId: null,
username: ''
})
provide('userInfo', userInfo)
// 模拟登录成功更新状态
const login = () => {
userInfo.token = 'mock_token_123'
userInfo.userId = 1001
userInfo.username = '小明同学'
}
</script>
<template>
<button @click="login">模拟登录</button>
<ProfileCard />
<OrderList />
</template>
<!-- 子组件 OrderList.vue -->
<script setup>
import { inject, watch } from 'vue'
const userInfo = inject('userInfo', reactive({ token: '', userId: null, username: '' }))
// 方式1:直接watch userInfo.userId(根属性自动是ref吗?对,组合式API中inject下来的reactive对象的根属性,在setup的上下文中已经是响应式的可以直接用,但watch时要传箭头函数)
watch(
() => userInfo.userId,
(newUserId) => {
if (newUserId) {
console.log(`用户 ${newUserId} 登录了,去加载订单列表`)
// fetchOrders(newUserId)
}
}
)
// 方式2:如果要监听多个属性,或者整个userInfo但只需要深层变化触发(比如userInfo里有个嵌套的address对象),可以加deep选项
watch(
() => userInfo,
(newUser) => {
console.log('用户信息发生变化', newUser)
},
{ deep: true } // 只有嵌套属性变化时才必须加deep
)
</script>
这里的关键点是:inject接收的reactive对象不是ref类型的,不能直接把它作为watch的第一个参数——必须传一个返回该对象或其属性的箭头函数,这样watch才能追踪到响应式依赖,如果是嵌套属性,比如userInfo.address.province,箭头函数就要写() => userInfo.address.province,这时候不加deep也能触发回调,因为箭头函数里访问的已经是具体的嵌套响应式属性了(但如果是直接替换整个address对象,比如userInfo.address = { province: '北京' },箭头函数() => userInfo.address也能触发,不用deep)。
场景3:父组件provide的是computed,子组件直接watch(缓存需求场景)
有时候父组件provide的不是原始状态,而是基于原始状态计算出来的派生状态,比如购物车的总价,这时候应该用computed包裹后再provide,既保证响应式,又有缓存效果——只有原始状态(比如购物车商品数量、单价)变化时,computed才会重新计算,子组件的watch也只会在computed的值变化时触发,不会因为其他无关状态更新而误触。
<!-- 父组件 Parent.vue -->
<script setup>
import { ref, computed, provide } from 'vue'
const cartItems = ref([
{ id: 1, name: 'Vue3实战书', price: 99, count: 1 },
{ id: 2, name: 'TypeScript入门', price: 69, count: 2 }
])
// 用computed计算购物车总价,只有cartItems或count/price变化时才重新算
const cartTotal = computed(() => {
return cartItems.value.reduce((total, item) => total + item.price * item.count, 0)
})
provide('cartTotal', cartTotal)
// 模拟加购
const addToCart = () => {
cartItems.value.push({ id: 3, name: 'React对比Vue', price: 59, count: 1 })
}
</script>
<template>
<div>购物车总价:{{ cartTotal }}</div>
<button @click="addToCart">加购</button>
<CheckoutPage />
</template>
<!-- 子组件 CheckoutPage.vue -->
<script setup>
import { inject, watch } from 'vue'
const cartTotal = inject('cartTotal', ref(0))
// 直接watch computed,和watch ref一样,自动解包
watch(cartTotal, (newTotal) => {
console.log(`购物车总价更新为 ${newTotal},可能需要调整满减逻辑`)
// 比如更新满减按钮的显示状态
})
</script>
Vue3 watch inject常见的响应式失效原因及解决方案
上面讲了正确的基础操作,但实际开发中踩坑的大多是失效问题,下面列4个最常见的:
原因1:provide时传的是ref.value(普通值)
这是新手最容易犯的低级错误,比如父组件里写provide('searchKw', searchKeyword.value),这时候传给子组件的是一个固定的字符串/数字/布尔值,根本不是响应式的,watch当然不会有反应。
解决方案:直接provide ref本身,不要加.value,就像场景1那样。
原因2:inject接收时没有给响应式默认值,且父组件可能没provide
如果父组件在某些条件下才provide(比如用户登录后才provide userInfo),子组件初始化时inject会拿到undefined,这时候即使给了一个普通的默认对象const userInfo = inject('userInfo', { token: '' }),这个默认对象也是非响应式的,watch同样失效。
解决方案:inject的默认值必须是响应式的——如果是单个值用ref,多个值用reactive或者computed,比如const userInfo = inject('userInfo', reactive({ token: '', userId: null })),这样即使父组件暂时没provide,子组件的默认值也是响应式的,后续父组件provide了新的响应式userInfo后,子组件的userInfo会自动替换吗?不对,这里要注意:Vue3的inject不会自动替换后续父组件provide的值——父组件provide是在组件挂载前就执行的,条件provide应该放在父组件的provide前面?或者换一种方式:父组件永远provide响应式源,只是初始值是空的,后续条件满足时更新这个响应式源,比如场景2的父组件,不管用户有没有登录,都provide userInfo这个reactive对象,初始值token是'',userId是null,这样就不会有问题了。
原因3:子组件把inject下来的响应式源重新赋值给了一个普通变量
比如子组件里写const localKw = inject('searchKw', ref('')).value,这时候localKw是普通值,watch localKw当然没用。
解决方案:要么直接用inject下来的响应式源,要么用ref/reactive重新包裹但要同步变化?不,同步的话还不如直接用原响应式源;如果需要局部修改inject的状态但不影响父组件,应该用computed或者watchEffect复制一个局部响应式源,
<script setup>
import { inject, ref, watchEffect } from 'vue'
const parentKw = inject('searchKw', ref(''))
// 用watchEffect同步到局部ref,局部修改localKw不影响parentKw
const localKw = ref('')
watchEffect(() => {
localKw.value = parentKw.value
})
// 监听局部localKw的变化
watch(localKw, (newVal) => console.log('局部搜索关键词变化', newVal))
</script>
原因4:父组件替换了整个reactive对象
比如场景2的父组件,不是修改userInfo的属性,而是直接写userInfo = reactive({ token: 'new_token' }),这时候provide出去的还是原来的旧userInfo对象,子组件inject的还是旧对象,旧对象的属性没变化,watch当然失效。
解决方案:永远不要替换reactive对象的本身,只修改它的属性——如果必须替换整个数据结构,应该用ref包裹reactive对象,
<!-- 父组件修改版 -->
<script setup>
import { ref, provide } from 'vue'
// 用ref包裹reactive,这样可以替换整个ref.value
const userInfo = ref(reactive({
token: '',
userId: null,
username: ''
}))
provide('userInfo', userInfo)
// 模拟登录成功替换整个用户信息(比如从后端拿到的完整对象)
const login = async () => {
const res = await fetch('/api/user/login')
const data = await res.json()
// 直接替换ref.value
userInfo.value = reactive(data)
}
</script>
<!-- 子组件修改版 -->
<script setup>
import { inject, watch } from 'vue'
const userInfo = inject('userInfo', ref(reactive({ token: '', userId: null })))
// 这时候要加deep吗?如果只替换ref.value,不用deep就能触发;如果修改ref.value里的嵌套属性,还是要加
watch(userInfo, (newUser) => {
console.log('用户信息完全替换或嵌套更新', newUser.value) // 这里注意!inject的是ref包裹的reactive,所以要手动解包userInfo.value
}, { deep: true })
</script>
这里又有一个细节:如果provide的是ref(reactive(obj)),子组件inject的也是这个双层响应式,watch时自动解包外层ref吗?会,watch会自动解包第一层ref,所以watch的第一个参数直接写userInfo,回调里的newVal/oldVal就是内层的reactive对象,但如果要修改内层reactive对象的属性,在模板里直接用userInfo.token会自动解包两次吗?会!模板里的ref会自动解包,所以不管是单层还是双层ref包裹的响应式,模板里直接用就行,不需要加.value。
watchEffect vs watch监听inject,该选哪个?
有些朋友可能会觉得,watch监听inject有时候要写箭头函数,不如watchEffect方便——确实,watchEffect会自动追踪回调函数里访问的所有响应式依赖,不管是ref、reactive还是inject的响应式源,都不需要手动指定监听对象:
<!-- 用watchEffect监听场景1的搜索关键词 -->
<script setup>
import { inject, watchEffect } from 'vue'
const searchKeyword = inject('searchKw', ref(''))
watchEffect(() => {
if (!searchKeyword.value.trim()) return
console.log(`watchEffect监听到搜索关键词变化:${searchKeyword.value}`)
})
</script>
那什么时候用watch,什么时候用watchEffect监听inject呢?这里给几个实用的判断标准:
- 需要获取旧值时,必须用watch:watchEffect只有新值,没有旧值的回调参数;
- 需要监听具体的某个或某几个属性,避免误触时,优先用watch:比如场景2中,只需要监听userId变化加载订单,username变化不需要,这时候watch更精准;
- 回调函数里依赖的响应式源很多,且每次变化都要执行相同逻辑时,优先用watchEffect:比如购物车的总价展示、多个状态联动更新UI时;
- 需要手动控制监听的开启和关闭时,优先用watch:watch和watchEffect都返回一个停止函数,但watch可以设置
immediate: false(默认就是false),在需要的时候手动调用停止函数,watchEffect默认是flush: 'pre'(组件挂载前执行一次,组件更新前再执行),如果要组件挂载后才执行,要设置flush: 'post'或者flush: 'sync'。
还有哪些进阶技巧?
技巧1:用readonly包裹provide的响应式源,防止子组件直接修改父组件状态
为了保证单向数据流,最好不要让子组件直接修改父组件provide的状态——这时候可以用Vue3的readonly API包裹响应式源后再provide:
<!-- 父组件 -->
import { ref, readonly, provide } from 'vue'
const searchKeyword = ref('')
provide('searchKw', readonly(searchKeyword))
// 如果需要子组件修改父组件状态,可以provide一个修改函数
provide('updateSearchKw', (newVal) => {
searchKeyword.value = newVal
})
<!-- 子组件 -->
import { inject, watch } from 'vue'
const searchKeyword = inject('searchKw', ref(''))
const updateSearchKw = inject('updateSearchKw', () => {})
// 子组件不能直接修改searchKeyword.value,会报错
// 应该调用updateSearchKw
const handleInput = (e) => {
updateSearchKw(e.target.value)
}
watch(searchKeyword, (newVal) => console.log('父组件的搜索关键词变化了', newVal))
技巧2:用provide/inject的key避免命名冲突
大型项目中,很多组件都会provide/inject相同的key,userInfo'、'searchKw',这时候很容易命名冲突——可以用Symbol作为key,因为Symbol是唯一的:
// 新建一个utils/keys.js文件,统一管理provide/inject的key
export const SEARCH_KW_KEY = Symbol('searchKw')
export const UPDATE_SEARCH_KW_KEY = Symbol('updateSearchKw')
export const USER_INFO_KEY = Symbol('userInfo')
<!-- 父组件 -->
import { ref, provide } from 'vue'
import { SEARCH_KW_KEY, UPDATE_SEARCH_KW_KEY } from '@/utils/keys.js'
const searchKeyword = ref('')
provide(SEARCH_KW_KEY, searchKeyword)
provide(UPDATE_SEARCH_KW_KEY, (newVal) => { searchKeyword.value = newVal })
<!-- 子组件 -->
import { inject, watch } from 'vue'
import { SEARCH_KW_KEY, UPDATE_SEARCH_KW_KEY } from '@/utils/keys.js'
const searchKeyword = inject(SEARCH_KW_KEY, ref(''))
const updateSearchKw = inject(UPDATE_SEARCH_KW_KEY, () => {})
watch(searchKeyword, (newVal) => console.log('唯一key的搜索关键词变化', newVal))
Vue3 watch inject的核心其实就是保证inject接收的是合法的响应式源,然后根据场景选择合适的watch写法——provide ref直接watch,provide reactive对象传箭头函数,provide computed直接watch,常见的失效原因只要避开低级错误(传ref.value、普通默认值、重新赋值普通变量、替换reactive对象本身)就可以解决,进阶技巧用readonly保证单向数据流、用Symbol避免命名冲突,watchEffect和watch根据需求选择就行。
最后再强调一遍:不管是provide还是watch,都要遵循Vue3的响应式系统规则,多写多练,踩坑多了自然就熟练了。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



