我同时维护 Vue3、React、Flutter 项目后,才意识到“状态管理”其实是伪命题

113 阅读6分钟

状态本身并不可怕,可控的边界才是关键。无论 Vue/React/Flutter,真正失控的场景总是因为谁该负责这块状态? 状态的生命周期与副作用不一样咋办? 状态有多个“真实来源”。比起盲目选用全局状态库,先设计状态边界、所有者与契约,再选工具,这才是我想写这篇文章的核心。

目前

我维护过三套完全不同的代码库:一个用 Vue3 做企业后台,一个用 React 做客户运营后台,还有一个用 Flutter 做用户端 App。开始的时候我和很多人一样,把“状态管理”当成救命稻草,拿着 Pinia、Redux、Provider 当工具箱里的万能胶,遇到问题就往全局塞状态。后来出现的问题不是框架的锅,而是我和团队没有先把状态的边界划清楚。最直观的一次教训是一个批量导入功能:导入进度既存在于页面的临时变量,也存在于全局 cache、后端轮询结果,还有一个后台任务队列的本地副本,结果在某些网络抖动场景下进度条反复闪烁、提交失败、回调覆盖更新,排查下来并不是网络问题,而是同一份“事实”有多个来源、多个所有者、多个副作用在不同地方生效,互相覆盖。修复并不靠换库,而是靠把“导入任务”明确成单一来源、改用只读查询加唯一写入入口,并把所有副作用集中到 service 层,这才把问题彻底堵住。

示例一:Vue3 中“状态失控”的典型写法 vs 有边界的写法

失控版本(真实会出事故的那种)

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

export const state = reactive({
  importTask: null,
  progress: 0,
})
// A.vue
state.progress = 30
// B.vue
state.progress = 80
// service.ts
setInterval(() => {
  state.progress += 10
}, 1000)

这种写法的问题不是 Vue,而是任何地方都能写,没有人知道谁是妆台管理者。
只要接口回调、定时轮询、用户操作同时发生一次,UI 就开始抽风。

但是

不同框架的问题表现不太一样,但本质相似。Vue3 的常见失控点是把 reactive 的对象随手导出,全项目任意模块直接改动,或者把大量短期 UI 状态塞进 Pinia,结果是到底哪个组件该清理、什么时候取消 watch、谁负责取消请求,没人说得清楚;React 的典型症状是把太多东西放进 Context 或一个大 Redux store,组件随便改对象属性(非不可变更新),导致 memo 无效、重渲染泛滥和难以回溯的副作用;Flutter 则常见在 setState 或全局单例里乱放数据,导致父组件更新引起大量子树不必要重建,或者 Provider/Riverpod 的边界划分混乱,让调试变得痛苦。无论是哪种框架,问题总在这些点交汇:谁该负责这块数据、这块数据的生命周期何时开始与结束、哪些副作用会影响它、它是否有多个事实来源。

示例二:React 中“useEffect 地狱”是怎么来的

常见但危险的写法

function ImportPage({ userId }) {
  const [progress, setProgress] = useState(0)

  useEffect(() => {
    fetch(`/api/task?user=${userId}`)
      .then(res => res.json())
      .then(data => setProgress(data.progress))
  }, [userId, progress])
}

这个代码我敢说你肯定见过。
问题在于:progress 既是结果,又被当成触发条件,effect 自己触发自己,网络抖一下就会循环请求。

把副作用和状态边界拆开

function useImportTask(userId: string) {
  const [progress, setProgress] = useState(0)

  useEffect(() => {
    let canceled = false

    fetch(`/api/task?user=${userId}`)
      .then(res => res.json())
      .then(data => {
        if (!canceled) setProgress(data.progress)
      })

    return () => {
      canceled = true
    }
  }, [userId])

  return progress
}
function ImportPage({ userId }) {
  const progress = useImportTask(userId)
  return <Progress value={progress} />
}

z仔细看看这段代码你会发现: 页面不再“拥有”状态,只是消费状态,失控的入口直接少了一半。

解决

既然问题的核心是“边界不清”,我给新项目的建议有几条可能听起来反直觉但非常实用:第一,不要急着建一个全局 store,先把状态放在页面或组件本地,等出现明确的跨页面共享需求并能量化受益时再抽象; 第二,写代码前先画张状态边界图,列出每个状态的 owner、生命周期、是否持久化、是否为衍生值、以及唯一的写入入口;第三,全局或模块化 store 只暴露只读查询和明确的写入 API,禁止项目随意直接修改内部结构;第四,副作用全部集中到 service/side-effect 层(无论是 React 的 hook、Vue 的 composable 还是 Flutter 的 repository),组件只负责展示与触发,副作用层负责取消、去重、重试和错误处理;第五,衍生状态不要持久化,能由原始状态算出来的就用 computed/selector 动态计算;第六,对于复杂的状态流转场景(例如多阶段导入、有超时和回滚逻辑)考虑用状态机来建模,状态可视化之后很多边界问题就能一目了然;第七,启用类型系统和不可变更新约定,这能大幅提升可追溯性和避免微妙的引用错误;第八,把变更审计和日志当成基础设施,为重要的全局状态记录 action 名称、时间戳和 trace id,出问题时能快速回溯。

哦对了,忘记距离flutter了,这个技术在掘金里面可能用到的人有点少,没有react vue那么多,但还是说一下吧。

示例三:Flutter 里最容易被忽略的全局状态

问题写法

class GlobalTask {
  static int progress = 0;
}
setState(() {
  GlobalTask.progress += 10;
});

这个问题非常隐蔽,因为 Flutter 重建 UI 很快,早期根本察觉不到。
但一旦页面多了、热重载多了、逻辑复杂了,你根本不知道是谁在改。

明确 owner 的写法(Provider / Riverpod 思路)

class ImportTaskState extends ChangeNotifier {
  int _progress = 0;

  int get progress => _progress;

  void updateProgress(int value) {
    _progress = value;
    notifyListeners();
  }
}
Consumer<ImportTaskState>(
  builder: (_, state, __) {
    return ProgressBar(value: state.progress);
  },
)

你不是在用 Provider,而是在声明:这块状态属于 ImportTaskState

还有一些衍生状态千万不要存,如果有复杂流程直接用状态机

错误思路

state.list = rawList
state.filteredList = rawList.filter(...)

正确思路

const filteredList = computed(() => {
  return state.list.filter(...)
})

这不是性能优化,这是防止状态分叉。 一旦存了两份,你迟早要花时间修同步问题。


状态机

const importMachine = createMachine({
  id: 'import',
  initial: 'idle',
  states: {
    idle: { on: { START: 'uploading' } },
    uploading: { on: { SUCCESS: 'processing', FAIL: 'error' } },
    processing: { on: { DONE: 'completed' } },
    completed: {},
    error: {},
  },
})

当你把流程画成状态图之后,会非常清楚地看到:哪些状态允许更新,哪些不允许,根本不需要猜。

结尾~~~

以下是我总结的几点建议~,针对我们当前三个技术栈项目了,按需自取哈~并不通用。

  • 一,功能设计阶段画状态边界图并在 PR 中提交;
  • 二,凡是跨页面共享的状态必须指定 owner 与 action;
  • 三,把网络请求、订阅、定时器等副作用统一封装在 service 层并实现取消逻辑;
  • 四,把 UI 临时数据留在本地,衍生数据用 selector;
  • 五,对复杂流程使用状态机并训练团队阅读状态图 没开玩笑, man!!!;