嘿,我是 Agnes。今天咱们不聊那些枯燥的定义,来聊聊 Vue 里一个既优雅又容易踩坑的“小可爱”——计算属性(Computed Properties)。特别是当多个计算属性之间需要互相“通气”、联动,同时还要兼顾性能优化时,你该怎么写才既漂亮又高效?
说实话,很多刚入门 Vue 的朋友,看到数据要联动,第一反应是 watch,甚至直接在 methods 里写逻辑。但如果你真的用好了 computed,你会发现代码变得像散文一样流畅,而且性能还炸裂。咱们今天就把这个事儿掰开了、揉碎了讲清楚。
一、为什么是计算属性?先别急着用 Watch
在深入“相互引用”之前,我得先说服你:大多数时候,computed 比 watch 更合适。
想象一下,你在做一个电商购物车。商品数量变了,总价要变;单价变了,总价也要变;优惠券变了,最后支付金额还要变。
如果你用 watch,你得写:
- watch 数量 → 更新总价
- watch 单价 → 更新总价
- watch 优惠券 → 更新最终金额
这代码写得像蜘蛛网,而且如果两个 watch 同时触发,你还要考虑执行顺序,怕不怕?
但用 computed 呢?
computed: {
total() {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
discount() {
return this.total > 100 ? this.total * 0.9 : this.total
},
finalPrice() {
return this.discount
}
}
看,finalPrice 依赖 discount,discount 依赖 total。你只需要定义它们的关系,Vue 会自动帮你追踪。当 items 变化时,Vue 会顺着这条链式依赖重新计算,而且——只计算必要的部分。
这就是“数据联动”的第一层含义:声明式地表达依赖,而不是命令式地触发更新。
二、计算属性相互引用的“雷区”
听起来很美好,对吧?但这里有个巨大的坑:循环依赖。
Vue 的计算属性系统是基于依赖追踪的。如果一个计算属性 A 依赖 B,B 又依赖 A,Vue 就懵了,直接报错或者陷入死循环。
// ❌ 绝对不要这样写!
computed: {
a() {
return this.b + 1
},
b() {
return this.a - 1 // 哦豁,死循环警告
}
}
Vue 在开发模式下会直接抛出错误:Maximum recursive updates exceeded。在生产环境,虽然可能不报错,但你的数据会乱得一塌糊涂。
所以,相互引用必须是单向的、有向无环图(DAG)结构。A 依赖 B,B 依赖 C,C 依赖原始数据,不能回头。
三、实战场景:用户列表的筛选、排序与分页联动
光说不练假把式。咱们来看一个真实项目里常见的场景:一个复杂的用户列表,支持多字段筛选、排序和分页。
假设我们有以下需求:
- 用户可以选择“姓名”或“邮箱”进行模糊搜索
- 用户可以按“注册日期”或“积分”排序
- 支持分页显示
3.1 数据结构设计
首先,我们的原始数据是 rawUsers,这是一个包含所有用户信息的数组。
data() {
return {
rawUsers: [
{ id: 1, name: 'Alice', email: 'alice@example.com', joinedAt: '2023-01-15', score: 1200 },
{ id: 2, name: 'Bob', email: 'bob@example.com', joinedAt: '2023-03-20', score: 850 },
{ id: 3, name: 'Charlie', email: 'charlie@example.com', joinedAt: '2022-11-10', score: 2100 },
// ... 假设有很多数据
],
// 用户的交互状态
searchQuery: '',
searchField: 'name', // 'name' | 'email'
sortBy: 'joinedAt',
sortOrder: 'desc', // 'asc' | 'desc'
currentPage: 1,
pageSize: 10
}
}
3.2 第一层:过滤(Filter)
computed: {
// 第一步:根据搜索条件过滤
filteredUsers() {
if (!this.searchQuery) return this.rawUsers
const query = this.searchQuery.toLowerCase().trim()
return this.rawUsers.filter(user => {
const fieldValue = user[this.searchField]?.toString().toLowerCase() || ''
return fieldValue.includes(query)
})
}
}
这里 filteredUsers 依赖 rawUsers、searchQuery 和 searchField。只要这三个任意一个变化,它都会重新计算。
3.3 第二层:排序(Sort)—— 依赖过滤结果
computed: {
filteredUsers() {
// ... 同上
},
// 第二步:在过滤后的结果上排序
sortedUsers() {
const sorted = [...this.filteredUsers] // 注意:一定要展开复制,不要直接修改原始数组
sorted.sort((a, b) => {
let valA = a[this.sortBy]
let valB = b[this.sortBy]
// 处理日期字符串比较
if (this.sortBy === 'joinedAt') {
valA = new Date(valA)
valB = new Date(valB)
}
// 处理字符串比较
if (typeof valA === 'string') {
valA = valA.toLowerCase()
valB = valB.toLowerCase()
}
if (valA < valB) return this.sortOrder === 'asc' ? -1 : 1
if (valA > valB) return this.sortOrder === 'asc' ? 1 : -1
return 0
})
return sorted
}
}
关键点:
sortedUsers依赖filteredUsers:这就是“相互引用”(更准确说是链式依赖)。只有过滤后的数据才会被排序。- 展开复制
[...this.filteredUsers]:sort()方法会原地修改数组,如果我们直接用this.filteredUsers.sort(),会破坏 Vue 的响应式追踪,也可能影响其他依赖filteredUsers的地方。
3.4 第三层:分页(Pagination)—— 依赖排序结果
computed: {
// ... filteredUsers, sortedUsers 同上
// 第三步:分页
paginatedUsers() {
const start = (this.currentPage - 1) * this.pageSize
const end = start + this.pageSize
return this.sortedUsers.slice(start, end)
},
// 总页数,依赖排序后的总数
totalPages() {
return Math.ceil(this.sortedUsers.length / this.pageSize)
}
}
paginatedUsers 依赖 sortedUsers,totalPages 也依赖 sortedUsers。它们之间没有直接依赖,但都通过 sortedUsers 间接关联。
3.5 第四层:衍生状态—— 多个计算属性共享基础
现在,假设我们需要显示一些统计信息:
- 当前页显示的用户数
- 总用户数(过滤后)
- 平均积分(过滤后)
computed: {
// ... 前面的所有计算属性
// 这些“叶子节点”计算属性互相独立,但都依赖上游
displayCount() {
return this.paginatedUsers.length
},
filteredCount() {
return this.sortedUsers.length
},
averageScore() {
if (this.filteredUsers.length === 0) return 0
const sum = this.filteredUsers.reduce((acc, user) => acc + user.score, 0)
return (sum / this.filteredUsers.length).toFixed(2)
},
// 一个更有用的:是否有更多页
hasNextPage() {
return this.currentPage < this.totalPages
},
hasPrevPage() {
return this.currentPage > 1
}
}
注意 averageScore 依赖 filteredUsers(不是 sortedUsers),因为平均积分应该基于所有过滤后的结果,而不是当前页的。而 hasNextPage 和 hasPrevPage 依赖 totalPages,totalPages 又依赖 sortedUsers。
四、缓存优化的秘密:Vue 是如何做到“按需重算”的
这是最关键的部分。很多人以为 computed 每次访问都会重新计算,这是错误的。
Vue 的计算属性有缓存。只有当它的响应式依赖发生变化时,它才会重新计算。否则,直接返回缓存的值。
4.1 依赖追踪机制
当你在模板中使用 {{ paginatedUsers }} 时:
- Vue 发现
paginatedUsers依赖sortedUsers sortedUsers依赖filteredUsersfilteredUsers依赖rawUsers、searchQuery、searchField
Vue 会建立一个依赖树。只要叶子节点(原始数据)没有变化,整棵树都不会重算。
4.2 什么时候会触发重算?
// 场景1:改变搜索关键词
this.searchQuery = 'alice'
// 触发链:searchQuery -> filteredUsers -> sortedUsers -> paginatedUsers, totalPages
// displayCount 也会重算,因为它依赖 paginatedUsers
// 但 averageScore 不会重算,因为它只依赖 filteredUsers,而 filteredUsers 的过滤结果没变(还是只有 Alice)
// 场景2:改变排序方式
this.sortBy = 'score'
// 触发链:sortBy -> sortedUsers -> paginatedUsers, totalPages, hasNextPage, hasPrevPage
// filteredUsers 不会重算,因为搜索条件没变
// averageScore 也不会重算
// 场景3:切换页码
this.currentPage = 2
// 触发链:currentPage -> paginatedUsers, displayCount, hasPrevPage
// filteredUsers、sortedUsers、averageScore 都不会重算!
看,averageScore 在整个过程中只计算了一次(当 searchQuery 改变时),之后无论你怎么翻页、排序,它都不动。这就是缓存的威力。
4.3 如何验证缓存是否生效?
你可以加个 console.log 来观察:
computed: {
filteredUsers() {
console.log('重新计算 filteredUsers')
// ...
},
sortedUsers() {
console.log('重新计算 sortedUsers')
// ...
},
paginatedUsers() {
console.log('重新计算 paginatedUsers')
// ...
}
}
然后你在控制台切换页码,你会发现只有 paginatedUsers 的 log 会打印,其他两个不会。这证明了缓存机制的工作方式。
五、高级技巧:惰性计算与计算属性的 setter
5.1 惰性计算:用方法替代计算属性
有时候,一个计算属性非常昂贵,但你并不总是需要它。比如,上面的 averageScore,如果你在一万个用户的列表中,每次过滤都要重新计算平均值,可能会卡顿。
这时,你可以把它变成方法,只在需要时调用:
methods: {
// 手动调用,惰性计算
getAverageScore() {
if (this._averageScoreCache && this._lastFilteredCount === this.filteredUsers.length) {
return this._averageScoreCache
}
if (this.filteredUsers.length === 0) return 0
const sum = this.filteredUsers.reduce((acc, user) => acc + user.score, 0)
this._averageScoreCache = (sum / this.filteredUsers.length).toFixed(2)
this._lastFilteredCount = this.filteredUsers.length
return this._averageScoreCache
}
}
但注意,这需要你自己管理缓存失效。对于大多数场景,直接用 computed 就够了,Vue 的缓存机制已经足够聪明。
5.2 计算属性的 setter:双向绑定的优雅实现
Vue 的计算属性默认是只读的(getter)。但你可以定义 setter,实现双向绑定:
computed: {
fullName: {
// getter
get() {
return `${this.firstName} ${this.lastName}`
},
// setter
set(newValue) {
const names = newValue.split(' ')
this.firstName = names[0] || ''
this.lastName = names.slice(1).join(' ') || ''
}
}
}
这样,你可以这样用:
<input v-model="fullName" />
当用户输入 “John Doe” 时,fullName 的 setter 会被调用,自动拆分并更新 firstName 和 lastName。这在处理组合字段时非常有用。
六、性能陷阱:避免在计算属性中执行副作用
这是一个常见的错误。计算属性应该是纯函数:相同的输入,永远得到相同的输出,且不产生副作用。
// ❌ 错误示范
computed: {
expensiveData() {
// 不要在这里发起 API 请求!
fetch('/api/data').then(res => res.json()).then(data => {
this.localData = data // 这会触发响应式更新,可能导致无限循环
})
return someLocalValue
}
}
正确的做法是在 mounted 或 watch 中发起请求,然后将结果存入 data,再由计算属性处理:
// ✅ 正确示范
data() {
return {
rawApiData: null
}
},
async mounted() {
const res = await fetch('/api/data')
this.rawApiData = await res.json()
},
computed: {
processedData() {
if (!this.rawApiData) return []
// 纯数据处理
return this.rawApiData.map(item => ({
...item,
processed: true
}))
}
}
七、实际项目中的完整代码示例
让我给你看一个更完整的、可直接运行的组件:
”`vue
<div class="controls">
<input
v-model="searchQuery"
placeholder="搜索姓名或邮箱..."
class="search-input"
/>
<select v-model="searchField" class="field-select">
<option value="name">姓名</option>
<option value="email">邮箱</option>
</select>
<select v-model="sortBy" class="sort-select">
<option value="joinedAt">注册日期</option>
<option value="score">积分</option>
</select>
<button @click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'" class="sort-btn">
{{ sortOrder === 'asc' ? '↑' : '↓' }}
</button>
</div>
<div class="stats">
<span>显示 {{ displayCount }} / {{ filteredCount }} 条</span>
<span>平均积分:{{ averageScore }}</span>
</div>
<table class="user-table">
<thead>
<tr>
<th>姓名</th>
<th>邮箱</th>
<th>注册日期</th>
<th>积分</th>
</tr>
</thead>
<tbody>
<tr v-for="user in paginatedUsers" :key="user.id">
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.joinedAt }}</td>
<td>{{ user.score }}</td>
</tr>
</tbody>
</table>
<div class="pagination">
<button
:disabled="!hasPrevPage"
@click="currentPage--"
class="page-btn"
>上一页</button>
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
<button
:disabled="!hasNextPage"
@click="currentPage++"
class="page-btn"
>下一页</button>
</div>
