销售找客户,很少只盯着一个搜索任务。例如一家设备服务团队,上午查看“包装设备采购”,接着切换到“设备维保需求”。两项任务都在读取线索,谁先返回,取决于网络、搜索来源和处理时间。
如果页面只做“请求返回后更新列表”,就可能发生一个很隐蔽的问题:标题已经是维保需求,下面却显示刚刚迟到的采购线索。销售沿着这张列表准备第一次沟通,业务方向从一开始就对错了。
这类问题不能只靠缩短加载时间解决。在意客AI的工作台源码中,读取和推进操作都需要回答同一个问题:这个结果,是否还属于用户当前正在处理的任务?
下面用一次任务切换,说明上下文绑定、请求代次和发送前检查分别解决什么。
把两个请求按实际发生的顺序排开:
时刻 | 发生的事 | 页面应该怎么处理 |
|---|---|---|
1 | 打开任务A,发起采购需求读取 | 等待A的结果 |
2 | 用户切换到任务B,发起维保需求读取 | 立即停止展示A的旧列表 |
3 | B先返回 | 展示B的线索 |
4 | A后返回 | 忽略A的迟到结果,继续展示B |

判断依据是结果归属,不是谁最后返回。同样的情况也会发生在搜索条件修改、账号切换、业务空间切换,或者同一任务连续刷新时。
很多页面会在切换时调用 AbortController.abort()。取消仍然有用,可以减少继续等待和无效处理,但不能把正确性完全交给取消:底层调用可能不响应信号,返回处理也可能已经排进微任务队列。结果真正写入页面前,仍要有一道检查。
只比较任务编号,容易遗漏登录身份和业务空间。编号相同,也不代表服务实例、使用者和可见数据范围仍然相同。
意客AI的 useTaskScope.ts 将服务实例、登录状态、用户、业务空间及其版本、任务变体共同作为上下文依赖。依赖变化时,会生成一个新的身份对象。旧操作持有旧对象,便不能再把自己当作当前操作。
这里的对象不负责保存业务数据,而是充当归属标记。源码中的检查很短:
const current = () => mounted.current && latest.current === identity;mounted.current 表示页面是否还在;对象相等表示上下文是否仍然一致。任务进度页面的变体还包含任务、轮次、状态以及来源版本等信息,因此旧轮次的结果不能只凭相同任务编号就继续写回。
另一个容易忽略的细节是切换后的第一次渲染。如果等到 useEffect 才清掉旧数据,中间可能有一帧仍然展示上一任务的列表。useResource 在返回给页面的数据中直接判断身份:身份不一致时,返回空数据和加载状态,而不是继续暴露旧数据。
销售看到列表时,必须能把它与当前的客户开发方向对应起来。
上下文没变,也会出现旧请求覆盖新请求。用户先刷新一次,随后又刷新一次;第二次先成功,第一次随后才回来。两者都属于任务B,但第一次结果已经过时。
useResource 为每次读取增加一个代次。写回时同时判断三个条件:页面仍在、上下文仍然相同、代次仍然最新。失败消息也经过同一组检查,避免旧错误覆盖新结果。
下面是可以用 Node.js 运行的最小复现,演示任务A迟到时,页面仍保留B。它简化了React状态和取消逻辑,只保留归属与代次检查:
const page = { scope: "A", generation: 0, view: "" };
const deferred = () => {
let resolve;
const promise = new Promise(r => { resolve = r; });
return { promise, resolve };
};
async function read(scope, promise) {
const generation = ++page.generation;
const result = await promise;
if (page.scope === scope && page.generation === generation) {
page.view = result;
}
}
const a = deferred(), b = deferred();
const readingA = read("A", a.promise);
page.scope = "B";
const readingB = read("B", b.promise);
b.resolve("设备维保线索");
await readingB;
a.resolve("包装设备采购线索");
await readingA;
console.log(page.view); // 设备维保线索保存为 scope-demo.mjs 后,执行 node scope-demo.mjs 即可观察输出。若去掉写回前的条件,迟到的A会覆盖B。若要检查同一任务的刷新竞态,可保持 scope 不变,让较早的请求最后返回。
这个例子也解释了为什么成功分支和失败分支都需要守卫。只挡旧数据,不挡旧错误,仍然可能让已经成功的页面重新显示报错。
读取错误会让列表不可信;操作错误还可能让系统推进一个用户已经离开的任务。
例如销售点击任务A的“继续研究”,请求尚未发出就切到B。点击时的检查已经通过,但这不代表稍后执行的代码仍然有权推进A。
在 ResearchProgress.tsx 中,推进函数先建立当前上下文检查,再把发送动作交给请求包装器。包装器真正调用发送函数时,再检查一次:
const advanceCurrent = (signal: AbortSignal) => {
if (!current()) throw new RequestCancelled();
return service.researchRuntime!.advance(taskId, runId, signal);
};这里的 current() 同时考虑任务上下文、当前控制状态、操作归属代次和取消信号。检查放在发送函数里面,才覆盖得住“点击已经发生,发送尚未发生”的间隙。
请求如果已经到达服务端,前端取消不等于服务端任务已经终止。此时应读取原任务的实际状态,不能因为浏览器没有收到回执就自动重发,也不能把超时显示成“什么都没发生”。
这种交互在正常网速下很容易看起来没问题。验证不能只按“A请求、A返回、切换B”的顺序点一遍。
更有效的做法,是分别控制两个请求何时完成:让B先成功,再释放A;让同一任务的新刷新先成功,再释放旧刷新;让旧请求最后报错。检查当前列表、加载状态和错误提示都没有退回旧结果。
对推进操作,还要在发送之前切换任务或失去控制资格,确认没有发出旧操作。如果请求已经发出,则检查页面能否查询并呈现原操作状态。两种情形的处理不能混为一谈。
这些处理服务于一个很具体的使用要求:销售在意客AI线索库中找到潜在需求后,切换任务、查看原文、准备第一轮沟通时,页面数据和操作都要对应当前业务。AI找客户的体验,既取决于找到什么,也取决于团队能否正确接着处理。
想看自己的业务怎样找到线索、判断需求并准备首次沟通,可在浏览器输入 www.tuokexing.net,预约意客AI产品演示。
意客AI产品团队/北京星河卓越科技有限公司
本文结合意客AI源码分析,使用AI辅助整理与编辑。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。