码上问学问代码之理,学编程之道

第 3 章 · 计算属性与侦听器

上一章你能把数据放到页面上了,但一旦要在模板里做点「加工」——算比率、格式化数字、挑出最热的那一条——表达式就会糊成一片。这一章给你两件专用工具:computed 负责「算出一个值」,watch 负责「数据变了去做一件事」。学完你就知道什么时候该用哪个,以及为什么用错会让代码越写越乱。

为什么需要计算属性

先看看反面教材
先讲为什么把原始数据直接摆在页面上,大家都会。可真实的页面很少这么老实:作品卡片要显示「点赞率 4.7%」而不是 58 / 1240;浏览量要显示成「1.2k」而不是 1240;书名要按「字母序」排好再列出来。
这些都是「算出来的数据」——它本身不存在于任何变量里,但每次用都要算一遍。放在模板里算会发生什么?看下面这个例子。
动手做把三个「算出来的值」直接写进模板,先跑起来,再体会问题在哪。
src/components/ArticleCardBad.vue(反面教材)vue
<script setup>
import { ref } from 'vue'

const article = ref({
  title: '念奴娇·赤壁怀古',
  author: '苏轼',
  views: 1240,
  likes: 58
})
</script>

<template>
  <h3>{{ article.title }}</h3>
  <p>热度:{{ (article.likes / article.views * 100).toFixed(1) }}%</p>
  <p>热度等级:{{ article.likes / article.views > 0.05 ? '很受欢迎' : '一般' }}</p>
  <p>浏览量:{{ article.views >= 1000 ? (article.views / 1000).toFixed(1) + 'k' : article.views }}</p>
  <p>再显示一次热度:{{ (article.likes / article.views * 100).toFixed(1) }}%</p>
</template>
页面效果组件渲染结果
运行结果: (页面能正常显示,功能上没问题) 念奴娇·赤壁怀古 热度:4.7% 热度等级:一般 浏览量:1.2k 再显示一次热度:4.7% -- 但代码里出现了三个问题: -- ① 模板被算式塞满,「热度」到底怎么算的,得盯着括号看半天 -- ② 同一个算式抄了两遍,改一处就得改两处,漏一处两边就不一致 -- ③ 每一次重新渲染,这几个算式都要重头算一遍
讲透:这三个问题各是什么性质
  • 可读性:模板应该只描述「页面长什么样」,业务算法不该出现在这里。以后要改「热度用点赞除以浏览量」这个规则,你得在模板里到处找。
  • 重复:同一个公式写两遍,是 bug 的温床。今天改了第一处忘了第二处,页面上就会出现两个不一样的热度。
  • 重复计算:模板里每用一次表达式,渲染时就算一次。相关数据一变,整个组件重渲染,所有算式再算一遍。
  • 怎么解决:把「算出来的值」变成有名有姓、可复用、还能缓存的东西——这就是计算属性 computed。

computed 的用法

写成一个函数,用得像一个变量
动手做把上面那三段算式各自抽成一个 computed。写法很像写一个函数,但用的时候当普通变量用,不加括号。
src/components/ArticleCard.vuevue
<script setup>
import { ref, computed } from 'vue'

const article = ref({
  title: '念奴娇·赤壁怀古',
  author: '苏轼',
  views: 1240,
  likes: 58
})

// 计算属性:写成一个返回值的函数;模板里当普通变量用
const hotPercent = computed(() => {
  return (article.value.likes / article.value.views * 100).toFixed(1) + '%'
})

// 只有一句时,可以省掉大括号和 return
const hotLevel = computed(() =>
  article.value.likes / article.value.views > 0.05 ? '很受欢迎' : '一般'
)

const viewsText = computed(() => {
  const v = article.value.views
  return v >= 1000 ? (v / 1000).toFixed(1) + 'k' : String(v)
})
</script>

<template>
  <h3>{{ article.title }}</h3>
  <p>热度:{{ hotPercent }}({{ hotLevel }})</p>
  <p>浏览量:{{ viewsText }}</p>
  <p>复用同一个计算属性:{{ hotPercent }}</p>
