8.计算属性
基础示例
模板中的表达式虽然方便,但也只能用来做简单的操作。如果在模板中写太多逻辑,会让模板变得臃肿,难以维护。比如说,我们有这样一个包含嵌套数组的对象:
js
javascript
const author = reactive({
name: 'John Doe',
books: [
'Vue 2 - Advanced Guide',
'Vue 3 - Basic Guide',
'Vue 4 - The Mystery'
]
})
我们想根据 author 是否已有一些书籍来展示不同的信息:
template
javascript
<p>Has published books:</p>
<span>{{ author.books.length > 0 ? 'Yes' : 'No' }}</span>
这里的模板看起来有些复杂。我们必须认真看好一会儿才能明白它的计算依赖于 author.books。更重要的是,如果在模板中需要不止一次这样的计算,我们可不想将这样的代码在模板里重复好多遍。
因此我们推荐使用计算属性来描述依赖响应式状态的复杂逻辑。这是重构后的示例:
vue
javascript
<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>
我们在这里定义了一个计算属性 publishedBooksMessage。computed() 方法期望接收一个 getter 函数,返回值为一个计算属性 ref 。和其他一般的 ref 类似,你可以通过 publishedBooksMessage.value 访问计算结果。计算属性 ref 也会在模板中自动解包,因此在模板表达式中引用时无需添加 .value。
Vue 的计算属性会自动追踪响应式依赖。它会检测到 publishedBooksMessage 依赖于 author.books,所以当 author.books 改变时,任何依赖于 publishedBooksMessage 的绑定都会同时更新。
计算属性缓存 vs 方法
你可能注意到我们在表达式中像这样调用一个函数也会获得和计算属性相同的结果:
template
javascript
<p>{{ calculateBooksMessage() }}</p>
js
javascript
// 组件中
function calculateBooksMessage() {
return author.books.length > 0 ? 'Yes' : 'No'
}
若我们将同样的函数定义为一个方法而不是计算属性,两种方式在结果上确实是完全相同的,然而,不同之处在于计算属性值会基于其响应式依赖被缓存 。一个计算属性仅会在其响应式依赖更新时才重新计算。这意味着只要 author.books 不改变,无论多少次访问 publishedBooksMessage 都会立即返回先前的计算结果,而不用重复执行 getter 函数。
这也解释了为什么下面的计算属性永远不会更新,因为 Date.now() 并不是一个响应式依赖:
js
javascript
const now = computed(() => Date.now())
相比之下,方法调用总是会在重渲染发生时再次执行函数。
为什么需要缓存呢?想象一下我们有一个非常耗性能的计算属性 list,需要循环一个巨大的数组并做许多计算逻辑,并且可能也有其他计算属性依赖于 list。没有缓存的话,我们会重复执行非常多次 list 的 getter,然而这实际上没有必要!如果你确定不需要缓存,那么也可以使用方法调用。
Vue 3的响应式系统没有一个固定的"个数",它更像一个由几个核心部分构成的、精巧协作的机制。要理解它,与其数"有几个",不如看它是"怎么工作的"。
这套机制主要围绕 "依赖"的收集与触发来运转,核心角色有三个:
🧩 响应式的三个核心角色
| 角色 | 它是谁? | 核心职责 |
|---|---|---|
| 🤔 数据 (Reactive Data) | 通过 reactive() 或 ref() 创建的对象 |
被监听的目标 。它本身是普通的JavaScript对象,但被Vue用Proxy"包装"了一层-1-8。当它的属性被读取或修改时,都会触发内部机制。 |
| 📋 依赖 (Dependency / Effect) | 所有依赖于数据的"副作用" | 等待执行的"任务清单" 。它可以是组件的渲染函数 、computed计算属性 、watch侦听器 ,或者是开发者用watchEffect创建的函数-4-5。在Vue内部,它们都被抽象为ReactiveEffect这个类的实例-5-6。 |
| 🗺️ 依赖收集器 (TargetMap) | 一个用来存储依赖关系的"笔记本" | 记录"谁依赖了谁" 。它是一个全局的WeakMap,结构大致如下: WeakMap{ 目标对象: Map{ 属性名: Set[依赖1, 依赖2] } }-5-7。它清晰地记录了每个对象的每个属性,都被哪些"依赖"所使用。 |
⚙️ 工作流程
这三个角色通过"收集"和"触发"两个步骤形成一个闭环:
- 阶段一:收集依赖
当一个副作用函数(比如组件的渲染)开始执行时,它会读取响应式数据。这时,响应式数据的Proxy拦截器(get)会调用内部的track函数-1-4。track函数就像图书管理员,它负责在"依赖收集器"(targetMap)里找到当前正在运行的副作用(即"依赖"),并把这条依赖关系记录下来-5。 - 阶段二:触发更新
当响应式数据的值发生变化时(比如用户点击按钮),Proxy拦截器(set)会调用内部的trigger函数-1-4。trigger函数会根据targetMap里的记录,找到所有依赖于这个属性的"依赖"们,然后依次去执行它们,从而完成视图更新或其它副作用-5-7。
💡 这和Vue 2有什么区别?
理解Vue 3的响应式,关键在于和Vue 2的对比,这能帮你更好地理解为什么它被称为"新"的:
| 对比维度 | Vue 2 的响应式 | Vue 3 的响应式 |
|---|---|---|
| 核心实现 | 基于 Object.defineProperty 递归遍历对象属性-1-8。 |
基于 Proxy 对整个对象进行代理-1-8。 |
| 动态属性 | 无法监听 对象属性的新增和删除 ,需要使用 Vue.set 和 Vue.delete 来处理-8。 |
天然支持 动态增删属性-4-8。 |
| 数组监听 | 无法通过索引 直接修改数组或修改length来触发响应式,需要对数组方法进行hack-4-8。 |
完美支持 数组的所有变更方式,包括通过索引赋值和修改length-4-8。 |
| 依赖追踪 | 组件渲染时静态地收集依赖。 | 通过Proxy在运行时动态地、精确地 收集依赖-5。 |
简单来说:Date.now() 不是响应式依赖,因为它和Vue的响应式系统"没有建立任何连接"。 它只是一个普通的JavaScript表达式,Vue无法知道它"变了",也不知道什么时候需要重新运行依赖它的代码。
让我们从Vue 3响应式系统的三个核心角色(数据、依赖、依赖收集器)的角度,来拆解一下为什么 Date.now() 会被排除在外:
- 它不是被"代理"的数据
响应式系统的前提是数据本身是响应式的 。Vue只能追踪那些经过 reactive() 或 ref() 包装过的数据。
Date.now()是什么?
它是在代码执行的那一瞬间 ,从系统时钟读取的一个静态的数字 。例如const time = Date.now(),此时time就是一个普通数字常量,比如1712323456789。- 为什么不行?
这个数字并没有被ref(1712323456789)包装过,也没有作为属性挂载在一个reactive()对象上。它对于Vue来说,就像一个普通的const a = 1一样,是一个不会变化的原始值。
- 它无法触发"依赖收集"的时机
回想一下响应式的工作流程:当副作用(如渲染函数)执行时,如果它读取了响应式数据,会触发 Proxy 的 get 拦截器,从而执行 track 函数把当前的副作用记录下来。
- 当代码执行
Date.now()时:
这只是一个普通的函数调用,返回值是一个数字。Vue 的Proxy拦截器根本没有被触发。Vue 只知道"有人读了一个响应式对象的属性",但不知道"有人获取了当前时间"。 - 结果:
依赖收集器(targetMap)里没有任何记录表明"这个副作用依赖了当前时间"。
- 它无法"通知"依赖更新
响应式系统另一个关键点是"变更通知"。当响应式数据变化时(通过 Proxy 的 set 拦截器),Vue 会去 targetMap 里找到对应的依赖并执行它们。
- 时间的变化:
时间是持续流动的,但它的变化不是由Vue触发的 。时钟的每次跳动是操作系统层面的事件,并没有通过 JavaScript 代码去"修改"某个响应式数据(例如执行time.value = newTime)。既然没有set操作,Vue 就不会去遍历依赖列表,自然也就不会更新界面。
一个直观的对比
为了帮你更好地理解,可以看看下面这段代码:
javascript
复制下载
javascript
import { ref, watchEffect } from 'vue'
// 1. 静态时间戳 - 普通 JavaScript 数字
const staticTime = Date.now(); // 例如: 1774537416548 (数字类型)
// 2. 响应式时间戳 - Vue 的 ref 对象
const reactiveTime = ref(Date.now()); // 返回 { value: 1774537416548 }
// 3. watchEffect - 自动追踪响应式依赖
watchEffect(() => {
console.log('静态时间' + staticTime); // 只在初始化时打印一次
console.log('响应式时间' + reactiveTime.value) // reactiveTime 变化时重新执行
})
// 4. 定时器 - 每秒更新响应式时间
setInterval(() => {
reactiveTime.value = Date.now(); // 更新 ref 的 value 属性
}, 1000)
总结
Date.now() 之所以不是响应式依赖,是因为:
- 它本身不是响应式数据 :它是一个原始值,没有被
ref或reactive包装。 - 没有依赖收集 :读取它时不会触发
Proxy的get拦截器,Vue 无法建立依赖关系。 - 没有变更通知 :时间流逝不会触发
Proxy的set拦截器,Vue 不知道"数据变了"。
如果你想在Vue中创建一个会随时间自动更新的响应式数据,你需要把它包装成 ref,然后手动更新它(例如用 setInterval 修改 ref 的值)。这样,Vue的响应式系统就能感知到变化,并更新所有依赖于它的地方。