MobX 和 Redux 的思路有什么不同?
MobX 让状态成为可观察的可变对象,组件和 computed 在执行时读到哪些属性就自动订阅哪些,改了属性就只更新用到它的地方;Redux 把状态放在一个不可变的 store 里,只能 dispatch 一个描述“发生了什么”的 action,由纯 reducer 算出新状态,组件靠 useSelector 比较选出的值决定是否更新。MobX 写起来直接、代码少,Redux 更显式,变化可记录、可回放,适合要求严格追踪的团队。
简答
同样是把一个待办事项标记为完成,MobX 里直接写
todo.finished = true,读过这个finished的组件自动更新;Redux 里要dispatch({ type: 'todos/toggled', payload: id }),reducer 根据旧状态返回一个新的待办列表,组件通过useSelector发现选出的值变了才重新渲染。根本区别在于怎样知道“谁需要更新”。MobX 是响应式的:状态被标记为 observable,组件、computed 执行时读到了哪些属性,MobX 就记下它们的依赖,属性改了只通知这些地方,和 Vue 的思路一样。Redux 不追踪读取:状态是不可变的,每次 action 产生一个新的状态对象,订阅者自己比较新旧值的引用,判断有没有变。
这带来不同的取舍。MobX 代码少,可以用类和普通对象建模,更新粒度天然很细;但变化是隐式发生的,哪里改了状态要靠 action 约定和工具去查。Redux 更啰嗦,但每次变化都是一个普通对象,可以记录、序列化、回放,DevTools 能做时间旅行调试,逻辑也容易单独测试。
要注意,MobX 只追踪在被追踪函数同步执行时读到的属性,
await之后或setTimeout里读的不算;修改状态的代码最好标成 action。Redux 则要保证 reducer 纯净、不原地修改,现在一般用 Redux Toolkit,它内置的 Immer 允许把不可变更新写成“直接修改”的样子。
同一个功能两种写法
先看 MobX,注意状态是类里的普通属性,修改方法直接改它:
import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';
class TodoStore {
todos = [];
constructor() {
makeAutoObservable(this);
}
get unfinishedCount() {
return this.todos.filter((todo) => !todo.finished).length;
}
toggle(id) {
const todo = this.todos.find((t) => t.id === id);
todo.finished = !todo.finished;
}
}
const store = new TodoStore();
const Counter = observer(() => <span>未完成:{store.unfinishedCount}</span>);
再看 Redux Toolkit,状态的修改写在 slice 的 reducer 里,组件通过 dispatch 和 useSelector 交互:
import { createSlice } from '@reduxjs/toolkit';
import { useDispatch, useSelector } from 'react-redux';
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
toggled(state, action) {
const todo = state.find((t) => t.id === action.payload);
todo.finished = !todo.finished;
},
},
});
function Counter() {
const count = useSelector((state) => state.todos.filter((t) => !t.finished).length);
return <span>未完成:{count}</span>;
}
function ToggleButton({ id }) {
const dispatch = useDispatch();
return <button onClick={() => dispatch(todosSlice.actions.toggled(id))}>切换</button>;
}
两段代码都写了 todo.finished = !todo.finished,看起来一样,区别在哪?MobX 里这行代码真的修改了那个对象,MobX 拦截到写入,通知读过它的 unfinishedCount 和 Counter。Redux Toolkit 里的 state 是 Immer 给的草稿,这次“修改”最后会生成一个新的状态树,原来的状态对象没有被改动。
MobX 怎么知道组件用了什么
MobX 文档把概念分为三类:状态(state)、修改状态的动作(action)和从状态派生出的东西(derivation)。派生又分两种,computed 是用纯函数从状态算出的值,reaction 是状态变化时自动执行的副作用,最常见的 reaction 就是界面组件。
文档对追踪规则的原话是:MobX 会对被追踪函数执行期间读到的任何 observable 属性做出反应。被追踪函数包括 computed 的表达式、observer 组件的渲染、autorun 等的第一个参数。“执行期间”指同步执行的那段时间,setTimeout、promise.then、await 之后的代码都不算。
所以上面的 Counter 用 observer 包住后,渲染时读了 store.unfinishedCount,这个 computed 又读了 todos 和每个 finished,于是这些都成了依赖。某个 finished 改了,unfinishedCount 重算,结果变了才通知 Counter。文档还特别说明,组件能访问到但实际没读的 observable,改了也不会让它重新渲染。
为什么修改要标成 action?action 会在事务里执行,最外层的 action 结束之前不会运行 reaction,所以中间状态不会被界面看到。MobX 的 enforceActions 配置默认是 "observed",被观察的状态必须在 action 里修改,否则会警告。异步流程里,每一步修改状态的代码也要用 runInAction 或 flow 包起来,因为 await 之后已经不在原来的 action 里了。
Redux 怎么知道组件用了什么
Redux 文档把它概括为三条原则:整个应用的状态存在一个 store 的对象树里;状态是只读的,唯一的改法是 dispatch 一个描述发生了什么的 action;修改由纯函数 reducer 完成。
Redux 本身不知道组件读了什么。每次 dispatch 后,React Redux 让每个 useSelector 重新执行选择函数,用 === 比较这次和上次的结果,不同才让组件重新渲染。所以上面的 Counter 只有在未完成数量真的变了时才更新。也因此,选择函数每次都返回新数组或新对象,会导致组件每次都重新渲染,这时要用 createSelector 缓存派生结果,详见《Redux 的单向数据流为什么强调 reducer 的纯粹性?》。
这种显式的方式让每次变化都可追溯。action 是普通对象,可以打日志、序列化、存下来回放;Redux DevTools 能显示状态的变化历史,做时间旅行调试。
两者放在一起比
| 方面 | MobX | Redux(Redux Toolkit) |
|---|---|---|
| 状态形态 | 可变的 observable 对象、类 | 不可变的单一状态树 |
| 修改方式 | 在 action 里直接赋值 | dispatch action,由 reducer 返回新状态 |
| 更新粒度 | 自动追踪读到的属性 | useSelector 比较选出的值 |
| 派生数据 | computed,自动缓存 | createSelector 手动声明输入 |
| 调试 | 依赖关系可用 getDependencyTree 等工具查看 | DevTools 记录每个 action,可时间旅行 |
| 代码量 | 少 | 较多,Toolkit 已大幅简化 |
怎样选
团队熟悉面向对象建模、领域模型复杂、对象之间互相引用多,或者希望少写样板代码,MobX 更顺手。团队规模大、需要严格约定所有修改的入口、重视可回放和可测试,或者已经在用 RTK Query 管理服务端数据,Redux Toolkit 更合适。
不管选哪个,服务端数据的请求和缓存都可以交给专门的查询库,状态库只管客户端状态。
常见追问
MobX 和 Vue 的响应式是一回事吗?
思路相同。Vue 文档在讲 signals 时提到,Vue 的 Options API 和 MobX 都基于同样的原理,只是把响应式原语藏在了对象属性后面。都是读的时候收集依赖,写的时候通知更新,computed 也都有缓存。
MobX 里为什么在 await 之后改状态会有警告?
因为 action 只包住了同步执行的那一段。
await之后的代码在新的微任务里运行,已经不在 action 里了,而默认配置要求被观察的状态必须在 action 里修改。把await之后的修改包进runInAction,或者把整个函数写成flow生成器即可。
Redux Toolkit 用了 Immer,是不是就和 MobX 一样了?
不一样。Immer 只是让 reducer 里的写法看起来像直接修改,最终产出的仍是新的不可变状态,更新仍然要经过 dispatch 和 reducer,组件仍靠
useSelector比较引用。MobX 是真的修改原对象,靠追踪读写来通知更新。