Skip to content

前端状态管理 ​

一提到状态管理,很多人的第一反应是“装个 Pinia / Redux”。但实际项目里,状态相关的 bug 大多长这样:

  • 在详情页改了名字,返回列表页还是旧名字;
  • 设置好筛选条件,刷新一下全没了;
  • 子组件里把 props 存成了本地变量,父组件更新后它还是旧值;
  • 退出登录换了个账号,页面上还显示着上一个人的数据。

这些问题换哪个状态库都解决不了,因为它们的根源是:同一份数据被存了好几份,或者存错了地方。

读完这篇你会

  • 把状态分成几类,给每一类找到合适的“家”
  • 用“单一数据源”和“派生状态”避免数据不同步
  • 判断什么时候才真正需要全局 store

1. 原理:每份数据只能有一个主人 ​

想象公司的通讯录:如果每个人都在自己本子上抄一份,某个同事换了手机号,就会有一半的本子是错的,而且没人知道哪本是对的。正确的做法是只保留一份公共通讯录,大家都去查它。

前端状态也一样,这叫单一数据源:每份数据只有一个“主人”负责存储和修改,其他地方要么去读它,要么从它算出来,而不是各自复制一份。

判断一个状态该放在哪里,先看它属于哪一类:

类型例子主人是谁
服务端数据文章列表、用户详情后端;前端只是缓存
URL 状态页码、筛选条件、当前标签页地址栏
本地界面状态弹窗开关、下拉是否展开、输入中的值所在的组件
全局客户端状态当前登录用户、主题、购物车全局 store
派生状态筛选后的列表、总价、是否全选不需要主人,随时算出来

2. 坑一:详情页改了数据,列表页还是旧的 ​

现象:在文章详情页把标题从“草稿”改成“正式版”,保存成功后返回列表,列表里仍然显示“草稿”,刷新页面才变过来。

原因:同一篇文章的数据存了两份:列表页请求了一份,详情页又请求了一份。详情页修改的是自己那份,列表页的那份没人通知它更新。如果再在 store 里存一份,就是三份。

修复:

  • 修改后让相关缓存失效:保存成功后,通知列表“你的数据过期了”,返回时重新获取;
  • 使用服务端状态缓存库:TanStack Query(支持 Vue 和 React)、SWR 等按“查询键”缓存数据,修改后调用 invalidateQueries({ queryKey: ['articles'] }),所有用到这份数据的地方都会自动刷新;
  • 简单场景:列表页在重新激活时(如 onActivated、路由返回时)重新请求一次。
js
// TanStack Query 示例:保存后让文章列表失效
const queryClient = useQueryClient()

const { mutate: saveTitle } = useMutation({
  mutationFn: (payload) => api.updateArticle(payload),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['articles'] })
  }
})

怎么避免再犯:服务端数据不要随手塞进全局 store 当作“共享副本”。它的主人是后端,前端要做的是管理缓存何时过期。

3. 坑二:刷新一下,筛选条件全没了 ​

现象:在订单列表里选了“已发货 + 最近 7 天 + 第 3 页”,刷新页面后回到了默认状态;把链接发给同事,同事看到的也是默认列表。

原因:筛选条件存在组件内部变量里,刷新就丢了,URL 里也没有它们。

修复:用户希望“刷新后还在、能分享给别人”的状态,放进 URL:

js
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

// 读:从 URL 派生,URL 是唯一的主人
const status = computed(() => route.query.status ?? 'all')
const page = computed(() => Number(route.query.page ?? 1))

// 写:修改 URL,而不是修改本地变量
function setStatus(value) {
  router.replace({ query: { ...route.query, status: value, page: 1 } })
}

切换筛选时把页码重置为 1,否则可能停在一个不存在的页码上。

怎么避免再犯:做列表页时先问一句:这个状态用户希望刷新后还在吗?想分享给别人吗?是,就放 URL。

4. 坑三:存了能算出来的数据,结果两边对不上 ​

现象:购物车角标显示 3 件,打开购物车列表却只有 2 件。

原因:代码里同时存了 items 和 count。删除商品时更新了 items,某个分支忘了更新 count。

js
// ❌ 两份数据需要手动同步
state = { items: [...], count: 3 }

