微信图片_20211219164119.jpg

监听变量的变化做出响应的反应我们通常会用到computed和watch和watchEffect这几种方式。

但三者是怎么使用的以及差异在哪,我们今天就来探个究竟

computed

类型声明

export type ComputedGetter<T> = (...args: any[]) => T
export interface ComputedRef<T = any> extends WritableComputedRef<T> {
  readonly value: T
  [ComputedRefSymbol]: true
}
// 只读的
export function computed<T>(
  getter: ComputedGetter<T>,
  debugOptions?: DebuggerOptions
): ComputedRef<T>
export interface WritableComputedOptions<T> {
  get: ComputedGetter<T>
  set: ComputedSetter<T>
}
export interface WritableComputedRef<T> extends Ref<T> {
  readonly effect: ReactiveEffect<T>
}
// 可写的
export function computed<T>(
  options: WritableComputedOptions<T>,
  debugOptions?: DebuggerOptions
): WritableComputedRef<T>
export interface DebuggerOptions {
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
}

export type DebuggerEvent = {
  effect: ReactiveEffect
} & DebuggerEventExtraInfo

export type DebuggerEventExtraInfo = {
  target: object
  type: TrackOpTypes | TriggerOpTypes
  key: any
  newValue?: any
  oldValue?: any
  oldTarget?: Map<any, any> | Set<any>
}

使用

根据类型定义我们知道computed可以接受一个 getter 函数,并根据 getter 的返回值返回一个不可变的响应式 ref 对象。

<script setup lang="ts">
import { ref, computed } from "vue";
const count = ref(1);
const plusOne = computed(() => count.value + 1);
console.log(plusOne.value);
// plusOne.value++; // Cannot assign to 'value' because it is a read-only property.
function addCount() {
  count.value++;
}
</script>

<template>
  <div>
    <p>count: {{ count }} plusOne: {{ plusOne }}</p>
    <button @click="addCount"></button>
  </div>
</template>

或者,接受一个具有 getset 函数的对象,用来创建可写的 ref 对象。

const plusOne = computed({
  get: () => count.value + 1,
  set: val => {
    count.value = val - 1
  }
})

plusOne.value = 1
console.log(count.value) // 0
console.log(plusOne.value) // 1

调试computed

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

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

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

const plusOne = computed(() => count.value + 1, {
  onTrack(e) {
    // 当 count.value 作为依赖被追踪时触发
    debugger
  },
  onTrigger(e) {
    // 当 count.value 被修改时触发
    debugger
  }
})
// 访问 plusOne,应该触发 onTrack
console.log(plusOne.value)
// 修改 count.value,应该触发 onTrigger
count.value++

onTrackonTrigger 仅在开发模式下生效。

watchEffect

类型声明

declare type InvalidateCbRegistrator = (cb: () => void) => void;

export declare type WatchEffect = (onInvalidate: InvalidateCbRegistrator) => void;

export declare function watchEffect(effect: WatchEffect, options?: WatchOptionsBase): WatchStopHandle;

export declare interface WatchOptionsBase extends DebuggerOptions {
    flush?: 'pre' | 'post' | 'sync';
}
    
export declare type WatchStopHandle = () => void;

使用

立即执行传入的一个函数,同时响应式追踪其依赖,并在其依赖变更时重新运行该函数。

const count = ref(0);
watchEffect(() => console.log(count.value));// 0
setTimeout(() => {
  count.value++;
  // -> logs 1
}, 1000);

停止监听

watchEffect 在组件的 setup() 函数或生命周期钩子被调用时,侦听器会被链接到该组件的生命周期,并在组件卸载时自动停止。

在一些情况下,也可以显式调用返回值以停止侦听:

const stop = watchEffect(() => {
  /* ... */
})

// later
stop()

清除副作用

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

  • 副作用即将重新执行时
  • 侦听器被停止 (如果在 setup() 或生命周期钩子函数中使用了 watchEffect,则在组件卸载时)
