Vue 3的watchEffect、watch、computed
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>
可以看到:
-
首先,watchEffect是立即执行的,所以组件初始化的时候就全部执行了一遍。
-
点击button1,打印10,很好理解。
-
s的传入值是个对象,button2修改的是属性a,那么,只有监听属性a的监听器才会有反应,只跟属性b相关的监听是不会有反应的,只监听s.value的监听器也不会有反应。点击button3和button4也会印证这个结论。
-
在watchEffect里操作响应式数据,不会引起无限循环监听,这虽然很显而易见,但是也在此说一句。
-
多个watchEffect的执行顺序是watchEffect的书写顺序。
-
watchEffect拿不到更新前的值,这一点要注意。
停止监听
- 自动停止
先说watchEffect生命周期的开始,是从组件的setup()函数或生命周期钩子被调用时开始。自动停止是在组件卸载时自动停止。
- 手动停止
将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,到底什么意思?
首先了解一下“主作用”,在Vue世界里,视图层和DOM层是两码事,尽管一些初级程序员认为它们是一码事。变更响应式数据的主作用就是变更后的数据能渲染到视图层。前端还有比这个事更重要的事吗?没有吧。
副作用就是响应式数据的变更造成的其他连锁反应,这些连锁反应都是变更响应式数据的副作用。在药物学里,副作用往往是不良反应,但是在Vue 3里并不是。上面标题里说“清除副作用”,也并不是说因为副作用是不良反应所以要清除,而是Vue 3提供一个方法让你随时可以取消副作用。
修改响应式数据的主要副作用有:
-
DOM更新
-
watchEffect
-
watch
-
computed
-
...
你没看错,既然更新视图层才是主作用,那么视图层更新到DOM上在Vue眼里是副作用,而且,变更响应式数据触发执行computed和触发执行watchEffect当然也是副作用。所以watchEffect本身就是副作用。
那么官方文档说的“清除副作用”到底在说什么?它意思是说,比如有一个页码组件,里面有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);
});
vue单文件的核心有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种结局:
-
一种是响应的太快,来不及取消的请求,这种请求会返回200,不过既然它响应太快,没有任何一次后续ajax能够来得及取消它,说明任何一次后续ajax开始之前,它就已经结束了,那么它一定会被后续某些请求所覆盖,所以这类请求的content会显示一瞬间,然后被后续的请求覆盖,绝对不会比后面的请求还晚。
-
另一种就是红色的那些被取消的请求,因为响应的慢,所以被取消掉了。
所以最终结果一定是正确的,而且节省了很多带宽,也节省了系统开销。
这就是官方说的“清除副作用”。清除定时器更简单,我不举例了。
副作用刷新时机
官方文档: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更新之后的DOM,就要配置flush: 'post'。
watch
Vue 3 watch与Vue 2 watch对比
- Vue 3 watch与Vue 2的实例方法vm.$watch(也就是this.$watch)的基本用法差不多,只不过程序员大多使用watch配置项,可能对$watch实例方法不太熟。实例方法的一个优势是更灵活,第一个参数可以接受一个函数,等于是接受了一个getter函数。
<template>
<div>
<button @click="r++">{{ r }}</button>
</div>
</template>
<script>
import axios from 'axios';
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>
- Vue 3 watch增加了同时监听多个变量的能力,用数组表达要监听的变量。回调参数是这种结构:
[newR, newS, newT], [oldR, oldS, oldT]
,不要理解成其他错误的结构。
<template>
<div>
<button @click="r++">{{ r }}</button>
</div>
</template>
<script>
import axios from 'axios';
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>
-
被监听的变量必须是:
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以及它们的数组。绝对不可以是纯对象或基本数据。 -
Vue 3的watch没有立即执行能力,也没有地方让你加
immediate: true
。如果想立即执行,请使用watchEffect。 -
Vue 3的深度监听还有没有?当然有,而且默认就是,无需声明。当然,前提是深层property也是响应式的。
Vue 3 watch与Vue 3 watchEffect的差异
这方面官方文档说的还可以:
- 惰性地执行副作用,也就是说不会立即执行一次;
- 更具体地说明应触发侦听器重新运行的状态,这句话翻译还是很晦涩,其实意思是说,你现在能一眼看出来哪个变量被监听;
- 能访问侦听状态的先前值和当前值,不要小看这个差别,有时候拿不到先前值就没法进行业务。
所以,当你不希望立即执行一次监听器,或者需要拿到先前值,或者想明确表明哪些变量被监听了,就用watch。
其他差异有:
- 如果监听一个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均可 |
- 如果监听ref,跟上面类似,只是有2个注意事项:一是p后面不要忘记加.value,二是所谓“p.value.a.b.c的任意上级property”最高只允许到
p.value
,不能到p
。
Vue 3 watch与Vue 3 watchEffect的共性
官方说,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>
- 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>
- 调整副作用刷新时机,可以尝试注释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对象,所以并不需要在计算函数里给返回值添加响应式,这属于画蛇添足。