04 【计算属性 侦听器】

04 【计算属性 侦听器】

1.计算属性

有时我们需要依赖于其他状态(普通proxy响应式数据)的状态(computed响应式数据):
在 Vue 中,这是用组件计算属性处理的,以直接创建计算值,我们可以使用 computed 函数:它接受 getter 函数并为 getter 返回的值返回一个不可变的响应式 ref 对象。

1.1 computed函数

与Vue2.x中computed配置功能一致

可以直接去看3.7.3完整写法

模板中的表达式虽然方便,但也只能用来做简单的操作。如果在模板中写太多逻辑,会让模板变得臃肿,难以维护。比如说,我们有这样一个包含嵌套数组的对象:

1
2
3
4
5
6
7
8
const author = reactive({
name: 'John Doe',
books: [
'Vue 2 - Advanced Guide',
'Vue 3 - Basic Guide',
'Vue 4 - The Mystery'
]
})

我们想根据 author 是否已有一些书籍来展示不同的信息:

1
2
<p>Has published books:</p>
<span>{{ author.books.length > 0 ? 'Yes' : 'No' }}</span>

这里的模板看起来有些复杂。我们必须认真看好一会儿才能明白它的计算依赖于 author.books。更重要的是,如果在模板中需要不止一次这样的计算,我们可不想将这样的代码在模板里重复好多遍。

因此我们推荐使用计算属性来描述依赖响应式状态的复杂逻辑。这是重构后的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script setup>
import { reactive, computed } from 'vue'

const author = reactive({
name: 'John Doe',
books: [
'Vue 2 - Advanced Guide',
'Vue 3 - Basic Guide',
'Vue 4 - The Mystery'
]
})

// 一个计算属性 ref
const publishedBooksMessage = computed(() => {
return author.books.length > 0 ? 'Yes' : 'No'
})
</script>

<template>
<p>Has published books:</p>
<span>{{ publishedBooksMessage }}</span>
</template>

在演练场中尝试一下

我们在这里定义了一个计算属性 publishedBooksMessagecomputed() 方法期望接收一个 getter 函数,返回值为一个计算属性 ref。和其他一般的 ref 类似,你可以通过 publishedBooksMessage.value 访问计算结果。计算属性 ref 也会在模板中自动解包,因此在模板表达式中引用时无需添加 .value

Vue 的计算属性会自动追踪响应式依赖。它会检测到 publishedBooksMessage 依赖于 author.books,所以当 author.books 改变时,任何依赖于 publishedBooksMessage 的绑定都会同时更新。

也可参考:为计算属性标注类型

1.2 其它使用

  • 接受一个具有 getset 函数的对象,用来创建可写的 ref 对象。
  • 该案例功能常用于父组件双向绑定,在子组件 props 与 computed结合使用很方便!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { ref,computed } from 'vue';
let test2_count = ref(0)

let test2 = computed({
// test2.value = 3 则会触发 set()
// set/get里都不能操作test2.value,否则会报错

get: () => {
return test2_count.value + '可变的响应式ref对象'
},
set: (val:any) => {
test2_count.value = val - 11
}
})
test2.value = 1 // test2_count.value = 1 - 11

调试 Computed

computed 可接受一个带有 onTrack 和 onTrigger 选项的对象作为第二个参数:

  • onTrack 会在某个响应式 property 或 ref 作为依赖被追踪时调用。
  • onTrigger 会在侦听回调被某个依赖的修改触发时调用。

所有回调都会收到一个 debugger 事件参数,其中包含了一些依赖相关的信息。推荐在这些回调内放置一个 debugger 语句以调试依赖。

1
2
3
4
5
6
7
8
9
10
11
12
import { ref,computed } from 'vue';
let count = ref(147)
let countComputed = computed(() => count.value + '计算属性',{
onTrack(e) {
// 当 count.value 作为依赖被追踪时触发。用人话将就是被访问的时候触发
console.log('onTrack',e);
},
onTrigger(e) {
// 当 count.value 被修改时触发
console.log('onTrigger',e);
}
})

