從新的 Context API 看 React 應用設計模式

在即將發布的 React v16.3.0 中,React 引入了新的聲明式的,可透傳 props 的 Context API,對於新版 Context API 還不太了解朋友可以看一下筆者之前的一個回答。

受益於這次改動,React 開發者終於擁有了一個官方提供的安全穩定的 global store,子組件跨層級獲取父組件數據及後續的更新都不再成為一個問題。這讓我們不禁開始思考,相較於 Redux 等其他的第三方數據(狀態)管理工具,使用 Context API 這種 vanilla React 支持的方式是不是一個更好的選擇呢?

Context vs. Redux

在 react + redux 已經成為了開始一個 React 項目標配的今天,我們似乎忘記了其實 react 本身是可以使用 state 和 props 來管理數據的,甚至對於目前市面上大部分的應用來說,對 redux 的不正確使用實際上增加了應用整體的複雜度及代碼量。

Vanilla React Global Store

import React from "react";import { render } from "react-dom";const initialState = { theme: "dark", color: "blue"};const GlobalStoreContext = React.createContext({ ...initialState});class GlobalStoreContextProvider extends React.Component { // initialState state = { ...initialState }; // reducer handleContextChange = action => { switch (action.type) { case "UPDATE_THEME": return this.setState({ theme: action.theme }); case "UPDATE_COLOR": return this.setState({ color: action.color }); case "UPDATE_THEME_THEN_COLOR": return new Promise(resolve => { resolve(action.theme); }) .then(theme => { this.setState({ theme }); return action.color; }) .then(color => { this.setState({ color }); }); default: return; } }; render() { return ( <GlobalStoreContext.Provider value={{ dispatch: this.handleContextChange, theme: this.state.theme, color: this.state.color }} > {this.props.children} </GlobalStoreContext.Provider> ); }}const SubComponent = props => ( <div> {/* action */} <button onClick={() => props.dispatch({ type: "UPDATE_THEME", theme: "light" }) } > change theme </button> <div>{props.theme}</div> {/* action */} <button onClick={() => props.dispatch({ type: "UPDATE_COLOR", color: "red" }) } > change color </button> <div>{props.color}</div> {/* action */} <button onClick={() => props.dispatch({ type: "UPDATE_THEME_THEN_COLOR", theme: "monokai", color: "purple" }) } > change theme then color </button> </div>);class App extends React.Component { render() { return ( <GlobalStoreContextProvider> <GlobalStoreContext.Consumer> {context => ( <SubComponent theme={context.theme} color={context.color} dispatch={context.dispatch} /> )} </GlobalStoreContext.Consumer> </GlobalStoreContextProvider> ); }}render(<App />, document.getElementById("root"));

在上面的例子中,我們使用 Context API 實現了一個簡單的 redux + react-redux,這證明了在新版 Context API 的支持下,原先 react-redux 幫我們做的一些工作現在我們可以自己來做了。另一方面,對於已經厭倦了整天都在寫 action 和 reducer 的朋友們來說,在上面的例子中忽略掉 dispatch,action 等這些 Redux 中的概念,直接調用 React 中常見的 handleXXX 方法來 setState 也是完全沒有問題的,可以有效地緩解 Redux 模板代碼過多的問題。而對於 React 的初學者來說,更是省去了學習 Redux 及函數式編程相關概念與用法的過程。

正確地使用 Redux

從上面 Context 版本的 Redux 中可以看出,如果我們只需要 Redux 來做全局數據源並配合 props 透傳使用的話,新版的 Context 可能是一個可以考慮的更簡單的替代方案。另一方面,原生版本 Redux 的核心競爭力其實也並不在於此,而是其中間件機制以及社區中一系列非常成熟的中間件。

在 Context 版本中,用戶行為(click)會直接調用 reducer 去更新數據。而在原生版本的 Redux 中,因為整個 action dispatch cycle 的存在,開發者可以在 dispatch action 前後,中心化地利用中間件機制去更好地跟蹤/管理整個過程,如常用的 action logger,time travel 等中間件都受益於此。

漸進式地選擇數據流工具

Context

  • 我需要一個全局數據源且其他組件可以直接獲取/改變全局數據源中的數據

Redux

  • 我需要一個全局數據源且其他組件可以直接獲取/改變全局數據源中的數據
  • 我需要全程跟蹤/管理 action 的分發過程/順序

redux-thunk

  • 我需要一個全局數據源且其他組件可以直接獲取/改變全局數據源中的數據
  • 我需要全程跟蹤/管理 action 的分發過程/順序
  • 我需要組件對同步或非同步的 action 無感,調用非同步 action 時不需要顯式地傳入 dispatch

redux-saga

  • 我需要一個全局數據源且其他組件可以直接獲取/改變全局數據源中的數據
  • 我需要全程跟蹤/管理 action 的分發過程/順序
  • 我需要組件對同步或非同步的 action 無感,調用非同步 action 時不需要顯式地傳入 dispatch
  • 我需要聲明式地來表述複雜非同步數據流(如長流程表單,請求失敗後重試等),命令式的 thunk 對於複雜非同步數據流的表現力有限

Presentational vs. Container

時間回到 2015 年,那時 React 剛剛發布了 0.13 版本,Redux 也還沒有成為 React 應用的標配,前端開發界討論的主題是 React 組件的最佳設計模式,後來大家得出的結論是將所有組件分為 Presentational(展示型) 及 Container(容器型)兩類可以極大地提升組件的可復用性。

但後來 Redux 的廣泛流行逐漸掩蓋了這個非常有價值的結論,開發者們開始習慣性地將所有組件都 connect 到 redux store 上,以方便地獲取所需要的數據。

組件與組件之間的層級結構漸漸地只存在於 DOM 層面,大量展示型的組件被 connect 到了 redux store 上,以至於在其他頁面想要復用這個組件時,開發者們更傾向於複製粘貼部分代碼。最終導致了 redux store 越來越臃腫,應用的數據流並沒有因為引入 Redux 而變得清晰,可復用的展示型組件越來越少,應用與應用之間越來越獨立,沒有人再願意去思考應用層面的抽象與復用,項目越做越多,收穫的卻越來越少。

當所有的組件都與數據耦合在一起,視圖層與數據層之間的界限也變得越來越模糊,這不僅徹底打破了 React 本身的分形結構,更是造成應用複雜度陡增的罪魁禍首。

Context + Redux = 更好的 React 應用設計模式

除了更克制地使用 connect,區分展示型與容器型組件之外,受制於現在 Context API,開發者通常也會將主題,語言文件等數據掛在 redux store 的某個分支上。對於這類不常更新,卻需要隨時可以注入到任意組件的數據,使用新的 Context API 來實現依賴注入顯然是一個更好的選擇。

import React from "react";import { render } from "react-dom";import { createStore } from "redux";import { Provider, connect } from "react-redux";const ThemeContext = React.createContext("light");class ThemeProvider extends React.Component { state = { theme: "light" }; render() { return ( <ThemeContext.Provider value={this.state.theme}> {this.props.children} </ThemeContext.Provider> ); }}const LanguageContext = React.createContext("en");class LanguageProvider extends React.Component { state = { laguage: "en" }; render() { return ( <LanguageContext.Provider value={this.state.laguage}> {this.props.children} </LanguageContext.Provider> ); }}const initialState = { todos: []};const todos = (state, action) => { switch (action.type) { case "ADD_TODO": return { todos: state.todos.concat([action.text]) }; default: return state; }};function AppProviders({ children }) { const store = createStore(todos, initialState); return ( <Provider store={store}> <LanguageProvider> <ThemeProvider>{children}</ThemeProvider> </LanguageProvider> </Provider> );}function ThemeAndLanguageConsumer({ children }) { return ( <LanguageContext.Consumer> {language => ( <ThemeContext.Consumer> {theme => children({ language, theme })} </ThemeContext.Consumer> )} </LanguageContext.Consumer> );}const TodoList = props => ( <div> <div> {props.theme} and {props.language} </div> {props.todos.map((todo, idx) => <div key={idx}>{todo}</div>)} <button onClick={props.handleClick}>add todo</button> </div>);const mapStateToProps = state => ({ todos: state.todos});const mapDispatchToProps = { handleClick: () => ({ type: "ADD_TODO", text: "Awesome" })};const ToDoListContainer = connect(mapStateToProps, mapDispatchToProps)( TodoList);class App extends React.Component { render() { return ( <AppProviders> <ThemeAndLanguageConsumer> {({ theme, language }) => ( <ToDoListContainer theme={theme} language={language} /> )} </ThemeAndLanguageConsumer> </AppProviders> ); }}render(<App />, document.getElementById("root"));

在上面的這個完整的例子中,通過組合多個 Context Provider,我們最終得到了一個組合後的 Context Consumer:

<ThemeAndLanguageConsumer> {({ theme, language }) => ( <ToDoListContainer theme={theme} language={language} /> )}</ThemeAndLanguageConsumer>

另一方面,通過分離展示型組件和容器型組件,我們得到了一個純凈的 TodoList 組件:

const TodoList = props => ( <div> <div> {props.theme} and {props.language} </div> {props.todos.map((todo, idx) => <div key={idx}>{todo}</div>)} <button onClick={props.handleClick}>add todo</button> </div>);

小結

在 React v16.3.0 正式發布後,用 Context 來做依賴注入(theme,intl,buildConfig),用 Redux 來管理數據流,漸進式地根據業務場景選擇 redux-thunk,redux-saga 或 redux-observable 來處理複雜非同步情況,可能會是一種更好的 React 應用設計模式。

選擇用什麼樣的工具從來都不是決定一個開發團隊成敗的關鍵,根據業務場景選擇恰當的工具,並利用工具反過來約束開發者,最終達到控制整體項目複雜度的目的,才是促進一個開發團隊不斷提升的核心動力。


推薦閱讀:

react+redux構建一個假假假的記事本
Redux狀態管理之痛點、分析與改良
【React/Redux/Router/Immutable】React最佳實踐的正確食用姿勢

TAG:React | Redux | 前端開發 |