精读 "Mobx React — Best Practices"

Cover

原文链接在此 https://medium.com/dailyjs/mobx-react-best-practices-17e01cec4140

在制定前端后台开发规范时,在谷歌上查阅了无数关于 Mobx 和 React 最佳实践的文章,从这篇文章中,获得了许多灵感。细思原因后,他改变了许多以往对 Mobx 的认知。

第一条: Store 的状态代表了 UI 的状态

下面是第一条的译文和代码

总是谨记 Store 代表了 UI 的状态。这意味着将 Store 中的数据存为一个文件,关闭程序并随着加载 Store 重新启动它,你能获得相同的程序,并且能看到与之前关闭时相同的东西。Store 并不是”本地数据库“,它们还包含着哪些按钮是否可见,是否被禁用,当前输入框字段的文字等等。

class SearchStore {
  @observable searchText;

  @action
  setSearchText = searchText => {
    this.searchText = searchText;
  };
}

@observer
class SearchInput extends React.Component {
  handleInputChanged = event => {
    const { searchStore } = this.props;
    searchStore.setSearchText(event.target.value);
  };

  render() {
    const { searchStore } = this.props;
    return (
      <input
        value={searchStore.searchText}
        onChange={this.handleInputChanged}
      />
    );
  }
}

想法

过去我们将 Mobx 仅仅当做一个存放 Ajax 和公共状态的一个容器(上文所说的"本地数据库")。这种想法不符合 Mobx 设计的本意。

通过官方文档的此处的说明,Mobx 将 Store 分为两部分,UI Store 和领域 Store。

UI Store

UI store 中应该能找到这些信息。他们广泛被各个

  • Session 信息
  • 应用已经加载了的相关信息
  • 不会存储到后端的信息
  • 全局性影响 UI 的信息
    • 窗口尺寸
    • 可访问性信息
    • 当前语言
    • 当前活动主题
  • 用户界面状态瞬时影响多个、毫不相关的组件:
    • 当前选择
    • 工具栏可见性, 等等
    • 向导的状态
    • 全局叠加的状态

领域 Store

他的职责如下

  • 实例化领域对象, 确保领域对象知道它们所属的 store。
  • 确保每个领域对象只有一个实例。 同一个用户、订单或者待办事项不应该在内存中存储两次。 这样,可以安全地使用引用,并确保正在查看的实例是最新的,而无需解析引用。 当调试时这十分快速、简单、方便。
  • 提供后端集成,当需要时存储数据。
  • 如果从后端接收到更新,则更新现有实例。
  • 为你的应用提供一个独立、通用、可测试的组件。
  • 要确保 store 是可测试的并且可以在服务端运行,你可能需要将实际的 websocket/http 请求移到单独的对象,以便你可以通过通信层抽象。 Store 应该只有一个实例。

综上所述,我们认为 Mobx 仅用于状态的共享是错误的。从用途上讲,它跟 React 的 State 用途一模一样,职责都是存储状态。而且从使用便利性和性能上说,Mobx 的 Store 更优。

那么 Store 代替 state 是完全可行的。Store 和 state 之间对于业务组件而言,需要二选一。因为这样保证数据源单一,更容易调试。

将 REST 请求(Ajax 请求)从 store 中分离出去

不要在 Store 中调用 REST 接口,这会导致它们很难被测试。而是把这些 REST 调用移除到额外的类中,并且将它们的实例通过构造函数传给各个 store。 当你写测试时,你能轻易地伪造这些 API 调用并且将各个伪 API 传给各个 store。

class TodoApi {
  fetchTodos = () => request.get('/todos');
}

class TodoStore {
  @observable todos = [];

  constructor(todoApi) {
    this.todoApi = todoApi;
  }

  fetchTodos = async () => {
    const todos = await this.todoApi.fetchTodos();

    runInAction(() => {
      this.todos = todos;
    });
  };
}

// Then in your main
const todoApi = new TodoApi();
const todoStore = new TodoStore(todoApi);

想法

理论上来看,分离 Ajax 请求的行为符合关注点分离原则,使得 Store 专注于处理业务逻辑和存储业务状态,能减缓 Store 中代码的膨胀速度。

至于易于测试这个优点,现实是大部分的前端是不会为自己业务写单元测试的。特别是需求多的时候。属于说得对,很难操作的建议。

