1. 引子

虽然 React 的状态管理是一个老生常谈的问题,网上和社区中也能搜到相当多的资料。这里还是想梳理下从我接触 React 开始到现在对状态管理的一些感想。

所有的新技术的出现和流行都是为了解决特定的场景问题,这里也会以一个非常简单的例子作为我们故事的开始。

有这样一个需求,我们需要在界面上展示某个商品的信息,可能我们会这样实现:

import React, { PureComponent } from 'react';

export default class ProductInfo extends PureComponent {
 constructor(props) {
    super(props);
    this.state = {
      data: {
        sku: '',
        desc: '',
      },
    };
  }

  componentDidMount() {
    fetch('url', { id: this.props.id })
      .then(resp => resp.json())
      .then(data => this.setState({ data }));
  }

  render() {
    const { sku } = this.state.data;
    return (
      <div>{sku}</div>
    );
  }
}

上述的场景虽然非常简单,但是在我们实际的需求开发中非常常见,采用上述的方式也能很好地解决这一类问题。

我们把场景变得稍微复杂一点,假如界面上有两个部分都需要展示商品的信息,只是展示的商品的属性不同而已,怎么处理了?我们也可以像上面那样再写一个类似的组件,但是问题是我们重复获取了同一个商品的信息,为了避免重复获取数据,那么我们就需要在两个组件之间共享商品信息。

2. props 解决数据共享

通过 props 解决数据共享问题,本质上是将数据获取的逻辑放到组件的公共父组件中。代码可能是这样的:

import React, { PureComponent } from 'react';

export default class App extends PureComponent {
 constructor(props) {
    super(props);
    this.state = {
      data: {
        sku: '',
        desc: '',
      },
    };
  }

  componentDidMount() {
    fetch('url', { id: this.props.id })
      .then(resp => resp.json())
      .then(data => this.setState({ data }));
  }

  render() {
    return (
      <div>
        <ProductInfoOne data={this.state.data} />
        <ProductInfoTwo data={this.state.data} />
      </div>
    );
  }
}

function ProductInfoOne({ data }) {
  const { sku } = data;
  return <div>{sku}</div>;
}

function ProductInfoTwo({ data }) {
  const { desc } = data;
  return <div>{desc}</div>;
}

对于这种组件嵌套层次只有 1、2 层的场景,通过将数据获取和存储的逻辑上移到公共的父组件就可以很好地解决。

但是如果界面呈现更加复杂一点,比如 ProductInfoOne 的子组件中也需要呈现商品的信息,我们可能会想到继续通过 props 向下传递数据,问题是随着嵌套的层次越来越深,数据需要从最外层一直传递到最里层,整个代码的可读性和维护性会变差。我们希望打破数据「层层传递」而子组件也能取到父辈组件中的数据。

3. Context API

React 16.3 的版本引入了新的 Context API,Context API 本身就是为了解决嵌套层次比较深的场景中数据传递的问题,看起来非常适合解决我们上面提到的问题。我们尝试使用 Context API 来解决我们的问题:

// context.js
const ProductContext = React.createContext({
  sku: '',
  desc: '',
});

export default ProductContext;

// App.js
import React, { PureComponent } from 'react';
import ProductContext from './context';

const Provider = ProductContext.Provider;

export default class App extends PureComponent {
 constructor(props) {
    super(props);
    this.state = {
      data: {
        sku: '',
        desc: '',
      },
    };
  }

  componentDidMount() {
    fetch('url', { id: this.props.id })
      .then(resp => resp.json())
      .then(data => this.setState({ data }));
  }

  render() {
    return (
      <Provider value={this.state.data}>
        <ProductInfoOne />
        <ProductInfoTwo />
      </Provider>
    );
  }
}

// ProductInfoOne.js
import React, { PureComponent } from 'react';
import ProductContext from './context';

export default class ProductInfoOne extends PureComponent {
  static contextType = ProductContext;

  render() {
    const { sku } = this.context;
    return <div>{sku}</div>;
  }
}

