主题
watchEffect与nextTick(Lesson24 3/3)
本页对应 Lesson24,共 6 段视频,约 49 分钟。
学习目标
- 能够使用
ref与v-for结合获取真实 DOM 元素数组,并按索引修改元素样式实现隔行变色。 - 能够使用
ref获取子组件实例,通过defineExpose暴露数据或方法,并在父组件中调用或修改。 - 能够说明
nextTick的作用,并使用回调函数和Promise/async-await两种方式在 DOM 更新后执行操作。 - 能够运用
nextTick解决v-if切换后获取元素失败的问题,并处理元素隐藏或销毁后的边界情况。 - 能够运用
nextTick在异步数据渲染完成后通过ref获取列表元素并设置样式。 - 能够运用
nextTick实现编辑状态切换后输入框自动聚焦,并同步更新按钮文案。
知识点
watchEffect 的定位:自动收集依赖的副作用函数
为什么需要:watch 往往要手动指定“监听谁”,当副作用里同时用到 count、obj.age 等多个响应式数据时,逐个列源很啰嗦。watchEffect 的思路是:先执行一次回调,执行过程中读到哪些响应式数据,就把哪些收集为依赖。
怎么想:它不是“先声明监听源再等变化”,而是“先跑一遍,边跑边收集”。所以回调必须立即执行,否则无法知道依赖是谁。
怎么做:本节先验证“立即调用”:
ts
// 一- 立即调用
// watchEffect(function(){
// console.log("over");
// })易错点:不要把 watchEffect 当成“只监听某个变量”的 API;它依赖的是回调同步执行期间实际读取到的响应式数据。没读到的数据不会成为依赖。
依赖变化后再次执行
为什么需要:副作用里可能依赖多个数据,任一依赖变化都应重新执行,才能保持副作用与状态同步。
怎么想:在回调里读取 count.value 和 obj.age,二者都会被收集;之后修改任意一个,回调都会再次执行。
怎么做:
ts
// 当count或obj.age发生改变,会执行回调函数。
// watchEffect(()=>{
// console.log(count.value,obj.age);
// })易错点:依赖收集发生在回调同步执行阶段。若把读取放进 setTimeout、setInterval 或异步请求回调里,它不会在本次执行中被收集。后文 Child.vue 就体现了这一点:num.value++ 在定时器回调中,不是 watchEffect 的同步依赖。
清除副作用 onInvalidate
为什么需要:副作用可能创建定时器、发请求、绑事件。若依赖变化导致回调重跑,或组件被销毁,旧副作用必须清理,否则会泄漏或重复执行。
怎么想:watchEffect 回调接收 onInvalidate,用它注册“清理函数”。清理函数会在下一次回调执行前、调用 stop() 停止时、组件销毁时执行。转写稿里反复强调组件挂载/销毁和定时器清理的调试思路,这里正好对应:先创建资源,再在清理函数里释放资源。
怎么做:
ts
// const stop = watchEffect(function(onInvalidate){
// console.log(count.value,obj.age);
// onInvalidate(function(){
// console.log("清理副作用!")
// })
// })
// setTimeout(()=>{
// stop();
// },5000)易错点:onInvalidate 不是“取消本次回调”,而是清理上一次副作用;异步请求场景要配合取消标记或 AbortController,不能只写一句日志。
Child.vue:用 onInvalidate 清理定时器
为什么需要:组件内 setInterval 若不在销毁时清理,会持续运行。用 watchEffect 创建定时器,并在 onInvalidate 中 clearInterval,可以把“创建—清理”放在同一处。
怎么想:watchEffect 立即执行,创建定时器;组件隐藏/销毁时触发清理。注意按这段代码,watchEffect 回调同步阶段只打印日志并创建定时器,没有读取 num.value,所以 num 不是它的依赖,回调不会因 num 自增而重跑;onInvalidate 主要在停止或销毁时执行。
怎么做:完整代码如下。
vue
<template>
<h3>watchEffect</h3>
<p @click="count++">count-->{{count}}</p>
<p @click="obj.age++">obj.age-->{{obj.age}}</p>
<hr/>
<button @click="isRender=!isRender">显示与隐藏</button>
<Child v-if="isRender"/>
</template>
<script setup lang="ts">
import Child from "./components/Child.vue";
// 1- watchEffect是组合API
// 2- watchEffect是一个函数
// 3- watchEffect,接收一个回调函数,且该函数会被立即调用
import {onMounted, reactive, ref, watchEffect} from "vue";
const isRender = ref(true);
// 一- 立即调用
// watchEffect(function(){
// console.log("over");
// })
// 二- 回调函数中所依赖的数据发生改变,会执行回调函数。
const count = ref(1);
const obj = reactive({
age:12
})
// 当count发生改变那么会执行
// watchEffect(()=>{
// console.log(count.value);
// })
// 当count或obj.age发生改变,会执行回调函数。
// watchEffect(()=>{
// console.log(count.value,obj.age);
// })
// 三- 清除副作用函数
// 当所依赖的数据发生改变会执行清除副作用函数。
// 当停止时执行。
// 当组件被销毁时执行。
// 不需要指定侦听的数据(不知道)
// const stop = watchEffect(function(onInvalidate){
// console.log(count.value,obj.age);
// onInvalidate(function(){
// console.log("清理副作用!")
// })
// })
// setTimeout(()=>{
// stop();
// },5000)
// 总结:watch与watchEffect有何不同?
// 1- watchEffect与watch的初次执行时机不同。
// 2- watchEffect回调函数所依赖的数据发生改变会调用,watch是指定的侦听数据发生改变才会执行。
// 3- watchEffect无法获取修改前的值。watch可以。
// 4- watchEffect拥有清理副作用函数。
</script>vue
<template>
<div>Child->{{num}}</div>
</template>
<script setup lang="ts">
import {onBeforeUnmount, onMounted, ref, watchEffect} from "vue";
const num = ref(1);
// 应用
watchEffect(function(onInvalidate){
console.log("watchEffect回调函数");
const timer = setInterval(function(){
console.log(1111);
num.value++;
},1000)
onInvalidate(function(){
console.log("onInvalidate");
clearInterval(timer);
})
})
// let timer = -1;
// onBeforeUnmount(function(){
// clearInterval(timer);
// })
// onMounted(function(){
// timer = setInterval(()=>{
// num.value++;
// console.log(11111)
// },1000)
// })
</script>
<style scoped>
</style>易错点:不要以为 num.value++ 一定会让 watchEffect 重跑;它没有被同步读取,就不会被收集。若希望 num 变化触发重跑,应在回调同步阶段读取 num.value。
watch 与 watchEffect 的对比
为什么需要:两者都能观察响应式变化,但适用场景不同。怎么想:需要旧值、需要精确指定源、需要 immediate 控制时用 watch;副作用依赖多个数据、希望自动收集时用 watchEffect。怎么做:按本节笔记总结:初次执行时机不同;watchEffect 自动依赖,watch 指定源;watchEffect 拿不到修改前值,watch 可以;本节还强调 watchEffect 的清理副作用函数。易错点:需要旧值或精确控制时不要硬用 watchEffect;清理逻辑要放在 onInvalidate 注册的清理函数中,而不是写在回调末尾。
先建立常识:数据改变,视图不会立刻更新
为什么需要 nextTick:Vue 的响应式更新是异步批处理的。你在同步代码里改了 count、userName,Vue 不会马上把新值写到 DOM,而是先收集本轮变更,等同步代码执行完再统一更新视图。转写稿里用 onBeforeUpdate 验证过:一次点击里改了两个数据,更新钩子只触发一次,说明 Vue 会合并更新。
怎么想:所以“改完数据立刻读 DOM”读到的是旧视图;要读更新后的 DOM,就等 Vue 更新完。
怎么做:用 nextTick。它有三种常见写法:回调、then、await。
易错点:nextTick 等的是“视图更新完成”,不是“数据赋值完成”;回调里不要漏写调用;await nextTick() 必须写在 async 函数中。
vue
<template>
<div>nextTick:{{count}}</div>
<div>nextTick:{{userName}}</div>
<button @click="changeNum">count+1</button>
</template>
<script setup lang="ts">
import {onBeforeUpdate, onUpdated, ref,nextTick} from "vue";
const count = ref(1);
const userName = ref("zhangsan");
const changeNum = async function(){
count.value++;
userName.value+="!";
// 1- 回调函数
// nextTick(function(){
// // 可以获取更新以后的视图信息
// console.log("nextTick->回调函数",(document.querySelector("div") as HTMLDivElement).innerText)
// })
// 2- Promise
// nextTick().then(()=>{
// console.log("nextTick->回调函数",(document.querySelector("div") as HTMLDivElement).innerText)
// })
await nextTick();
console.log("nextTick->回调函数",(document.querySelector("div") as HTMLDivElement).innerText)
// console.log("changeNum",(document.querySelector("div") as HTMLDivElement).innerText)
}
// onBeforeUpdate(function(){
// console.log("onBeforeUpdate")
// })
// onUpdated(function(){
// console.log("onUpdated",(document.querySelector("div") as HTMLDivElement).innerText);
// })
</script>应用一:v-if 显示元素后再操作 DOM
为什么需要:v-if 为 false 时元素根本没渲染,divRef.value 拿不到真实 DOM;你把 isRender 改成 true 后立刻操作,仍会失败。
怎么想:先改状态,再等 nextTick,等 DOM 插入后再设置样式。
怎么做:changeIsRender 里先取反,进入显示态时 await nextTick(),再访问 divRef.value。
易错点:再次点击隐藏时不能再操作 divRef.value,否则元素已被移除;所以要先判断 isRender.value 为 true。
vue
<template>
<button @click="changeIsRender">点我</button>
<div ref="divRef" v-if="isRender"></div>
</template>
<script setup lang="ts">
import {ref,nextTick} from "vue";
const isRender = ref(false);
const divRef = ref();
const changeIsRender = async function(){
isRender.value = !isRender.value;
if(isRender.value){
await nextTick();
divRef.value.style.width="300px";
divRef.value.style.height="300px";
divRef.value.style.background = "red";
}
}
</script>应用二:v-for 列表渲染后设置隔行变色
为什么需要:异步拿到列表数据后直接操作 pRef,此时列表还没渲染,拿不到元素。转写稿里也强调 ref 与 v-for 结合得到的是数组,不是单个元素。
怎么想:先给 state.userList 赋值,再在 nextTick 回调里遍历 pRef.value。
怎么做:用 index % 2 判断奇偶行,分别设置背景色。
易错点:pRef.value 是数组,要 forEach;元素类型可写 HTMLParagraphElement;不要把它当单个 DOM 直接 .style。
vue
<template>
<p ref="pRef" v-for="(item,index) in state.userList" :key="index">{{item}}</p>
</template>
<script setup lang="ts">
import {ref, nextTick, reactive} from "vue";
type TState = {
userList:string[]
}
const state = reactive<TState>({
userList: []
});
const pRef = ref();
setTimeout(() => {
const data = ["张三", "李四", "王五", "赵六"];
state.userList = data;
nextTick(function(){
pRef.value.forEach((item:HTMLParagraphElement ,index:number)=>{
if(index % 2){
item.style.background = "red";
}else{
item.style.background = "green";
}
})
})
}, 3000)
</script>应用三:切换编辑态后让输入框自动聚焦
为什么需要:输入框由 v-if="isEdit" 控制,点击“编辑”后立刻 focus(),输入框还没挂载,inputRef.value 为空。
怎么想:先让 isEdit 变为 true,等 nextTick 后再聚焦。
怎么做:在 onEdit 中判断进入编辑态时执行 nextTick(() => inputRef.value.focus())。
易错点:只有进入编辑态才聚焦;不要在 isEdit 为 false 时访问输入框;v-else 显示文本,切换后按钮文案也要随状态变化。
vue
<template>
<div>
<input ref="inputRef" v-if="isEdit" v-model="str" type="text">
<span v-else>{{ str }}</span>
<button @click="onEdit">{{isEdit?"完成":"编辑"}}</button>
</div>
</template>
<script setup lang="ts">
import {ref, nextTick, reactive} from "vue";
let str = ref("你现在过的还好吗?");
let isEdit = ref(false);
let inputRef = ref();
const onEdit = function(){
isEdit.value = !isEdit.value;
if(isEdit.value){
nextTick(function(){
inputRef.value.focus();
})
}
}
</script>课堂画面