watchEffect(onInvalidate => {
  const token = performAsyncOperation(id.value)
  onInvalidate(() => {
    // id has changed or watcher is stopped.
    // invalidate previously pending async operation
    token.cancel()
  })
})

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

在执行数据请求时,副作用函数往往是一个异步函数:

const data = ref(null)
watchEffect(async onInvalidate => {
  onInvalidate(() => {
    /* ... */
  }) // 我们在Promise解析之前注册清除函数
  data.value = await fetchData(props.id)
})

我们知道异步函数都会隐式地返回一个 Promise,但是清理函数必须要在 Promise 被 resolve 之前被注册。另外,Vue 依赖这个返回的 Promise 来自动处理 Promise 链上的潜在错误。

副作用刷新时机

Vue 的响应性系统会缓存副作用函数,并异步地刷新它们,这样可以避免同一个“tick” 中多个状态改变导致的不必要的重复调用。在核心的具体实现中,组件的 update 函数也是一个被侦听的副作用。当一个用户定义的副作用函数进入队列时,默认情况下,会在所有的组件 update 执行:

<template>
  <div>{{ count }}</div>
</template>

<script>
export default {
  setup() {
    const count = ref(0)

    watchEffect(() => {
      console.log(count.value)
    })

    return {
      count
    }
  }
}
</script>

在这个例子中:

  • count 会在初始运行时同步打印出来
  • 更改 count 时,将在组件更新前执行副作用。

如果需要在组件更新(例如:当与模板引用一起)重新运行侦听器副作用,我们可以传递带有 flush 选项的附加 options 对象 (默认为 'pre'):

// 在组件更新后触发,这样你就可以访问更新的 DOM。
// 注意:这也将推迟副作用的初始运行,直到组件的首次渲染完成。
watchEffect(
  () => {
    /* ... */
  },
  {
    flush: 'post'
  }
)

flush 选项还接受 sync,这将强制效果始终同步触发。然而,这是低效的,应该很少需要。

从 Vue 3.2.0 开始,watchPostEffectwatchSyncEffect 别名也可以用来让代码意图更加明显。

监听器调试

与computed差不多

watch

类型定义

// 监听单一源
export declare function watch<T, Immediate extends Readonly<boolean> = false>(source: WatchSource<T>, cb: WatchCallback<T, Immediate extends true ? T | undefined : T>, options?: WatchOptions<Immediate>): WatchStopHandle;

export declare type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() => T);

export declare type WatchCallback<V = any, OV = any> = (value: V, oldValue: OV, onInvalidate: InvalidateCbRegistrator) => any;

declare type InvalidateCbRegistrator = (cb: () => void) => void;

export declare interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
    immediate?: Immediate;
    deep?: boolean;
}
// 监听多个源
export declare function watch<T extends MultiWatchSources, Immediate extends Readonly<boolean> = false>(sources: [...T], cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>, options?: WatchOptions<Immediate>): WatchStopHandle;

使用

侦听单一源

侦听器数据源可以是一个具有返回值的 getter 函数,也可以直接是一个 ref或computedRef

// 侦听一个 getter
const state = reactive({ count: 0 })
watch(
  () => state.count,
  (count, prevCount) => {
    /* ... */
  }
)

// 直接侦听一个 ref
const count = ref(0)
watch(count, (count, prevCount) => {
  /* ... */
})

// 监听computedRef
const plusOne = computed(() => count.value + 1);
watch(plusOne, (plusOne, prevplusOne) => {
  console.log(plusOne, prevplusOne)
})

侦听多个源

侦听器还可以使用数组以同时侦听多个源:

watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => {
  /* ... */
})

如果你在同一个函数里同时改变这些被侦听的来源,侦听器仍只会执行一次:

setup() {
  const firstName = ref('')
  const lastName = ref('')

  watch([firstName, lastName], (newValues, prevValues) => {
    console.log(newValues, prevValues)
  })

  const changeValues = () => {
    firstName.value = 'John'
    lastName.value = 'Smith'
    // 打印 ["John", "Smith"] ["", ""]
  }

  return { changeValues }
}

