結合具體場景,聊聊 React 的狀態管理方案

ES2049發表於2019-01-23

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 解決問題的思路:

結合具體場景,聊聊 React 的狀態管理方案

這裡不想講太多 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 中的響應式的原理,其實質可以簡單理解為觀察者模式,資料是被觀察的物件,「響應」是觀察者,響應可以是計算值或者函式,當資料發生變化時,就會通知「響應」執行。借用一張網上的圖(圖片來源)描述下原理:

結合具體場景,聊聊 React 的狀態管理方案

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. 參考

文章可隨意轉載,但請保留此 原文連結

非常歡迎有激情的你加入 ES2049 Studio,簡歷請傳送至 caijun.hcj(at)alibaba-inc.com

相關文章