上一章你能把数据放到页面上了,但一旦要在模板里做点「加工」——算比率、格式化数字、挑出最热的那一条——表达式就会糊成一片。这一章给你两件专用工具:computed 负责「算出一个值」,watch 负责「数据变了去做一件事」。学完你就知道什么时候该用哪个,以及为什么用错会让代码越写越乱。
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<!-- 第 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>
第 3 题的答案:「显示成 8.4 分、加神作标签」用 computed(它要的是一批「能直接渲染的值」);「把关键词存进历史」用 watch(它要的是「存东西」这个动作,而且动作不该在计算里发生)。另外注意两个细节:排序时要 [...articles.value] 先复制,直接 sort 会改到原数组;历史去重用的是 filter 把自己剔掉再拼到最前面,这样重复关键词不会占两条。