</template>
页面效果组件渲染结果
运行结果: (页面显示的内容和反面教材完全一样) 念奴娇·赤壁怀古 热度:4.7%(一般) 浏览量:1.2k 复用同一个计算属性:4.7% -- 模板里只剩变量名,读起来就是一句人话 -- 把 views 改成 900,保存后自动变成: 浏览量:900 热度:6.4%(很受欢迎)
讲透:computed 的四个关键点
  • 接收一个 getter 函数:computed(() => 运算结果)。函数里用到哪些响应式数据,Vue 就自动记下来当作「依赖」,你不用手写依赖列表。
  • 返回的是一个「只读的 ref」。所以脚本里访问它要写 hotPercent.value,和 ref 一个规矩。
  • 但在模板里不用加 .value,而且是不加括号——{{ hotPercent }},不是 {{ hotPercent() }}。这一点和「方法」正好相反。
  • 它只负责「算」,不负责「做」。getter 里应该是纯计算:不要发请求、不要写 localStorage、不要改别的变量。要「做事情」用下一节的 watch。
  • 它就该「被算出来」:如果一个值的正确性依赖于「什么时候被更新」,那它不适合做 computed——那是 ref 的活。
避坑把计算属性当方法调用——模板里写 {{ hotPercent() }}——控制台会报 hotPercent is not a function。computed 拿到的是一个「值」,不是函数:模板里写名字(不加括号),脚本里写 .value。写方法时才需要括号,两者别混。

缓存:依赖不变就不重算

用 console.log 亲眼看一遍
computed 的缓存:依赖没变,就算页面重渲染也不重算——这是它和「函数调用」的本质区别方法调用:每次渲染都执行渲染① → 执行 filter+sort渲染② → 又执行一遍渲染③ → 又执行一遍……列表 1000 条 = 每次渲染算 1000 次computed:依赖不变就用缓存渲染① → 计算一次,存结果渲染② → 依赖没变,直接用缓存依赖变了 → 才重新计算用 console.log 写在 computed 里,亲眼看它跳过重算判据:带逻辑的展示值一律用 computed——又缓存又能像变量一样用
先讲为什么一个算法跑一次几百毫秒,页面上还用了三处,那重渲染一次就要等一秒。可很多时候数据根本没变——只是旁边一个无关的变量动了。重复算同一件事,是纯粹的浪费。
computed 的答案是「记住上次的结果」:它会记下自己依赖了谁,只有依赖真的变了才重新算;依赖没变,就直接把缓存的值还给你,函数一次都不执行。
动手做在 getter 里加一句 console.log,然后分别去点两个按钮,看日志什么时候出现、什么时候不出现。
src/components/CacheDemo.vuevue
<script setup>
import { ref, computed } from 'vue'

const count = ref(0)
const other = ref(0)

// 只要依赖(count)没变,这个函数就不会再次执行
const double = computed(() => {
  console.log('computed 执行了:count =', count.value)
  return count.value * 2
})

// 计算属性可以依赖另一个计算属性,形成「链式」
const quadruple = computed(() => double.value * 2)
</script>

<template>
  <p>count:{{ count }} double:{{ double }} quadruple:{{ quadruple }}</p>
  <p>other:{{ other }}</p>
  <button @click="count++">count +1</button>
  <button @click="other++">other +1</button>
</template>
页面效果组件渲染结果
运行结果: (页面刚打开,控制台打印一次) computed 执行了:count = 0 (点「count +1」) computed 执行了:count = 1 -- double 在模板里被用了两处(自己显示 + 喂给 quadruple), -- 但这句话只打印了一次 —— 说明它只算了一遍,后面直接读缓存 (点「other +1」) (控制台没有任何输出) -- other 变了会触发重新渲染,但 double 的依赖 count 没变, -- Vue 直接把上次的结果给它,getter 一次都没执行 (把 double 从 computed 换成普通函数 function double() {...}, 模板里改成 double()) 每点任意一个按钮,控制台都会打印一次 —— 函数没有缓存
讲透:缓存的两个附带好处
  • 一个渲染周期内只算一次。上面模板里 double 用了两处,第一次算完就把结果放在那儿,第二次直接读。
  • 无关数据变化时不算。other 变了会重渲染,但 double 的依赖没变,直接跳过。
  • 可以用它做「昂贵」的活:过滤、排序、求和、分组这类对整张列表的运算,放 computed 里最合适——数据没变就不会白算一遍。
  • 依赖是自动追踪的。getter 里用到 article.value.views,它就只盯 views;如果连 author 都没用到,作者改名它理都不理。别在 getter 里写「用到但不该依赖」的东西,那会造成多余的重算。

