Vue 3的watchEffect、watch、computed API详解

watchEffect

执行监听

watchEffect比较奇特,它跟Vue 2的watch有所区别,它的写法是:

watchEffect(() => {
  // 执行一些操作,其中必须含有响应式变量
})

为什么感觉怪怪的?watchEffect并没有要求你声明被监听的变量,而是,你在执行体里写哪个变量,Vue就收集、监听哪个变量,而且可以同时监听多个变量,看下例:

<template>
  <div>
    <button @click="r++">{{ r }}</button>
    <button @click="s.a++">{{ s.a }}</button>
    <button @click="s.b++">{{ s.b }}</button>
    <button @click="s.a++;s.b++">{{ s.a }} - {{ s.b }}</button>
  </div>
</template>

<script>
import { ref, computed, watchEffect } from 'vue';
export default {
  setup() {
    let r = ref(10);
    watchEffect(() => {
      console.log(r.value);
    });
    let s = ref({a: 100, b: 200});
    watchEffect(() => {
      console.log('a:', s.value.a);
    });
    watchEffect(() => {
      console.log('b:', s.value.b);
    });
    watchEffect(() => {
      console.log('a - b:', s.value.a + '-' + s.value.b);
    });
    watchEffect(() => {
      console.log('value:', s.value);
    });
    return {
      r,s
    };
  },
};
</script>

可以看到:

  1. 首先,watchEffect是立即执行的,所以组件初始化的时候就全部执行了一遍。

  2. 点击button1,打印10,很好理解。

  3. s的传入值是个对象,button2修改的是属性a,那么,只有监听属性a的监听器才会有反应,只跟属性b相关的监听是不会有反应的,只监听s.value的监听器也不会有反应。点击button3和button4也会印证这个结论。

  4. 在watchEffect里操作响应式数据,不会引起无限循环监听,这虽然很显而易见,但是也在此说一句。

  5. 多个watchEffect的执行顺序是watchEffect的书写顺序。

  6. watchEffect拿不到更新前的值,这一点要注意。

停止监听

  1. 自动停止

先说watchEffect生命周期的开始,是从组件的setup()函数或生命周期钩子被调用时开始。自动停止是在组件卸载时自动停止。

  1. 手动停止

将watchEffect赋值给变量,执行这个变量即可手动停止。比如:

const xx = watchEffect(() => {
  console.log('a:', s.value.a);
  s.value.a += 10
});
// 后来某个时间执行了:
xx(); // 停止监听

清除副作用

官方文档:https://v3.cn.vuejs.org/guide/reactivity-computed-watchers.html#清除副作用

官方文档里偶尔会蹦出来一个词“副作用”,初学者看完一头雾水,什么鬼副作用?英文文档里副作用是Side Effect,到底什么意思?

纯函数里的副作用概念

