2.3 computed 计算属性

9 阅读10分钟

阶段1:自动算盘

目标:创建一个 computed(计算属性)

操作

// 导包
import { ref, computed } from 'vue'
// 操作代码
let count = ref(1)
// 定义一个计算属性:总是等于 count 的 2 倍
let double = computed(() => count.value * 2)

模板(<template>)里写

<div>原价: {{ count }}</div>
<div>双倍: {{ double }}</div>

结果: 页面显示 1 和 2

傻瓜口令:"computed 是算盘,专门算结果"


阶段2:它是活的

目标:修改依赖,computed 自动变

操作: 接上一阶段代码

function test() {
    count.value = 5 // 我只改了 count
}

模板(<template>)里写

<button @click="test">改为5</button>

结果: 点按钮,double 自动立刻变成了 10。 不用你手动去改 double,它自己盯着 count 呢。

傻瓜口令:"源头变了它就变,反应超灵敏"


阶段3:多头联动

目标:一个 computed 依赖多个数据

操作

// 导包
import { ref, computed } from 'vue'
// 操作代码
let x = ref(10)
let y = ref(20)

// 依赖 x 和 y
let he = computed(() => x.value + y.value)

模板(<template>)里写

{{ he }}

结果: 页面显示 30。 不管 x 变了,还是 y 变了,he 都会自动跟着变。

傻瓜口令:"多个数据一起算,谁变它都变"


阶段4:它会偷懒 (缓存)

目标:验证缓存特性(它很聪明,不会做无用功)

操作

import { ref, computed } from 'vue'
let count = ref(1)

let lazyNum = computed(() => {
    console.log('💦 辛苦计算中...')
    return count.value + 100
})

模板(<template>)里写

<!-- 用了三次 -->
{{ lazyNum }}
{{ lazyNum }}
{{ lazyNum }}

结果: 虽然页面用了3次,但控制台里只打印了1次“辛苦计算中”。 因为它发现 count 没变,就直接把上次算好的答案背出来了。

傻瓜口令:"没变就不算,直接报答案"


阶段5:身份揭秘

目标:验证 computed 返回的是 Ref

操作

import { ref, computed } from 'vue'
let count = ref(1)
let c = computed(() => count.value + 1)

// 在 script 里取值
console.log(c.value) // 必须点 .value

结果: computed 产生的也是一个 Ref 盒子,在 js 里操作记得拆包。

傻瓜口令:"computed 也是盒,取值点 value"


阶段6:禁止修改

目标:试图给 computed 赋值(演示错误)

操作

import { ref, computed } from 'vue'
let count = ref(1)
let c = computed(() => count.value + 1)

// ❌ 错误操作
c.value = 999 

结果: 控制台红色/黄色警告:computed value is readonly。 因为它是个“结果”,你不能强行修改结果,只能去修改“源头”(count)。

傻瓜口令:"它是计算器,不能写数字"


阶段7:能读也能写

目标:Writable Computed(可写计算属性)

操作

// 导包
import { ref, computed } from 'vue'
// 操作代码
let count = ref(1)

let double = computed({
    // 读的时候:乘2
    get: () => count.value * 2,
    // 写的时候:除2(反向控制 count)
    set: (val) => count.value = val / 2
})

// 以前只能读,现在可以写!
double.value = 10 

模板(<template>)里写

count: {{ count }}

结果: 把 double 改成 10,count 自动变成了 5

傻瓜口令:"要想可写加 set,反向控制源数据"


阶段8:绑定输入框

目标:v-model 绑定 computed

操作: 接上一阶段代码

// 必须是可写的 computed (有set)

模板(<template>)里写

输入双倍值: <input v-model="double">
<p>原始值 count: {{ count }}</p>

结果: 你输入 100,count 自动变 50。 你输入 20,count 自动变 10。

傻瓜口令:"输入框里双向绑,数据变动自动响"


阶段9:自动筛子

目标:列表过滤

操作