可写计算属性:getter 与 setter

一个开关控制一批值
先讲为什么收藏列表页上有一个「全选」复选框。它的值并不存在数据库里,也不存在任何一个变量里——它是「所有条目都已收藏」算出来的,这就是个典型的计算属性。
但用户还要能点它:勾上 = 所有条目都收藏,取消 = 全部取消。一个「算出来的值」,还得能反着写回去,这时就用带 setter 的可写计算属性。
动手做把 computed 的参数从「一个函数」换成「一个带 get / set 的对象」。
src/components/FavoriteAll.vuevue
<script setup>
import { ref, computed } from 'vue'

const items = ref([
  { id: 1, title: '静夜思', favorited: true },
  { id: 2, title: '春望', favorited: true },
  { id: 3, title: '陋室铭', favorited: false }
])

// 可写计算属性:get 负责「读」,set 负责「写」
const allFavorited = computed({
  get() {
    // 全选的前提是「至少有一条」,再判断是不是每一条都收藏了
    return items.value.length > 0 && items.value.every(i => i.favorited)
  },
  set(value) {
    // 写入时不是给 allFavorited 赋值,而是回头去改真正的源数据
    items.value.forEach(i => {
      i.favorited = value
    })
  }
})
</script>

<template>
  <label>
    <input type="checkbox" v-model="allFavorited" />全选收藏
  </label>
  <ul>
    <li v-for="i in items" :key="i.id">
      <input type="checkbox" v-model="i.favorited" />{{ i.title }}
    </li>
  </ul>
</template>
页面效果组件渲染结果
运行结果: (初始状态:第三条「陋室铭」没收藏) 全选框:未勾选 静夜思:已勾选 春望:已勾选 陋室铭:未勾选 (点「全选收藏」,set(true) 被调用,把三条都改成 true) 全选框:自动变成勾选 静夜思 / 春望 / 陋室铭:三条全部勾选 (再点掉「全选收藏」,set(false)) 三条列表项:全部取消勾选 -- 反过来,手动把三条都勾上,全选框也会自己变成勾选状态
讲透:set 里到底发生了什么
  • set 不是「保存这个值」,而是「反推出源数据该怎么改」。上面 set(true) 做的事是:把 items 里每一条的 favorited 都设成 true。
  • 源数据一变,get 立刻重跑,allFavorited 自然跟着变成新的结果——所以界面上那个复选框会自己同步。
  • 不写 set 就是只读的。computed(() => ...) 只传一个函数,等于只有 getter,这时候给它赋值会报错。
  • 别滥用。可写计算属性把「读」和「写」的路径搅在一起,用多了数据流会变得很难追。它真正合适的场景只有一类:用一个开关去控制一批值(全选、全部已读、批量启用)。
避坑给只读的计算属性赋值,比如对 const double = computed(() => count.value * 2) 执行 double.value = 10,Vue 会警告 Write operation failed: computed value is readonly,赋值不会生效。看到这行提示就说明两件事之一:你要么该给它补一个 setter,要么根本不该用 computed——这个值本来就该是一个 ref。

watch 侦听器

数据变了,去做一件事
先讲为什么computed 解决的是「算什么」,但有一大类需求根本不是要一个值,而是要在数据变化时做一件事:关键词一变就去请求接口、搜索历史变了就存进 localStorage、切换分类时把滚动条回到顶部、用户改了表单就弹一个「有未保存的修改」提示。
这些事统称为副作用——它们不是「结果」,而是「动作」。动作不该写在 computed 里(那会让计算变得不可预测),所以 Vue 另给了一个工具:watch。
动手做监听搜索关键词:值一变就发请求。先用 console.log 把过程打出来,看清「什么时候触发、触发了什么」。
src/components/LiveSearch.vuevue
<script setup>
import { ref, watch } from 'vue'

