侧边栏壁纸
  • 累计撰写 37 篇文章
  • 累计创建 10 个标签
  • 累计收到 15 条评论

目 录CONTENT

文章目录

Vue3响应式陷阱-store数组重新赋值导致刷新丢数据

小杨学JAVA
2026-08-28 / 0 评论 / 1 点赞 / 14 阅读 / 0 字 / 正在检测是否收录...
温馨提示:
"请尊重网络安全,避免恶意攻击,让我们共同维护良好的网络环境。"

Vue3 响应式踩坑记:store 里的数组重新赋值,刷新一下数据就没了

作者:小杨学JAVA
场景:Vue 3 + Vite + TS,自封装的模块级 store(没用 Pinia)

一、先说现象,对号入座

项目里有个预算管理模块,页面三个 tab 分别展示「预算说明」「结算项」「特殊规则」。问题是这样的:

  • 重新登录:数据正常显示,三个 tab 都有内容 ✅
  • 按 F5 刷新页面:三个 tab 全空了,数据不见了 ❌

更诡异的是,控制台没报错,网络请求也正常返回了数据,接口确实把数据拉回来了,但页面就是不渲染。

这种「登录好好的、刷新就废」的现象,十有八九是响应式引用断了。

二、项目里的 store 长啥样

为了说明问题,先把 store 的结构简化贴出来。我们没用 Pinia,是自己拿 reactive 封的模块级单例:

// budgetStore.ts
import { reactive } from 'vue'

const state = reactive({
  budget: { groups: [] },
  explainGroups: [],   // 预算说明
  settleItems: [],     // 结算项
  specialRules: [],    // 特殊规则
  salaryRatios: [],    // 工资比例
})

// 关键就在这个 hook
export function useBudget() {
  return {
    budget: state.budget,
    groups: state.budget.groups,
    explainGroups: state.explainGroups,
    settleItems: state.settleItems,
    specialRules: state.specialRules,
    salaryRatios: state.salaryRatios,
    loadAll,
    // ...其它方法
  }
}

然后在组件里这么用:

// budget.vue
const { explainGroups, settleItems, specialRules, loadAll } = useBudget()

onMounted(() => {
  loadAll()   // 拉数据
})

模板里直接 v-for 渲染 explainGroups 这些数组。

三、出问题的 load 函数

最早的写法是这样(错误示范):

async function loadExplain() {
  const list = await budgetApi.getExplain(projectId)
  // ❌ 直接整体重新赋值
  state.explainGroups = list.map(g => ({ ... }))
}

看着没毛病对吧?statereactive 的,给它的属性赋个新数组,Vue 应该能追踪到才对。

理论上能追踪到,但组件拿到的引用对不上。

四、根因:useBudget 返回的是「调用那一刻」的引用

这是这次踩坑最核心的点,掰开揉碎讲。

4.1 reactive 的代理引用

reactive(state) 返回的是一个 Proxy 对象,我们叫它 proxyAstate.explainGroups 这个属性指向的也是一个代理过的数组,叫 proxyArr1

state (proxyA)
  └─ explainGroups → proxyArr1 (数组代理)

4.2 组件拿到的引用

组件 setup 里执行 const { explainGroups } = useBudget(),这一行执行的时候,useBudget() 返回的对象里 explainGroups: state.explainGroups当场读取的值,也就是 proxyArr1

组件里 explainGroups 这个变量,从这一刻起,就死死指向 proxyArr1

4.3 直接赋值会发生啥

当 load 函数执行 state.explainGroups = newArr

state (proxyA)
  └─ explainGroups → proxyArr2 (新的数组代理!旧的 proxyArr1 被替换掉了)

state 内部的 explainGroups 属性现在指向一个新的代理 proxyArr2

但是!组件里那个 explainGroups 变量,还指着 proxyArr1 呢!proxyArr1 早就被替换出去了,里面是空的(或者是上次的内容),Vue 的依赖追踪也是基于 proxyArr1 建立的。

所以组件看到的就是空的、旧的。引用对不上,响应式自然断了。

4.4 为啥登录正常、刷新就废

这个现象最能说明问题,我琢磨了好一会儿才想通:

  • 登录时:不刷新页面,state 这个模块级单例一直都在。组件 setup 执行时拿到 proxyArr1,紧接着 loadAll 把数据塞进去。但如果是 state.xxx = newArr 这种整体赋值,其实是把 state 内部换成了 proxyArr2,组件拿着 proxyArr1 照样看不到——等等,那登录为啥能看到?

    这里有个时序细节:登录后第一次进页面,如果 setup 先执行拿到 proxyArr1,然后 load 里 reassign 成 proxyArr2,按理也该看不到。但实际登录能看到的常见原因是:第一次 loadAll 跑的时候,组件还没渲染或者依赖还没建立完成,等模板真正渲染时读到的是最新的 state.xxx(因为模板每次渲染都会重新访问 state.xxx,走的是 setup return 的代理对象)。具体取决于你的 return 方式和渲染时机。

    不纠结这个边界情况,核心结论不变:整体 reassign 会让「调用 useBudget 时捕获的引用」失效。

  • 刷新时:页面重新加载,模块重新初始化,组件 setup 重新跑一遍。如果 store 模块顶层有默认值(空数组),组件拿到的是初始的空 proxyArr1。load 函数 reassign 成 proxyArr2,组件还指着空的 proxyArr1——刷新必丢数据

