阶段1:侦探数字
目标:监听数字变化
操作:
js
// 导包
import { ref, watch } from 'vue'
const num = ref(0)
const info = ref('没变')
// 监听 num,变了就改 info
watch(num, () => info.value = '变了')
模板(<template>)里写:
html
{{ num }} {{ info }}
<button @click="num++">点我</button>
结果: 点击按钮,数字变大,后面显示"变了"。
错误示例:
js
// 错误:没有导包
watch(num, () => {})
// 后果:白屏,报错 watch is not defined
傻瓜口令:"watch 盯住 数字"
阶段2:侦探文字
目标:监听文字变化
操作:
js
import { ref, watch } from 'vue'
const str = ref('你好')
const info = ref('没变')
// 监听 str
watch(str, () => info.value = '变了')
模板(<template>)里写:
html
<input v-model="str">
{{ info }}
结果: 在输入框打字,下面文字变成"变了"。
错误示例:
js
// 错误:拼写错误
witch(str, () => {})
// 后果:报错 witch is not defined
傻瓜口令:"watch 盯住 文字"
阶段3:侦探开关
目标:监听开关变化
操作:
js
import { ref, watch } from 'vue'
const open = ref(false)
const info = ref('没变')
// 监听 open
watch(open, () => info.value = '变了')
模板(<template>)里写:
html
<button @click="open = !open">开关</button>
{{ info }}
结果: 点击开关按钮,下面文字变成"变了"。
错误示例:
js
// 错误:忘记写箭头函数
watch(open, info.value = '变了')
// 后果:页面一刷新就执行了一次,后面不执行
傻瓜口令:"watch 盯住 开关"
阶段4:拿到新的
目标:获取变化后的值
操作:
js
import { ref, watch } from 'vue'
const num = ref(0)
const info = ref('')
// 第一个参数是新值
watch(num, (newVal) => info.value = newVal)
模板(<template>)里写:
html
{{ num }} 最新是:{{ info }}
<button @click="num++">加1</button>
结果: 点击加1,"最新是:"后面显示当前的数字。
错误示例:
js
// 错误:参数写反了(其实watch不分反不反,但容易记混)
// 假设想拿旧值却只写了一个参数
watch(num, (oldVal) => ...)
// 后果:拿到的其实是新值
傻瓜口令:"watch 拿到 新值"
阶段5:拿到旧的
目标:获取变化前的值
操作:
js
import { ref, watch } from 'vue'
const num = ref(0)
const info = ref('')
// 第二个参数是旧值
watch(num, (n, oldVal) => info.value = oldVal)
模板(<template>)里写:
html
{{ num }} 原来是:{{ info }}
<button @click="num++">加1</button>
结果: 点击加1,"原来是:"后面显示加1之前的数字。
错误示例:
js
// 错误:只写一个参数想拿旧值
watch(num, (oldVal) => info.value = oldVal)
// 后果:显示的是新值,不是旧值
傻瓜口令:"watch 拿到 旧值"
阶段6:盯错人了
目标:错误监听普通值
操作:
js
import { ref, watch } from 'vue'
let num = 0
// 错误:监听普通变量
watch(num, () => console.log('变了'))
模板(<template>)里写:
html
{{ num }}
<button @click="num++">点我</button>
结果: 点多少次按钮,都不会有反应。控制台可能会有黄色警告。
错误示例:
js
let a = 1
watch(a, () => {})
// 后果:Invalid watch source(无效的监听源)
傻瓜口令:"watch 不盯 普通数"
阶段7:只能盯表面
目标:默认不监听内部
操作:
js
// 导包
import { ref, watch } from 'vue'
const user = ref({ name: '小明' })
const info = ref('没变')
// 默认只盯 user 整体,不盯内部属性
watch(user, () => info.value = '变了')
模板(<template>)里写:
html
{{ user.name }} {{ info }}
<button @click="user.name = '小红'">改名字</button>
<button @click="user = { name: '大明' }">改整个人</button>
结果: 点击"改名字",没反应。点击"改整个人",变成了"变了"。
练习步骤:
- 抄写上面的代码
- 点击两个按钮对比效果
- 记住默认看不到内部变化
错误示例:
js
// 错误:以为默认能听到内部
watch(user, () => {})
// 后果:改属性没反应,排查很久
傻瓜口令:"ref 只能 盯表面"
阶段8:开启深度游
目标:监听ref内部变化
操作:
js
import { ref, watch } from 'vue'
const user = ref({ name: '小明' })
const info = ref('没变')
// 第三个参数加上 { deep: true }
watch(user, () => info.value = '变了', { deep: true })
模板(<template>)里写:
html
{{ user.name }} {{ info }}
<button @click="user.name = '小红'">改名字</button>
结果: 点击"改名字",这次变成了"变了"。
练习步骤:
- 抄写代码
- 加上
{ deep: true } - 再次测试修改名字
错误示例:
js
// 错误:deep 拼写错误
{ dep: true }
// 后果:配置无效,还是听不到
傻瓜口令:"开启 deep 盯深处"
阶段9:天生千里眼
目标:reactive自动深度监听
操作:
js
import { reactive, watch, ref } from 'vue'
const user = reactive({ name: '小明' })
const info = ref('没变')
// reactive 不需要 deep,自动是深度的
watch(user, () => info.value = '变了')
模板(<template>)里写:
html
{{ user.name }} {{ info }}
<button @click="user.name = '小红'">改名字</button>
结果: 点击"改名字",直接变了。
练习步骤:
- 把 ref 改成 reactive
- 去掉
{ deep: true } - 验证是否有效
错误示例:
js
// 错误:给 reactive 加 deep
watch(user, ..., { deep: true })
// 后果:虽然没错,但是是废话,浪费性能
傻瓜口令:"reactive 天生 深层"
阶段10:只盯一点点
目标:监听对象的某个属性
操作:
js
import { reactive, watch, ref } from 'vue'
const user = reactive({ name: '小明', age: 18 })
const info = ref('没变')
// 写成箭头函数,只盯 name
watch(() => user.name, () => info.value = '名字变了')
模板(<template>)里写:
html
姓名:{{ user.name }} 年龄:{{ user.age }} {{ info }}
<button @click="user.name = '小红'">改名</button>
<button @click="user.age++">改年龄</button>
结果: 点改可名,info 变了。点改年龄,info 不变。
练习步骤:
- 抄写代码
- 尝试不写箭头函数直接写
user.name - 观察报错(下一章 L3 会讲)
错误示例:
js
// 错误:直接写属性值
watch(user.name, ...)
// 后果:报错或无效,必须写成函数
傻瓜口令:"箭头函数 盯特写"
阶段11:一上来就干
目标:立即执行监听
操作:
js
import { ref, watch } from 'vue'
const count = ref(0)
const info = ref('初始')
// 加上 immediate: true
watch(count, (n) => info.value = `数字是${n}`, { immediate: true })
模板(<template>)里写:
html
{{ info }}
结果: 页面一刷新,直接显示"数字是0",不需要等数据变。
练习步骤:
- 抄写代码
- 去掉
immediate: true对比效果 - 加上它,观察初始渲染
错误示例:
js
// 错误:拼写错误
{ immediate: trun }
// 后果:无效
傻瓜口令:"开启 immediate 马上做"
阶段12:同时盯两个
目标:监听多个数据
操作:
js
import { ref, watch } from 'vue'
const a = ref(0)
const b = ref(0)
// 用数组包起来
watch([a, b], () => console.log('有一个变了'))
模板(<template>)里写:
html
A: {{ a }} B: {{ b }}
<button @click="a++">改A</button>
<button @click="b++">改B</button>
结果: 无论点哪个按钮,控制台都会打印。
练习步骤:
- 定义两个 ref
- 用数组
[]包裹放在 watch 第一个参数 - 测试两个按钮
错误示例:
js
// 错误:没用数组包起来
watch(a, b, () => ...)
// 后果:参数错乱,报错
傻瓜口令:"数组包裹 盯多个"
阶段13:停止不盯了
目标:停止监听
操作:
js
import { ref, watch } from 'vue'
const count = ref(0)
const info = ref('正在监听')
// watch 会返回一个停止函数
const stop = watch(count, () => info.value = '变了')
const stopWatch = () => {
stop() // 调用它就停止了
info.value = '已停止'
}
模板(<template>)里写:
html
{{ count }} {{ info }}
<button @click="count++">加1</button>
<button @click="stopWatch">停止监听</button>
结果: 一开始点加1,状态会变。点停止监听后,再点加1,状态不再变。
练习步骤:
- 接收 watch 的返回值
- 创建一个按钮调用这个返回值
- 验证停止效果
错误示例:
js
// 错误:以为会自动停止
// 组件不销毁,它就一直盯着
傻瓜口令:"拿到 stop 停止盯"
阶段14:变了做判断
目标:结合逻辑判断
操作:
js
import { ref, watch } from 'vue'
const score = ref(0)
const result = ref('不及格')
// 在回调里写 if
watch(score, (n) => {
if (n >= 60) result.value = '及格'
else result.value = '不及格'
})
模板(<template>)里写:
html
分数:<input v-model.number="score">
结果:{{ result }}
结果: 输入 50 显示不及格,输入 70 显示及格。
练习步骤:
- 抄写代码
- 修改判断条件(比如改为 90 分优秀)
- 观察结果变化
错误示例:
js
// 错误:逻辑写反了
if (n < 60) result.value = '及格'
// 后果:逻辑错误,老师打人
傻瓜口令:"数据变了 做判断"
阶段15:算结果 vs 干苦力
目标:理解两者区别
对比表格:
| 场景 | 写法A (Computed) | 写法B (Watch) |
|---|---|---|
| 用途 | 就算出一个新结果 | 不算结果,去执行动作 |
| 代码 | computed(() => ...) |
watch(src, () => ...) |
操作:
js
// 导包
import { ref, computed, watch } from 'vue'
const num = ref(1)
// 写法A:我是算命的,只算结果
const double = computed(() => num.value * 2)
// 写法B:我是干活的,只打印
watch(num, (n) => console.log('变了', n))
模板(<template>)里写:
html
{{ double }}
<button @click="num++">加1</button>
结果: 页面显示数字翻倍。控制台打印"变了"。
傻瓜口令:"computed 算结果 watch 去干活"
阶段16:要显示 vs 要动作
目标:场景选择
对比表格:
| 场景 | 写法A (显示) | 写法B (动作) |
|---|---|---|
| 用途 | 页面要显示某个值 | 数据变了要弹窗/发请求 |
| 代码 | 用 Computed | 用 Watch |
操作:
js
import { ref, computed, watch } from 'vue'
const str = ref('hello')
// A:为了显示变大写
const upper = computed(() => str.value.toUpperCase())
// B:为了弹窗骚扰用户
watch(str, () => alert('你改了文字!'))
模板(<template>)里写:
html
<input v-model="str">
<p>{{ upper }}</p>
结果: 输入文字,下面大写自动变(Computed功劳)。浏览器弹出警告框(Watch功劳)。
傻瓜口令:"要显示 用计算 要动作 用监听"
阶段17:手动深 vs 自动深
目标:ref与reactive的watch区别
对比表格:
| 场景 | 写法A (Ref对象) | 写法B (Reactive对象) |
|---|---|---|
| 用途 | 监听 Ref 包裹的对象 | 监听 Reactive 包裹的对象 |
| 代码 | 需要 deep: true |
不需要,由其自动深度 |
操作:
js
import { ref, reactive, watch } from 'vue'
const r1 = ref({ n: 1 })
const r2 = reactive({ n: 1 })
// A: Ref 必须加 deep
watch(r1, () => console.log('Ref变了'), { deep: true })
// B: Reactive 自动 deep
watch(r2, () => console.log('Reactive变了'))
模板(<template>)里写:
html
<button @click="r1.n++">改Ref内部</button>
<button @click="r2.n++">改Reactive内部</button>
结果: 两个按钮点击后,控制台都会打印。
傻瓜口令:"ref 加深度 reactive 天然深"
阶段18:盯整体 vs 盯局部
目标:监听对象 vs 监听属性
对比表格:
| 场景 | 写法A (整体) | 写法B (局部) |
|---|---|---|
| 用途 | 任何属性变了都知道 | 只有这个特定属性变了才执行 |
| 代码 | watch(user, ...) |
watch(() => user.name, ...) |
操作:
js
import { reactive, watch } from 'vue'
const user = reactive({ name: '小明', age: 10 })
// A: 只要 user 动,我就动
watch(user, () => console.log('整体变了'))
// B: 只有名字动,我才动
watch(() => user.name, () => console.log('名字变了'))
模板(<template>)里写:
html
<button @click="user.age++">改年龄</button>
<button @click="user.name='红'">改名字</button>
结果: 改年龄:只有"整体变了"。 改名字:"整体变了"和"名字变了"都有。
傻瓜口令:"盯整体 写变量 盯局部 写函数"
阶段19:等变化 vs 马上做
目标:普通 vs 立即执行
对比表格:
| 场景 | 写法A (普通) | 写法B (Immediate) |
|---|---|---|
| 用途 | 数据变了再执行 | 从一开始就执行一次,变了再执行 |
| 代码 | 默认配置 | { immediate: true } |
操作:
js
import { ref, watch } from 'vue'
const n = ref(0)
// A: 懒人,踢一脚才动
watch(n, () => console.log('A动了'))
// B: 勤快人,上来就先干活
watch(n, () => console.log('B动了'), { immediate: true })
模板(<template>)里写:
html
<button @click="n++">踢一脚</button>
结果: 刷新页面:打印 "B动了"。 点击按钮:打印 "A动了" 和 "B动了"。
傻瓜口令:"不急 等变化 加急 马上做"
阶段20:独行侠 vs 组团跑
目标:单个 vs 数组
对比表格:
| 场景 | 写法A (单个) | 写法B (数组) |
|---|---|---|
| 用途 | 分开监听,逻辑不同 | 多个数据变了执行同一个逻辑 |
| 代码 | 写两个 watch | 写一个 watch,传数组 |
操作:
js
import { ref, watch } from 'vue'
const a = ref(0)
const b = ref(0)
// A: 分家
watch(a, () => console.log('a变'))
watch(b, () => console.log('b变'))
// B: 组团
watch([a, b], () => console.log('a或b变'))
模板(<template>)里写:
html
<button @click="a++">改A</button>
<button @click="b++">改B</button>
结果: 改A:打印 "a变" 和 "a或b变"。 改B:打印 "b变" 和 "a或b变"。
傻瓜口令:"分开 这一行 数组 包一起"
阶段21:终极二选一
目标:总结选择心法
对比表格:
| 场景 | 选谁? |
|---|---|
| 如果你需要根据数据生成一个新的值 | ✅ Computed |
| 如果你需要监控数据变化去发请求/存缓存 | ✅ Watch |
| 如果你只需要页面显示 | ✅ Computed |
| 如果你只需要打印日志 | ✅ Watch |
操作:
js
import { ref, computed, watch } from 'vue'
const count = ref(0)
// 选 Computed
const msg = computed(() => '数字:' + count.value)
// 选 Watch
watch(count, () => console.log('记录日志'))
模板(<template>)里写:
html
{{ msg }}
<button @click="count++">加</button>
结果: 页面显示最新消息,控制台有日志。各司其职。
傻瓜口令:"计算 找computed 办事 找watch"
阶段22:禁止直接盯数值
目标:监听属性的正确姿势
错误场景:想监听 reactive 里的一个属性,结果报错。
错误代码:
js
const user = reactive({ count: 1 })
// ❌ 错误:user.count 是个数字 1,watch 不能盯着数字 1
watch(user.count, () => console.log('变了'))
错误后果 : 控制台黄色警告:Invalid watch source(无效的监听源),页面没反应。
正确代码:
js
// ✅ 正确:用函数包起来,返回这个值
watch(() => user.count, () => console.log('变了'))
解决方法 : 想监听对象的属性,必须写成 () => user.prop。
傻瓜口令:"禁止 直接 盯数值"
阶段23:旧值也变了
目标:对象引用的坑
错误场景:监听对象时,想对比新旧值有什么不同。
错误代码:
js
const user = reactive({ age: 10 })
watch(user, (newVal, oldVal) => {
// ❌ 两个打印出来是一样的,都是新值
console.log(newVal.age === oldVal.age) // true
})
错误后果: 你拿不到修改之前的值,因为它们指向同一个对象内存。
正确代码:
js
// ✅ 正确:如果非要对比数字,监听具体属性
watch(() => user.age, (n, o) => console.log(n, o))
解决方法 : 监听对象本身时,别指望 oldVal 是旧的。
傻瓜口令:"引用类型 旧值相同"
阶段24:死亡螺旋
目标:避免无限循环
错误场景:监听到数据变了,又去修改数据,导致无限触发。
错误代码:
js
const count = ref(0)
watch(count, () => {
// ❌ 听到变了又去加1,又触发变了,又加1...
count.value++
})
错误后果 : 浏览器卡死,或者Vue报错 Maximum recursive updates exceeded。
正确代码:
js
// ✅ 正确:不要在回调里修改正在监听的数据
watch(count, () => console.log('只看不动'))
解决方法: 管住手,别自己改自己。
傻瓜口令:"里面修改 死循环"
阶段25:深度太深
目标:性能陷阱
错误场景:给超级大的对象开启深度监听。
错误代码:
js
// 假设 data 有 1000 层,1万个属性
watch(bigData, () => {}, { deep: true })
错误后果: 修改其中一个小数字,Vue 要遍历整个大对象,页面卡顿。
正确代码:
js
// ✅ 正确:只监听你需要关心的那个小属性
watch(() => bigData.info.name, () => {})
解决方法: 按需监听,别贪大求全。
傻瓜口令:"大对象 别用 deep"
阶段26:看不到DOM
目标:DOM 更新时机
错误场景:数据变了,想马上获取界面上的新内容。
错误代码:
js
watch(count, () => {
// ❌ 这时候界面还没更新呢
console.log(document.getElementById('box').innerHTML)
})
错误后果: 打印出来的还是旧的内容。
正确代码:
js
// ✅ 正确:加上 flush: 'post',表示"界面更完之后"
watch(count, () => { ... }, { flush: 'post' })
解决方法 : 想看更新后的界面,加 flush: 'post'。
傻瓜口令:"想看DOM 加 post"
阶段27:喜新厌旧
目标:清理副作用
错误场景:搜索框输入太快,第1次的请求比第2次还晚回来,结果覆盖了第2次。
错误代码:
js
watch(search, (kw) => {
// 发请求... 如果网络卡,旧请求会覆盖新请求
axios.get(kw)
})
错误后果: 显示的数据和搜索词在界面上对不上。
正确代码:
js
// ✅ 正确:使用 onCleanup
watch(search, (kw, o, onCleanup) => {
let expired = false
onCleanup(() => expired = true) // 新的来了,把旧的标记过期
fetch(kw).then(() => {
if (!expired) showData()
})
})
解决方法 : 利用 onCleanup 只有在下次触发前执行的特性。
傻瓜口令:"请求太快 记得清理"
阶段28:自动停车
目标:理解生命周期绑定
对比表格:
| 场景 | 什么时候停止监听? |
|---|---|
| 在 setup() 里写 | 组件销毁时,自动停止 ✅ |
| 在 setTimeout 里写 | 只要不刷新页面,一直监听 ❌ |
错误代码:
js
setTimeout(() => {
// 危险:这个监听器可能永远不会死
watch(count, () => ...)
}, 1000)
正确代码:
js
// ✅ 始终在 setup 根作用域里写 watch
watch(count, () => ...)
解决方法: 不要在异步回调里创建 watch,除非你手动 stop。
傻瓜口令:"在组件里 自动停"
阶段29:避坑指南
目标:全局总结
特训总结:
- 盯局部 :不要
watch(obj.n),要watch(() => obj.n) - 防死锁:不要在回调里改该数据
- 惜性能 :大对象不要无脑
deep - 重时机 :想操作DOM要
post
最终口令:
傻瓜口令:"避开 坑点 走坦途"