const keyword = ref('')

// 侦听 keyword:值一变就执行这个回调
// 参数依次是「新值」「旧值」
watch(keyword, async (newVal, oldVal) => {
  console.log('keyword 从 [' + oldVal + '] 变成 [' + newVal + ']')

  if (!newVal) return          // 清空输入框时不用发请求

  const res = await fetch('/api/article/list?keyword=' + newVal)
  const json = await res.json()
  if (json.code === 200) {
    console.log('拿到 ' + json.data.total + ' 条结果')
  }
})
</script>

<template>
  <input v-model="keyword" placeholder="搜作品名 / 作者" />
</template>
页面效果组件渲染结果
运行结果: (在输入框里敲「静」) keyword 从 [] 变成 [静] 拿到 1 条结果 (接着敲「夜」,值变成「静夜」) keyword 从 [静] 变成 [静夜] 拿到 0 条结果 (把输入框清空) keyword 从 [静夜] 变成 [] (清空时不发请求 —— 因为 if (!newVal) return 提前返回了)
讲透:watch 的三个参数与四种源
  • 结构:watch(源, 回调, 选项)。回调拿 (newVal, oldVal)。
  • 源可以是四种:① 一个 ref;② 一个 reactive 对象;③ 一个 getter 函数——watch(() => keyword.value.length, ...),用来只盯对象的某个属性;④ 一个数组——watch([a, b], ...) 同时盯多个。
  • 立刻给搜索框加请求是有代价的:用户敲「静夜思」三个字会触发三次请求,而且返回顺序不保证,可能出现「后发先到的旧结果覆盖新结果」。真实项目里会加防抖——等用户停止输入 300 毫秒再发。这个技巧第 5 章写组合式函数时再收拾。
  • 回调可以是 async 函数,这样就能直接在里面 await 接口。但要注意:请求回来的结果可能已经过期,严谨的做法是每次发请求前记一个序号,回来时对一下是不是最新的那个。
  • 组件卸载时 watch 会自动停掉,不用手动清理,这一点比原生 addEventListener 省心。
避坑不要在 watch 的回调里修改被监听的那个源。写 watch(count, () => { count.value++ }),count 一变就再触发一次,触发又改 count……浏览器会直接卡死,控制台刷出一屏警告。同理,在 watch(keyword, ...) 里再给 keyword 赋值(比如自动补全、自动纠正),也会踩进同一个坑——这类需求必须加条件判断,或者改用「写入另一个变量」的方式实现。

watchEffect 与 immediate / deep

三个让 watch 更好用的东西
先讲为什么用 watch 时会碰到三种别扭的情况。一是「要盯的变量有点多」,一个个列出来很啰嗦——Vue 给了 watchEffect:你只管在函数里用它,用了谁它就自动盯谁,而且进门先执行一次。
二是「页面刚打开就要做一次」,比如先把上次保存的搜索历史读出来——这时加 immediate: true。
三是「对象内部属性变了」,默认监听不到——这时加 deep: true。
动手做先用 watchEffect 写一个「条件一变就存进本地」的效果,感受「不用写依赖」是什么体验。
src/components/SearchEffect.vuevue
<script setup>
import { ref, watchEffect } from 'vue'

const keyword = ref('静夜思')
const dynasty = ref('唐')

// 不用告诉它监听谁:函数体里用了 keyword 和 dynasty,它就自动盯着这两个
watchEffect(() => {
  console.log('当前搜索条件:' + dynasty.value + ' · ' + keyword.value)
  localStorage.setItem('last_search', dynasty.value + '|' + keyword.value)
})
</script>

<template>
  <p>{{ dynasty }} · {{ keyword }}</p>
</template>
页面效果组件渲染结果
运行结果: (页面一打开就立刻执行一次,不需要改任何数据) 当前搜索条件:唐 · 静夜思 (把 dynasty 改成「宋」) 当前搜索条件:宋 · 静夜思 (浏览器 Application 面板里) localStorage.last_search = 宋|静夜思 -- 和 watch 最大的差别就在这里:watchEffect 默认「立刻执行一次」
动手做再看 immediate 和 deep 这两个选项。
src/components/WatchOptions.vuevue
<script setup>
import { ref, watch } from 'vue'

