你不知道的 vue3 watch 新特性 == 优雅解决接口竞速问题

12 阅读7分钟

你不知道的 vue3 watch 新特性 == 优雅解决接口竞速问题

目录

[TOC]

在深入理解 onCleanup 之前,我们先回顾它与 flush: 'sync' 在Vue 3中的协同作用:

  • onCleanup :用于注册“清理”回调,当当前 watcher 的副作用即将被重新执行或卸载时自动调用,用来撤销上一轮的副作用,例如取消请求、清除定时器等 Vue.js Medium

  • flush: 'sync' :指定 watcher 回调“同步”执行,不走队列缓冲,值改变后立即触发,并保证在同一事件循环内执行清理与新副作用,适合配合 onCleanup 保证旧任务及时失效

下面我们从 Vue 基础的角度,分步剖析它们的作用与原理。

📜 onCleanup 基础概念

  • 定义与用途
    onCleanupwatchwatchEffect 回调的第三个(或第一个)参数,用来注册一个清理函数。当下一次副作用触发前,或者组件卸载时,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)几乎同时发起,执行顺序与完成顺序不一致。

  • 状态污染 :不同任务共享了同一个变量( timerflag )或者同一套逻辑,导致后发起的任务意外影响到先发起的任务。

竞速问题若不加管理,就会让界面展示 “过时” 数据,或者产生难以排查的 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' });
  1. 清空旧任务queue.forEach(cancelFn => cancelFn()) 遍历执行,给每个旧任务打上 flag = false

  2. 注册本次任务的清理函数 :将一个能标记本次 flag 的函数放到队列中。

  3. 只留最新一个queue = […] 确保队列长度始终为 1,只有最新任务能继续存在。

  4. 异步完成后检查 :只有 flag === true 时才更新视图。

2.3 局限性
  • 手动管理队列 :需要显式地清空、替换队列,代码臃肿。

  • 闭包变量难理解 :对初学者来说,队列中存的其实是“改变闭包内变量”的函数,稍显绕。

  • 多任务处理麻烦 :只能一对一管理,如果需要保留多个历史任务,逻辑会更复杂。

三、方案二:onCleanup 的优雅解法

Vue 3 的 watch 回调,除了接收 newValoldVal ,还提供了第三个参数 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' ):

    1. Vue 先执行上一次的清理函数:把 第一次flag 设为 false

    2. 然后进入第二次回调,创建新的 flag 并注册新的清理函数。

  • 第一次异步完成 :它拿到的是老的 flag (已被清理函数设为 false ),所以跳过更新。

  • 第二次异步完成 :它的 flag 仍为 true ,则正常更新。

3.2 闭包原理剖析
  1. 每次进入回调,都会创建一个新的 flag (函数作用域内的局部变量),和一个绑定在 onCleanup 的清理函数闭包。

  2. 上一次的清理函数闭包保留了上一轮的 flag ,调用时修改的正是它对应作用域里的那个变量。

  3. 这样,无需手动队列,就能确保“旧任务”被自动标记失效。

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. 第 1 秒 :先触发一次, timer 从 3000 变为 2000。

  2. 第 1 秒(紧接着) :同步触发第二次,Vue 调用第一次的 onCleanup ,把第一次的 flag1 设为 false ;然后新建 flag2 ,并注册其清理函数; timer 变为 1000。

  3. 第 2 秒 :第二次的 getData(1000) 完成,由于 flag2 === true ,页面展示 1000

  4. 第 2 秒后 :第一次的 getData(2000) 完成,但 flag1 === false ,跳过渲染。


五、小结

  • 竞速问题 源于多次异步调用对同一状态或共享变量的相互干扰,导致页面展示“过期”或“错序”数据。

  • 队列方案 虽能控制执行顺序,但需要手动管理,代码量大且易错。

  • onCleanup 方案 利用 Vue 3 提供的钩子和闭包特性,自动清理旧任务,更简洁、更易读,也更契合框架的生命周期。

误放在异步之后调用

await fetchData();
onCleanup(() => { /* 无效! */ });

清理函数注册太晚,Vue 无法关联上一轮副作用。

  • 乱用 sync 导致性能问题
    sync 模式每次赋值都会触发 watcher,缺乏队列缓冲,可能带来额外开销。仅在确实需要“马上清理、马上执行”时使用。 GitHub

  • 慎选场景

    • 需要实时响应并清理旧任务(如输入联想、逐字翻译)

    • 复杂异步间必须严格时序管理

掌握 onCleanup ,能让你在面对更多异步场景时,都能用优雅、低侵入的方式控制副作用,保持界面状态与业务逻辑的一致性。