计算属性computed和侦听器watch的区别

104 阅读7分钟

计算属性

在之前的文章中,实现过computed,如下

function computed(getter) {
    let value          // 用来缓存上一次计算的值
    let dirty = true   // dirty标志 用来表示是否重新计算值
    const effectFn = effect(getter,
        {
            lazy: true,
            scheduler() {       //调度器 这样子当响应式数据发生变化时会执行
                if (!dirty) {
                    dirty = true
                    trigger(obj, 'value')    // 当计算属性依赖的响应式数据发生变化时,手动调用trigger函数触发响应
                }
            }
        })
    const obj = {
        get value() {
            if (dirty) {
                value = effectFn()
                dirty = false
            }
            // 当读取value时,手动调用track函数进行追踪(为了解决:effect嵌套导致的响应式数据变化但是副作用不执行)
            track(obj, 'value')
            return value
        }
    }
    return obj
}

通过上面的代码,computed的优势在于计算属性缓存,当计算属性的依赖项发生变化时,计算属性才会重新计算,有助于避免不必要的重复计算

为什么需要缓存?

// 计算属性缓存
< 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>

// 通过调用这样的一个函数也会获得和计算属性相同的结果:
function calculateBooksMessage() {
return author.books.length > 0 ? 'Yes' : 'No'
}
<p>{{ calculateBooksMessage() }}</p>      

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

而方法调用总是会在重新渲染发生时再次执行函数

缓存的好处是什么呢?

假如我们有一个非常耗性能的计算属性list,需要循环遍历一个巨大的数组并做许多计算逻辑,并且可能也有其他计算属性依赖于list,没有缓存的话,会重复执行非常多次list的getter,然而这实际上没有必要

  1. Getter不应该有副作用

    计算属性的getter应只做计算而没有任何其他的副作用。这一点很重要,不要在getter中做异步请求或者更改DOM!一个计算属性的声明中描述的是如何根据其他值派生一个值。因此getter的职责应该仅为计算和返回该值,应使用侦听器根据其他响应式状态的变更来创建副作用

  2. 避免直接修改计算属性值

    从计算属性返回的值是派生状态,可以把它看作是一个‘临时快照’,每当源状态发生变化时,就会创建一个新的快照,更改快照是没有意义的,因此计算属性的返回值应该被视为是只读的,并且永远不应该被更改

  3. 运用场景

    假设有一个需求是在输入框中输入一段文字,并在另一个地方显示这段文字的长度。可以使用computed属性来计算这段文字的长度;假设有一个购物车,computed属性可以方便地计算购物车中商品总价...

侦听器

watch()

在之前的文章中实现过watch

function watch(source, cb, options = {}) {
    let getter
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }
    let oldValue, newValue
    let cleanup   // cleanup用来存储用户注册的过期回调
    function onInvalidate(fn) {
        cleanup = fn   // 将过期回调存储到 cleanup中
    }
    const job = () => {
        newValue = effectFn()
        if (cleanup) {
            cleanup()
        }
        cb(newValue, oldValue, onInvalidate)    // 将onInvalidate作为回调函数的第三个参数 以便用户使用
        oldValue = newValue
    }
    const effectFn = effect(
        () => getter(),
        {
            lazy: true,
            scheduler: () => {
                if (options.flush === 'post') {
                    const p = Promise.resolve()
                    p.then(job)
                } else {
                    job()
                }
            }
        }
    )
    if (options.immediate) {
        job()
    } else {
        oldValue = effectFn()
    }
}
// 例子
let finalData
watch(obj, async (newVal, oldVal, onInvalidate) => {
    let expired = false
    onInvalidate(() => {
        expired = true
    })
    const res = await fetch('')
    if (!expired) {
        finalData = res
    }
})
obj.foo++
setTimeout(() => {
    obj, foo++   // 200ms后做第二次修改
}, 200)       

当我们需要在状态发生变化时执行一些‘副作用’,例如更改DOM或是根据异步操作的结果去修改另一处的状态,在组合式API中,可以只用watch函数在每次响应式状态发生变化时触发回调函数

watch的第一个参数,可以是一个ref(包括计算属性)、一个响应式对象、一个getter函数多个 数据源 组成的数组,但是注意不能直接侦听响应式对象的属性值:

