Skip to content

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>

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

① 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 还是旧的。

常见错误 ​

  1. 现象:v-if 切换为 true 后立即访问 divRef.value.style,报 Cannot read properties of undefined。
    原因:数据已改,但 DOM 尚未渲染,ref 仍为空。
    修法:先修改状态,再 await nextTick() 或把操作放进 nextTick 回调。

  2. 现象:v-for 列表数据赋值后直接遍历 pRef.value,结果为空或报错。
    原因:列表 DOM 尚未随数据更新完成。
    修法:在 nextTick 回调中再读取 pRef.value 并操作元素。

  3. 现象:父组件通过 ref 拿子组件数据,得到 undefined。
    原因:子组件未向外暴露数据或方法。
    修法:子组件使用 defineExpose({ count, setCount }) 暴露后再由父组件访问。

  4. 现象:watchEffect 中启动的 setInterval 在组件销毁后仍在执行。
    原因:未清理副作用,定时器没有被清除。
    修法:使用 onInvalidate 回调清除定时器,或保存 stop 在适当时机停止监听。

  5. 现象:用 watchEffect 想获取修改前后的值,发现拿不到旧值。
    原因:watchEffect 只自动收集依赖,不提供 oldValue。
    修法:需要旧值时改用 watch 指定侦听源,或自行保存上一次的值。

  6. 现象:在数据修改前调用 nextTick,或在回调外直接读 DOM,拿到的仍是旧内容。
    原因:nextTick 的调用时机错误,未等待本次更新队列。
    修法:先修改响应式数据,再调用 nextTick 或 await nextTick() 后读取 DOM。

练习 ​

  1. 使用 watchEffect 同时监听 count 和 obj.age,在回调中启动定时器,并通过 onInvalidate 在依赖变化、手动停止、组件销毁时清理定时器。
  2. 实现一个按钮:点击后显示一个 div,并在显示后立即将其宽高设为 300px、背景设为红色;要求使用 nextTick 完成。
  3. 使用 v-for 渲染用户列表,数据异步加载完成后,通过 ref 获取所有 p 元素并在 nextTick 中实现隔行变色。
  4. 父组件通过 ref 调用子组件暴露的 setCount 方法,将子组件中的 count 修改为指定值;子组件需暴露 count 和 setCount。
  5. 实现“编辑/完成”切换:点击编辑显示输入框并自动聚焦,点击完成恢复文本显示;对比直接 focus() 与 nextTick 后 focus() 的差异。

原视频对照 ​

#视频时长
1ref与v-for结合使用05:46
2ref与组件结合使用11:25
3nextTick基本使用10:28
4nextTick应用一06:24
5nextTick应用二08:49
6nextTick应用三06:39

小结 ​

  1. watchEffect 会立即执行,并自动收集回调中访问到的响应式依赖。
  2. watchEffect 的 onInvalidate 用于清理副作用,在依赖变化、调用 stop、组件销毁时触发。
  3. watchEffect 不提供旧值;需要新旧值对比时应使用 watch。
  4. nextTick 用于等待 DOM 更新完成,支持回调、Promise 和 async/await 三种写法。
  5. v-if、v-for 更新后,ref 对应的 DOM 可能尚未存在或尚未更新,必须结合 nextTick 操作。
  6. ref 与组件结合可获取子组件实例,但子组件必须通过 defineExpose 暴露数据或方法。
  7. ref 与 v-for 结合得到的是元素数组,顺序与渲染顺序一致;动态列表更新后应重新读取。
  8. 数据变化到视图更新是异步批量的,不能假设修改数据后 DOM 会立刻变化。