注意:onTrack 和 onTrigger 仅在开发模式下生效 !!

1.3 计算属性vs方法

你可能注意到我们在表达式中像这样调用一个函数也会获得和计算属性相同的结果:

1
2
3
4
5
<p>{{ calculateBooksMessage() }}</p>
// 组件中
function calculateBooksMessage() {
return author.books.length > 0 ? 'Yes' : 'No'
}

若我们将同样的函数定义为一个方法而不是计算属性,两种方式在结果上确实是完全相同的,然而,不同之处在于计算属性值会基于其响应式依赖被缓存。一个计算属性仅会在其响应式依赖更新时才重新计算。这意味着只要 author.books 不改变,无论多少次访问 publishedBooksMessage 都会立即返回先前的计算结果,而不用重复执行 getter 函数。

这也解释了为什么下面的计算属性永远不会更新,因为 Date.now() 并不是一个响应式依赖:

1
const now = computed(() => Date.now())

相比之下,方法调用总是会在重渲染发生时再次执行函数。

为什么需要缓存呢?想象一下我们有一个非常耗性能的计算属性 list,需要循环一个巨大的数组并做许多计算逻辑,并且可能也有其他计算属性依赖于 list。没有缓存的话,我们会重复执行非常多次 list 的计算函数,然而这实际上没有必要!如果你确定不需要缓存,那么也可以使用方法调用。

官网的建议

  1. 计算函数不应有副作用#
    计算属性的计算函数应只做计算而没有任何其他的副作用,这一点非常重要,请务必牢记。举例来说,不要在计算函数中做异步请求或者更改 DOM!一个计算属性的声明中描述的是如何根据其他值派生一个值。因此计算函数的职责应该仅为计算和返回该值。在之后的指引中我们会讨论如何使用监听器根据其他响应式状态的变更来创建副作用。

  2. 避免直接修改计算属性值#
    从计算属性返回的值是派生状态。可以把它看作是一个“临时快照”,每当源状态发生变化时,就会创建一个新的快照。更改快照是没有意义的,因此计算属性的返回值应该被视为只读的,并且永远不应该被更改——应该更新它所依赖的源状态以触发新的计算。

2.侦听器

2.1 基本使用

在组合式 API 中,我们可以使用 watch 函数在每次响应式状态发生变化时触发回调函数

与Vue2.x中watch配置功能一致

详细信息

watch() 默认是懒侦听的,即仅在侦听源发生变化时才执行回调函数。

第一个参数是侦听器的。这个来源可以是以下几种:

  • 一个函数,返回一个值
  • 一个 ref
  • 一个响应式对象
  • …或是由以上类型的值组成的数组

第二个参数是在发生变化时要调用的回调函数。这个回调函数接受三个参数:新值、旧值,以及一个用于注册副作用清理的回调函数。该回调函数会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求。

当侦听多个来源时,回调函数接受两个数组,分别对应来源数组中的新值和旧值。

第三个可选的参数是一个对象,支持以下这些选项:

  • **immediate**:在侦听器创建时立即触发回调。第一次调用时旧值是 undefined
  • **deep**:如果源是对象,强制深度遍历,以便在深层级变更时触发回调。参考深层侦听器一节。
  • **flush**:调整回调函数的刷新时机。参考回调的刷新时机一节。
  • **onTrack / onTrigger**:调试侦听器的依赖。参考调试侦听器一节。

缓冲回调:缓冲回调不仅可以提高性能,还有助于保证数据的一致性。在执行数据更新的代码完成之前,侦听器不会被触发。简单来说,同步修改数据时,修完操作执行完毕后才会触发回调。注意是同步!所以异步操作的时候,要注意多次触发watch的问题。(所有同步操作为1次,异步操作有几次就触发多少次监听!!)

