你不知道的 vue3 watch 新特性 == 优雅解决接口竞速问题
目录
[TOC]
在深入理解 onCleanup
之前,我们先回顾它与 flush: 'sync'
在Vue 3中的协同作用:
下面我们从 Vue 基础的角度,分步剖析它们的作用与原理。
📜 onCleanup 基础概念
-
定义与用途
onCleanup
是watch
和watchEffect
回调的第三个(或第一个)参数,用来注册一个清理函数。当下一次副作用触发前,或者组件卸载时,Vue 会自动调用它,帮助我们释放资源或标记旧副作用失效 -
使用时机
必须在 watcher 回调 同步阶段 调用,也就是 在任何await
之前 。如果放在异步之后注册,Vue 将无法正确关联清理函数,导致无法按预期在下次触发时自动执行清理 -
典型场景
-
取消未完成的 HTTP 请求(AbortController)
-
清除定时器或订阅(
clearInterval
) -
撤销自定义事件监听
⏱ flush: 'sync' 配置详解
- 三种模式对比
选项 | 时机 | 缓冲队列 | 适用场景 |
---|---|---|---|
pre (默认) | 在组件更新之前触发,同一事件循环缓冲 | 会缓冲但去重,多次变更只触发一次 | 性能优先,数据一致性要求中等 |
post | 在组件更新之后触发,同步缓冲 | 同 pre | 依赖 DOM 更新结果的副作用 |
sync | 立刻 调用,不走缓冲队列 | 不缓冲 ,每次变更都触发 | 需要严格同步副作用和清理的场景 |
摘要
在使用 Vue 的 watch
并设置了 flush: 'sync'
后,如果多次快速修改被监听的值,就会产生“竞速”问题:第一次异步操作未完成时,第二次修改会覆盖第一次的结果,导致页面展示的不是预期的最新数据。本文将通过逐步剖析,先说明竞速带来的问题;再介绍用“队列”思路如何缓解;最后引入 Vue 3 提供的 onCleanup
钩子,借助闭包特性优雅地解决竞速,同时讲解闭包中 flag
标志位的运行逻辑,帮助你加深对异步清理和状态管理的理解。
一、竞速带来的问题
1.1 问题复现
考虑下面的场景:
-
初始定时器时间
timer = 3000
毫秒 -
第一次修改触发异步操作:等待 2000ms 后才拿到结果
-
第二次修改紧接着触发异步操作:等待 1000ms 后拿到结果
理想情况是页面展示最新的 1000ms 返回值,但在实际代码中却显示 2000ms ,也就是第一次操作的结果覆盖了第二次。
<script type="module">
import { ref, watch } from "/node_modules/vue/dist/vue.esm-browser.js"
const value = ref('');
function getData(timer) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(timer);
}, timer);
});
}
let timer = 3000;
watch(value, async (newVal, oldVal, onCleanup) => {
timer -= 1000;
const res = await getData(timer);
app.innerHTML = res;
}, {
flush: 'sync' // 同步执行
});
// 第一次请求是2s后,2000 第二次请求是1s后 1000 「理想应该是1000」 现在是2000
setTimeout(() => {
value.value = '123';
value.value = '456';
}, 1000);
</script>
1.2 竞速的本质
-
异步竞速 :多个异步任务(Promise)几乎同时发起,执行顺序与完成顺序不一致。
-
状态污染 :不同任务共享了同一个变量(
timer
、flag
)或者同一套逻辑,导致后发起的任务意外影响到先发起的任务。
竞速问题若不加管理,就会让界面展示 “过时” 数据,或者产生难以排查的 Bug。
二、方案一:队列(Queue)的思路
2.1 思路概述
使用“队列”来维护每一次更新的“执行权”,确保只有队列头部的请求能够最终更新视图。
-
每次触发时,将一个更新函数压入队列
-
执行新任务之前,先把队列中所有之前的任务依次“撤销”或“标记为不可执行”
-
然后异步完成后,只有最初入队(队首)的任务,才会更新页面
let queue = [];
watch(value, async () => {
// 把之前的所有任务都标记为失效
queue.forEach(cancelFn => cancelFn());
// 本次任务的标记函数
let flag = true;
queue = [() => flag = false];
timer -= 1000;
const res = await getData(timer);
if (flag) {
app.innerHTML = res;
}
}, { flush: 'sync' });
-
清空旧任务 :
queue.forEach(cancelFn => cancelFn())
遍历执行,给每个旧任务打上flag = false
。 -
注册本次任务的清理函数 :将一个能标记本次
flag
的函数放到队列中。 -
只留最新一个 :
queue = […]
确保队列长度始终为 1,只有最新任务能继续存在。 -
异步完成后检查 :只有
flag === true
时才更新视图。
2.3 局限性
-
手动管理队列 :需要显式地清空、替换队列,代码臃肿。
-
闭包变量难理解 :对初学者来说,队列中存的其实是“改变闭包内变量”的函数,稍显绕。
-
多任务处理麻烦 :只能一对一管理,如果需要保留多个历史任务,逻辑会更复杂。
三、方案二:onCleanup 的优雅解法
Vue 3 的 watch
回调,除了接收 newVal
、 oldVal
,还提供了第三个参数 onCleanup
。它可以注册清理函数:每次副作用(effect)即将重新执行或卸载时,Vue 会自动调用上一次注册的清理逻辑。借此,我们无需手动维护队列,也能达到相同效果。
3.1 onCleanup 使用方式
watch(value, async (newVal, oldVal, onCleanup) => {
// 标志本次异步是否仍有效
let flag = true;
// 注册清理函数:当下一次 watch 触发前或组件卸载时调用
onCleanup(() => {
flag = false;
});
timer -= 1000;
const res = await getData(timer);
if (flag) {
app.innerHTML = res;
}
}, { flush: 'sync' });
-
第一次触发 :
flag
初始为true
,注册清理函数。 -
第二次触发 (同步执行,因
flush: 'sync'
):-
Vue 先执行上一次的清理函数:把 第一次 的
flag
设为false
。 -
然后进入第二次回调,创建新的
flag
并注册新的清理函数。
-
-
第一次异步完成 :它拿到的是老的
flag
(已被清理函数设为false
),所以跳过更新。 -
第二次异步完成 :它的
flag
仍为true
,则正常更新。
3.2 闭包原理剖析
-
每次进入回调,都会创建一个新的
flag
(函数作用域内的局部变量),和一个绑定在onCleanup
的清理函数闭包。 -
上一次的清理函数闭包保留了上一轮的
flag
,调用时修改的正是它对应作用域里的那个变量。 -
这样,无需手动队列,就能确保“旧任务”被自动标记失效。
3.3 优势对比
特性 | 手动队列 | onCleanup |
---|---|---|
代码量 | 多行队列管理,修改麻烦 | 极简,直接在回调内注册清理 |
易读性 | 需要理解队列与闭包 | 一看即懂“注册清理——旧任务失效” |
扩展性 | 手动改写队列逻辑,复杂度成倍增长 | 顺应 Vue 生命周期,与 watch 深度整合 |
四、完整示例与逻辑梳理
<script type="module">
import { ref, watch } from "/node_modules/vue/dist/vue.esm-browser.js";
const value = ref('');
const app = document.getElementById('app');
let timer = 3000;
function getData(ms) {
return new Promise(resolve => setTimeout(() => resolve(ms), ms));
}
watch(value, async (newVal, oldVal, onCleanup) => {
// 1. 每次触发新回调,都新建一个 flag
let flag = true;
// 2. 注册清理逻辑:在下次或卸载时把 flag 设为 false
onCleanup(() => {
flag = false;
});
// 3. 倒计时逻辑
timer -= 1000;
const res = await getData(timer);
console.log(res, 'res');
// 4. 只有 flag 仍为 true 时,才渲染到页面
if (flag) {
app.innerHTML = res;
}
}, {
flush: 'sync' // 同步触发,确保 onCleanup 及时执行
});
// 模拟用户快速输入
setTimeout(() => {
value.value = '123';
value.value = '456';
}, 1000);
</script>
执行流程
-
第 1 秒 :先触发一次,
timer
从 3000 变为 2000。 -
第 1 秒(紧接着) :同步触发第二次,Vue 调用第一次的
onCleanup
,把第一次的flag1
设为false
;然后新建flag2
,并注册其清理函数;timer
变为 1000。 -
第 2 秒 :第二次的
getData(1000)
完成,由于flag2 === true
,页面展示1000
。 -
第 2 秒后 :第一次的
getData(2000)
完成,但flag1 === false
,跳过渲染。
五、小结
-
竞速问题 源于多次异步调用对同一状态或共享变量的相互干扰,导致页面展示“过期”或“错序”数据。
-
队列方案 虽能控制执行顺序,但需要手动管理,代码量大且易错。
-
onCleanup
方案 利用 Vue 3 提供的钩子和闭包特性,自动清理旧任务,更简洁、更易读,也更契合框架的生命周期。
❌ 误放在异步之后调用
await fetchData();
onCleanup(() => { /* 无效! */ });
清理函数注册太晚,Vue 无法关联上一轮副作用。
-
❌ 乱用
sync
导致性能问题
sync
模式每次赋值都会触发 watcher,缺乏队列缓冲,可能带来额外开销。仅在确实需要“马上清理、马上执行”时使用。 GitHub -
✅ 慎选场景
-
需要实时响应并清理旧任务(如输入联想、逐字翻译)
-
复杂异步间必须严格时序管理
-
掌握 onCleanup
,能让你在面对更多异步场景时,都能用优雅、低侵入的方式控制副作用,保持界面状态与业务逻辑的一致性。