// ProductInfoTwo.js
import React, { PureComponent } from 'react';
import ProductContext from './context';

export default class ProductInfoTwo extends PureComponent {
  static contextType = ProductContext;

  render() {
    const { desc } = this.context;
    return <div>{desc}</div>;
  }
}

看起来一切都很美好,到目前为止我们也只是使用了 React 库本身的功能,并没有引入任何第三方的库,实际上对于这类比较简单的场景,使用以上的方式来解决确实是最直接、简单的方案。

现实中的需求往往要稍微复杂点,上述的几个场景中我们偏重于信息的呈现,而真实场景中我们避免不了一些交互的操作,比如我们需要在呈现商品信息的同时还需要可以编辑商品的信息,由于 ProductInfoOne、ProductInfoTwo 是受控组件,并且数据源在 App 组件中,为了实现数据的修改,我们可能通过 Context API 传递修改数据的「回调函数」。

上述的几个场景中我们偏重于有嵌套关系的组件之间数据的共享,如果场景再复杂一点,假设平行组件之间需要共享数据,例如和 App 没有父子关系的 App1 组件也需要呈现商品信息,怎么办,看起来 Conext API 也是束手无策。

4. Redux

终于到了 Redux,相信很多读者觉得啰里啰嗦,但是本着技术方案是为了解决特定问题的原则,还是觉得有必要做一些铺垫,如果你的问题场景没有复杂到 React 本身没有太好的解决方式的地步,建议也不要引入额外的技术(有更好的解决方案除外),包括 Redux。

Redux 确实是很强大,目前在 React 状态管理中也还是最活跃和使用最广的解决方案。这里还是引用一张图(图片来源)来简单说明下 Redux 解决问题的思路:

这里不想讲太多 Redux 的概念和原理,网上也是一大推资料,相信很多人也对 Redux 非常熟悉了。先看看采用 Redux 解决我们上述问题,代码大概是这样的(只列出部分重点代码):

// store.js
import { createStore } from 'redux';
import reducer from './reducer';

const store = createStore(reducer);

export default store;

// reducer.js
import * as actions from './actions';
import { combineReducers } from 'redux';

function ProductInfo(state = {}, action) {
  switch (action.type) {
    case actions.SET_SKU: {
      return { ...state, sku: action.sku };
    }
    case actions.SET_DESC: {
      return { ...state, desc: action.desc };
    }
    case actions.SET_DATA: {
      return { ...state, ...action.data };
    }
    default: {
      return state;
    }
  }
}

const reducer = combineReducers({
  ProductInfo,
});

export default reducer;

// action.js
export const SET_SKU = 'SET_SKU';
export const SET_DESC = 'SET_DESC';
export const SET_DATA = 'SET_DATA';

export function setSku(sku) {
  return {
    type: SET_SKU,
    sku,
  };
}

export function setDesc(desc) {
  return {
    type: SET_DESC,
    desc,
  };
}

export function setData(data) {
  return {
    type: SET_DESC,
    data,
  };
}

// App.js
import React, { PureComponent } from 'react';
import { Provider } from 'react-redux';
import store from './store';
import * as actions from './actions';

class App extends PureComponent {
  componentDidMount() {
    fetch('url', { id: this.props.id })
      .then(resp => resp.json())
      .then(data => this.props.dispatch(actions.setData(data)));
  }

  render() {
    return (
      <Provider store={store}>
        <ProductInfoOne />
        <ProductInfoTwo />
      </Provider>
    );
  }
}

function mapStateToProps() {
  return {

  };
}

