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. 參考
- https://dev.to/joshua/the-state-of-the-state-react-state-management-in-2018-2l0c
- https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367
- https://cn.mobx.js.org/
- https://www.apollographql.com/docs/react/
- https://medium.com/skillshare-team/how-we-ditched-redux-for-mobx-a05442279a2b
- http://graphql.cn/graphql-js
- https://www.apollographql.com/docs/react/
文章可隨意轉載,但請保留此 原文鏈接。非常歡迎有激情的你加入 ES2049 Studio,簡歷請發送至 caijun.hcj(at)alibaba-inc.com 。