const history = ref([])
const form = ref({ nickname: '青莲居士', intro: '爱读唐诗' })

// ① immediate:不等数据变化,进入页面就先执行一次
watch(
  history,
  (val) => {
    localStorage.setItem('search_history', JSON.stringify(val))
  },
  { immediate: true }
)

// ② deep:对象「内部的属性」变了也要触发
//    注意:源是 ref({...}) 时必须显式写 deep;源的 reactive(obj) 则是隐式深度监听
watch(
  form,
  (val) => {
    console.log('表单内容变了:' + val.nickname + ' / ' + val.intro)
  },
  { deep: true }
)

function loadHistory() {
  history.value = ['唐诗', '宋词']
}
</script>

<template>
  <button @click="loadHistory">载入搜索历史</button>
  <p>昵称:{{ form.nickname }} 简介:{{ form.intro }}</p>
</template>
页面效果组件渲染结果
运行结果: (页面加载时,immediate 让回调先跑一次) localStorage.search_history = [] (点「载入搜索历史」) localStorage.search_history = ["唐诗","宋词"] (把 form.value.intro 改成「爱读唐诗宋词」) 表单内容变了:青莲居士 / 爱读唐诗宋词 -- 去掉 deep: true 这一行,上面这句话就不会打印 (把 form.value 整个换成一个新对象,比如 form.value = {...}) 表单内容变了:青莲居士 / 爱读唐诗宋词 -- 替换整个对象时,不写 deep 也会触发
讲透:三者的取舍
  • watchEffect 省事但依赖不显眼。好处是不用写依赖列表、会自动立刻执行;坏处是「谁触发了它」要靠读函数体才知道。函数一长就该换回 watch。
  • immediate: true:让回调「进来先跑一次」。读本地缓存、初始化数据、先把当前值同步给别处,都用它。它的副作用是——第一次执行时 oldVal 是 undefined,别拿它去比较。
  • deep: true:让嵌套属性也能触发。能不写就不写——深度遍历一个几百条记录的列表,每次变化都要跑一遍,性能会明显变差。
  • 更省的替代方案:只监听具体属性。watch(() => form.value.intro, cb) 只看简介这一项,既精准又快,效果和 deep 一样,代价小得多。这是实战里更该养成的习惯。
避坑用 getter 监听对象的某个属性时,别忘了 .value:watch(() => form.value.intro, cb) 是对的;写成 watch(form.value.intro, cb) 传进去的是一个字符串,Vue 会警告 Invalid watch source,回调永远不触发。判断口诀:源是「变量本身」(keyword)就直接传;源是「由别的值算出来的」(form.value.intro、list.value.length)就包一层箭头函数。

computed 与 watch 怎么选

一句话判据
computed 与 watch 怎么选:一个「算出值」,一个「去做事」computed:算出一个值· 过滤后的列表· 格式化的日期· 购物车总价· 有没有登录(布尔)结果要渲染到页面上 → computedwatch:数据变了去做事· 发请求(重新拉列表)· 存 localStorage· 弹提示、埋点· 联动另一个异步操作结果是「动作」不是值 → watch一句话判据:能用 computed 就用 computed,实在不行再 watch反例:用 watch 监听后改另一个数据 → 大概率 computed 一行就够
先讲为什么这两个工具都能「跟着数据变化做反应」,所以最容易用混。区分它们的判据只有一句:
你要的是一个「值」,还是要做一件「事」?
页面上要显示「共 3 个字」——这是一个值,用 computed。用户敲完关键词要把历史存到本地——这是一件事(副作用),用 watch。用错不会立刻报错,但代码会越写越别扭,所以这个判据值得背下来。
动手做同一个搜索框,两个需求分别用正确和错误的方式各写一遍,对比代码量。
computed-vs-watch.vuevue
<script setup>
import { ref, computed, watch } from 'vue'

const keyword = ref('静夜思')

/* ---------- 需求 A:页面上要显示「关键词有几个字」 = 要一个值 ---------- */

