第08章 计算属性 & 侦听器

650 阅读4分钟

一、计算属性

计算属性(computed)主要是基于已有数据,计算另一种数据。如果计算属性依赖的数据发生变化,那么会重新计算。计算属性不能被修改,且不能在计算属性中做异步操作。

1. 示例

我们先来看一组示例,100积分等于1元,现在实现输入积分数量,计算等价金额,保留两位小数。

<script setup lang="ts">
import { ref, computed } from 'vue';

const point = ref<string | number>('');
const amount = computed(() => {
  return (+point.value / 100).toFixed(2);
});
</script>

<template>
  <div>
    <span>输入积分:</span>
    <input type="number" v-model="point" placeholder="请输入积分数量" />
  </div>
  <div>
    <span>等价金额:</span>
    <span>&yen;{{ amount }}</span>
  </div>
</template>

看看效果:

computed_basic.gif

可以看到,当我们修改变量 point 的值时,会自动计算 amount 的值并刷新视图。

我们再来一个稍微复杂一点的示例,帮助大家理解计算属性。当用户输入身份证号码时,自动计算出出生年月:

<script setup lang="ts">
import { ref, computed } from 'vue';

const idNo = ref('');
const birth = computed(() => {
  if (idNo.value.length !== 18 || isNaN(Number(idNo.value))) {
    return '';
  }
  let t = idNo.value;
  let year = t.slice(6, 10);
  let month = t.slice(10, 12);
  let day = t.slice(12, 14);
  return `${year}-${month}-${day}`;
});
</script>

<template>
  <div>
    <span>身份证号:</span>
    <input type="text" v-model="idNo" placeholder="请输入身份证号" />
  </div>
  <div>
    <span>出生年月:</span>
    <span>{{ birth }}</span>
  </div>
</template>

查看效果:

computed_birth.gif

2. set & get

计算属性默认只有 getter ,不过在需要时你也可以提供一个 setter

<script setup lang="ts">
const count = ref(1);
const plusOne = computed({
  set: (v) => {
    console.log(v);
    count.value = v - 1;
  },
  get: () => {
    return count.value + 1;
  },
});
plusOne.value = 10;
</script>

3. 计算属性 vs 方法

我们可以通过方法实现计算属性的功能。从最终结果来说,这两种实现方式确实是完全相同的。然而,不同的是 计算属性将基于它们的响应依赖关系缓存。计算属性只会在相关响应式依赖发生改变时重新求值。比如在示例1中,只要 count 没有发生改变,多次访问 amount 时计算属性会立即返回之前的计算结果,而不必再次执行函数。相比之下,每当触发重新渲染时,调用方法将始终会再次执行函数。从性能上来讲,计算属性比方法更优。

我们为什么需要缓存?假设我们有一个性能开销比较大的计算属性 list,它需要遍历一个巨大的数组并做大量的计算。然后我们可能有其他的计算属性依赖于 list。如果没有缓存,我们将不可避免的多次执行 list 的 getter!如果你不希望有缓存,请用 method 来替代。

二、侦听器

虽然计算属性在大多数情况下更合适,但有时也需要一个自定义的侦听器。这就是为什么 Vue 通过 watch 选项提供了一个更通用的方法来响应数据的变化。当需要在数据变化时执行异步或开销较大的操作时,这个方式是最有用的。

1. watch

示例:

<script setup lang="ts">
import { ref, reactive, watch } from 'vue';

// refs
const name = ref('Muzili');
const age = ref(18);
const tel = ref('15999999999');
const otherName = reactive({
  firstName: '李',
  lastName: '杰',
});

// methods
const fullName = () => otherName.firstName + otherName.lastName;

// watchs
// 1. 监听指定属性
watch(name, (v, o) => {
  console.log(`新值:${v},旧值:${o}`);
});

// 2. 监听函数返回值
watch(fullName, (v) => {
  // 当otherName中的 firstName或者lastName发生变化时,都会进入这个函数
  console.log(`我叫${v}.`);
});
// 3. 监听多个属性变化
watch([age, tel], ([v1, v2], [o1, o2]) => {
  console.log(`age -> 新值:${v1} 旧值:${o1}`);
  console.log(`tel -> 新值:${v2} 旧值:${o2}`);
});
// 模拟修改数据
setTimeout(() => {
  name.value = '木子李';
  otherName.firstName = '张';
  age.value = 28;
  tel.value = '15888888888';
}, 1000);
</script>

<template></template>

输出:

新值:木子李,旧值:Muzili
我叫张杰.
age -> 新值:28 旧值:18
tel -> 新值:15888888888 旧值:15999999999

2. watchEffect

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

// refs
const page = ref(1);
const pageSize = ref(10);
// effects
watchEffect(() => {
  console.log(`请求数据 -> 页码:${page.value},每页条数:${pageSize.value}`);
});
// 模拟修改数据
setTimeout(() => {
  page.value = 2;
}, 1000);

2.1. 停止监听

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

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

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

// later
stop()

2.2. 清除副作用

有时副作用函数会执行一些异步的副作用,这些响应需要在其失效时清除 (即完成之前状态已改变了) 。所以侦听副作用传入的函数可以接收一个 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 链上的潜在错误。

2.3. 刷新时机

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

<script setup lang="ts">
import { ref, watchEffect } from 'vue';
const count = ref(0);
watchEffect(() => {
  console.log(count.value);
});

</script>

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

在这个例子中:

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

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

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

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

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

3. 侦听器 vs 计算属性

Vue 提供了一种更通用的方式来观察和响应当前活动的实例上的数据变动:侦听属性。当你有一些数据需要随着其它数据变动而变动时,watch 很容易被滥用——特别是如果你之前使用过 AngularJS。然而,通常更好的做法是使用计算属性而不是命令式的 watch 回调。