Published on

watch 与 watchEffect

Authors
  • avatar
    Name
    Deng Hua
    Twitter

目录

watch 用法

监听 ref 定义的响应式数据

<template>
  <div>
    <div>value:{{count}}</div>
    <button @click="add">change</button>
  </div>
</template>

<script>
import { ref, watch } from 'vue';
export default {
  setup(){
    const count = ref(0);
    const add = () => {
      count.value ++
    };
    watch(count,(newVal,oldVal) => {
      console.log('The value has changed:',newVal,oldVal)
    })
    return {
      count,
      add,
    }
  }
}
</script>

监听 reactive 定义的响应式数据

当直接侦听一个响应式对象时,侦听器会自动启用深层模式。

<template>
  <div>
    <div>{{ obj.name }}</div>
    <div>{{ obj.age }}</div>
    <button @click="changeName">change</button>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref, watch } from "vue";

const obj = reactive({
  name: "zs",
  age: 14,
});
const changeName = () => {
  obj.name = "ls";
};
watch(() => obj.name, (newVal, oldVal) => {
  console.log("The value has changed:", newVal, oldVal);
});
</script>

监听多个反应数据

<template>
  <div>
    <div>{{ obj.name }}</div>
    <div>{{ obj.age }}</div>
    <div>{{ count }}</div>
    <button @click="changeName">change</button>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref, watch } from "vue";

let count = ref(0);
const obj = reactive({
  name: "zs",
  age: 14,
});

const changeName = () => {
  obj.name = "ls";
  count.value++;
};
watch([count, () => obj.name], ([newCount, newName], [oldCount, oldName]) => {
  console.log(newCount); // 1
  console.log(oldCount); // 0

  console.log(newName); // ls
  console.log(oldName); // zs
});
</script>

监听响应式对象某一属性的变化。

<template>
  <div>
    <div>{{ obj.name }}</div>
    <div>{{ obj.age }}</div>
    <button @click="changeName">change</button>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref, watch } from "vue";


const obj = reactive({
  name: "zs",
  age: 14,
});
const changeName = () => {
  obj.name = "ls";
};
watch(
  () => obj.name,
  () => { console.log("The obj.name being monitored has changed.")}
);
</script>

deep、immediate

<template>
  <div>
    <div>{{ obj.brand.name }}</div>
    <button @click="changeBrandName">change</button>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref, watch } from "vue";

const obj = reactive({
  name: "zs",
  age: 14,
  brand: {
    id: 1,
    name: "bwm",
  },
});
const changeBrandName = () => {
  obj.brand = {
    id: 2,
    name: "aaa",
  };
};
watch(
  () => obj.brand,
  (newVal, oldVal) => {
    console.log(newVal.name);  // aaa
    console.log(oldVal?.name); // bwm

    console.log(newVal.id);  // 2
    console.log(oldVal?.id); // 1

  },
  { deep: true, immediate: true }
);
</script>

watchEffect 用法

基本

<template>
  <div>
    <input type="text" v-model="obj.name" />
  </div>
</template>

<script setup lang="ts">
import { reactive, watchEffect } from "vue";
let obj = reactive({
  name: "zs",
});

setTimeout(() => {
  obj.name = "ls";
}, 2000);

watchEffect(() => {
  console.log("name:", obj.name); // name: zs
  // and 1s after print 'name: ls'
});
</script>

停止 watchEffect

<template>
  <div>
    <input type="text" v-model="obj.name" />
  </div>
</template>

<script setup lang="ts">
import { reactive, watchEffect } from "vue";

let obj = reactive({
  id: 1,
});

const intervalId = setInterval(() => {
  obj.id++;
}, 1000);

const stop = watchEffect(() => {
  console.log("id:", obj.id);

  if (obj.id > 5) {
    stop();
    clearInterval(intervalId);
  }
});
/**
  print:

  id: 1
  id: 2
  id: 3
  id: 4
  id: 5
  id: 6
 */