// 正确:computed,有依赖、有返回值,算出来的
const keywordLength = computed(() => keyword.value.length)

// 错误:用 watch 硬算一个值,要多造一个 ref、还要手动初始化、还容易漏
// const keywordLength = ref(0)
// watch(keyword, (v) => { keywordLength.value = v.length })

/* ---------- 需求 B:关键词变了要记进搜索历史 = 要做一件事 ---------- */

// 正确:watch,没有返回值,它要做的是「存东西」这个动作
watch(keyword, (v) => {
  if (!v) return
  const history = JSON.parse(localStorage.getItem('search_history') || '[]')
  if (!history.includes(v)) {
    history.unshift(v)
    localStorage.setItem('search_history', JSON.stringify(history.slice(0, 10)))
  }
})

// 错误:computed 里不该有副作用(写 localStorage、发请求都算)
// const saved = computed(() => {
//   localStorage.setItem('last_keyword', keyword.value)
//   return keyword.value
// })
</script>

<template>
  <input v-model="keyword" />
  <p>共 {{ keywordLength }} 个字</p>
</template>
页面效果组件渲染结果
运行结果: (输入框里默认是「静夜思」) 共 3 个字 ← computed 算出来的 localStorage.search_history = ["静夜思"] ← watch 做出来的 (把它改成「静夜思 唐」) 共 5 个字 localStorage.search_history = ["静夜思 唐","静夜思"] -- 两件事各归各位:一个负责「算出值给模板用」,一个负责「数据变了去做事」
讲透:判据 + 常见场景对照
  • 该用 computed 的场景:浏览量格式化成「1.2k」、点赞率、作品总数、按分类分组、按热度排序、按关键词过滤、拼接「唐诗 · 李白」这样的展示文案。共同点:都是在回答「是什么」。
  • 该用 watch 的场景:关键词变了发请求、数据变了存 localStorage、页码变了滚动到顶部、切换路由前确认有未保存内容、表单字段 A 变了自动清空字段 B。共同点:都是在做「动作」。
  • 一个可直接套用的判断法:如果这段逻辑「删掉之后页面上少了一块显示」,那是 computed;如果「删掉之后某个副作用没发生(比如没发请求、没存数据)」,那是 watch。
  • 两者常常成对出现:computed 算出「要发给后端的参数对象」,watch 监听这个对象去做请求。这样「参数怎么拼」和「什么时候发」就分开了,各自都好改。
  • 最后一条原则:能用 computed 表达的,就不要用 watch。用 watch 去同步一个「算出来的值」,等于把 Vue 自动帮你维护的依赖关系统统改成了手动挡。

综合案例:作品列表的格式化展示

把本章串起来
动手做做一个「热门作品」列表:从 GET /api/article/list?order_by=hot 拿数据,然后用 computed 把它加工成「直接能渲染」的样子——浏览量显示成 1.2k、点赞率算成百分比、顺便挑出最热的那一篇。
src/components/HotArticleList.vuevue
<script setup>
import { ref, computed } from 'vue'

const loading = ref(false)
const list = ref([])

async function loadArticles() {
  loading.value = true
  const res = await fetch('/api/article/list?page=1&page_size=6&order_by=hot')
  const json = await res.json()
  if (json.code === 200) {
    list.value = json.data.list
  }
  loading.value = false
}

loadArticles()

// 一个纯函数负责「数字怎么变好看」,computed 里只调用它
function formatCount(n) {
  if (n >= 10000) return (n / 10000).toFixed(1) + '万'
  if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
  return String(n)
}

// 派生数据 ①:给每条作品补上「可以直接显示的字段」
const articles = computed(() =>
  list.value.map(item => ({
    ...item,
    viewsText: formatCount(item.views),
    likesText: formatCount(item.likes),
    hotPercent: item.views === 0
      ? '0.0%'
      : (item.likes / item.views * 100).toFixed(1) + '%'
  }))
)

// 派生数据 ②:从加工后的列表里挑出浏览量最高的那条
const hottest = computed(() => {
  if (!articles.value.length) return null
  return articles.value.reduce((a, b) => (b.views > a.views ? b : a))
})