// ✅ 只存原始数据,其余的算出来
state = { items: [...] }
const count = computed(() => state.items.length)
const total = computed(() => state.items.reduce((sum, i) => sum + i.price * i.qty, 0))
不要存应该怎么做
原始列表 + 筛选后的列表存原始列表和筛选条件,结果用 computed 算
商品列表 + 总价只存商品列表
选中的 ID + 选中的整个对象只存 ID,对象从列表里查

computed 只在依赖变化时重新计算,大多数情况下性能不是问题。只有真正昂贵的计算(比如对上万条数据排序)才需要考虑额外的缓存。

5. 坑四:子组件把 props 存成本地变量,之后再也不更新 ​

现象:父组件切换了选中的用户,子组件表单里显示的还是上一个用户的名字。

js
// 子组件(Vue)
const props = defineProps({ user: Object })
const name = ref(props.user.name) // ❌ 只在创建时读取一次

原因:ref(props.user.name) 只是在组件创建那一刻复制了一份值,之后父组件传入新的 user,这份副本不会跟着变。这又是“存了两份”。

修复:要看你想要的行为:

  • 只是展示:直接用 props.user.name,或者 computed(() => props.user.name);
  • 需要编辑后再提交(表单草稿):父组件切换数据时,同步重置草稿:
js
const draft = ref({ ...props.user })

watch(
  () => props.user,
  (user) => {
    draft.value = { ...user } // 切换用户时,丢弃旧草稿,用新数据重新初始化
  }
)

6. 坑五:切换账号后,看到了上一个人的数据 ​

现象:A 退出登录后,B 在同一台电脑上登录,页面上短暂(甚至一直)显示着 A 的订单、A 的消息数。

原因:全局 store 和请求缓存在退出登录时没有被清空,B 登录后,部分页面直接用了缓存里的旧数据。这不只是体验问题,还是数据泄露。

修复:退出登录时统一重置所有用户相关的状态:

js
async function logout() {
  await api.logout()
  userStore.$reset()       // Pinia 选项式 store 自带 $reset;组合式 store 需要自己实现
  cartStore.$reset()
  queryClient.clear()      // 清空服务端数据缓存
  sessionStorage.clear()   // 清掉本次会话存的数据(注意别误删需要保留的设置)
  router.replace('/login')
}

更彻底的做法是退出后直接 location.href = '/login' 整页刷新,内存里的状态全部清空。

怎么避免再犯:在“切换账号”这条路径上专门测一遍:A 登录 → 操作 → 退出 → B 登录,逐页检查有没有残留数据。

7. 什么时候真的需要全局 store ​

信号举例
很多互不相关的组件都要读写同一份客户端状态登录用户、主题、权限、购物车
状态的修改逻辑复杂,需要集中管理和追踪多步骤编辑器的撤销、重做
数据需要跨页面保留未提交的购物车、草稿

如果只是父子组件之间传值,用 props 和事件;跨两三层,用 provide/inject(Vue)或 Context(React);服务端数据交给缓存库。全局 store 不是“放所有状态的地方”,放进去的东西越多,越难追踪是谁在什么时候改了它。

排查思路 ​

  1. 用框架的开发者工具看状态:Vue Devtools 可以查看组件的 props、本地状态和 Pinia store,还能看到 store 每次变化的时间线;React DevTools 同理。
  2. 数据对不上时,先数一数这份数据存了几份:全局搜索字段名,看有多少地方在存它、多少地方在改它。
  3. 找“是谁改的”:在 Pinia 里用 store.$subscribe 打印每次变化和调用栈,或者在 Devtools 的时间线里查看。
  4. 刷新验证:任何状态问题都试试刷新页面。刷新后好了,说明是内存里的副本过期了;刷新后还不对,说明是数据源本身的问题。

检查清单 ​

  • [ ] 每份数据都能说出它的唯一主人
  • [ ] 服务端数据修改后,相关缓存会失效或重新获取
  • [ ] 分页、筛选、标签页等状态放在 URL 里
  • [ ] 能算出来的数据用 computed,不重复存储
  • [ ] 子组件没有把 props 复制成不再更新的本地变量
  • [ ] 退出登录时清空了所有用户相关的状态和缓存
  • [ ] 全局 store 只放真正全局的客户端状态

延伸阅读 ​

为复用而记录,为理解而整理。