前端知库

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,注意状态是类里的普通属性,修改方法直接改它:

JSX
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 交互:

JSX
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 能显示状态的变化历史,做时间旅行调试。

两者放在一起比

方面MobXRedux(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 是真的修改原对象,靠追踪读写来通知更新。