</script>

watchEffect 的清除副作用

有时Effect函数可能会执行一些异步副作用,这些副作用在无效时可能需要清除。

一个场景:有一个分页组件,含有5个页面,点击它会异步请求数据。所以设置一个监听器,监听当前页码,只要有变化就发出请求。

问题:如果点击得非常快,一口气从第1页到第5页,这将发出5个请求,那么最终会显示哪一页呢?第5页吗?这假定第5页请求的 ajax 响应是最后收到的,但真的是这样吗?未必。所以这可能会造成混乱。

另一个问题,如果您连续快速点击分页数字5次,基本上您对前4页的内容不感兴趣,那么这些页面的前4个请求的带宽是否都被浪费掉了,这也不太好。


官方的解决方案是,监听器副作用函数可以接受一个 onInvalidate 函数作为参数,可以注册该函数用于在回调无效时清除回调。当发生以下情况时,会触发该失效回调:

  • 副作用即将重新执行时

  • 侦听器已停止((如果在 setup() 或生命周期钩子函数中使用了 watchEffect,则会在组件卸载时进行)。

watchEffect(onInvalidate => {
  const token = performAsyncOperation(id.value)
  onInvalidate(() => {
    // Id已变化 或者 监听器 stop()。
    // 使先前挂起的异步操作失效
    token.cancel()
  })
})

异步操作必须是可中止的。对于计时器来说,要停止它们非常简单,如clearInterval这样的函数就可以做到。但对于ajax来说,那就需要使用ajax库(如axios)提供的abort ajax方法来中止ajax。

示例

以下代码用于演示中止异步操作

首先,创建一个简单的Node服务器:

import http from 'http';

const server = http.createServer((req, res) => {
  res.setHeader('Access-Control-Allow-Origin', "*");
  res.setHeader('Access-Control-Allow-Credentials', true);
  res.setHeader('Access-Control-Allow-Methods', 'POST, GET, PUT, DELETE, OPTIONS');
  res.writeHead(200, { 'Content-Type': 'application/json' });
});
server.listen(2000, () => {
  console.log('Server is running...');
});
server.on('request', (req, res) => {
  setTimeout(() => {
    if (/\d.json/.test(req.url)) {
      const data = {
        content: 'I am the returned content from' + req.url
      }
      res.end(JSON.stringify(data));
    }
  }, Math.random() * 2000);
});

接下来是使用该server的Vue组件:

<template>
  <div>
    <div>content: {{ content }}</div>
    <button @click="changePageNumber">Page {{ pageNumber }}</button>
  </div>
</template>

<script setup lang="ts">
import axios from "axios";
import { ref, watchEffect } from "vue";

let pageNumber = ref(1);
let content = ref("");

const changePageNumber = () => {
  pageNumber.value++;
};
watchEffect((onInvalidate) => {
  const CancelToken = axios.CancelToken;
  const source = CancelToken.source();
  onInvalidate(() => {
    // source.cancel();
  });
  axios
    .get(`http://localhost:2000/${pageNumber.value}.json`, {
      cancelToken: source.token,
    })
    .then((response) => {
      content.value = response.data.content;
    })
    .catch(function (err) {
      if (axios.isCancel(err)) {
        console.log("Request canceled", err.message);
      }
    });
});
</script>

未注释source.cancel()之前,连续点击请求,每个请求都不会被取消,当由于接口响应时间随机,导致最终的请求结果并不一定就是最后一次点击时的结果。

放开注释source.cancel()后,最终的结果一定是正确的,同时也节省了大量的带宽,减少了并发。


副作用刷新的时机

Vue 的响应式系统会缓存副作用函数并异步刷新它们,这可以防止同一个 tick 中的多个状态变化导致不必要的重复调用。

tick 的含义是,Vue 的内部机制将根据最科学的计算规则将视图刷新的请求合并为一个 tick。 每个 "tick" 刷新视图一次,例如 a=1;b=2;仅会触发一次视图刷新。$nextTick 中的 "Tick" 就指的是这个。

例如, watchEffect 正在侦听两个变量 countcount2 。当我调用 countAdd 时,你认为监听器会被调用两次吗?

<template>
  <div>
    <div>{{count}} {{count2}}</div>
    <button @click="countAdd">Increase</button>
  </div>
</template>

<script>
import { ref, watchEffect } from 'vue';
export default {
  setup() {
    let count = ref(0);
    let count2 = ref(10);
    const countAdd = () => {
      count.value++;
      count2.value++;
    }
    watchEffect(() => {
      console.log(count.value, count2.value)
    })
    return {
      count,
      count2,
      countAdd
    }
  }
}
</script>

当然不是,Vue 会将其合并到一个执行中。 console.log 只会执行一次。


在Vue的核心实现中,组件的更新函数也是一个被监听的副作用。当用户定义的副作用函数进入队列时,默认情况下它会在所有组件更新之前执行。

所谓组件更新函数,就是Vue内置的一个函数,用来更新DOM,这也是一个副作用。

此时有一个问题: 默认情况下,Vue 会先执行组件 DOM 更新,还是先执行监听器?换句话说,监听器是在DOM更新之前执行?还是之后?

<template>
  <div>
    <div id="value">{{ count }}</div>
    <button @click="countAdd">Increase</button>
  </div>
</template>

<script setup lang="ts">
import { ref, watchEffect } from "vue";

let count = ref(0);
const countAdd = () => {
  count.value++;
};
watchEffect(() => {
  console.log(count.value);
  console.log(document.querySelector("#value") && document.querySelector("#value")?.innerText);
});
// result

// before click
// 0
// null

// first click
// 1
// 0

// second click
// 2
// 1
</script>

未点击click前,打印innerText,值为null,我们知道了,默认先执行监听器,后更新DOM。

首次运行时,虽然count.value1了,但是Vue还没有进行DOM更新操作,最新的DOM还没有生成,所以自然innerTextnull

且在第一次和第二次点击后,发现document.querySelector('#value').innerText 总是获取点击前的DOM的内容。

这也说明,默认情况下,Vue会先执行监听器,所以才到取上次的内容,然后再进行组件更新。

Vue 2实际上也使用了这种机制。 Vue 2 在组件更新后使用 this.$nextTick() 来获取 DOM。

watchEffect 中,不需要使用 this.$nextTick()(也不能),但是有一种方法可以在组件更新后获取 DOM,那就是配置flush

// 它在组件更新后触发,因此可以访问更新后的DOM。
// 注意:这也会延迟Effect的初始执行,直到第一个组件渲染完成。
watchEffect(
  () => {
    /* ... */
  },
  {
    flush: 'post'
  }
)

// result

// before click
// 0
// 0

// first click
// 1
// 1

// second click
// 2
// 2

总结: 默认情况下,先执行 watchEffect 监听器,然后执行 DOM 更新。如果要对“更新后的 DOM”进行操作,则需要配置 flush: 'post'

flush 选项具有以下几个值:

  • pre (默认)

  • post (组件更新后触发,这样就可以访问更新后的 DOM。这也会延迟副作用的首次执行,直到第一个组件渲染完成)

  • sync (与 watch 一样,强制侦听器每次更新后触发,但效率低下,很少使用)

调试 watchEffect 监听器

onTrackonTrigger 可用于调试侦听器。

当响应式属性或ref作为依赖项被跟踪时, onTrack 被调用。

当依赖项更改触发副作用时,将调用 onTrigger

两个回调都会收到一个debugger事件,其中包含有关依赖项的信息

建议在以下回调中编写 debugger 语句来检查依赖关系:

watchEffect(
  () => {
    /* side effects */
  },
  {
    onTrigger(e) {
      debugger
    }
  }
)

onTrackonTrigger 仅在开发模式下工作。

End.