先记住这个答案
watchEffect 只跟踪效果函数同步执行期间访问的响应式状态。第一次 await 之后继续读取的字段,不会仅因这次读取被加入该效果的依赖。要让字段变化触发重跑,应在 await 前读取并保存本轮值,或者改用 watch 显式声明源。把读取前置同时确定了请求参数快照,但仍需独立处理旧异步结果的失效与清理。
- 自动跟踪截止于同步执行窗口
- 先读取参数再开始等待
- 依赖修复与请求竞态修复是两件事
不是 async 关键字禁止了跟踪
async 函数在遇到第一个 await 之前仍会同步执行,所以这一段访问的 ref 或代理属性可以被收集。真正的边界是让出执行权后的继续执行,而不是函数声明上是否写了 async。
如果先等待鉴权令牌再读取搜索词,搜索词只出现在 await 后,那么用户继续输入可能不会让这个效果重跑。此时请求逻辑本身没有报错,表现却是只请求了一次或搜索条件不跟随更新。
让每轮工作拿到自己的参数快照
示例同步读取 query,然后交给异步加载器。这样 query 的改变可以重新运行效果,每一轮也使用自己的 key,不会在等待完成后突然读取另一个时刻的输入并与旧任务混在一起。
清理将本轮标记为无效,成功与失败记录都检查该标记。示例选择把错误也交给 commit,以便控制请求替身时观察完整结果;真实界面可拆成 data 和 error,但仍要保持同一资格判断。
import { watchEffect } from 'vue'
import type { Ref } from 'vue'
function observeQuery(query: Ref<string>, load: (key: string) => Promise<string>, commit: (value: string) => void) {
return watchEffect(async (onCleanup) => {
const key = query.value
let active = true
onCleanup(() => { active = false })
try {
const result = await load(key)
if (active) commit(result)
} catch (error) {
if (active) commit('error:' + String(error))
}
})
}key 是该轮普通字符串快照,query.value 的同步读取建立订阅。即便旧加载器不支持取消,旧轮失效后也不能再调用 commit;本例没有声称停止观察会终止加载器内部工作。
确认你要的是快照还是最新值
有些流程希望等待完成后再读取最新状态,这本身可以成立,但该读取不会自动变成触发来源。应把用于触发的源与用于提交校验的最新值分开命名,避免同一个变量同时承担两种时间语义。
如果依赖列表本来就固定,直接用 watch 监听 query 或多源数组往往更清楚。不要为了重新收集而套多个无关效果,尤其不要在每次异步完成后新建 watchEffect,否则还会叠加生命周期与重复订阅问题。
容易答错的地方
- 在 await 后随手读取来补依赖
- 跟踪窗口已经结束,多读几次也不会让此次异步继续执行重新获得原效果的同步上下文。修复应移动必要读取的位置或显式声明源,而不是通过重复访问来碰运气。
- 前置读取后删除所有过期保护
- 建立了正确依赖意味着源变化可以启动更多轮请求,反而更需要处理先后完成的竞争。快照防止参数混用,有效标记防止旧结果提交,两者解决的问题不同,不能互相替代。
面试官还会怎么问?
同步调用的辅助函数内部读取会被跟踪吗?
会,只要辅助函数在效果的同步执行路径中实际读取响应式状态。若辅助函数自己先等待再读取,那部分读取仍然越过了窗口,不能因为调用入口发生在同步阶段就保证所有内部操作都被跟踪。
await Promise.resolve 也会产生这个边界吗?
会。即使 Promise 已完成,await 后的继续执行仍然会异步恢复。不要把网络耗时长短当成依赖是否收集的条件,使用立即完成的 Promise 也能稳定复现这一语义。
如何证明问题来自依赖遗漏而不是请求失败?
用没有网络的可控加载器记录调用参数,只修改 await 后才读取的字段,再等待响应式刷新。如果没有新调用,而把读取前置后立即出现新调用,就能将依赖问题与传输失败分离。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。