将你的业务逻辑写在 Store 中

不要将业务逻辑写在组件中。当你把业务逻辑写在组件中时,你将没有就会去复用它。

你的业务逻辑被分割成很多份,分布于不同的组件中,这会使得重构或复用变得艰难。将业务逻辑和方法写在 Store 中,并且在组件中调用那些方法。

看法

将代码写在 Store 中,最大的优点还是使得组件代码专注于状态的展示,不负责状态改变的行为。一个简短的组件绝对比一个杂糅了复杂状态变化逻辑的千行代码组件更容易维护。

至于逻辑复用,可以通过继承 Store 来实现,这点在推啊某个核心页面的重构中已经实践过了。当然,与状态无关的逻辑,更好的处理方式是抽离到 Service 或者 Util 中。

关于React Hooks,因为与 Mobx 存在冲突,不在讨论范围之内。

不要创建全局 Store 的实例

不要再创建全局 Store 实例。这会使你无法为组件写出合理且可靠的测试。使用Providerinject将 store 注入到 props 中替代它。 之后在你的测试中,你能轻松地模拟这些 Store。

const searchStore = new SearchStore();

const app = (
  <Provider searchStore={searchStore}>
    <SearchInput />
  </Provider>
);

ReactDom.render(app, container);

想法

React 的设计中,组件可以表示为一个函数Component = F(props, state)。使用 Inject 和 Provider 能使得组件更加 declarable。组件仅由 props 决定,而不会由闭包引入外部的依赖。 另外,Redux 传统的 Container + Components 的模式中,会带来这样一个问题:

各种属性和方法需要从 Container 往下传递时,需要通过 Props 一层一层往下传。使得组件的 props 列表变得很长。

因此通过 inject 分发 store 的状态到对应状态的使用者中,能完美解决 props 列表过长的问题。

只允许 Store 改变它自己的属性

永远不要在组件中改变 Store 的属性。仅允许 Store 能改变它自己的属性。总是调用 Store 中用于改变 Store 的属性们的方法。除此之外你的应用状态(store 即为应用的状态)(此处"应用"大部分时候指的是网页应用,译者注)被在任何地方更新,你会渐渐地失去对它的控制。这会导致 Debug 变得困难。

想法

限制 Store 的数据仅在 Store 中更改,是百利而无一害的(虽然看起来能在各处更改 Store 的状态很爽,但维护成本会高到吓人,甚至被其他同事口吐芬芳)。 因为这样限定了可能改变 Store 代码区域的范围。显然小范围代码的 Debug 明显比在整个应用追踪 Store 的属性改变简单了不少。

为每个组件注解@observer

为每个组件注解@observer能允许各个组件在 store 注入的 props 改变时更新。除此之外,父组件被@observer注解的时候,当它需要重新渲染的时候,子组件也会重新渲染(此处的渲染通常是 React 中的调和,译者注)。因此更少的组件需要被渲染。

想法

使用 React 的 State 或者 Redux 存储状态和下发状态,性能问题是这样造成的: 页面上某块很小的组件的状态改变时,比如输入框的值产生变化,在不使用React.PureComponentReact.memo的情况下,这个 Container 下的所有组件都会触发 React 的调和机制。当页面足够复杂或者有大量表单时,就会引起性能问题。

@observer使用了一种成本极低的方式,通过追踪 observable 对象的依赖,做到按需更新,提高页面性能。

使用 @computed

假设你想当用户不是管理员时,禁用按钮,并且使得应用为”非管理员模式“,单个想isAdmin这样的属性在 store 中并不能满足这种情况。在你的 store 中,你需要一个计算属性。

class ApplicationStore {
  @observable loggedInUser;

  @observable isInAdminMode;

  @computed isAdminButtonEnabled = () => {
    return this.loggedInUser.role === 'admin' && this.isInAdminMode;
  };
}

想法

对于从observable中衍生出来的状态,使用 computed 能显著减少心智负担,更好地专注于业务。

参考资料

  • Mobx React — Best Practices https://medium.com/dailyjs/mobx-react-best-practices-17e01cec4140
  • Mobx 文档中定义数据存储章节 https://cn.mobx.js.org/best/store.html