Computed 聚合依赖+ 竞态处理

出现问题: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)",如果不一致,说明我已经过时了,直接放弃更新。

这段代码在金融级表单中十分重要,它能确保用户看到的永远是最后一次操作的结果!

相关推荐
李明卫杭州3 小时前
Vue3 Suspense 组件详解:从入门到避坑指南
vue.js
gis开发之家6 小时前
vue3组合式函数(Composables)的艺术——将业务逻辑从UI组件中连根拔起
前端·javascript·vue.js·前端框架·vue3·前端工程化·前端架构
uuai6 小时前
vue导出pdf
前端·vue.js·pdf
Cobyte7 小时前
17.响应式系统比对:链表如何实现响应式系统的高效更新
前端·javascript·vue.js
前端青山21 小时前
Vue 3 避坑指南:从入门到精通的实战经验总结
前端·javascript·vue.js
濮水大叔1 天前
在大型 Vue 项目中,如何有效管理复杂的状态?“散装版” vs “套装版”
前端·vue.js·typescript
laowang3571 天前
依赖故障时,日志体系需要埋哪些关键节点才能快速定位是安装失败、解析失败还是运行时缺失?
前端·javascript·vue.js·webpack·node.js
大爱编程♡1 天前
Vue3基础
前端·javascript·vue.js
志尊宝1 天前
Vue3 环境搭建 + 第一个 HelloVue 项目(零基础入门)
vue.js·node.js·vue·css3