代码示例
① src/App.vue(watchEffect 基本用法)
vue
<template>
<h3>watchEffect</h3>
<p @click="count++">count-->{{ count }}</p>
<p @click="obj.age++">obj.age-->{{ obj.age }}</p>
<hr />
<button @click="isRender = !isRender">显示与隐藏</button>
<Child v-if="isRender" />
</template>
<script setup lang="ts">
import Child from "./components/Child.vue";
// 1- watchEffect是组合API
// 2- watchEffect是一个函数
// 3- watchEffect,接收一个回调函数,且该函数会被立即调用
import { onMounted, reactive, ref, watchEffect } from "vue";
const isRender = ref(true);
// 一- 立即调用
// watchEffect(function(){
// console.log("over");
// })
// 二- 回调函数中所依赖的数据发生改变,会执行回调函数。
const count = ref(1);
const obj = reactive({
age: 12,
});
// 当count发生改变那么会执行
// watchEffect(()=>{
// console.log(count.value);
// })
// 当count或obj.age发生改变,会执行回调函数。
// watchEffect(()=>{
// console.log(count.value,obj.age);
// })
// 三- 清除副作用函数
// 当所依赖的数据发生改变会执行清除副作用函数。
// 当停止时执行。
// 当组件被销毁时执行。
// 不需要指定侦听的数据(不知道)
const stop = watchEffect(function (onInvalidate) {
console.log(count.value, obj.age);
onInvalidate(function () {
console.log("清理副作用!");
});
});
setTimeout(() => {
stop();
}, 5000);
// 总结:watch与watchEffect有何不同?
// 1- watchEffect与watch的初次执行时机不同。
// 2- watchEffect回调函数所依赖的数据发生改变会调用,watch是指定的侦听数据发生改变才会执行。
// 3- watchEffect无法获取修改前的值。watch可以。
// 4- watchEffect拥有清理副作用函数。
</script>要点 / 易错点
watchEffect(回调)在组件 setup 阶段立即执行一次,这一点与watch默认的懒执行不同。- 不需要手动传依赖:回调里读到了哪个响应式数据(这里是
count.value、obj.age),就自动收集哪个;模板里点击count++、obj.age++都会让回调重新执行。 - 易错:
ref必须写.value(count.value),reactive直接写obj.age;写成count会收集不到依赖,回调永远不会重新执行。 onInvalidate(回调)注册的清理函数会在三种时机执行:依赖数据变化(重新执行回调前)、调用stop()时、组件被销毁时。watchEffect返回stop函数,setTimeout(() => stop(), 5000)就是手动停止侦听,停止后改数据不再执行回调。- 与
watch的差别:watchEffect拿不到修改前的旧值,watch可以;watchEffect无法显式指定侦听源。
② src/components/Child.vue(watchEffect 清理副作用的应用)
vue
<template>
<div>Child->{{ num }}</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watchEffect } from "vue";
const num = ref(1);
// 应用
watchEffect(function (onInvalidate) {
console.log("watchEffect回调函数");
const timer = setInterval(function () {
console.log(1111);
num.value++;
}, 1000);
onInvalidate(function () {
console.log("onInvalidate");
clearInterval(timer);
});
});
// let timer = -1;
// onBeforeUnmount(function(){
// clearInterval(timer);
// })
// onMounted(function(){
// timer = setInterval(()=>{
// num.value++;
// console.log(11111)
// },1000)
// })
</script>
<style scoped></style>要点 / 易错点
- 回调里执行了
num.value++,读取了num.value,所以num也被收集成依赖;定时器每秒改一次num,watchEffect回调就会每秒重新执行一次。 - 关键在
onInvalidate:每次重新执行前先clearInterval(timer)清掉上一次的定时器,否则定时器会不断叠加(1 个变 2 个、3 个……)。这是最容易踩的坑。 - 每次重新执行时
timer是局部变量,只能靠闭包里的onInvalidate拿到“上一次那个”定时器,所以清理函数必须写在回调内部。 - 组件被销毁时(父组件
v-if="isRender"切换为 false)会自动触发onInvalidate,因此不需要再写onBeforeUnmount+ 手动clearInterval,这正是下面被注释掉的旧写法想做的事。 - 易错:把
setInterval写在watchEffect外部、清理函数写在内部,会导致清理不到;创建与清理必须在同一个回调作用域里配对。
③ src/components/NextTickDemo.vue(nextTick 应用:编辑与聚焦)
vue
<template>
<div>
<input ref="inputRef" v-if="isEdit" v-model="str" type="text" />
<span v-else>{{ str }}</span>
<button @click="onEdit">{{ isEdit ? "完成" : "编辑" }}</button>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, reactive } from "vue";
let str = ref("你现在过的还好吗?");
let isEdit = ref(false);
let inputRef = ref();
const onEdit = function () {
isEdit.value = !isEdit.value;
if (isEdit.value) {
nextTick(function () {
inputRef.value.focus();
});
}
};
</script>要点 / 易错点
- 常识前提:数据改变不会立刻更新视图。
isEdit.value = true之后,<input>还没被渲染出来,此时inputRef.value是undefined,直接.focus()必然报错。 nextTick(回调)会把回调推迟到DOM 更新完成之后执行,此时inputRef.value才是真实的 input 元素,focus()才生效。另外两种写法等价:nextTick().then(...)、await nextTick()后再执行后续代码(见笔记 4-9 基本使用)。v-if为 false 时用v-else显示文本、为 true 时显示输入框,切换完成后才聚焦,顺序不能颠倒。- 易错:
inputRef必须与模板中ref="inputRef"同名;v-if隐藏期间ref为undefined,任何对它的取值都要放在nextTick之后。 - 同类场景:
v-if显示元素后设置宽高/背景色(应用一)、v-for渲染完列表后遍历pRef.value设置隔行变色(应用二),都必须先await nextTick()再操作 DOM,因为数据赋值那一刻 DOM 还是旧的。
常见错误
现象:
v-if切换为true后立即访问divRef.value.style,报Cannot read properties of undefined。
原因:数据已改,但 DOM 尚未渲染,ref仍为空。
修法:先修改状态,再await nextTick()或把操作放进nextTick回调。现象:
v-for列表数据赋值后直接遍历pRef.value,结果为空或报错。
原因:列表 DOM 尚未随数据更新完成。
修法:在nextTick回调中再读取pRef.value并操作元素。现象:父组件通过
ref拿子组件数据,得到undefined。
原因:子组件未向外暴露数据或方法。
修法:子组件使用defineExpose({ count, setCount })暴露后再由父组件访问。现象:
watchEffect中启动的setInterval在组件销毁后仍在执行。
原因:未清理副作用,定时器没有被清除。
修法:使用onInvalidate回调清除定时器,或保存stop在适当时机停止监听。现象:用
watchEffect想获取修改前后的值,发现拿不到旧值。
原因:watchEffect只自动收集依赖,不提供oldValue。
修法:需要旧值时改用watch指定侦听源,或自行保存上一次的值。现象:在数据修改前调用
nextTick,或在回调外直接读 DOM,拿到的仍是旧内容。
原因:nextTick的调用时机错误,未等待本次更新队列。
修法:先修改响应式数据,再调用nextTick或await nextTick()后读取 DOM。
练习
- 使用
watchEffect同时监听count和obj.age,在回调中启动定时器,并通过onInvalidate在依赖变化、手动停止、组件销毁时清理定时器。 - 实现一个按钮:点击后显示一个
div,并在显示后立即将其宽高设为300px、背景设为红色;要求使用nextTick完成。 - 使用
v-for渲染用户列表,数据异步加载完成后,通过ref获取所有p元素并在nextTick中实现隔行变色。 - 父组件通过
ref调用子组件暴露的setCount方法,将子组件中的count修改为指定值;子组件需暴露count和setCount。 - 实现“编辑/完成”切换:点击编辑显示输入框并自动聚焦,点击完成恢复文本显示;对比直接
focus()与nextTick后focus()的差异。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | ref与v-for结合使用 | 05:46 |
| 2 | ref与组件结合使用 | 11:25 |
| 3 | nextTick基本使用 | 10:28 |
| 4 | nextTick应用一 | 06:24 |
| 5 | nextTick应用二 | 08:49 |
| 6 | nextTick应用三 | 06:39 |
小结
watchEffect会立即执行,并自动收集回调中访问到的响应式依赖。watchEffect的onInvalidate用于清理副作用,在依赖变化、调用stop、组件销毁时触发。watchEffect不提供旧值;需要新旧值对比时应使用watch。nextTick用于等待 DOM 更新完成,支持回调、Promise 和async/await三种写法。v-if、v-for更新后,ref对应的 DOM 可能尚未存在或尚未更新,必须结合nextTick操作。ref与组件结合可获取子组件实例,但子组件必须通过defineExpose暴露数据或方法。ref与v-for结合得到的是元素数组,顺序与渲染顺序一致;动态列表更新后应重新读取。- 数据变化到视图更新是异步批量的,不能假设修改数据后 DOM 会立刻变化。