副作用其实是一个比较生僻的概念,最早来自于“纯函数”,纯函数是编程界早期的一个概念,具体可以看(https://zhuanlan.zhihu.com/p/139659155https://juejin.cn/post/6950059795659358221)。纯函数特征:

它应始终返回相同的值。不管调用该函数多少次,无论今天、明天还是将来某个时候调用它。
自包含(不使用全局变量)。
它不应修改程序的状态或引起副作用(修改全局变量)。

注意看,这里就出现了“副作用”。所以,纯函数的副作用就是:

一个函数除了返回确定的值之外,还做了其他的事情,那么这个函数做的这些事情就叫做“副作用”。

比如console.log(123)就有副作用,它返回undefined是主作用,但是我们不需要它的主作用,它的副作用就是在控制台打印123,我们要的是它的副作用。

再比如:

let counter = 0;

// 有副作用,副作用是把外部变量改了
incCounter() {
    counter += 1;
    return counter;
}

// 没有副作用
incNumber(m) {
    return m + 1;
}
React中的副作用概念

React等框架早先就在使用这个词,Vue从3.0开始,在文档里出现这个词。

那么,在React中,是不是副作用也是这个定义呢?未必。看看React是怎么说的:

你之前可能已经在 React 组件中执行过数据获取、订阅或者手动修改过 DOM。我们统一把这些操作称为“副作用”,或者简称为“作用”。
useEffect 就是一个 Effect Hook,给函数组件增加了操作副作用的能力。它跟 class 组件中的 componentDidMountcomponentDidUpdatecomponentWillUnmount 具有相同的用途,只不过被合并成了一个 API。
例如,下面这个组件在 React 更新 DOM 后会设置一个页面标题:

import React, { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  // 相当于 componentDidMount 和 componentDidUpdate:
  useEffect(() => {
    // 使用浏览器的 API 更新页面标题
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

当你调用 useEffect 时,就是在告诉 React 在完成对 DOM 的更改后运行你的“副作用”函数。由于副作用函数是在组件内声明的,所以它们可以访问到组件的 props 和 state。默认情况下,React 会在每次渲染后调用副作用函数 —— 包括第一次渲染的时候。
副作用函数还可以通过返回一个函数来指定如何“清除”副作用。

看完了之后,我们就知道React对于副作用的解释:

在执行所有业务逻辑之前,也就是组件初始化的时候,组件根据开发者的设定,由自身驱动的第一次DOM修改,就是主作用。此时,组件还没有执行任何一行逻辑代码。

主作用之后,组件就开始执行用户逻辑了,这里你眼里的业务逻辑代码,在React眼里都是副作用。

Vue 3里的副作用概念

跟React应该是说的一个意思,具体说:

所以首先了解一下“主作用”,在Vue世界里,视图层和DOM层是两码事,尽管一些初级程序员认为它们是一码事。变更响应式数据的主作用就是变更后的数据能渲染到视图层。前端还有比这个事更重要的事吗?没有吧。

副作用就是响应式数据的变更造成的其他连锁反应,以及后续逻辑,这些连锁反应都叫副作用。在药物学里,副作用往往是不良反应,但是在Vue 3里并不是。上面标题里说“清除副作用”,也并不是说因为副作用是不良反应所以要清除,而是Vue 3提供一个方法让你随时可以取消副作用。

副作用主要有:

  1. DOM更新

  2. watchEffect

  3. watch

  4. computed

  5. ...

你没看错,既然更新视图层才是主作用,那么视图层更新到DOM上在Vue眼里是副作用,而且,变更响应式数据触发执行computed和触发执行watchEffect当然也是副作用。所以watchEffect本身就是副作用。

清除副作用是什么意思

那么官方文档说的“清除副作用”到底在说什么?它意思是说,如果有些副作用是异步的,这就意味着你可以取消它,那么Vue创始人就给你提供了一个方法,让你优雅的取消这些异步副作用。

比如你有一个页码组件,里面有5个页码,点击就会异步请求数据。于是我就做了一个监听,监听当前页码,只要有变化就ajax一次。下例是不可直接运行的演示代码:

    let content = '';
    const pageNumber = ref(1);
    function onClickPageNumber(val) {
      pageNumber.value = val;
    }
    watchEffect(() => {
      ajax({pageNumber}).then(response => {
        content = response.data;
      })
    });

现在问题是,如果我点击的比较快,从1到5全点了一遍,那么会有5个ajax请求,最终页面会显示第几页的内容?你说第5页?那你是假定请求第5页的ajax响应的最晚,事实呢?并不一定。于是这就会导致错乱。还有一个问题,我连续快速点5次页码,等于我并不想看前4页的内容,那么是不是前4次的请求都属于带宽浪费?这也不好。于是官方就给出了一种解决办法:

首先,你的异步操作必须是能中止的异步操作,对于定时器来讲中止定时器很容易,clearInterval之类的就可以,但对于ajax来讲,需要借助ajax库(比如axios)提供的中止ajax办法来中止ajax。现在我写一个能直接运行的范例演示一下中止异步操作:

我先搭建一个最简Node服务器,3300端口的:

    const http = require('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(3300, () => {
      console.log('Server is running...');
    });
    server.on('request', (req, res) => {
      setTimeout(() => {
        if (/\d.json/.test(req.url)) {
          const data = {
            content: '我是内容,来自' + req.url
          }
          res.end(JSON.stringify(data));
        }
      }, Math.random() * 2000);
    });

清除副作用的核心有2点:

  1. 异步副作用要给出取消自身的办法

  2. watchEffect提供取消副作用的接口,也就是onInvalidate方法。Invalidate中文译义是作废,onInvalidate也就是作废监听器。

<template>
  <div>
    <div>content: {{ content }}</div>
    <button @click="pageNumber = (pageNumber++ % 5) + 1">{{ pageNumber }}</button>
  </div>
</template>

<script>
import axios from 'axios';
import { ref, watchEffect } from 'vue';
export default {
  setup() {
    let pageNumber = ref(1);
    let content = ref('');

    watchEffect((onInvalidate) => {
      // const CancelToken = axios.CancelToken;
      // const source = CancelToken.source();
      // onInvalidate(() => {
      //   source.cancel();
      // });
      axios
        .get(`http://localhost:3300/${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);
          }
        });
    });
    return {
      pageNumber,
      content,
    };
  },
};
</script>

上面注释掉的代码先保持注释掉,然后我们经过20多次疯狂点击之后,得到这个结果,显然,内容错乱了:

image.png

现在我取消注释,重新20多次疯狂点击,得到的结果就正确了:

image.png

除了最后一个请求,上面那些请求有2种结局:

  1. 一种是响应的太快,来不及取消的请求,这种请求会返回200,不过既然它响应太快,没有任何一次后续ajax能够来得及取消它,说明任何一次后续ajax开始之前,它就已经结束了,那么它一定会被后续某些请求所覆盖,所以这类请求的content会显示一瞬间,然后被后续的请求覆盖,绝对不会比后面的请求还晚。

  2. 另一种就是红色的那些被取消的请求,因为响应的慢,所以被取消掉了。

所以最终结果一定是正确的,而且节省了很多带宽,也节省了系统开销。

这就是官方说的“清除副作用”。清除定时器更简单,我不举例了。

副作用刷新时机

官方文档:https://v3.cn.vuejs.org/guide/reactivity-computed-watchers.html#副作用刷新时机

官方文档里的“副作用刷新时机”更晦涩,我解释一下。

Vue 的响应性系统会缓存副作用函数,并异步地刷新它们,这样可以避免同一个“tick”中多个状态改变导致的不必要的重复调用。

同一个“tick”的意思是,Vue的内部机制会以最科学的计算规则将视图刷新请求合并成一个一个的"tick",每个“tick”刷新一次视图,比如a=1;b=2;只会触发一次视图刷新。$nextTick的Tick就是指这个。

继续说,比如有个watchEffect监听了2个变量a和b,我的业务写了a=1;b=2;,你觉得监听器会调用2次?当然不会,Vue会合并成1次去执行,代码如下,console.log只会执行一次:

<template>
  <div>
    <button
      @click="
        r++;
        s++;
      "
    >
      {{ r }} - {{ s }}
    </button>
  </div>
</template>

<script>
import { ref, watchEffect } from 'vue';
export default {
  setup() {
    let r = ref(2);
    let s = ref(10);
    watchEffect(() => {
      console.log(r.value, s.value);
    });
    return {
      r,
      s,
    };
  },
};
</script>

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

所谓组件的update函数是Vue内置的用来更新DOM的函数,它也是副作用,上文已经提到过。这时候有一个问题,就是默认下,Vue会先执行组件DOM update,还是先执行监听器?测一下:

<template>
  <div>
    <button
      id="aa"
      @click="
        r++;
        s++;
      "
    >
      {{ r }} - {{ s }}
    </button>
  </div>
</template>

<script>
import { ref, watchEffect } from 'vue';
export default {
  setup() {
    let r = ref(2);
    let s = ref(10);
    watchEffect(
      () => {
        console.log(r.value, s.value);
        console.log(document.querySelector('#aa') && document.querySelector('#aa').innerText);
      }
    );
    return {
      r,
      s,
    };
  },
};
</script>

点击若干次(比如2次)按钮,得到的结果是:

image.png
image.png

为什么点之前按钮的innerText打印null?因为事实就是默认先执行监听器,然后更新DOM,此时DOM还未生成,当然是null。

当我第1和2次点击完,你会发现,document.querySelector('#aa').innerText获取到的总是点击之前DOM的内容。这也说明,默认Vue先执行监听器,所以取到了上一次的内容,然后执行组件update。

Vue 2其实也是这种机制,Vue 2使用this.$nextTick()去获取组件更新完成之后的DOM,在watchEffect里就不需要用this.$nextTick()(也没法用),有一个办法能获取组件更新完成之后的DOM,就是使用:

watchEffect(
  () => {
    /* ... */
  },
  {
    flush: 'post'
  }
)

现在设上flush配置项,重新进入组件,再看看:

没设flush: 'post' 设了flush: 'post'
image.png
image.png

所以结论是,如果要操作“更新之后的DOM”,就要配置flush: 'post'。

watch

Vue 3 watch与Vue 2 watch对比

  1. Vue 3 watch与Vue 2的实例方法vm.$watch(也就是this.$watch)的基本用法差不多,只不过程序员大多使用watch配置项,可能对$watch实例方法不太熟。实例方法的一个优势是更灵活,第一个参数可以接受一个函数,等于是接受了一个getter函数。
<template>
  <div>
    <button @click="r++">{{ r }}</button>
  </div>
</template>

<script>
import { ref, watch } from 'vue';
export default {
  setup() {
    let r = ref(1);
    let s = ref(10);
    watch(
      () => r.value + s.value,
      (newVal, oldVal) => {
        console.log(newVal, oldVal);
      }
    );
    return {
      r,
      s,
    };
  },
};
</script>
  1. Vue 3 watch增加了同时监听多个变量的能力,用数组表达要监听的变量。回调参数是这种结构:[newR, newS, newT], [oldR, oldS, oldT],不要理解成其他错误的结构。
<template>
  <div>
    <button @click="r++">{{ r }}</button>
  </div>
</template>

<script>
import { ref, watch } from 'vue';
export default {
  setup() {
    let r = ref(1);
    let s = ref(10);
    let t = ref(100);
    watch(
      [r, s, t],
      ([newR, newS, newT], [oldR, oldS, oldT]) => {
        console.log([newR, newS, newT], [oldR, oldS, oldT]);
      }
    );
    return {
      r,
    };
  },
};
</script>
  1. 被监听的变量必须是:A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.也就是说,可以是getter/effect函数、ref、Proxy以及它们的数组。绝对不可以是纯对象或基本数据。

  2. 想要Vue 3的watch立即执行,可以在watch的最后一个参数写上{immediate: true}

  3. Vue 3的深度监听还有没有?当然有,而且默认就是,无需声明。当然,前提是深层property也是响应式的。如果深层property无响应式,那么即便写上{deep: true}也没用。

Vue 3 watch与Vue 3 watchEffect的差异

这方面官方文档说的还可以:

  • 惰性地执行副作用,也就是说不会立即执行一次;
  • 更具体地说明应触发侦听器重新运行的状态,这句话翻译还是很晦涩,其实意思是说,你现在能一眼看出来哪个变量被监听;
  • 能访问侦听状态的先前值和当前值,不要小看这个差别,有时候拿不到先前值就没法进行业务。

所以,当你不希望立即执行一次监听器,或者需要拿到先前值,或者想明确表明哪些变量被监听了,就用watch。

其他差异有:

  1. 如果监听一个Proxy变量p,它的内部值结构是{a: {b: {c: 2}}}{a: {b: {c: {d: 3}}}},我打算监听p.a.b.c,那么:
watchEffect watch且p.a.b.c是基本类型 watch且p.a.b.c是引用类型
必须监听p.a.b.c自身 必须监听p.a.b.c的任意一级上级property 监听p.a.b.c自身和任意上级property均可
  1. 如果监听ref,跟上面类似,只是有2个注意事项:一是p后面不要忘记加.value,二是所谓“p.value.a.b.c的任意上级property”最高只允许到p.value,不能到p

Vue 3 watch与Vue 3 watchEffect的共性

官方说,watch也有停止侦听,清除副作用、副作用刷新时机和侦听器调试行为。简单举例:

  1. watch停止监听:

停止监听watch很简单,watch的时候就必须赋值给一个变量,这时候就开始监听。想停止监听就把这个变量当函数执行一下。

<template>
  <div>
    <button @click="r++">{{ r }}</button>
    <button @click="s()">stop</button>
  </div>
</template>

<script>
import { ref, watch } from 'vue';
export default {
  setup() {
    let r = ref(2);
    let s = watch(r, () => {
      console.log(r.value);
    });
    return {
      r,
      s,
    };
  },
};
</script>
  1. watch清除副作用:
<template>
  <div>
    <div>content: {{ content }}</div>
    <button @click="pageNumber = (pageNumber++ % 5) + 1">{{ pageNumber }}</button>
  </div>
</template>

<script>
import axios from 'axios';
import { ref, watch } from 'vue';
export default {
  setup() {
    let pageNumber = ref(1);
    let content = ref('');

    watch(pageNumber, (newVal, oldVal, onInvalidate) => {
      const CancelToken = axios.CancelToken;
      const source = CancelToken.source();
      onInvalidate(() => {
        source.cancel();
      });
      axios
        .get(`http://localhost:3300/${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);
          }
        });
    });
    return {
      pageNumber,
      content,
    };
  },
};
</script>
  1. 调整副作用刷新时机,可以尝试注释flush: 'post',作为对比:
<template>
  <div>
    <button
      id="aa"
      @click="
        r++;
        s++;
      "
    >
      {{ r }} - {{ s }}
    </button>
  </div>
</template>

<script>
import { ref, watch } from 'vue';
export default {
  setup() {
    let r = ref(2);
    let s = ref(10);
    watch(r,
      () => {
        console.log(r.value, s.value);
        console.log(document.querySelector('#aa') && document.querySelector('#aa').innerText);
      },
      {
        flush: 'post'
      }
    );
    return {
      r,
      s,
    };
  },
};
</script>

computed

Vue 3跟Vue 2的computed的差别在于,Vue 2是所有计算属性都是根对象的属性,Vue 3是计算属性都是独立变量,其他区别很小,就不细说了。

Vue 3 computed特点:

  • computed默认接收getter函数,也可以接收一个对象,对象里有get和set方法。set方法接收一个val参数。初学者可能会忘记写getter函数,只写计算表达式,要注意这点。

  • computed一定返回ref对象,所以并不需要在计算函数里给返回值添加响应式,这属于画蛇添足。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容