Vue3:watch 的使用场景及常见问题
前言
在使用 Vue3
提供的 watch API
时
-
有时会遇到监听的数据变了,但是不触发
watch
的情况; -
有时修改数据会触发
watch
,重新赋值无法触发; -
有时重新赋值能触发
watch
,但是修改内部数据又不触发; -
再或者监听外部传入的数据时,是否和直接监听组件内部数据时的行为一致?
面临这些问题,决心通过下面的应用场景一探究竟!避免重复踩坑,对应不同的问题,找到合适的解决方案。
本文已收录在 Github: github.com/beichensky/… 中,欢迎 Star,欢迎 Follow!
一、Vue3 中响应式数据的两种类型
-
使用
reactive
声明的响应式数据,类型是Proxy
-
使用
ref
声明的响应式数据,类型是RefImpl
-
使用
computed
得到的响应式数据,类型也属于RefImpl
-
使用
ref
声明时,如果是引用类型,内部会将数据使用reactive
包裹成Proxy
二、Watch API
watch(source, callback, options)
-
source
: 需要监听的响应式数据或者函数 -
callback
:监听的数据发生变化时,会触发callback
-
newValue
:数据的新值 -
oldValue
:数据的旧值 -
onCleanup
:函数类型,接受一个回调函数。每次更新时,会调用上一次注册的onCleanup
函数
-
-
options
:额外的配置项-
immediate
:Boolean
类型,是否在第一次就触发watch
-
deep
:Boolean
类型,是否开启深度监听 -
flush
:pre
|post
|sync
-
pre
:在组件更新前执行副作用 -
post
:在组件更新后运行副作用 -
sync
:每个更改都强制触发watch
-
-
onTrack
:函数,具备event
参数,调试用。将在响应式property
或ref
作为依赖项被追踪时被调用 -
onTrigger
:函数,具备event
参数,调试用。将在依赖项变更导致副作用被触发时被调用。
-
三、watch 监听 reactive 声明的响应式数据
1. 监听 reactive 声明的响应式数据时
-
当监听的
reactive
声明的响应式数据时,修改响应式数据的任何属性,都会触发watch
const state = reactive({ name: '张三', address: { city: { cityName: '上海', }, }, }); watch( state, (newValue, oldValue) => { console.log(newValue, oldValue); }, { deep: false, } ); setTimeout(() => { state.name = '李四'; }, 1000); setTimeout(() => { state.address.city.cityName = '北京'; }, 2000);
可以发现,name
和 cityName
发生变化时,都会触发 watch
。但是,这里会发现两个问题:
-
无论是修改
name
或者cityName
时,oldValue
和newValue
的值是一样的; -
尽管我们将
deep
属性设置成了false
,但是cityName
的变化依然会触发watch
。
这里得出两个结论:
-
当监听的响应式数据是
Proxy
类型时,newValue
和oldValue
由于是同一个引用,所以属性值是一样的; -
当监听的响应式数据是
Proxy
类型时,deep
属性无效,无论设置成true
还是false
,都会进行深度监听。
2. 监听 Proxy 数据中的某个属性时
由于在业务开发中,定义的数据中可能属性比较多,我们指向监听其中某一个属性,那我们看看该如何操作
当监听的属性是基本类型时
-
如果只想监听
name
属性时,由于name
是个基本类型,所以source
参数需要用回调函数的方式进行监听:watch( () => state.name, (newValue, oldValue) => { console.log(newValue, oldValue); } ); setTimeout(() => { state.name = '李四'; }, 1000); setTimeout(() => { state.address.city.cityName = '北京'; }, 2000);
这是可以看到,
newValue
为 张三,oldValue
为 李四,并且在修改cityName
时,不会再触发watch
。
当监听的属性为引用类型时
-
监听
address
属性时,我们也可以使用回调函数的方式进行监听watch( () => state.address, (newValue, oldValue) => { console.log(newValue, oldValue); } ); setTimeout(() => { state.name = '李四'; }, 1000); setTimeout(() => { state.address.city.cityName = '北京'; }, 2000);
豁。。。发现控制台现在一次日志 都不打印了,按道理说,修改 name
时,不触发 watch
是正常的,但是修改 cityName
时,是想要触发的啊。
先看一下现在这种情况,如何触发 watch
:
watch(
() => state.address,
(newValue, oldValue) => {
console.log(newValue, oldValue);
}
);
setTimeout(() => {
state.name = '李四';
}, 1000);
setTimeout(() => {
state.address = {
city: {
cityName: '北京',
},
};
}, 2000);
这个时候,发现 1秒 和 2秒 之后,控制台出现打印结果了。那我们知道了,需要修改 address
属性,才能触发监听,修改更深层的属性,触发不了,这个时候明白了,应该是没有深度监听,Ok,那我们把 deep
属性设置为 true
试试:
watch(
() => state.address,
(newValue, oldValue) => {
console.log(newValue, oldValue);
},
{
deep: true,
}
);
setTimeout(() => {
state.name = '李四';
}, 1000);
setTimeout(() => {
state.address.city.cityName = '杭州';
}, 3000);
setTimeout(() => {
state.address = {
city: {
cityName: '北京',
},
};
}, 2000);
果不其然,控制台中,正常的打印了两次日志,说明,无论直接修改 address
还是修改 address
内部的深层属性,都可以正常的触发 watch
。
好的,到这里,可能有些同学说了:那我直接监听 state.address
不就可以了吗?这样 deep
属性也不用加。
那我们演示一下看看会不会存在问题:
watch(state.address, (newValue, oldValue) => {
console.log(newValue, oldValue);
});
setTimeout(() => {
state.address.city.cityName = '杭州';
}, 3000);
setTimeout(() => {
state.address = {
city: {
cityName: '北京',
},
};
}, 2000);
在控制台,只打印了第一次修改 cityName
时的日志,第二次修改 address
时,无法触发 watch
好,现在把上面两次修改调换一下位置:
watch(state.address, (newValue, oldValue) => {
console.log(newValue, oldValue);
});
setTimeout(() => {
state.address = {
city: {
cityName: '北京',
},
};
}, 2000);
setTimeout(() => {
state.address.city.cityName = '杭州';
}, 3000);
控制台里,一次日志都没有了,也就意味着,修改 address
时,无法触发监听,并且之后,由于 address
的引用发生变化,导致后续 address
内部的任何修改也都触发不了 watch
了。这是一个致命问题。
这里也得出了两个结论:
-
当指向监听响应式数据的某一个属性时,需要使用函数的方式设置
source
参数:-
如果属性类型是基本类型,可以正常监听,并且
newValue
和oldValue
,可以正常返回; -
如果属性类型是引用类型,需要将
deep
设置为true
才能进行深度监听。
-
-
如果属性类型时引用类型,并且没有用函数的方式注册
watch
,那么在使用时,一旦重新对该属性赋值,会导致监听失效。
四、watch 监听 ref 声明的响应式数据
1. ref 声明的数据为基本类型时
ref
声明的数据为基本类型时,直接使用 watch
监听即可
const state = ref('张三');
watch(state, (newValue, oldValue) => {
console.log(newValue, oldValue);
});
setTimeout(() => {
state.value = '李四';
}, 1000);
1秒 后,在控制台可以看到,打印出了 李四 和 张三。
众所周知,ref
声明的数据,都会自带 value
属性。所以下面这种写法效果同上:
const state = ref('张三');
watch(() => state.value, (newValue, oldValue) => {
console.log(newValue, oldValue);
});
setTimeout(() => {
state.value = '李四';
}, 1000);
2. ref 声明的数据为引用类型时
ref
声明的数据为引用类型时,内部会接入 reactive
将数据转化为 Proxy
类型。所以该数据的 value
对应的是 Proxy
类型。
const state = ref({
name: '张三',
address: {
city: {
cityName: '上海',
},
},
});
watch(state, (newValue, oldValue) => {
console.log(newValue, oldValue);
});
setTimeout(() => {
state.value = {
name: '李四',
address: {
city: {
cityName: '上海',
},
},
};
}, 1000);
setTimeout(() => {
state.value.address.city.cityName = '北京';
}, 2000);
1秒后,控制台打印出了日志,但是 2秒后,却没有日志再出现了,这又是什么原因呢,我们把上面的代码转个形。在 ref
声明的数据为基本类型时,这段里说过,监听 state
和 () => state.value
,效果是一样的,那我们看一下转换后的代码:
watch(() => state.value, (newValue, oldValue) => {
console.log(newValue, oldValue);
});
上面说了,当 ref
声明的数据是引用类型时,内部会借助 reactive
转化为 Proxy
类型。那这段代码是不是感觉似曾相识?哈哈,不就是将 deep
属性设置为 true
就可以了么。
const state = ref({
name: '张三',
address: {
city: {
cityName: '上海',
},
},
});
watch(
state,
(newValue, oldValue) => {
console.log(newValue, oldValue);
},
{
deep: true,
}
);
setTimeout(() => {
state.value = {
name: '李四',
address: {
city: {
cityName: '上海',
},
},
};
}, 1000);
setTimeout(() => {
state.value.address.city.cityName = '北京';
}, 2000);
加上 deep
之后,可以看到,在 1秒及 2秒后,都会在控制台打印出日志。说明此时,无论是修改 state
的 value
,还是修改深层属性,都会触发 watch
。
有些同学可能说了,我直接函数返回 state
行不行:
const state = ref({
name: '张三',
address: {
city: {
cityName: '上海',
},
},
});
watch(
() => state,
(newValue, oldValue) => {
console.log(newValue, oldValue);
}
);
setTimeout(() => {
state.value = {
name: '李四',
address: {
city: {
cityName: '上海',
},
},
};
}, 1000);
setTimeout(() => {
state.value.address.city.cityName = '北京';
}, 2000);
好的,这里我帮大家试过了,跟上面的效果有些区别:
-
当
deep
为false
时,修改value
或者深层属性,都不会触发 watch -
而设置
deep
为true
时,修改 vaue 或者深层属性,都会触发 watch
五、watch 监听传入的 prop 时
1. Proxy 作为 prop 传递时
既然是 Proxy
类型的数据,那么我们直接按照之前演示的方式,直接使用不就好了么:
App 组件
<script setup>
import { reactive } from 'vue';
import Child from './Child.vue';
const state = reactive({
name: '张三',
address: {
city: {
cityName: '上海',
},
},
});
setTimeout(() => {
state.name = '李四';
}, 1000);
setTimeout(() => {
state.address.city.cityName = '北京';
}, 2000);
</script>
<template>
<Child :data="state" />
</template>
Child 组件
<script setup>
import { watch } from 'vue';
const props = defineProps(['data']);
watch(props.data, (newValue, oldValue) => {
console.log(newValue, oldValue);
});
</script>
好的,在 1秒 和 2秒之后,可以看到控制台打印出的有两次日志。Ok,乍一看感觉没有问题哈,那我们修改一下 App
组件里的数据传递:
App 组件
<script setup>
import { reactive, ref } from 'vue';
import Child from './Child.vue';
const state = reactive({
name: '张三',
address: {
city: {
cityName: '上海',
},
},
});
const otherState = reactive({
name: '李四',
});
const flag = ref(true);
setTimeout(() => {
flag.value = false;
}, 500);
setTimeout(() => {
state.name = '李四';
}, 1000);
setTimeout(() => {
state.address.city.cityName = '北京';
}, 2000);
</script>
<template>
<Child :data="flag ? otherState : state" />
</template>
有些同学可能就问,
flag ? otherState : state
这里用computed
包装一下不行吗?当然可以,但是这里不是为了演示问题嘛,一切写法皆有可能对吧。
修改完 App
组件之后,按道理应该会打印三次日志,但是惊讶的发现:无论多久,控制台里都不会有日志打印,也就是说,data 属性的变化根本没有触发 watch
。这是为啥呢?又该怎么处理呢?
-
因为在
App
组件中,我们切换了要传递给Child
组件的数据,所以watch
监听的prop
不是同一个了 -
所以需要使用函数的方式监听
prop
Child 组件
<script setup>
import { watch } from 'vue';
const props = defineProps(['data']);
watch(() => props.data, (newValue, oldValue) => {
console.log(newValue, oldValue);
});
</script>
确实哈,修改完之后,控制台里打印了一次日志,而且新旧值不同,说明切换数据的时候监听到了,但还是不对,还少了两次。
到了这里,还记得我们上面讨论过的,使用函数作为 source
监听时,想监听深层的属性,那就需要添加 deep
属性为 true
才可以。
Child 组件
<script setup>
import { watch } from 'vue';
const props = defineProps(['data']);
watch(
() => props.data,
(newValue, oldValue) => {
console.log(newValue, oldValue);
},
{
deep: true,
}
);
</script>
好的,添加了 deep: true
之后,控制台中分别在 500ms、1秒、2秒后打印出了日志。此时达到了想要的效果。很棒!
2. ref 定义的数据作为 prop 传递时
当 ref 定义的数据作为
prop
进行传递时,会进行脱ref
的操作,也就是说,基本类型会直接将数据作为prop
传递,引用类型会作为Proxy
传入
ref 定义数据为基本类型时
直接使用函数作为 source
参数,进行监听即可:
App 组件
<script setup>
import { ref } from 'vue';
import Child from './Child.vue';
const state = ref('张三');
setTimeout(() => {
state.value = '李四';
}, 1000);
</script>
<template>
<Child :data="state" />
</template>
Child 组件
,此时由于 ref
定义的是基本数据类型,所以也不存在是否需要深度监听的问题
<script setup>
import { watch } from 'vue';
const props = defineProps(['data']);
watch(
() => props.data,
(newValue, oldValue) => {
console.log(newValue, oldValue);
}
);
</script>
当 ref 定义数据为引用类型时
上面说过,
ref
作为prop
传递时,会脱ref
,也就意味着,传给子组件的就是Proxy
类型的数据,用法及可能遇到的问题,请参照 proxy 作为 prop 传递时 里的代码和示例。
六、watch 监听 provide 提供的数据时
1. 提供的数据为 Proxy 时
同proxy 作为 prop 传递时,请参照 proxy 作为 prop 传递时 里的代码和示例。
2. 提供的数据为 ref 时
provide API
提供的数据为ref
时,不会进行脱ref
操作,同 四、watch 监听 ref 声明的响应式数据,请参照 四、watch 监听 ref 声明的响应式数据 里的代码和示例
3. 提供的数据不是响应式数据时
可以在 watch 中使用函数的方式进行监听,前提是需要将
deep
设置true
哦,这样对象内部如果包含了响应式的数据,也是可以触发监听的。
七、监听多个数据
在实际开发过程中,可能会需要同时监听多个值,我们看一下多个值的情况,watch
是如何处理以及响应的:
import { reactive, ref, watch } from 'vue';
const state = reactive({
name: '张三',
address: {
city: {
cityName: '上海',
},
},
});
consotherState = reactive({
name: '李四',
});
const flag = ref(true);
watch([state, () => otherState.name, flag], (newValue, oldValue) => {
console.log(newValue, oldValue);
});
setTimeout(() => {
flag.value = false;
}, 500);
setTimeout(() => {
otherState.name = '李四';
}, 1000);
setTimeout(() => {
state.address.city.cityName = '北京';
}, 2000);
可以再控制台看到,三次变化都会输出日志,并且 newValue
和 oldValue
都是一个数组,里面值的顺序对应着 source
里数组的顺序。
八、竞态问题
在业务开发的过程中,时常面临这样的需求:监听某个数据的变化,当数据发生变化时,重新进行网络请求。下面写一段代码,来模拟这个需求:
<script setup>
import { reactive, ref, watch } from 'vue';
let count = 2;
const loadData = (data) =>
new Promise((resolve) => {
count--;
setTimeout(() => {
resolve(`返回的数据为${data}`);
}, count * 1000);
});
const state = reactive({
name: '张三',
});
const data = ref('');
watch(
() => state.name,
(newValue) => {
loadData(newValue).then((res) => {
data.value = res;
});
}
);
setTimeout(() => {
state.name = '李四';
}, 100);
setTimeout(() => {
state.name = '王五';
}, 200);
</script>
<template>
<div>{{ data }}</div>
</template>
可以看到界面上展示的结果是:返回的数据为李四,显然这不是我们想要的结果。最后一次是将 name
修改为了王五,所以肯定是希望返回的结果为王五。那出现这个异常的原因是什么呢?
数据每次变化,都会发送网络请求,但是时间长短不确定,所以就有可能导致,后发的请求先回来了,所以会被先发的请求返回结果给覆盖掉。
那么该如何解决呢?上面提到过,watch
的 callback
中具备第三个参数 onCleanup
,我们来尝试着用一下:
watch(
() => state.name,
(newValue, oldValue, onCleanup) => {
let isCurrent = true;
onCleanup(() => {
isCurrent = false;
});
loadData(newValue).then((res) => {
if (isCurrent) {
data.value = res;
}
});
}
);
此时,在浏览器上,只会出现:返回的数据为王五。
onCleanup
接受一个回调函数,这个回调函数,在触发下一次watch
之前会执行,因此,可以在这里,取消上一次的网络请求,亦或做一些内存清理及数据变更等任何操作。
九、watchEffect
上面说了很多 watch 的应用场景和常见问题。在需要监听多个数据时,可以使用数组作为 source。但是多个数据,如果是很多个呢,可能比较负责的逻辑,其中使用了较多的响应式数据,这个时候,使用 watch 去监听,显然不太适合。
这里可以使用新的 API:watchEffect
1. watchEffect API
watchEffect(effect, options)
-
effect
: 函数。内部依赖的响应式数据发生变化时,会触发effect
重新执行onCleanup
:形参,函数类型,接受一个回调函数。每次更新时,会调用上一次注册的onCleanup
函数。作用同 watch 中的 onCleanup 参数。
-
options
:-
flush
:pre
|post
|sync
-
pre
:在组件更新前执行副作用; -
post
:在组件更新后运行副作用,可以使用watchPostEffect
替代; -
sync
:每个更改都强制触发watch
,可以使用watchSyncEffect
替代。
-
-
onTrack
:函数,具备event
参数,调试用。将在响应式property
或ref
作为依赖项被追踪时被调用 -
onTrigger
:函数,具备event
参数,调试用。将在依赖项变更导致副作用被触发时被调用。
-
2. watchEffect 用法
import { reactive, ref, watchEffect } from 'vue';
const state = reactive({
name: '张三',
});
const visible = ref(false);
watchEffect(() => {
console.log(state.name, visible.value);
});
setTimeout(() => {
state.name = '李四';
}, 1000);
setTimeout(() => {
visible.value = true;
}, 2000);
2秒之后,查看控制台,发现打印了三次日志:
-
第一次是初始值
-
第二次是修改 name 触发的监听
-
第三次是修改 visible 触发的监听
-
而且每次打印的都是当前最新值
由此可以看出:
-
watchEffect 默认监听,也就是默认第一次就会执行;
-
不需要设置监听的数据,在 effect 函数中,用到了哪个数据,会自动进行依赖,因此不用担心类似 watch 中出现深层属性监听不到的问题;
-
只能获取到新值,由于没有提前指定监听的是哪个数据,所以不会提供旧值。
3. watchEffect 可能会出现的问题
在上面的用法中,感觉 watchEffect
使用起来还是很方便的,会自动依赖,而且还不用考虑各种深度依赖的问题。那 watchEffect
会不会有什么陷阱需要注意呢?
import { reactive, ref, watchEffect } from 'vue';
const state = reactive({
name: '张三',
});
const visible = ref(false);
watchEffect(() => {
setTimeout(() => {
console.log(state.name, visible.value);
})
});
setTimeout(() => {
state.name = '李四';
}, 1000);
setTimeout(() => {
visible.value = true;
}, 2000);
这次在 effect
函数中添加了异步任务,在 setTimeout
中使用响应式数据,会发现,控制台一直都只展示一个日志:第一次进入时打印的。
也就是说,在异步任务(无论是宏任务还是微任务)中进行的响应式操作,watchEffect
无法正确的进行依赖收集。所以后面无论数据如何变更,都不会触发 effect
函数。
如果真的需要用到异步的操作,可以在外面先取值,再放到异步中去使用
watchEffect(() => {
const name = state.name;
const value = visible.value;
setTimeout(() => {
console.log(name, value);
});
});
修改之后,在控制台中可以正常的看到三次日志。
十、总结
-
当监听 Reactive 数据时:
-
deep
属性失效,会强制进行深度监听; -
新旧值指向同一个引用,导致内容是一样的。
-
-
当
watch
的source
是RefImpl
类型时:-
直接监听
state
和 监听() => state.value
是等效的; -
如果
ref
定义的是引用类型,并且想要进行深度监听,需要将deep
设置为true
。
-
-
当
watch
的source
是函数时,可以监听到函数返回值的变更。如果想监听到函数返回值深层属性的变化,需要将deep
设置为true
; -
如果想监听多个值的变化,可以将
source
设置为数组,内部可以是Proxy
对象,可以是RefImpl
对象,也可以是具有返回值的函数; -
在监听组件
props
时,建议使用函数的方式进行watch
,并且希望该prop
深层任何属性的变化都能触发,可以将deep
属性设置为true
; -
使用
watchEffect
时,注意在异步任务中使用响应式数据的情况,可能会导致无法正确进行依赖收集。如果确实需要异步操作,可以在异步任务外先获取响应式数据,再将值放到异步任务里进行操作。
十一:相关链接
写在后面
如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。
如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。
转载自:https://juejin.cn/post/7126364048886595598