// 派生数据 ③:总数
const total = computed(() => articles.value.length)
</script>

<template>
  <p v-if="loading">加载中…</p>
  <p v-else>共 {{ total }} 篇,最热的是《{{ hottest ? hottest.title : '—' }}》</p>

  <ul>
    <li v-for="a in articles" :key="a.id">
      <h3>{{ a.title }}<span class="meta">{{ a.dynasty }} · {{ a.author }}</span></h3>
      <p>{{ a.summary }}</p>
      <p>浏览 {{ a.viewsText }} | 点赞 {{ a.likesText }} | 热度 {{ a.hotPercent }}</p>
    </li>
  </ul>
</template>
页面效果组件渲染结果
运行结果: (接口按 order_by=hot 返回,页面上渲染出) 共 6 篇,最热的是《念奴娇·赤壁怀古》 念奴娇·赤壁怀古 宋 · 苏轼 大江东去,浪淘尽,千古风流人物。 浏览 412 | 点赞 58 | 热度 14.1% 静夜思 唐 · 李白 床前明月光…… 浏览 328 | 点赞 42 | 热度 12.8% (其余 4 篇同理,此处省略) -- 数据还是接口给的那份,页面看到的却是加工过的版本 -- 模板里只有变量名,格式化规则全在 script 里,改规则只改一处
讲透:这个组件里的三个设计决定
  • 格式化函数与 computed 分开写。formatCount 是一个不依赖任何响应式数据的纯函数,写成普通函数就行;computed 只负责在数据变化时调用它。纯逻辑与响应式逻辑分开,两边都好测。
  • 用「一层 map」把加工做好。articles 是原始接口数据的「显示版」,模板直接遍历它即可。好处是模板完全不懂业务规则,以后换 UI 库、改布局都不用碰格式化逻辑。
  • computed 之间可以互相依赖。hottest 和 total 都依赖 articles。当 list 变化时,articles 重算一次,随后这两个各自取缓存结果,不会重复跑三遍。这就是「链式 computed」的价值。
  • 边界情况要处理:reduce 在空数组上会报错,所以 hottest 里先判 length;views 为 0 时除法会得到 Infinity,所以 hotPercent 里单独兜一下。计算属性里的每个「除」和「取第一项」都要想一遍空值。
  • 格式化放在前端还是后端?「1.2k」这种是展示格式,属于前端的事——换一种语言、换一个终端展示时规则可能完全不同。后端只管给准确的原始数字。
  • 例子里的 v-if / v-else / v-for 是条件渲染与列表渲染,第 4 章细讲,这里先照着用。
动手练习
1给定一个作品数组(views 依次为 328、256、412、298、187、203),用 computed 算出三个值:作品总数、总浏览量、平均浏览量(保留 1 位小数)。要求模板里只出现变量名,算式全写在 script 里。
2接着用 computed 挑出浏览量最高的那篇作品,再派生出它的标题与浏览量;另定义一个 orderBy(可选 hot / latest),用 computed 返回排好序的列表,并用 watch 在排序方式变化时打印一条日志(含变化前后的值和新的第一条标题)。
3迁移练习:「红影赏析」的电影列表里,rating 要显示成「8.4 分」,评分大于等于 9 的加一个「神作」标签;同时要求用户输入的关键词一变就存进 localStorage 的 search_history(最多留 10 条,重复的不再存)。想一想:这两件事分别该用 computed 还是 watch?
参考答案
练习答案(三题各一个文件).vuevue
<!-- 第 1 题:统计三个数 -->
<script setup>
import { ref, computed } from 'vue'

const articles = ref([
  { id: 1, title: '静夜思', views: 328 },
  { id: 2, title: '春望', views: 256 },
  { id: 3, title: '念奴娇·赤壁怀古', views: 412 },
  { id: 4, title: '声声慢', views: 298 },
  { id: 5, title: '天净沙·秋思', views: 187 },
  { id: 6, title: '陋室铭', views: 203 }
])

const total = computed(() => articles.value.length)

const totalViews = computed(() =>
  articles.value.reduce((sum, a) => sum + a.views, 0)
)