watch 的第一个参数可以是不同形式的“数据源”:它可以是一个 ref (包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组

功能1:停止侦听

此处监听的ref对象,传入的是ref对象则会自动解包 .value(基于源码的解释)

1
2
3
4
5
6
7
8
9
<script lang='ts' setup>
import { watch,ref } from 'vue';
let count = ref(0)
//! 关于 watch() 返回的 StopHandle 函数,调用stop()将会停止侦听。
let stop = watch(count, (newValue, oldValue,InvalidateCbRegistrator) => {
console.log('watch : count-:' + newValue, oldValue)
})
setTimeout(() => stop(), 3333); // stop() 停止侦听
</script>

功能2:监听 reactive() 的对象:

直接监听reactive声明的proxy对象,最终vue会默认赋值为true,所以自己传什么都没用。(基于源码的解释)

1
2
3
4
5
6
7
8
9
<script lang='ts' setup>
import { watch,reactive } from 'vue';
let proxy1 = reactive({})
watch(proxy1, (newValue, oldValue) => {
console.log('proxy1--',newValue , oldValue )
},{
deep:'干啥勒,对我做啥我都不从' //! 基于上述解释,此处传什么都是无效的,最终会被默认覆盖为true
})
</script>

功能3:监听嵌套对象的某个属性 (传入函数)

单独监听嵌套的某个属性 则需要传入函数的返回值

1
2
3
4
5
6
7
8
<script lang='ts' setup>
import { watch,reactive } from 'vue';
// reactive ref都行,看了 refs 篇章你也知道其原理
let proxy1 = reactive({t1:'嵌套数据'})
watch(()=>proxy1.t1, (newValue, oldValue) => {
console.log(newValue, oldValue)
})
</script>

功能4:监听多个源的形式 (传入数组)。

1
2
3
4
5
6
7
8
<script lang='ts' setup>
import { watch,reactive } from 'vue';
let data1 = reactive({t1:'t1嵌套数据'})
let data2 = reactive({t2:'t2嵌套数据'})
watch([data1,data2], (newValue, oldValue) => {
console.log(newValue, oldValue)
})
</script>

注意,你不能直接侦听响应式对象的属性值,例如:

1
2
3
4
5
6
const obj = reactive({ count: 0 })

// 错误,因为 watch() 得到的参数是一个 number
watch(obj.count, (count) => {
console.log(`count is: ${count}`)
})

这里需要用一个返回该属性的 getter 函数:

1
2
3
4
5
6
7
// 提供一个 getter 函数
watch(
() => obj.count,
(count) => {
console.log(`count is: ${count}`)
}
)

功能5:deep的使用:

直接给 watch() 传入一个响应式对象,会隐式地创建一个深层侦听器——该回调函数在所有嵌套的变更时都会被触发:

1
2
3
4
5
6
7
8
9
const obj = reactive({ count: 0 })

watch(obj, (newValue, oldValue) => {
// 在嵌套的属性变更时触发
// 注意:`newValue` 此处和 `oldValue` 是相等的
// 因为它们是同一个对象!
})

obj.count++

相比之下,一个返回响应式对象的 getter 函数,只有在返回不同的对象时,才会触发回调:

1
2
3
4
5
6
watch(
() => state.someObject,
() => {
// 仅当 state.someObject 被替换时触发
}
)

你也可以给上面这个例子显式地加上 deep 选项,强制转成深层侦听器:

1
2
3
4
5
6
7
8
watch(
() => state.someObject,
(newValue, oldValue) => {
// 注意:`newValue` 此处和 `oldValue` 是相等的
// *除非* state.someObject 被整个替换了
},
{ deep: true }
)

谨慎使用

深度侦听需要遍历被侦听对象中的所有嵌套的属性,当用于大型数据结构时,开销很大。因此请只在必要时才使用它,并且要留意性能。

功能6:第三个参数的3种形式:

  1. pre 模式下,指定的回调在模板渲染前被调用,所以立马获取对应的内容,将不会是最新的!!
  2. post 模式下,将回调推迟到模板渲染之后的,这时候用$ref获取对应内容则会是最新的,等于 pre 模式下用 nextTick()
  3. sync 就不多说了,回调改为同步调用,即取消 <缓冲回调> 这个功能
  4. ‘pre’ 和 ‘post’ 回调使用队列进行缓冲,这也是为什么<同步>多次修改后,只触发最后一次监听回调的原因。
1
2
3
4
5
6
7
8
9
10
11
12
<script lang='ts' setup>
import { watch,reactive } from 'vue';
let data1 = reactive({t1:'t1嵌套数据'})
watch(data1, (newValue, oldValue) => {
console.log('data1--',newValue, oldValue)
},{
deep:false,
immediate:false,
flush:'post' // 'pre' | 'post' | 'sync' // 默认值是'pre'
})

</script>

功能6:callback的第三个参数 onInvalidate > 注册失效回调
关于这个的解释,请移步watchEffect()文章,再来看这第六点,这样理解起来最佳!

1
2
3
4
5
6
7
8
9
10
11
12
<script lang='ts' setup>
import { watch,reactive } from 'vue';
let data1 = reactive({t1:'t1嵌套数据'})
let stop = watch(data1, (newValue, oldValue,onInvalidate ) => {
onInvalidate(()=>{
console.log('执行了');
})
})
data1.t1 = 1
settimeout(() => data1.t1 = 321, 1500)
setTimeout(() => stop(), 3333); // stop() 停止侦听
</script>

该注册回调的触发机制:

  • 副作用即将重新执行时(第一次不执行)
  • 侦听器被停止 (如果在 setup() 或生命周期钩子函数中使用了 watchEffect,在组件卸载时会自动停止侦听。)

与 watchEffect 共享的行为

watch 与 watchEffect共享停止侦听,清除副作用 (相应地 onInvalidate 会作为回调的第三个参数传入)、副作用刷新时机和侦听器调试行为。

特性7:

组件创建时的生命周期里同步执行的侦听会被收集,组件销毁时会自动销毁侦听器。
组件创建完后的再创建的侦听器,需要自己手动销毁,

2.2 watch 函数总结

  • 两个小“坑”:

    • 监视reactive定义的响应式数据时:oldValue无法正确获取、默认强制开启了深度监视(deep配置失效)。
    • 监视reactive定义的响应式数据中某个属性时:deep配置有效。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    //情况一:监视ref定义的响应式数据
    watch(sum,(newValue,oldValue)=>{
    console.log('sum变化了',newValue,oldValue)
    },{immediate:true})

    //情况二:监视多个ref定义的响应式数据
    watch([sum,msg],(newValue,oldValue)=>{
    console.log('sum或msg变化了',newValue,oldValue)
    })

    /* 情况三:监视reactive定义的响应式数据
    若watch监视的是reactive定义的响应式数据,则无法正确获得oldValue!!
    若watch监视的是reactive定义的响应式数据,则强制开启了深度监视
    */
    watch(person,(newValue,oldValue)=>{
    console.log('person变化了',newValue,oldValue)
    },{immediate:true,deep:false}) //此处的deep配置不再奏效

    //情况四:监视reactive定义的响应式数据中的某个属性
    watch(()=>person.job,(newValue,oldValue)=>{
    console.log('person的job变化了',newValue,oldValue)
    },{immediate:true,deep:true})

    //情况五:监视reactive定义的响应式数据中的某些属性
    watch([()=>person.job,()=>person.name],(newValue,oldValue)=>{
    console.log('person的job变化了',newValue,oldValue)
    },{immediate:true,deep:true})

    //特殊情况
    watch(()=>person.job,(newValue,oldValue)=>{
    console.log('person的job变化了',newValue,oldValue)
    },{deep:true}) //此处由于监视的是reactive素定义的对象中的某个属性,所以deep配置有效

如果能使用computed实现的就不用watch

2.3 watchEffect函数

为了根据响应式状态自动应用重新应用 副作用,我们可以使用 watchEffect 函数。它立即执行传入的一个函数,同时响应式追踪其依赖,并在其依赖变更时重新运行该函数。
传入的函数就是一个 effect函数 ,它会根据侦听的响应式数据的变化执行该函数。

  • watch的套路是:既要指明监视的属性,也要指明监视的回调。

  • watchEffect的套路是:不用指明监视哪个属性,监视的回调中用到哪个属性,那就监视哪个属性,并且初始就会执行一次回调函数。

  • watchEffect有点像computed:

    • 但computed注重的计算出来的值(回调函数的返回值),所以必须要写返回值。
    • 而watchEffect更注重的是过程(回调函数的函数体),所以不用写返回值。
  • 立即执行传入的一个函数,同时响应式追踪其依赖,并在其依赖变更时重新运行该函数。

注意,响应式数据必须要触发get才能劫持对应的内容为该副作用函数的依赖。

  • watchEffect 接收2个参数 ,并且有返回一个 StopHandle 函数用来停止侦听。
    • 1、第一个参数:(必传)
      effect 函数,收集依赖,并且组件初始化时立即执行一次;
      并且 effect 函数可以接受一个 onInvalidate 函数参数,该参数执行并传入一个 callback ,每次监听回调执行前都会执行该 callback。
    • 2、第二个参数对象(非必传): flush 、 onTrack 、 onTrigger;
      flush 跟 watch() $watch() watch:{} 的flush 完全一致。同理也具备 <缓冲回调>
      onTrack 、 onTrigger 看代码写的注释讲解!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<script lang='ts' setup>
import { watchEffect,ref,computed } from 'vue';
let count = ref(0)
let Cc = computed(()=> count.value * 2)
let obj = reactive({
data:123,
inObj:{d1:0}
})

const stop = watchEffect((onInvalidate) => {
//! 立即执行传入的回调函数,同时响应式追踪其所有依赖,并在依赖变更时重新运行该回调函数。
console.log(Cc.value); // 注意,如果依赖是 ref 和 computed 对象,必须要.value,否则并不会视为依赖
console.log(count.value);
console.log(obj.data); // 同理,reactive数据也只会侦听触发了get的property(属性)


//! 注意首次执行并不会执行onInvalidate ,第二次开始才会!
onInvalidate(()=>{
//! 在回调触发前会调用该函数,并且stop()停止侦听的时候也会触发一次!
console.log('watchEffect的onInvalidate');
})

},{
flush:'pre', // flush?: 'pre' | 'post' | 'sync' // 默认:'pre'

//! 跟踪之前会触发该函数,收集了多少个依赖就触发多少次!返回对应依赖信息
onTrack(e){console.log(e.target,'onTrack触发')},

//! 跟他名字一样依赖更改就触发执行,而且是同步的!没有所谓的缓冲回调
onTrigger(e){console.log(e.target,'onTrigger触发')}
})


setTimeout(() => {
stop() // 停止侦听 则调用该返回值即可
}, 1000*5);

</script>

提示

watchEffect 仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await 正常工作前访问到的属性才会被追踪。

1
2
3
4
watchEffect(async () => {
const response = await fetch(url.value)
data.value = await response.json()
})

这个例子中,回调会立即执行。在执行期间,它会自动追踪 url.value 作为依赖(和计算属性的行为类似)。每当 url.value 变化时,回调会再次执行。

关于这个例子,我觉得fetch函数应该比await先执行,这样才能满足在第一个 await 正常工作前访问到的属性才会被追踪这句话。

2.4 回调的触发时机

当你更改了响应式状态,它可能会同时触发 Vue 组件更新和侦听器回调。

默认情况下,用户创建的侦听器回调,都会在 Vue 组件更新之前被调用。这意味着你在侦听器回调中访问的 DOM 将是被 Vue 更新之前的状态。

副作用刷新时机 flush 一般使用post

pre sync post
更新时机 组件更新前执行 强制效果始终同步触发 组件更新后执行
1
2
3
4
5
flush:'pre' // 'pre' | 'post' | 'sync'  // 默认值是'pre'
// pre 模式下,指定的回调在模板渲染前被调用,所以立马获取对应的内容,将不会是最新的!!
// post 模式下,将回调推迟到模板渲染之后的,这时候用$ref获取对应内容则会是最新的,等于 pre 模式下用 nextTice()
// sync 就不多说了,回调改为同步调用,即取消 <缓冲回调> 这个功能
// 'pre' 和 'post' 回调使用队列进行缓冲,这也是为什么<同步>多次修改后,只触发最后一次监听回调的原因。

如果想在侦听器回调中能访问被 Vue 更新之后的DOM,你需要指明 flush: 'post' 选项:

1
2
3
4
5
6
7
watch(source, callback, {
flush: 'post'
})

watchEffect(callback, {
flush: 'post'
})

后置刷新的 watchEffect() 有个更方便的别名 watchPostEffect()

1
2
3
4
5
import { watchPostEffect } from 'vue'

watchPostEffect(() => {
/* 在 Vue 更新后执行 */
})

watchPostEffect v3.2+

watchEffect 的别名,固定 flush: 'post' 选项。

watchSyncEffect v3.2+

watchEffect 的别名,固定 flush: 'sync' 选项。

watchSyncEffect 为例,简单讲就是除了 flush 参数固定为 ‘sync’ ,其他所有功能跟 watchEffect 一样。

onTrigger 可以帮助我们调试 watchEffect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { watchEffect, ref } from 'vue'
let message = ref<string>('')
let message2 = ref<string>('')
watchEffect((oninvalidate) => {
//console.log('message', message.value);
oninvalidate(()=>{

})
console.log('message2', message2.value);
},{
flush:"post",
//! 跟他名字一样依赖更改就触发执行,而且是同步的!没有所谓的缓冲回调
onTrigger(e){console.log(e.target,'onTrigger触发')}
})

2.5 停止侦听

1
2
3
4
5
6
7
8
9
10
11
// 返回值是一个用来停止该副作用的函数。

const stop = watchEffect(() => {

console.log(obj.data);
})


setTimeout(() => {
stop() // 停止侦听 则调用该返回值即可
}, 1000*5);

2.6 清除副作用

在上面的代码中,你细心的话会注意到副作用函数有 onInvalidate 这么个函数参数!

有时副作用函数内会执行一些异步的函数,这些异步响应需要在其失效时清除 (即完成之前状态已改变了) 。所以侦听副作用传入的函数可以接收一个 onInvalidate 函数作入参,用来注册清理失效时的回调。当以下情况发生时,这个失效回调会被触发:

  • 副作用即将重新执行时
  • 侦听器被停止 (如果在 setup() 或生命周期钩子函数中使用了 watchEffect,在组件卸载时会自动停止侦听。)

我们之所以是通过传入一个函数去注册某个功能的失效回调,而不是从回调返回它,是因为返回值对于异步错误处理很重要。


在执行数据请求时,副作用函数往往是一个异步函数:
当id的值变化快,频繁http请求时,若前面的请求未完成,则会造成重复请求,则可以使用副作用函数的onCleanup参数,取消之前的http请求。
(onCleanup是vue3最新版英文官网的名字定义


04 【计算属性 侦听器】
https://flepeng.github.io/021-frontend-04-Vue-01-course-vue3-04-【计算属性-侦听器】/
作者
Lepeng
发布于
2023年8月1日
许可协议