import { ref, computed } from 'vue'
let list = ref([10, 20, 5, 8, 100])

// 自动筛选所有大于 10 的数
let bigList = computed(() => {
    return list.value.filter(n => n > 10)
})

模板(<template>)里写

{{ bigList }}

结果: 显示 [20, 100]。 如果你往 list 里再加个数,bigList 也会自动更新。

傻瓜口令:"列表筛选不用急,computed 帮你理"


阶段10:自动排队

目标:列表排序

操作

import { ref, computed } from 'vue'
let list = ref([3, 1, 5, 2])

let sortedList = computed(() => {
    // [...list.value] 是为了复制一份,不要修改原数组
    return [...list.value].sort()
})

模板(<template>)里写

{{ sortedList }}

结果: 显示 [1, 2, 3, 5]

傻瓜口令:"自动排队很整齐,不用动手去搬运"


阶段11:连环计

目标:链式依赖(Computed 依赖 Computed)

操作

import { ref, computed } from 'vue'
let price = ref(100)
let num = ref(2)

// 总价 = 单价 * 数量 (200)
let total = computed(() => price.value * num.value)

// 打折价 = 总价 * 0.8 (160)
// 这里依赖了上面的 total
let discount = computed(() => total.value * 0.8)

模板(<template>)里写

打折后: {{ discount }}

结果: 改 price 或 num,discount 都会自动算对

傻瓜口令:"一环扣一环,全都不用管"


阶段12:变装大师

目标:返回样式对象 (Style)

操作

import { ref, computed } from 'vue'
let color = ref('red')
// 返回一个样式对象
let myStyle = computed(() => {
    return { color: color.value, fontSize: '20px' }
})

模板(<template>)里写

<div :style="myStyle">我是变色龙</div>
<button @click="color = 'blue'">变蓝</button>

结果: 点按钮,字变蓝

傻瓜口令:"样式装在盒子里,根据心情自动换"


阶段13:贴标签

目标:返回类名对象 (Class)

操作

import { ref, computed } from 'vue'
let isError = ref(false)

// 返回类名对象:key是类名,value是开关
let myClass = computed(() => {
    return { 
        'error-box': isError.value,
        'normal-box': !isError.value
    }
})

模板(<template>)里写

<div :class="myClass">盒子</div>
<button @click="isError = !isError">切换状态</button>

结果: class 会在这个 div 上自动切换

傻瓜口令:"标签自动贴上去,样式开关很随意"


阶段14:开关裁判

目标:返回布尔值(逻辑判断)

操作

import { ref, computed } from 'vue'
let score = ref(59)
// 自动判断是否及格
let isPass = computed(() => score.value >= 60)

模板(<template>)里写

{{ score }}分 - 及格了吗?{{ isPass }}

结果: 59分显示 false,改成 60分显示 true

傻瓜口令:"是错是对自动判,裁判就看 computed"


阶段15:赛跑比赛 (vs Method)

目标:对比 Method(方法)和 Computed(计算属性)的性能

操作

// 导包
import { ref, computed } from 'vue'
// 操作代码
let count = ref(1)

// 选手1:普通方法 (没缓存)
function getNumFunc() {
    console.log('🏃‍♂️ Method 跑了一次步')
    return count.value + 100
}

// 选手2:计算属性 (有缓存)
let getNumComp = computed(() => {
    console.log('🛌 Computed 翻了个身')
    return count.value + 100
})

模板(<template>)里写

方法: {{ getNumFunc() }} {{ getNumFunc() }}
计算: {{ getNumComp }}   {{ getNumComp }}

结果: 控制台里: Method 跑了两次(每次调用都跑)。 Computed 只动了一次(第二次直接背答案)。

傻瓜口令:"Method 每次跑断腿,Computed 躺着背答案"


阶段16:二手加工 (Props)

目标:基于 Reactive 对象(模拟 Props)进行二次计算

操作

import { reactive, computed } from 'vue'

