返回文章列表

组件拆得越细,为什么线上状态 Bug 反而更难收敛:问题不是复用,是状态所有权被切碎

把一份状态切成多个局部真相之后,时序就变成概率事件

线上这个 Bug 的症状很像“偶现”,但它不是随机。

同一个页面上,一份业务状态会在不同组件里以不同形态存在: URL 参数,父组件 state,子组件局部 state,请求返回的缓存,甚至某个 selector 计算出来的派生值。组件拆得越细,这些“局部真相”越多。只要没有先把“谁能写,谁兜底,谁负责时序”收敛成一条规则,线上错状态的根因就会从“某段代码写错了”变成“多段代码都写对了,但写入顺序不稳定”。

这类问题最难的是排查,不是修。因为你看起来每个组件都很合理: 都在维护自己那一小块状态,都在做缓存,都在做 loading 兜底。但合起来之后,系统没有一个唯一的状态所有者,最后表现为: 刷新一下就好,切换 tab 就好,重试一下就好。你想用日志把链路串起来,发现同一份字段一会儿来自 props,一会儿来自本地缓存,一会儿来自请求回包,谁覆盖谁完全取决于渲染节奏和请求时延。

这篇文章的判断很简单:

组件拆分如果没有先收敛“状态由谁写,谁兜底,谁负责时序”,同一份状态会被切成多个局部真相,更新顺序变成概率事件,最后把复用收益换成偶现错状态,重复渲染和排障成本。

下面我用一次很典型的线上错状态排查,把它怎么一步步收敛讲清楚。

现场: 一个“偶现”的错状态

页面是一个列表 + 顶部筛选条。

  • URL 上有 query: ?tab=all&sort=latest&city=sh
  • 顶部筛选条拆成了多个小组件: Tab,Sort,下拉 City
  • 列表组件自己做了一个“上一次请求结果”的缓存,避免切换筛选时闪烁

用户反馈是: 快速切换 Tab 和 Sort,有时列表会显示“新的 Sort 的筛选项”,但列表数据还是“旧 Sort 的结果”。再点一下同一个 Sort 又正常。

第一次看很像接口不一致,但抓包发现接口返回没问题,返回体里 sort 回显也正确。也就是说,服务端是对的,错的是前端“展示的那一份状态”。

第一次误判: 以为是请求竞态

直觉会怀疑: A 请求慢,B 请求快,B 先回来渲染了正确结果,然后 A 又回来把旧结果覆盖了。

这类竞态确实常见,所以我们先加了 requestId,对回包做丢弃: 只接受最后一次发出的请求。

上线后问题缓解了一点,但没消失。说明“回包覆盖”不是唯一通道。

这一步的价值是: 它把一个看起来很大的问题空间先砍掉一块。现在可以确定,至少有一部分错状态不是网络顺序导致的。

第二次误判: 以为是缓存逻辑写错

接着就去看列表组件的缓存。

缓存策略是:

  • props 里传进来 filters
  • 列表组件内部用 useRef 存了 lastGoodData
  • 如果 filters 变化就触发请求
  • 请求期间先继续展示 lastGoodData,等新数据回来再替换

这逻辑在“减少闪烁”上没毛病,但它埋了一个前提: filters 必须是稳定且单一来源的真相。否则你很容易出现: filters 已经变了,但列表还在用旧的 lastGoodData,而你以为它只是 loading 期间的兜底。

我以为是 filters 对象引用不稳定,导致 effect 触发时机混乱。改成了显式序列化 key: filtersKey = tab + sort + city

还是没根治。

真正的根因: 状态所有权被切碎

最后把日志打全之后,问题的形态变清楚了:

  • Tab 组件只关心 tab,它会:
    • 点击时先 setLocalTab(nextTab) 立刻高亮
    • 然后再 onChange(nextTab) 通知父组件
    • 父组件收到后再去 setFilters({ ... })
  • Sort 组件也有同样的模式
  • 父组件为了支持“刷新可恢复”,在 mount 时会:
    • 先从 URL parse 出初始 filters
    • 再把它写入 state
  • 列表组件同时接收:
    • 父组件的 filters props
    • 以及一个来自缓存模块的 getCachedResult(filtersKey)

也就是说,一份状态至少有三套来源:

  1. 子组件的局部 state: 用来立刻反馈交互
  2. 父组件的 state: 作为页面级 filters
  3. 缓存模块: 作为数据展示兜底

它们之间没有一个严格的“写入顺序”契约。

