状态本身并不可怕,可控的边界才是关键。无论 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!!!;