// 模拟父组件传来的 props (它是 reactive 的)
const props = reactive({ width: 10, height: 20 })

// 我们不改 props,而是算一个新的面积
const area = computed(() => props.width * props.height)

模板(<template>)里写

尺寸: {{ props.width }} x {{ props.height }}
面积: {{ area }}
<button @click="props.width++">加宽</button>

结果: 点击加宽,面积自动更新。这是组件开发中最常用的模式。

傻瓜口令:"父传子,子再算,响应链路不断线"


阶段17:万能接口 (传参)

目标:实现带参数的计算(“我想算 id 为 5 的那个人的分”)

操作

import { ref, computed } from 'vue'
const scores = ref({ xiaoming: 90, xiaohong: 100 })

// 技巧:Computed 返回一个函数!
const getScore = computed(() => {
    return (name) => {
        return scores.value[name] + '分'
    }
})

模板(<template>)里写

小明: {{ getScore('xiaoming') }}
小红: {{ getScore('xiaohong') }}

结果: 显示对应分数。 注意:这种写法会失去具体值的缓存优势,但保留了响应性。

傻瓜口令:"返回一个函数,就能接收参数"


阶段18:侦探与算盘 (vs Watch)

目标:区分 Computed (算盘) 和 Watch (侦探) 的职责

操作

import { ref, computed, watch } from 'vue'
let money = ref(100)

// 算盘:负责算账 (无副作用)
let double = computed(() => money.value * 2)

// 侦探:负责报警 (有副作用:打印、发请求)
watch(money, (newVal) => {
    console.log('🚨 警告:金额变动!通知银行...', newVal)
})

模板(<template>)里写

资产: {{ double }}
<button @click="money += 100">发财</button>

结果: computed 默默更新界面,watch 在控制台大喊大叫。

傻瓜口令:"计算用 computed,做事用 watch"


阶段19:监控摄像头

目标:使用 onTrigger 调试 Computed 为什么变了

操作

import { ref, computed } from 'vue'
let count = ref(0)
let double = computed(() => count.value * 2, {
    // 调试钩子
    onTrigger(e) {
        console.log('谁动了我?', e)
    }
})

模板(<template>)里写

{{ double }}
<button @click="count++">动一下</button>

结果: 控制台会显示详细信息:哪个 key 变了,新旧值是多少。

傻瓜口令:"装上监控摄像头,谁动数据看个够"


阶段20:急性子 (No Async)

目标:演示 Computed 不能包含异步操作

操作

import { ref, computed } from 'vue'

// ❌ 错误示范:async 函数
let badComputer = computed(async () => {
    return 100 // 这里的 return 其实是 return Promise
})

模板(<template>)里写

结果: {{ badComputer }}

结果: 页面显示 [object Promise]。 因为 Computed 必须立刻、同步拿到结果。它等不了异步请求。 注:异步计算需配合 Watch 或 VueUse 的 computedAsync。

傻瓜口令:"计算不能这等那等,必须立刻返回"


阶段21:结构化输出

目标:返回对象,简化复杂的模板逻辑

操作

import { reactive, computed } from 'vue'
let user = reactive({ age: 20, vip: true, banned: false })

// ❌ 模板里不想写这么长: v-if="user.age >= 18 && user.vip && !user.banned"

// ✅ 封装到 computed
let canEnter = computed(() => {
    return user.age >= 18 && user.vip && !user.banned
})

模板(<template>)里写

<div v-if="canEnter">欢迎光临 VIP 俱乐部</div>
<div v-else>禁止入内</div>

结果: 模板极其清爽,逻辑清晰。

傻瓜口令:"复杂逻辑打包回,模板清爽不扎堆"


阶段22:禁止捣乱 (No Side Effects)

目标:不要在 computed 里做额外操作(副作用)

操作

// 导包
import { ref, computed } from 'vue'
// 操作代码
let count = ref(1)

let double = computed(() => {
    // ❌ 错误行为:这里不应该去修改 DOM 或者 alert
    document.title = '偷改标题' 
    return count.value * 2
})