注意多个同步更改只会触发一次侦听器。

通过更改设置 flush: 'sync',我们可以为每个更改都强制触发侦听器,尽管这通常是不推荐的。或者,可以用 nextTick 等待侦听器在下一步改变之前运行。例如:

const changeValues = async () => {
  firstName.value = 'John' // 打印 ["John", ""] ["", ""]
  await nextTick()
  lastName.value = 'Smith' // 打印 ["John", "Smith"] ["John", ""]
}

侦听响应式对象

使用侦听器来比较一个数组或对象的值,这些值是响应式的,要求它有一个由值构成的副本。

const numbers = reactive([1, 2, 3, 4])

watch(
  () => [...numbers],
  (numbers, prevNumbers) => {
    console.log(numbers, prevNumbers)
  }
)

numbers.push(5) // logs: [1,2,3,4,5] [1,2,3,4]

尝试检查深度嵌套对象或数组中的 property 变化时,仍然需要 deep 选项设置为 true。

const state = reactive({ 
  id: 1,
  attributes: { 
    name: '',
  }
})

watch(
  () => state,
  (state, prevState) => {
    console.log('not deep', state.attributes.name, prevState.attributes.name)
  }
)

watch(
  () => state,
  (state, prevState) => {
    console.log('deep', state.attributes.name, prevState.attributes.name)
  },
  { deep: true }
)

state.attributes.name = 'Alex' // 日志: "deep" "Alex" "Alex"

然而,侦听一个响应式对象或数组将始终返回该对象的当前值和上一个状态值的引用。为了完全侦听深度嵌套的对象和数组,可能需要对值进行深拷贝。这可以通过诸如 lodash.cloneDeep 这样的实用工具来实现。

import _ from 'lodash'

const state = reactive({
  id: 1,
  attributes: {
    name: '',
  }
})

watch(
  () => _.cloneDeep(state),
  (state, prevState) => {
    console.log(state.attributes.name, prevState.attributes.name)
  }
)

state.attributes.name = 'Alex' // 日志: "Alex" ""

与watchEffect的区别

watchEffect 比较,watch 允许我们:

  • 懒执行副作用;
  • 更具体地说明什么状态应该触发侦听器重新运行;
  • 访问侦听状态变化前后的值。

watchEffect 共享的行为

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

与computed的区别

对于Computed:

  • 它⽀持缓存,只有依赖的数据发⽣了变化,才会重新计算

  • 不⽀持异步,当Computed中有异步操作时,⽆法监听数据的变化

  • computed的值会默认⾛缓存,计算属性是基于它们的响应式依赖进⾏缓存的,也就是基于data声 明过,或者⽗组件传递过来的props中的数据进⾏计算的。

  • 如果⼀个属性是由其他属性计算⽽来的,这个属性依赖其他的属性,⼀般会使⽤computed

  • 如果computed属性的属性值是函数,那么默认使⽤get⽅法,函数的返回值就是属性的属性值;在 computed中,属性有⼀个get⽅法和⼀个set⽅法,当数据发⽣变化时,会调⽤set⽅法。

对于Watch:

  • 它不⽀持缓存,数据变化时,它就会触发相应的操作
  • ⽀持异步监听 监听的函数接收两个参数,第⼀个参数是最新的值,第⼆个是变化之前的值
  • 当⼀个属性发⽣变化时,就需要执⾏相应的操作 监听数据必须是data中声明的或者⽗组件传递过来的props中的数据,当发⽣变化时,会触发其他 操作,函数有两个的参数:
    • immediate:副作用刷新时机
    • deep:深度监听,发现数据内部的变化,在复杂数据类型中使⽤,例如数组中的对象发⽣变 化。需要注意的是,deep⽆法监听到数组和对象内部的变化。为了完全侦听深度嵌套的对象和数组,可能需要对值进行深拷贝。这可以通过诸如 lodash.cloneDeep 这样的实用工具来实现。