登录和刷新表现不一致,本质就是「引用捕获时机」和「state 生命周期」组合出来的边界现象。不用太钻牛角尖,记住结论就行。

五、正确的修法:原地替换内容,别换代理

关键思路:不要把数组整个换掉,而是在原来的代理数组上原地改内容。 这样 proxyArr1 这个代理对象本身没变,组件持有的引用还有效,Vue 能正常追踪到内容变化。

splice

async function loadExplain() {
  if (!projectId) return
  const list = await budgetApi.getExplain(projectId)
  // 不能直接 reassign 整个数组,useBudget 返回的是旧 proxy 引用会丢失
  // 用 splice 原地替换内容,保持响应式引用有效
  const arr = (list || []).map((g, i) => ({
    id: `exg_${++groupSeq}_${i}`,
    name: g.name,
    // ...
  }))
  state.explainGroups.splice(0, state.explainGroups.length, ...arr)
}

splice(0, length, ...newArr) 的意思是:从下标 0 开始,删掉整个数组长度个元素(等于清空),然后把新数组的内容塞进去。代理对象没换,内容变了,响应式正常。

这次一共修了 4 个 load 函数,全是同样的毛病:

// 预算说明
state.explainGroups.splice(0, state.explainGroups.length, ...arr)
// 结算项
state.settleItems.splice(0, state.settleItems.length, ...arr)
// 特殊规则
state.specialRules.splice(0, state.specialRules.length, ...arr)
// 工资比例
state.salaryRatios.splice(0, state.salaryRatios.length, ...ratioArr)

改完刷新页面,三个 tab 数据稳稳显示,再也不丢了。

六、一个容易混淆的点:对象属性 reassign 为啥没事

有人会问:那 state.budget 是个对象,我经常 state.budget.groups = [...] 这么整体赋值,咋没问题?

这是另一个坑,得说清楚,别搞混了。

state.budget.groups = newArr   // ✅ 没问题
state.explainGroups = newArr   // ❌ 有问题

区别在于赋值发生在哪一层

  • state.budget.groups = newArr:你改的是 state.budget 这个对象代理的 groups 属性。state.budget 这个代理本身没换,Vue 能追踪到它内部属性的变化。组件如果通过 state.budget.groups 访问(每次渲染重新读),就能拿到新值。
  • state.explainGroups = newArr:你直接把 state 顶层的 explainGroups 属性指向了新数组代理。如果组件在 setup 时把 state.explainGroups(旧代理)解构出来存成了局部变量,那这个局部变量就失效了。

简单记法:

  • 改对象内部属性 → 安全(父代理没换)
  • 把数组/对象整体替换到顶层暴露字段 → 危险(组件持有的旧引用失效)

所以判断标准不是「能不能 reassign」,而是「你 reassign 的那个字段,有没有被组件在 setup 阶段直接解构捕获」。如果有,就只能原地 mutate;如果没有(组件每次渲染都重新访问 state.xxx),reassign 也没事。

七、避坑规则总结

这次踩坑后,给自己定了条硬规矩,以后写 store 都按这个来:

  1. 模块级 store 暴露的数组字段,绝不直接 reassign。 永远用 splice(0, length, ...newArr) 原地替换内容。
  2. hook 返回时直接返回 state.xxx 数组引用的,要特别警惕。 组件解构后拿到的是「调用那一刻」的代理引用,后续 reassign 会让它失效。
  3. 对象内部属性可以 reassign,顶层暴露的数组字段不行。 别把这俩搞混。
  4. 现象对照表:登录正常 + 刷新丢数据 = 响应式引用断了,先查 store 里有没有 state.xxx = newArr
  5. 如果非要用 reassign 风格,hook 就别直接返回 state.xxx,改成返回 getter 或者 toRef(state, 'xxx'),让组件拿到的永远是「会重新求值」的引用。不过我们项目里就直接用 splice 了,简单粗暴不容易错。

八、附带提一嘴:Pinia 为啥没这毛病

顺嘴说一句,Pinia 的 storeToRefs 之所以不会踩这个坑,是因为它把每个 state 字段转成了 toRef,返回的是 ref,ref 的 .value 每次访问都重新读 state,所以 reassign 能被正确追踪。

我们自己手撸的 store 没做这层处理,直接 return { xxx: state.xxx },就埋了这颗雷。这也是「能用 Pinia 就别自己封」的一个理由——人家帮你把坑都填好了。

九、最后

这个坑隐蔽就隐蔽在:不刷新你根本发现不了。开发的时候登录→看页面→数据正常,一交付测试,测试兄弟 F5 一刷新就提 bug,你复现半天复现不出来。

记住一句话:reactive 数组整体赋值有风险,splice 原地替换最稳。

共勉。


本文基于项目模块真实踩坑经历整理,修复 commit 涉及 budgetStore.ts 中 4 个 load 函数。

1
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区