最近在做一个金融级表单,遇到了一个典型的问题:watch监听直接把页面搞挂了。翻来覆去调试了几轮,总算把坑填平了,这里把排查过程和优化思路梳理一下。
问题现场的代码大致是这样:

//计算期权费(名义本金*期权费率(%)*期限/计息基础)请求接口
let calcTimer = null;
watch(
() => [
form.value.nominalPrincipal,
form.value.premiumRate,
form.value.dayCount,
form.value.startDate,
form.value.mtrDate,
],
async (
[nominalPrincipal, premiumRate, dayCount, startDate, mtrDate]
) => {
if (calcTimer) clearTimeout(calcTimer);
if (form.value.secondClass === "场内期权") {
form.value.premiumAmount = null;
return;
}
if (
!nominalPrincipal ||
!premiumRate ||
!dayCount ||
!startDate ||
!mtrDate
) {
form.value.premiumAmount = null;
return;
}
calcTimer = setTimeout(async () => {
const res = await calcPremiumAmount({
nominalPrincipal,
premiumRate,
dayCount,
startDate,
mtrDate,
});
form.value.premiumAmount = res.data;
}, 300);
}
);
第一步改进,加入新旧值的比较,页面就恢复正常了:
//计算期权费(名义本金*期权费率(%)*期限/计息基础)请求接口
let calcTimer = null;
watch(
() => [
form.value.nominalPrincipal,
form.value.premiumRate,
form.value.dayCount,
form.value.startDate,
form.value.mtrDate,
],
async (
[nominalPrincipal, premiumRate, dayCount, startDate, mtrDate],
[oldNominalPrincipal, oldPremiumRate, oldDayCount, oldStartDate, oldMtrDate]
) => {
if (
nominalPrincipal === oldNominalPrincipal &&
premiumRate === oldPremiumRate &&
dayCount === oldDayCount &&
startDate === oldStartDate &&
mtrDate === oldMtrDate
)
return;
if (calcTimer) clearTimeout(calcTimer);
if (form.value.secondClass === "场内期权") {
form.value.premiumAmount = null;
return;
}
if (
!nominalPrincipal ||
!premiumRate ||
!dayCount ||
!startDate ||
!mtrDate
) {
form.value.premiumAmount = null;
return;
}
calcTimer = setTimeout(async () => {
const res = await calcPremiumAmount({
nominalPrincipal,
premiumRate,
dayCount,
startDate,
mtrDate,
});
form.value.premiumAmount = res.data;
}, 300);
}
);
考虑到代码的可读性,又尝试用Computed聚合依赖加上竞态处理,但页面还是挂了:
const premiumCalcDeps = computed(() => ({
secondClass: form.value.secondClass,
nominalPrincipal: form.value.nominalPrincipal,
premiumRate: form.value.premiumRate,
dayCount: form.value.dayCount,
startDate: form.value.startDate,
mtrDate: form.value.mtrDate,
}));
let calcTimer = null;
let currentRequestId = 0;
watch(premiumCalcDeps, async (newVal) => {
if (calcTimer) clearTimeout(calcTimer);
const { secondClass, nominalPrincipal, premiumRate, dayCount, startDate, mtrDate } = newVal;
if (secondClass === "场内期权") {
form.value.premiumAmount = null;
return;
}
if (!nominalPrincipal || !premiumRate || !dayCount || !startDate || !mtrDate) {
form.value.premiumAmount = null;
return;
}
calcTimer = setTimeout(async () => {
const requestId = ++currentRequestId;
try {
const res = await calcPremiumAmount({
nominalPrincipal,
premiumRate,
dayCount,
startDate,
mtrDate,
});
if (requestId === currentRequestId) {
form.value.premiumAmount = res.data;
}
} catch (error) {
console.error("期权费计算失败:", error);
}
}, 300);
});
加上newVal和oldVal的比较后,才算稳定运行:
const premiumCalcDeps = computed(() => ({
secondClass: form.value.secondClass,
nominalPrincipal: form.value.nominalPrincipal,
premiumRate: form.value.premiumRate,
dayCount: form.value.dayCount,
startDate: form.value.startDate,
mtrDate: form.value.mtrDate,
}));
let calcTimer = null;
let currentRequestId = 0;
watch(premiumCalcDeps, async (newVal, oldVal) => {
if (
newVal.secondClass === oldVal.secondClass &&
newVal.nominalPrincipal === oldVal.nominalPrincipal &&
newVal.premiumRate === oldVal.premiumRate &&
newVal.dayCount === oldVal.dayCount &&
newVal.startDate === oldVal.startDate &&
newVal.mtrDate === oldVal.mtrDate
) {
return;
}
if (calcTimer) clearTimeout(calcTimer);
const { secondClass, nominalPrincipal, premiumRate, dayCount, startDate, mtrDate } = newVal;
if (secondClass === "场内期权") {
form.value.premiumAmount = null;
return;
}
if (!nominalPrincipal || !premiumRate || !dayCount || !startDate || !mtrDate) {
form.value.premiumAmount = null;
return;
}
calcTimer = setTimeout(async () => {
const requestId = ++currentRequestId;
try {
const res = await calcPremiumAmount({
nominalPrincipal,
premiumRate,
dayCount,
startDate,
mtrDate,
});
if (requestId === currentRequestId) {
form.value.premiumAmount = res.data;
}
} catch (error) {
console.error("期权费计算失败:", error);
}
}, 300);
});
最后一步优化,把计算属性改为返回字符串,彻底切断对象引用:
const premiumCalcDeps = computed(() => {
return [
form.value.secondClass,
form.value.nominalPrincipal,
form.value.premiumRate,
form.value.dayCount,
form.value.startDate,
form.value.mtrDate,
].join("|");
});
let calcTimer = null;
let currentRequestId = 0;
watch(premiumCalcDeps, async (newVal, oldVal) => {
if (newVal === oldVal) return;
if (calcTimer) clearTimeout(calcTimer);
const [secondClass, nominalPrincipal, premiumRate, dayCount, startDate, mtrDate] = newVal.split("|");
if (secondClass === "场内期权") {
form.value.premiumAmount = null;
return;
}
if (!nominalPrincipal || !premiumRate || !dayCount || !startDate || !mtrDate) {
form.value.premiumAmount = null;
return;
}
calcTimer = setTimeout(async () => {
const requestId = ++currentRequestId;
try {
const params = Qs.stringify({
nominalPrincipal: Number(nominalPrincipal),
premiumRate: Number(premiumRate),
dayCount,
startDate,
mtrDate,
});
const res = await calcPremiumAmount(params);
if (requestId === currentRequestId) {
form.value.premiumAmount = res.data;
}
} catch (error) {
console.error("期权费计算失败:", error);
}
}, 300);
});
为什么对象比对会出问题?Vue 3的响应式系统里,对象的比对基于内存引用,字符串则基于内容。把依赖项拼成字符串,就彻底切断了与form对象结构的依赖关系,从根本上杜绝了因为对象引用变化引发的“幽灵触发”和死循环。
1. 什么是竞态处理?
在金融或表单业务中,竞态问题通常发生在“异步请求”加“防抖延迟”的组合下。举个例子:你正在修改期权费率(premiumRate),先输入了5,再删掉改成6。
- 输入5时,触发一个300ms定时器,准备请求接口A(参数5)。
- 200ms后输入6,触发新的300ms定时器,准备请求接口B(参数6)。
如果没有竞态处理,网络不可控时,接口A(参数5)可能比接口B(参数6)晚返回。最终用户输入的是6,页面却显示了5的结果,这就是竞态导致的数据错乱。
2. 代码执行过程深度解析
// 1. 全局变量,只要组件不销毁,它的值就会一直保留并累加
let currentRequestId = 0;
// 2. 每次 watch 触发(即每次 setTimeout 执行时),都会生成一个新的局部变量
const requestId = ++currentRequestId;
场景:用户快速输入5,然后改成6
- 第一次触发(输入5):currentRequestId从0变成1,局部变量requestId赋值为1。进入setTimeout准备发请求,这个setTimeout内部的requestId被锁死在1。
- 第二次触发(输入6):currentRequestId从1变成2,局部变量requestId赋值为2,进入新的setTimeout。此时全局的currentRequestId已经是2了。
if (requestId === currentRequestId) {
form.value.premiumAmount = res.data;
}
- 如果接口A(参数5)晚返回:它拿着自己的requestId=1,去和全局currentRequestId=2比较,1===2为false,数据被丢弃。
- 如果接口B(参数6)正常返回:它拿着requestId=2,和全局的2比较,2===2为true,数据成功写入。
3. 总结
关键在于:局部变量requestId是用const声明的,每次watch执行都会创建全新的、独立的requestId——它就像那次请求的“出生证明”,一旦生成永远不变。而全局的currentRequestId每次触发都自增,相当于当前世界的“最新时间戳”。
竞态处理的本质就是:用“局部的旧快照(requestId)”去对比“全局的新状态(currentRequestId)”,如果不一致,说明已经过时了,直接放弃更新。
这套逻辑在金融级表单里至关重要,它确保用户看到的永远是最后一次操作的结果。
