出现问题:watch监听,导致页面挂了,代码如下:
js
//计算期权费(名义本金*期权费率(%)*期限/计息基础)请求接口
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);
}
);
改善第一步:比较newvalue 和oldvalue的比较,页面正常运行:
js
//计算期权费(名义本金*期权费率(%)*期限/计息基础)请求接口
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 聚合依赖+ 竞态处理:代码如下还是页面挂了:
js
// 1. 使用计算属性聚合依赖,代码更简洁、语义更清晰
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,
}));
// 2. 声明防抖定时器和请求标识符(用于解决异步竞态问题)
let calcTimer = null;
let currentRequestId = 0;
// 3. 监听计算属性
watch(premiumCalcDeps, async (newVal) => {
// 每次触发先清除上一次的定时器,实现防抖
if (calcTimer) clearTimeout(calcTimer);
const {
secondClass,
nominalPrincipal,
premiumRate,
dayCount,
startDate,
mtrDate,
} = newVal;
// 场内期权直接清空
if (secondClass === "场内期权") {
form.value.premiumAmount = null; // 直接写回 form,因为精准监听,不会死循环
return;
}
// 基础校验:任一必填项缺失则清空
if (
!nominalPrincipal ||
!premiumRate ||
!dayCount ||
!startDate ||
!mtrDate
) {
form.value.premiumAmount = null; // 直接写回 form
return;
}
// 300ms 防抖延迟
calcTimer = setTimeout(async () => {
// 每次发起请求前,生成一个新的请求 ID
const requestId = ++currentRequestId;
try {
const res = await calcPremiumAmount({
nominalPrincipal,
premiumRate,
dayCount,
startDate,
mtrDate,
});
// 【关键】只有当当前请求 ID 等于最新 ID 时,才更新数据
// 防止旧请求晚返回,覆盖了新请求的数据
if (requestId === currentRequestId) {
form.value.premiumAmount = res.data; // 最终结果安全地写回 form
}
} catch (error) {
console.error("期权费计算失败:", error);
}
}, 300);
});
第一步完善添加newval和oldval的判断,页面正常运行:
js
// 1. 使用计算属性聚合依赖,代码更简洁、语义更清晰
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,
}));
// 2. 声明防抖定时器和请求标识符(用于解决异步竞态问题)
let calcTimer = null;
let currentRequestId = 0;
// 3. 监听计算属性
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; // 直接写回 form,因为精准监听,不会死循环
return;
}
// 基础校验:任一必填项缺失则清空
if (
!nominalPrincipal ||
!premiumRate ||
!dayCount ||
!startDate ||
!mtrDate
) {
form.value.premiumAmount = null; // 直接写回 form
return;
}
// 300ms 防抖延迟
calcTimer = setTimeout(async () => {
// 每次发起请求前,生成一个新的请求 ID
const requestId = ++currentRequestId;
try {
const res = await calcPremiumAmount({
nominalPrincipal,
premiumRate,
dayCount,
startDate,
mtrDate,
});
// 【关键】只有当当前请求 ID 等于最新 ID 时,才更新数据
// 防止旧请求晚返回,覆盖了新请求的数据
if (requestId === currentRequestId) {
form.value.premiumAmount = res.data; // 最终结果安全地写回 form
}
} catch (error) {
console.error("期权费计算失败:", error);
}
}, 300);
});
完善第二步:计算属性返回字符串,隔离对象响应:
js
// 1. 将6个字段拼接成一个字符串(用特殊符号分隔,防止数据粘连)
const premiumCalcDeps = computed(() => {
return [
form.value.secondClass,
form.value.nominalPrincipal,
form.value.premiumRate,
form.value.dayCount,
form.value.startDate,
form.value.mtrDate,
].join("|"); // 使用 '|' 作为分隔符,确保每个字段的独立性
});
// 2. 声明防抖定时器和请求标识符(用于解决异步竞态问题)
let calcTimer = null;
let currentRequestId = 0;
// 3. 监听计算属性
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; // 直接写回 form,因为精准监听,不会死循环
return;
}
// 基础校验:任一必填项缺失则清空
if (
!nominalPrincipal ||
!premiumRate ||
!dayCount ||
!startDate ||
!mtrDate
) {
form.value.premiumAmount = null; // 直接写回 form
return;
}
// 300ms 防抖延迟
calcTimer = setTimeout(async () => {
// 每次发起请求前,生成一个新的请求 ID
const requestId = ++currentRequestId;
try {
const params = Qs.stringify({
nominalPrincipal: Number(nominalPrincipal),
premiumRate: Number(premiumRate),
dayCount,
startDate,
mtrDate,
});
const res = await calcPremiumAmount(params);
// 【关键】只有当当前请求 ID 等于最新 ID 时,才更新数据
// 防止旧请求晚返回,覆盖了新请求的数据
if (requestId === currentRequestId) {
form.value.premiumAmount = res.data; // 最终结果安全地写回 form
}
} catch (error) {
console.error("期权费计算失败:", error);
}
}, 300);
});
在 Vue 3 的响应式系统中,对象(Object)的比对是基于内存引用(Reference)的,而字符串(String)的比对是基于内容(Value)的。将依赖项拼接成字符串,可以彻底切断与 form 对象结构的依赖,从根本上杜绝因为对象引用变化引发的"幽灵触发"和死循环。
1. 什么是竞态处理(Race Condition)?
在金融或表单业务中,竞态问题通常发生在"异步请求"+"防抖延迟"的组合下。
举个生动的例子:
假设你正在输入期权费率(premiumRate),你快速输入了 5,然后马上删掉改成了 6。
- 当你输入
5时,触发了一个 300ms 的定时器,准备去请求接口 A(参数是 5)。 - 200ms 后,你输入了
6,触发了新的 300ms 定时器,准备去请求接口 B(参数是 6)。
如果没有竞态处理,会发生什么?
网络是不可控的。有可能接口 A(参数 5)因为某种原因(比如服务器卡顿)比接口 B(参数 6)晚返回 。
结果就是:用户明明最后输入的是 6,但页面上却显示了接口 A 返回的 5 的结果。这就是"竞态"导致的数据错乱。
2. 代码执行过程深度解析
具体的数值变化:
ini
// 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 了。
接口返回时的"身份验证":
ini
if (requestId === currentRequestId) {
form.value.premiumAmount = res.data;
}
- 如果接口 A(参数 5)晚返回 :
它拿着自己的requestId = 1,去和全局的currentRequestId = 2比较。
1 === 2❌ 结果为false。
结果:数据被丢弃,页面不会更新。 - 如果接口 B(参数 6)正常返回 :
它拿着自己的requestId = 2,去和全局的currentRequestId = 2比较。
2 === 2✅ 结果为true。
结果 :数据成功写入form.value.premiumAmount。
3. 总结
"requestId 是 只是作用在当时的 setTimeout 它的内部的值,不会根据多次 setTimeout 变化?"
因为它是用 const 声明的局部变量,每次 watch 执行都会创建一个全新的、独立的 requestId。它就像是那次请求的"出生证明",一旦生成,永远不会改变。
"currentRequestId 会一直变化?"
它是外部的全局变量,每次触发 watch 都会自增(++)。它就像是当前世界的"最新时间戳",永远代表最新的状态。
一句话概括竞态处理的本质 :
用"局部的旧快照(requestId)"去对比"全局的新状态(currentRequestId)",如果不一致,说明我已经过时了,直接放弃更新。
这段代码在金融级表单中十分重要,它能确保用户看到的永远是最后一次操作的结果!