const x = ref(0)
const y = ref(0)
// 单个 ref
watch(x, (newX) => {
    console.log(`x is ${newX}`)
})

// getter 函数
watch(
    () => x.value + y.value,
    (sum) => {
        console.log(`sum of x + y is: ${sum}`)
    }
)

// 多个来源组成的数组
watch([x, () => y.value], ([newX, newY]) => {
    console.log(`x is ${newX} and y is ${newY}`)
})

// !错误 因为watch()得到的参数是一个number
const obj = reactive({ count: 0 })
watch(obj.count, (count) => {
    console.log(`count is: ${count}`)
})
// 这里需要一个返回该属性的getter函数
watch(
    () => obj.count,
    (count) => {
        console.log(`count is: ${count}`)
    }
)    
  1. 深层侦听器

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

    const obj = reactive({ count: 0 })
    watch(obj, (newValue, oldValue) => {
    // 在嵌套的属性变更时触发
    // 注意:`newValue` 此处和 `oldValue` 是相等的
    // 因为它们是同一个对象!
    })
    obj.count++
    
  2. 即时回调的侦听器

    watch默认是懒执行的,回顾我们实现watch,会在配置项中添加lazy: true,即仅当数据源变化时,才会执行回调。如果需要回调立即执行,可以通过传入immediate: true

    watch(
    source,
    (newValue, oldValue) => {
        // 立即执行,且当 `source` 改变时再次执行
    },
    { immediate: true }
    )
    
  3. 运用场景

    (权限管理)监听用户权限数据的变化,根据用户的权限动态展示或隐藏页面内容或功能按钮;监听状态变化触发定时器的开启、关闭或者异步任务的启动、暂停...

watchEffect()

  1. watchEffetc函数允许我们自动追踪回调的响应式依赖,即不需要手动添加依赖项

        // watch()
        const todoId = ref(1)
        const data = ref(null)
        watch(
          todoId,
          async () => {
            const response = await fetch(
              `https://jsonplaceholder.typicode.com/todos/${todoId.value}`
            )
            data.value = await response.json()
          },
          { immediate: true }
        )
    
        // watchEffect()
        watchEffect(async () => {
          const response = await fetch(
            `https://jsonplaceholder.typicode.com/todos/${todoId.value}`
          )
          data.value = await response.json()
        })
    

    该回调会立即执行,不需要指定immediate: true

    在执行期间,它会自动追踪todoId.value作为依赖(和计算属性类似),每当todoId.value发生变化时,回调会再次执行

    有了watchEffect(),我们不再需要明确传递todoId作为源值

  2. 当只有一个依赖项的例子来说,watchEffect()的好处相对较小,但是对于多个依赖项的侦听器来说,使用watchEffect()可以消除手动维护依赖列表的负担。此外,如果需要侦听一个嵌套数据结构中的几个属性,watchEffect()可能会比深度侦听器更有效,因为它将只跟踪回调中被使用到的属性,而不是递归地跟踪所有的属性

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

watchwatchEffect
相同点都能响应式地执行副作用的回调
不同点:追踪响应式依赖的方式只追踪明确侦听的数据源 不会追踪任何在回调中访问到的东西 仅在数据源确实改变时才会触发回调会在副作用发生期间追踪依赖 它会在同步执行过程中,自动追踪所有能访问到的响应式属性
优缺点会避免在发生副作用时追踪依赖,因此可以更加精确地控制回调函数的触发时机方便,代码往往更加简洁,但是有时其响应式依赖关系会不那么明确

回调的触发时机

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

  2. 默认情况下,用户创建的侦听器回调,都会在Vue组件更新之前被调用,若项在侦听器回调中能访问被Vue更新之后的DOM,需要指明flush:'post'选项

        watch(source, callback, {
          flush: 'post'
        })
    
        watchEffect(callback, {
          flush: 'post'
        })
        // 后置刷新的watchEffect有个方便的别名
        import { watchPostEffect } from 'vue'
        watchPostEffect(() => {
          /* 在 Vue 更新后执行 */
        })
    

参考文章:

计算属性 | Vue.js

计算属性computed - 掘金

watch的实现原理 - 掘金