2.2 计算属性与侦听器 本节摘要:模板里放不下复杂逻辑,Vue 给出两个出口——计算属性解决"派生值"(带缓存的依赖驱动计算),侦听器解决"副作用"(数据变化时执行动作)。本节拆解计算属性缓存的实现机制、两者的适用边界,以及 nextTick 与异步更新队列的时序关系。 学习目标 阅读完本节,你应当能够: 解释计算属性的缓存何时命中、何时失效,并用代码验证; 在"方法、计算属性、侦听器"三者之间按需求正确选型; 写出带 getter/setter 的双向计算属性,以及侦听器中 immediate 与 deep 的取舍; 描述异步更新队列的时序,说明 nextTick 为什么能拿到更新后的 DOM。
本节摘要:模板里放不下复杂逻辑,Vue 给出两个出口——计算属性解决"派生值"(带缓存的依赖驱动计算),侦听器解决"副作用"(数据变化时执行动作)。本节拆解计算属性缓存的实现机制、两者的适用边界,以及 nextTick 与异步更新队列的时序关系。
阅读完本节,你应当能够:
假设要显示购物车总价:
items: [ { name: '书', price: 59, count: 2 }, { name: '笔', price: 12, count: 5 } ]
把求和表达式直接塞进模板 {{ items.reduce((s, i) => s + i.price * i.count, 0) }},能用,但每次渲染都重算一遍,且模板可读性崩塌。换个思路放进 methods?也还能用,但方法调用没有缓存——模板里任何无关数据更新引起的重渲染,都会重新执行求和。
计算属性的价值正在这里:结果被缓存,依赖不变就不重算。
// Vue 2 computed: { total() { console.log('total 重算了'); return this.items.reduce((s, i) => s + i.price * i.count, 0); } }
运行上面的例子,控制台只在首次渲染和 items 变化时打印"total 重算了";而把同样的逻辑放进 methods 并在模板里调用,任何无关更新(比如改一个时间戳字符串)都会让它重算。这个差异不是玄学,是机制。
回到上一节的依赖模型。计算属性本质上是一个"计算 Watcher":
也就是说缓存的失效是惰性的:没人读,脏了也不算。若某个计算属性在当前模板分支中已不被引用(v-if 切走了),它依赖的数据再怎么变,都不产生计算开销。
Vue 3 中用 ref 或 reactive 的 getter 表达同样的事:
import { computed, ref } from 'vue'; const items = ref([]); const total = computed(() => items.value.reduce((s, i) => s + i.price * i.count, 0) );
计算属性默认只有 getter,但 fullName 这类双向场景需要 setter:
computed: { fullName: { get() { return this.firstName + ' ' + this.lastName; }, set(val) { const parts = val.split(' '); this.firstName = parts[0]; this.lastName = parts[1] || ''; } } }
配合 v-model 即可实现"显示时拼合、输入时拆分"。
一个常见反例:用侦听器拼接 fullName。
// 反例:能用,但啰嗦且命令式 watch: { firstName(val) { this.fullName = val + ' ' + this.lastName; }, lastName(val) { this.fullName = this.firstName + ' ' + val; } }
两条侦听、两份拼接逻辑,还要处理初始值(得加 immediate)。派生值的需求用计算属性表达,代码量减半且不可能出现两条分支不同步的 bug。
什么时候轮到 watch?当数据变化要触发的是渲染之外的动作:发请求、操作 localStorage、开定时器、联动图表库。
watch: { keyword(newVal, oldVal) { // 典型副作用:搜索请求 this.debouncedSearch(newVal); } }
两个高频参数要会取舍:
Vue 3 的写法更直观:
import { watch, watchEffect } from 'vue'; watch(keyword, (newVal, oldVal) => { debouncedSearch(newVal); }, { immediate: true }); // watchEffect:立即执行并自动收集回调里用到的依赖,无需指明监听目标 watchEffect(() => { document.title = keyword.value; });
三者选型可以压缩成一张决策表:
| 需求 | 选什么 | 理由 |
|---|---|---|
| 由现有数据算出新值 | computed | 缓存 + 声明式,杜绝不同步 |
| 数据变化后执行动作 | watch | 副作用与渲染解耦 |
| 模板里的简单格式化 | 表达式 | 三元、取反这个量级不值得抽出去 |
| 立即执行并自动追踪依赖 | watchEffect | 少写监听源,适合简单联动 |
看一个真实困惑:
this.message = '新内容'; console.log(document.querySelector('#msg').textContent); // 还是旧内容!
原因在第 1 章末尾埋过伏笔:同一轮同步代码里的多次数据变更会被调度器合并,DOM 更新被推迟到当前微任务队列的末尾。想拿到更新后的 DOM,用 nextTick 把读取动作排到更新之后:
this.message = '新内容'; this.$nextTick(() => { console.log(document.querySelector('#msg').textContent); // 新内容 });
这个设计的收益是性能:循环里改一百次数据只触发一次渲染。代价是心智负担——"改完数据立刻读 DOM"这个直觉动作失效了。典型受害场景是"更新后聚焦输入框""根据内容高度算位置",这些都必须包进 nextTick。Vue 3 里对应 import { nextTick } from 'vue',机制完全相同。
数据层的第一站讲完了。但渲染函数从哪来?下一章往上游走:你写的模板字符串是怎么变成可执行代码的。