问题发生的那条链路,通常是这样:

  • 用户点 Sort
  • Sort 组件立刻更新了自己的 local state,UI 显示“新 Sort 已选中”
  • 父组件的 filters 还没来得及更新(或更新了但下一帧才传下去)
  • 列表组件此时会重新计算 filtersKey
    • 但它算的不是父组件的新 filters
    • 而是某个派生路径混进了 Sort 的 local 值(比如通过 context 或 selector)
  • filtersKey 变了,于是列表去缓存模块取了一个“看起来匹配”的旧结果
  • 请求回来时,因为 requestId 丢弃策略,只要它不是最后一次,就会被丢掉
  • 最终 UI 形成一个很怪的组合: 筛选条是新值,列表数据来自旧缓存

这就是“多段代码都写对了,但顺序不稳定”。

组件拆分把状态写入权切碎了: 子组件为了交互即时反馈先写一份,父组件为了可回放再写一份,缓存为了体验兜底又写一份。任何一处都没有错,但系统缺少一个统一的状态所有权。

该怎么收口: 先定所有权,再谈复用

这类问题的解法不是“再加几个 effect”或“再加几个 memo”,而是把状态的写入权、派生规则和兜底策略写成一条能执行的契约。

我最后收口成三条规则。

规则 1: 页面 filters 只有一个可写源

子组件不再维护自己的 local filters state。

交互即时反馈由父组件 state 提供,子组件只负责发事件,不负责存值。也就是:

  • 子组件: onSelect(next)
  • 父组件: setFilters(reduce(prev, action))
  • 子组件展示: 只读 value={filters.sort}

这样做的代价是: 子组件会“变笨”,复用时需要把更多 props 传进去。但它换来的是一条确定的写入路径。

规则 2: 派生值必须显式标注来源

所有用于请求和缓存的 key,都只允许从 filters 生成。

禁止从 context,selector 或 URL 直接生成另一套 key。

这条看起来像洁癖,但它是为了避免“同名字段不同来源”。一旦你允许 sort 同时来自 local state 和父 state,你就会遇到今天这种“UI 一套,数据一套”。

规则 3: 缓存只兜底展示,不参与状态判断

缓存模块只提供一个能力: getLastGoodData(filtersKey)

它不能决定当前 filtersKey 是什么,更不能把某个旧 key 的数据拿来当“当前结果”。

具体做法是:

  • 列表请求态明确: currentFiltersKey 是父组件传下来的
  • 展示时允许:
    • data = loading ? cache[currentFiltersKey] ?? null : result
  • 但缓存永远不会反向影响 filters

这条把缓存从“参与系统状态”降级成“纯展示兜底”。它会牺牲一点体验,比如某些切换会空一下,但你换回的是确定性。

反例: “把所有状态都提升到父组件”也会失败

有人听完会说: 那就把所有 state 都 lift 到顶层就好了。

我见过失败的版本是: 顶层确实成了唯一 source of truth,但它同时承担:

  • URL 同步
  • 本地持久化
  • 请求节流
  • 缓存命中
  • UI 交互态(hover,focus,panel open)

结果顶层 reducer 变成一个业务内核,任何小交互都要穿过一堆逻辑,改动成本暴涨。最后大家开始绕开它,在子组件里又偷偷加回 local state,系统又回到“多个局部真相”。

所以关键不是“把状态升高”,而是“明确哪些状态需要唯一所有权”。

我一般用一句话判断: 只要它会影响请求,缓存 key,或跨组件一致性,它就必须唯一所有权。纯 UI 瞬态可以局部化。

适用边界: 不是所有组件拆分都会踩坑

这篇批评的不是组件拆分本身。

拆分本来是为了控制复杂度,但它有一个隐含成本: 你必须额外设计“状态写入的边界”。

当你的页面满足下面任意一条,这个坑就很容易出现:

  • 有 URL/持久化恢复
  • 有并发请求或取消
  • 有缓存兜底展示
  • 有跨组件共享的筛选条件

反过来,如果页面非常简单,状态不会影响请求,也没有缓存和恢复,局部 state 就不会变成灾难。

结尾

组件拆得细不会自动带来复用收益,它会先制造状态所有权问题。

你不先回答“谁能写,谁兜底,谁负责时序”,系统就会自己回答: 谁先渲染谁算,谁晚回来谁覆盖。

等线上出现“偶现”的错状态时,你会发现真正贵的不是修 Bug,而是把一份状态从多个局部真相里重新收回到一条确定的写入链路里。\