后果: Computed 应该是一个“纯粹”的算盘,只负责接收输入,返回输出。 如果你在里面搞破坏(副作用),代码会变得极其难以预测和调试。

傻瓜口令:"只管算账,别搞破坏"


阶段23:禁止自吃 (No Mutation)

目标:不要修改依赖项(死循环陷阱)

操作

import { ref, computed } from 'vue'
let num = ref(1)

// ❌ 危险操作
let danger = computed(() => {
    // 我依赖 num,我又改了 num
    num.value++ 
    return num.value
})

模板(<template>)里写

{{ danger }}

结果: 控制台报错:Maximum recursive updates exceeded(递归更新次数过多)。 因为:计算 -> 改num -> 触发计算 -> 改num -> 触发计算 ... 💥

傻瓜口令:"算账别改账,小心死循环"


阶段24:只认熟人 (Non-reactive)

目标:识别非响应式陷阱

操作

import { computed } from 'vue'

// ❌ Date.now() 不是响应式的
let now = computed(() => {
    return Date.now()
})

模板(<template>)里写

当前时间戳: {{ now }}
<button @click="">刷新没用</button>

结果: 无论怎么点按钮,这个时间戳永远不会变。 因为 computed 发现里面没有它认识的 reactive/ref 朋友,所以它决定永远不重新算。

傻瓜口令:"外人它不理,只认响应式"


阶段25:势利眼 (Conditional)

目标:理解动态依赖收集(没用到的不收集)

操作

import { ref, computed } from 'vue'
let open = ref(false)
let msg = ref('秘密信息')

let content = computed(() => {
    // 如果 open 是 false,Vue 根本不会去读 msg.value
    // 所以 msg 变了也不会触发重算(节省性能)
    return open.value ? msg.value : '请先打开'
})

模板(<template>)里写

{{ content }}

结果open 为 false 时,你改 msg 改出花来,content 并不会重新计算(不会打印日志)。只有当你需要用到 msg 时,它才会理你。

傻瓜口令:"没用到的数据,变了也不算"


阶段26:别摸空气 (DOM Access)

目标:避免在 computed 访问未挂载的 DOM

操作

import { ref, computed, onMounted } from 'vue'
const box = ref(null)

const width = computed(() => {
    // 第一次执行时,DOM 还没画上去,box.value 是 null
    // ❌ 直接 box.value.offsetWidth 会报错
    // ✅ 必须判空
    return box.value?.offsetWidth || 0
})

模板(<template>)里写

<div ref="box" style="width: 100px">盒子</div>
宽度: {{ width }}

结果: 加上 ?. 保护后,先显示 0,挂载后变成 100。无需报错。

傻瓜口令:"界面没画好,千万别去摸"


阶段27:不要太累 (Performance)

目标:避免耗时计算

原理: Computed 是同步的。如果你的计算公式里面写了一个“循环一亿次”,那么每次数据变动,你的页面就会卡顿一下。 解决方法:如果是极其庞大的计算,考虑 WebWorker 或 优化算法。

傻瓜口令:"计算太复杂,页面会卡吧"


阶段28:异步替身 (Solutions)

目标:解决异步需求(Computed 做不到的事)

场景: “我想根据 userId 变化,去服务器 API 获取用户信息。” Computed 做不到(不支持 async)。

正确做法: 使用 watchwatchEffect

watch(userId, async (newId) => {
    userInfo.value = await fetchUser(newId) // 这里是异步
})

傻瓜口令:"想要异步算,出门找 Watch"


阶段29:Computed 终极守则

目标:三条铁律

  1. 纯粹 (Pure): 别改 DOM,别发请求,别改依赖。
  2. 同步 (Sync): 必须立刻返回,不能 await。
  3. 响应式 (Reactive): 必须依赖 Ref/Reactive,否则不更新。

傻瓜口令:"纯粹、同步、响应式,三条铁律"