先记住这个答案
watch 可以接收 ref、响应式对象和 getter 组成的来源数组。某个有效来源变化后,回调获得按来源顺序排列的新值数组与旧值数组,因此可以在同一处处理共同决定请求的输入。应传入 ref 本身或字段 getter,而不是提前读取出的普通值。多个同步修改可能被批量调度成一次回调,数组中的对象也不是自动深克隆的历史快照。
- 来源顺序决定新旧参数的对应位置
- 字段原始值应通过 getter 提供持续读取入口
- 同一轮同步修改可能合并,回调不等于逐赋值日志
页码与筛选状态可以在同一回调中处理
一个列表请求由 page 和 status 决定,分别写两个监听器可能重复实现请求、取消和错误处理。来源数组可以集中描述这两个输入,让回调拿到当前组合。这个组织方式并不自动保证请求只发送一次,还要考虑输入重置与异步竞争,但至少减少两条同步逻辑的重复维护。
例如筛选变化时还要把页码重置为一,应明确这是一组业务状态转换,避免先请求新筛选下的旧页码,再在另一个回调里修正页码引起第二次请求。可以在同一事件或状态更新入口中表达相关变化,再让同步逻辑处理最终输入。
代码中的来源和参数按位置一一对应
下面监听 page ref 和 filters.status getter,连续修改两者后等待下一次调度完成,再查看记录。示例使用默认调度,展示同一轮同步变化可以形成一次回调;它没有用同步 flush 将每次赋值强行拆开,也没有执行网络请求。
如果把第二项写成 filters.status,传进去的只是当时字符串,后续无法凭它继续读取字段。若数组中包含对象来源,旧参数也可能与新参数共享对象引用,不能因为外层是两个数组就假定内部已经有独立历史。需要旧字段数值时,应选择对应的原始值 getter。
import { nextTick, reactive, ref, watch } from 'vue';
const page = ref(1);
const filters = reactive({ status: 'open' });
const records: Array<{ next: unknown[]; previous: unknown[] }> = [];
const stop = watch([page, () => filters.status], (next, previous) => {
records.push({ next: [...next], previous: [...previous] });
});
page.value = 2;
filters.status = 'closed';
await nextTick();
stop();在支持顶层 await 的 Vue 模块环境中,records 包含一条记录:next 为 [2, closed],previous 为 [1, open]。这两项都是原始值,代码没有控制台输出。
来源数量增加后仍应保持业务边界
只有共同决定同一副作用的输入才值得放在一起。把主题、文档、鼠标位置和表单错误全装成一个来源数组,会让回调的触发条件越来越难解释。无关同步可以拆成独立过程,各自管理需要的资源与清理,而不是依赖位置很多的数组作为万能事件总线。
回调如果发请求,需要保留本轮参数并处理迟到结果;不能在请求完成时再从全局读取可能已经变化的 ID,然后把旧结果当成新请求结果。对输入组合建立明确的请求键或版本,有助于取消、缓存与验收。数组 API 只是提供来源关系,不会自动解决这些异步协议。
容易答错的地方
- 数组里写任意字段值都能自动追踪
- 普通原始值只是一张快照。要让 Vue 后续重新读取,应提供 ref、响应式对象或 getter,并注意对象入口与属性替换的区别。
- 新旧参数是不同数组,所以其中对象也是独立快照
- 外层数组不同不意味着嵌套对象被克隆。需要完整历史时应单独设计快照;只关心少量原始字段时直接监听那些字段更清楚。
面试官还会怎么问?
两个来源同一轮变化一定执行两次吗?
默认调度可能将同步变化合并,回调看到这一轮的最终组合;同步 flush 或跨调度轮次则可能不同。业务不能把来源数量当成固定回调次数。
immediate 首次多源回调有完整旧值数组吗?
没有此前观察结果时,不能假定每个旧位置都已有业务值。应按初始化情况处理,而不是无条件读取旧对象字段或用旧数组长度证明有历史。
监听来源数组与 getter 返回数组一样吗?
不完全一样。前者把每一项当作独立来源解析,后者是一个 getter 的返回结果,新的数组引用会影响结果比较;应按实际想表达的变化关系选择。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。