// 用 totalViews 而不是再算一遍 —— computed 可以依赖 computed
const avgViews = computed(() =>
  total.value === 0 ? '0.0' : (totalViews.value / total.value).toFixed(1)
)
</script>

<template>
  <p>共 {{ total }} 篇,总浏览 {{ totalViews }} 次,平均 {{ avgViews }} 次</p>
</template>

<!-- 第 2 题:最热的一篇 + 排序 -->
<script setup>
import { ref, computed, watch } from 'vue'

const articles = ref([
  { id: 1, title: '静夜思', views: 328 },
  { id: 2, title: '春望', views: 256 },
  { id: 3, title: '念奴娇·赤壁怀古', views: 412 },
  { id: 4, title: '声声慢', views: 298 },
  { id: 5, title: '天净沙·秋思', views: 187 },
  { id: 6, title: '陋室铭', views: 203 }
])

const orderBy = ref('hot')

const sorted = computed(() => {
  const copy = [...articles.value]          // 先复制,别原地改原数组
  if (orderBy.value === 'hot') {
    copy.sort((a, b) => b.views - a.views)
  } else {
    copy.sort((a, b) => b.id - a.id)
  }
  return copy
})

const hottest = computed(() => {
  if (!sorted.value.length) return null
  return sorted.value.reduce((a, b) => (b.views > a.views ? b : a))
})

const hottestTitle = computed(() => (hottest.value ? hottest.value.title : '—'))

// 只监听「排序方式」这一项,属于「数据变了做一件事」,所以用 watch
watch(orderBy, (newVal, oldVal) => {
  console.log('排序方式从 ' + oldVal + ' 变成 ' + newVal +
    ',第一条是《' + sorted.value[0].title + '》')
})
</script>

<template>
  <p>最热:《{{ hottestTitle }}》{{ hottest ? hottest.views : '' }} 次浏览</p>
  <button @click="orderBy = orderBy === 'hot' ? 'latest' : 'hot'">切换排序</button>
</template>

<!-- 第 3 题:红影赏析(表结构不同,思路完全一致) -->
<script setup>
import { ref, computed, watch } from 'vue'

const keyword = ref('')
const movies = ref([
  { id: 1, title: '流浪地球', rating: 8.4 },
  { id: 2, title: '星际穿越', rating: 9.3 }
])

// 要一个「能直接渲染的值」→ computed
const rows = computed(() =>
  movies.value.map(m => ({
    ...m,
    ratingText: m.rating.toFixed(1) + ' 分',
    isMasterpiece: m.rating >= 9
  }))
)

// 要「数据变了去做一件事」→ watch
watch(keyword, (v) => {
  if (!v) return
  const history = JSON.parse(localStorage.getItem('search_history') || '[]')
  const next = [v, ...history.filter(item => item !== v)].slice(0, 10)
  localStorage.setItem('search_history', JSON.stringify(next))
})
</script>

<template>
  <input v-model.trim="keyword" placeholder="搜片名" />
  <ul>
    <li v-for="m in rows" :key="m.id">
      {{ m.title }} —— {{ m.ratingText }}
      <span v-if="m.isMasterpiece">神作</span>
    </li>
  </ul>
</template>
页面效果组件渲染结果
运行结果: 第 1 题 共 6 篇,总浏览 1684 次,平均 280.7 次 第 2 题 最热:《念奴娇·赤壁怀古》412 次浏览 (点「切换排序」) 排序方式从 hot 变成 latest,第一条是《陋室铭》 第 3 题 流浪地球 —— 8.4 分 星际穿越 —— 9.3 分 神作 (输入「星」之后) localStorage.search_history = ["星"] -- 第 1 题里 avgViews 依赖 totalViews:把它们拆成两个 computed, -- 比在一个函数里算三遍清楚得多,而且都能被缓存

第 3 题的答案:「显示成 8.4 分、加神作标签」用 computed(它要的是一批「能直接渲染的值」);「把关键词存进历史」用 watch(它要的是「存东西」这个动作,而且动作不该在计算里发生)。另外注意两个细节:排序时要 [...articles.value] 先复制,直接 sort 会改到原数组;历史去重用的是 filter 把自己剔掉再拼到最前面,这样重复关键词不会占两条。

本章小结