function mapDispatchToProps(dispatch) {
  return {
    dispatch,
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(App);

// ProductInfoOne.js
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import * as actions from './actions';

class ProductInfoOne extends PureComponent {
  onEditSku = (sku) => {
    this.props.dispatch(actions.setSku(sku));
  };

  render() {
    const { sku } = this.props.data;
    return (
      <div>{sku}</div>
    );
  }
}

function mapStateToProps(state) {
  return {
    data: state.ProductInfo,
  };
}

function mapDispatchToProps(dispatch) {
  return {
    dispatch,
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(ProductInfoOne);

// ProductInfoTwo.js
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import * as actions from './actions';

class ProductInfoTwo extends PureComponent {
  onEditDesc = (desc) => {
    this.props.dispatch(actions.setDesc(desc));
  };

  render() {
    const { desc } = this.props.data;
    return (
      <div>{desc}</div>
    );
  }
}

function mapStateToProps(state) {
  return {
    data: state.ProductInfo,
  };
}

function mapDispatchToProps(dispatch) {
  return {
    dispatch,
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(ProductInfoTwo);

Redux 确实能够解决我们上面提到的问题,从代码和 Redux 的原理中我们也可以知道,Redux 做了很多概念的抽象和分层,store 专门负责数据的存储,action 用于描述数据修改的动作,reducer 用于修改数据。咋一看,Redux 使我们的代码变得更加复杂了,但是它抽象出来的这些概念和一些强制的规定,会让数据的共享和修改变得有迹可循,这种约定的规则,在多人协助开发的大型项目中,会让代码的逻辑更加清晰、可维护性更好。

但是,Redux 被大家诟病的地方也很多,网上也有越来越多对 Redux 批判的声音,暂且不谈技术的学习成本,笔者在使用过程中觉得有几点让人抓狂的地方:

  • 对于「简单」系统来说太啰嗦了,笔者所负责的系统是偏向中后台系统,系统本身也不复杂,并且是一个人负责开发,为了修改某个数据,需要修改多个文件;过一段时间再去看某个数据变动的逻辑,需要将整个数据变动的流程过一遍,不够直接。尤其是需要处理一些异步操作时,还需要引入一些副作用处理库,例如 redux-thunk、redux-saga、redux-observables,这样反而会导致一个简单的系统更加复杂,有一种「杀鸡焉用牛刀」的感觉。
  • 数据缓存问题,Redux 中 store 是全局唯一的对象,不会随着某个组件的消亡而消亡。这个问题需要辩证来看,在需要缓存数据的场景中,Redux 天然就支持;但是在某些不需要缓存的场景中,可能会带来非常严重的后果,比如笔者负责开发的一个商品交易页面,每次跳转到该页面时会获取商品的信息并存到 store 中,如果某次获取商品信息的部分接口失败,那么会导致 store 中存放的部分商品信息是缓存的上次购买的商品信息,这样会导致界面呈现的商品信息是错误的。对于这种场景我们还需要额外有一段代码去处理 store 中缓存的数据,要么在组件销毁的时候清空对应的缓存,要么在获取数据前或者获取数据失败的函数中处理 store 中的缓存。

那么有没有一些更加轻量级的状态管理库了?

5. MobX

Mobx 从 2016 年开始发布第一个版本,到现在短短两年多的时间,发展也是非常迅速,受到越来越多人的关注。MobX 的实现思路非常简单直接,类似于 Vue 中的响应式的原理,其实质可以简单理解为观察者模式,数据是被观察的对象,「响应」是观察者,响应可以是计算值或者函数,当数据发生变化时,就会通知「响应」执行。借用一张网上的图(图片来源)描述下原理:

Mobx 我理解的最大的好处是简单、直接,数据发生变化,那么界面就重新渲染,在 React 中使用时,我们甚至不需要关注 React 中的 state,我们看下用 MobX 怎么解决我们上面的问题:

// store.js
import { observable } from 'mobx';

const store = observable({
  sku: '',
  desc: '',
});
export default store;

// App.js
import React, { PureComponent } from 'react';
import store from './store.js';

export default class App extends PureComponent {
  componentDidMount() {
    fetch('url', { id: this.props.id })
      .then(resp => resp.json())
      .then(data => Object.assign(store, data));
  }

  render() {
    return (
      <div>
        <ProductInfoOne />
        <ProductInfoTwo />
      </div>
    );
  }
}

// ProductInfoOne.js
import React, { PureComponent } from 'react';
import { action } from 'mobx';
import { observer } from 'mobx-react';
import store from './store';

@observer
class ProductInfoOne extends PureComponent {
  @action
  onEditSku = (sku) => {
    store.sku = sku;
  };

  render() {
    const { sku } = store;
    return (
      <div>{sku}</div>
    );
  }
}

export default ProductInfoOne;

// ProductInfoTwo.js
import React, { PureComponent } from 'react';
import { action } from 'mobx';
import { observer } from 'mobx-react';
import store from './store';

@observer
class ProductInfoTwo extends PureComponent {
  @action
  onEditDesc = (desc) => {
    store.desc = desc;
  };

  render() {
    const { desc } = store;
    return (
      <div>{desc}</div>
    );
  }
}

export default ProductInfoTwo;

稍微解释下用到的新的名词,observable 或者 @observable 表示声明一个可被观察的对象,@observer 标识观察者,其本质是将组件中的 render 方法用 autorun 包装了下,@action 描述这是一个修改数据的动作,这个注解是可选的,也就是不用也是可以的,但是官方建议使用,这样代码逻辑更清晰、底层也会做一些性能优化、并且在调试的时候结合调试工具能够提供有用的信息。

我们可以对比下 Redux 的方案,使用 MobX 后代码大大减少,并且数据流动和修改的逻辑更加直接和清晰。声明一个可被观察的对象,使用 @observer 将组件中的 render 函数变成观察者,数据修改直接修改对象的属性,我们需要做的就是这些。

但是从中也可以看到,Mobx 的数据修改说的好听点是「灵活」,不好听点是「随意」,好在社区有一些其他的库来优化这个问题,比如 mobx-state-tree 将 action 在模型定义的时候就确定好,将修改数据的动作集中在一个地方管理。不过相对于 Redux 而言,Mobx 还是灵活很多,它没有太多的约束和规则,在少量开发人员或者小型项目中,会非常地自由和高效,但是随着项目的复杂度和开发人员的增加,这种「无约束」反而可能会带来后续高昂的维护成本,反之 Redux 的「约束」会确保不同的人写出来的代码几乎是一致的,因为你必须按照它约定的规则来开发,代码的一致性和可维护性也会更好。

6. GraphQL

前面提到的不管是 Redux 还是 MobX, 两者都是侧重于管理数据,说的更明白点就是怎样存储、更新数据,但是数据是从哪里来的,它们是不关注的。那么未来有没有一种新的思路来管理数据了,GraphQL 其实提出了一种新的思路。

我们开发一个组件或者前端系统的时候,有一部分的数据是来自于后台的,比如上面场景中的商品信息,有一部分是来自于前台的,比如对话框是否弹出的状态。GraphQL 将远程的数据和本地的数据进行了统一,让开发者感觉到所有的数据都是查询出来的,至于是从服务端查询还是从本地查询,开发人员不需要关注。

这里不讲解 GraphQL 的具体原理和使用,大家有兴趣可以去查看官网的资料。我们看看如果采用 GraphQL 来解决我们上面的问题,代码会是怎么样的?

// client.js
import ApolloClient from 'apollo-boost';

const client = new ApolloClient({
  uri: 'http://localhost:3011/graphql/productinfo'
});

export default client;

// app.js
import React from 'react';
import { ApolloProvider, Query, Mutation } from 'react-apollo';
import gql from 'graphql-tag';
import client from './index';
import ProductInfoOne from './ProductInfoOne';
import ProductInfoTwo from './ProductInfoTwo';

const GET_PRODUCT_INFO = gql`
  query ProductInfo($id: Int) {
    productInfo(id: $id){
      id
      sku
      desc
    }
  }
`;
export default class App extends React.PureComponent {
  constructor(props) {
    super(props);
    this.state = {
      id: 1,
    };
  }

  render() {
    return (
      <ApolloProvider client={client}>
        <Query query={GET_PRODUCT_INFO} variables={{ id: this.state.id }}>
          {({ loading, error, data }) => {
            if (loading) return 'loading...';
            if (error) return 'error...';
            if (data) {
              return (
                <div>
                  <ProductInfoOne data={data.productInfo} />
                  <ProductInfoTwo data={data.productInfo} />
                </div>
              );
            }
            return null;
          }}
        </Query>
      </ApolloProvider>
    );
  }
}

// ProductInfoOne.js
import React from 'react';
import { Mutation } from 'react-apollo';
import gql from 'graphql-tag';

const SET_SKU = gql`
  mutation SetSku($id: Int, $sku: String){
    setSku(id: $id, sku: $sku) {
      id
      sku
      desc
    }
  }
`;
export default class ProductInfoOne extends React.PureComponent {
  render() {
    const { id, sku } = this.props.data;
    return (
      <div>
        <div>{sku}</div>
        <Mutation mutation={SET_SKU}>
          {(setSku) => (
            <button onClick={() => { setSku({ variables: { id: id, sku: 'new sku' } }) }}>修改 sku</button>
          )}
        </Mutation>
      </div>
    );
  }
}

// ProductInfoTwo.js
import React from 'react';
import { Mutation } from 'react-apollo';
import gql from 'graphql-tag';

const SET_DESC = gql`
  mutation SetDesc($id: Int, $desc: String){
    setDesc(id: $id, desc: $desc) {
      id
      sku
      desc
    }
  }
`;
export default class ProductInfoTwo extends React.PureComponent {
  render() {
    const { id, desc } = this.props.data;
    return (
      <div>
        <div>{desc}</div>
        <Mutation mutation={SET_DESC}>
          {(setDesc) => (
            <button onClick={() => { setDesc({ variables: { id: id, desc: 'new desc' } }) }}>修改 desc</button>
          )}
        </Mutation>
      </div>
    );
  }
}

我们可以看到,GraphQL 将数据封装成 Query 的 GraphQL 语句,将数据的更新封装成了 Mutation 的 GraphQL 语句,对开发者来讲,我需要数据,所以我需要一个 Query 的查询,我需要更新数据,所以我需要一个 Mutation 的动作,数据既可以来自于远端服务器也可以来自于本地。

使用 GraphQL 最大的问题是,需要服务器端支持 GraphQL 的接口,才能真正发挥它的威力,虽然现在主流的几种 Web 服务器端语言,比如 Java、PHP、Python、JavaScript,均有对应的实现版本,但是将已有的系统整改为支持 GraphQL,成本也是非常大的;并且 GraphQL 的学习成本也不低。

但是 GraphQL 确实相比于传统的状态管理方案,提供了新的思路。我们和后台人员制定接口时,总是会有一些模糊有争议的灰色地带,比如页面上要展示一个列表,前端程序员的思维是表格中的一行是一个整体,后台应该返回一个数组,数组中的每个元素对应的就是表格中的一行,但是后端程序员可能会从数据模型设计上区分动态数据和静态数据,前台应该分别获取动态数据和静态数据,然后再拼装成一行数据。后端程序员的思维是我有什么,是生产者的视角;前端程序员的思维是我需要什么,是消费者的视角。但是 GraphQL 会强迫后台人员在开发接口的时候从消费者的视角来制定前后台交互的数据,因为 GraphQL 中的查询参数往往是根据界面呈现推导出来的。这样对前端而言,会减少一部分和后台制定接口的纠纷,同时也会把一部分的工作「转嫁」到后台。

7. 总结
  • 建议优先从 1、2、3 点来解决问题。
  • 在小型项目或者少量开发人员的项目中,可以采用 MobX,效率会更高一点。
  • 大型项目或者多人协助的项目,考虑采用 Redux,后续维护成本更低。
  • GraphQL 重点去学习和理解下它的思路,在个人项目中可以尝试使用。

8. 参考

03-05 23:43