揭秘 React 狀態管理
原文地址
請關注我的知乎專欄:敲代碼,學編程
我的博客:Lindz"s blog
閱讀本文之前,希望你掌握 React,ES6 等相關知識
如果覺得本文有幫助,可以點 star 鼓勵下,本文所有代碼都可以從 github 倉庫下載,讀者可以按照下述打開:
git clone https://github.com/happylindz/react-state-management-tutorial.gitcd react-state-management-tutorialcd controlxxxx/npm i npm start
React 是 Facebook 提出的前端框架,作為 View 層很好地解決了視圖層渲染問題,但是卻缺乏有效的狀態管理,在構建大型的前端應用就會顯得十分乏力時,需要有一個良好的狀態管理,如:Flux,Redux,Mobx 等等.
起初在使用的時候我也曾感到疑惑:為什麼會有這麼多東西,寫起來不是更麻煩了嗎,對其缺乏系統上的認知,以至於我在工作中用的蹩手蹩腳,最近有空認真地學習了一下其內在思想,希望能夠幫助大家理解它們內在的含義。
本文將會介紹一下我對 React 狀態管理方案的一些理解,並對 Flux, Rudux, Mobx 等等狀態方案進行介紹以及使用。
全文將圍繞一個例子 Counter 計數器為線索,並不斷優化代碼:
從圖中可以看出,該例子包含三個計數器和一個總計數器,似乎很輕鬆,計數器為一個組件,總計數器為另一個組件,然後用一個容器包含三個計數器組件和一個總計數器組件即可。
有些經驗是從看《深入淺出 React 和 Redux》這本書而來的,作者十分用心,寫得挺不錯的,有興趣的可以買來看看:深入淺出React和Redux
一、父子組件間通信
首先我們先用沒有任何狀態管理框架來做這個例子,由於總計數器組件的值需要結合三個計數器的值之和,形成聯動的效果,根據下面的即可查看效果。
cd controlpanel/npm i
我們知道,父組件給子組件傳遞信息是通過 props 屬性,而子組件給父組件傳遞信息是通過傳遞函數。
我把組件放在 view 中,目錄結果如下:
├── index.js└── views ├── ControlPanel.js ├── Counter.js └── Summary.js
主要代碼:
export default class ControlPanel extends Component { //... render() { return ( <div> <Counter caption={ "First" } value={ this.initValues["First"] } onCounterUpdate={ this.onCounterUpdate } /> <Counter caption={ "Second" } value={ this.initValues["Second"] } onCounterUpdate={ this.onCounterUpdate } /> <Counter caption={ "Third" } value={ this.initValues["Third"] } onCounterUpdate={ this.onCounterUpdate } /> <Summary value={ this.state.sum } /> </div> ) }}
- 通過 props 向子組件傳遞信息,如 caption, value 等等欄位。
- 子組件通過父組件定義好的函數將信息放在函數參數中傳遞給父組件執行,如 onCounterUpdate 欄位。
值得注意的是,讀者可能會好奇,為什麼沒有這樣的 bind 代碼?
export default class ControlPanel extends Component { constructor(props) { super(props) this.onCounterUpdate = this.onCounterUpdate.bind(this) }}
那是因為我在創建類方法的時候使用了箭頭函數,在創建該方法的時候 this 已經綁定了類的上下文,所以不用再運行的時候再動態綁定,後面都是按照這種寫法,不再贅述。
二、結合 MVC 管理
對於像我們這樣簡單的例子,可能這樣就可以結束了,但是試想一下:
- 對於大型的項目來說,這樣的通信方式顯然有點拙荊見肘,維護起來將會十分痛苦。
- 這裡剛好只是父子組件的通信,如果嵌套層次較深的組件通信通過這樣的方式不夠優雅。
所以我們自然而然地想到,可以將這些需要聯動的數據抽離出來,形成全局 Model 層,通過一個 Controller 去控制,如下圖:
當計數器增減的時候,View 通知 Controller 更新 Model 數據,之後 Controller 通知所有監聽該 Model 數據的 View 視圖更新組件。
cd controlpanel_with_mvc/npm i npm start
重新改變下目錄結構,將這三部分抽離
├── controller│ └── index.js├── index.js├── model│ └── index.js└── views ├── ControlPanel.js ├── Counter.js └── Summary.js
Model 數據:
export const counterData = { "First": 0, "Second": 10, "Third": 20,}
Controller 代碼:
import { counterData } from "../model"const events = []const controller = { onChange: (callback) => { events.push(callback) }, emitChange: () => { for(let i = 0; i < events.length; i++) { events[i]() } }, setCounterValue: (caption, value) => { counterData[caption] = value controller.emitChange() }, getCounterValue: caption => { return counterData[caption] }, getTotalValues: () => { let total = 0 for(let key in counterData) { if(counterData.hasOwnProperty(key)) { total += counterData[key] } } return total },}export default controller
創建一個 eventHub,將監聽數據的組件的回調函數傳入到 events 中,更新輸入則通過 controller.setCounterValue 方法,controller 更新數據後通過 emitChange 觸發所有監聽數據的函數,從而更新視圖。
新的 Counter 組件:Counter 保存著全局 Model 數據與自己組件相關數據的映射,在 componentDidMount 執行時,將 onCounterUpdate 傳入 events 隊列中,即當數據發生變化時候同步保持局部數據與全局數據的一致性,從而形成聯動效果。
import controller from "../controller"export default class Counter extends Component { //... onHandleClickChange = (isIncrement) => { const { caption } = this.props let value = isIncrement ? this.state.value + 1 : this.state.value - 1 controller.setCounterValue(caption, value) } onCounterUpdate = () => { const { caption } = this.props this.setState({ value: controller.getCounterValue(caption) }) } componentDidMount() { controller.onChange(this.onCounterUpdate) }}
現在父組件完成成為一個容器組件,數據與邏輯已經抽離出來了 View 層。
import controller from "../controller"export default class ControlPanel extends Component { render() { return ( <div> { controller.getDataKeys().map(caption => (<Counter key={ caption } caption={ caption } />)) } <Summary /> </div> ) }}
現在如果你想添加計數器只需要在 Model 層添加一個 Fourth: 30 欄位即可。
MVC 的缺陷:上述的 MVC(View、Model、Controller 是1:1:1的關係)只是一種理想狀態。現實中的程序往往是多視圖,多模型。更嚴重的是視圖與模型之間還可以是多對多的關係。也就是說,單個視圖的數據可以來自多個模型,單個模型更新是需要通知多個視圖,用戶在視圖上的操作可以對多個模型造成影響。
試想一下:當你再不經意間將數據設為 null 時,之前的數據將直接被你覆蓋掉了,你沒有對數據改變進行嚴格的控制。
import controller from "../controller"export default class Counter extends Component { //... onHandleClickChange = (isIncrement) => { const { caption } = this.props let value = isIncrement ? this.state.value + 1 : this.state.value - 1 controller.setCounterValue(caption, null) }}
另外,在實際框架實現中,總允許 View 和 Model 可以直接通信,當代嗎量增大之後,我們的應用將會變得:
當出現多模型多視圖,在 MVC 中讓 View 和 Model 直接通信簡直就是災難。
三、Flux 架構
為此,Facebook 提出了 Flux 架構,提供更加嚴格的數據流控制,說白了就是讓你無法直接修改數據,為所欲為了。
一個 Flux 應用包含四個部分:
- Dispatcher,處理動作的分發,修改 Store 上的數據
- Store,負責存儲數據和處理數據相關的邏輯
- Action,驅動 Dipatcher 的 JS 對象
- VIew,視圖部分,展示數據
可以看出,Action 是事先定義好的動作,比如:計數器增減操作,除此之外,用戶無法通過其他方式直接對數據進行修改。當用戶觸發事件,通過 Dipatcher 將 Action 分發,Store 接收並處理 Action 對象,所以計數器例子我們就可以進行修改了。
cd controlpanel_with_flux/npm i npm start
目錄結構:
├── actionCreator│ └── index.js├── actionTypes│ └── index.js├── dispatch│ └── index.js├── index.js├── store│ ├── CounterStore.js│ └── SummaryStore.js└── views ├── ControlPanel.js ├── Counter.js └── Summary.js
actionTypes 用於定義 Action 類型,通常暴露一下常量給組件使用,比如這裡我們需要兩個 Action 類型:增加、減少計數值。
export const INCREMENT = "INCREMENT"export const DECREMENT = "DECREMENT"
actionCreator 用於創建 action 函數,暴露出 Action 函數給組件使用,函數內分發 action 對象給 dispatcher 使用。
import * as actionTypes from "../actionTypes"import AppDispatcher from "../dispatch"export default { increment: (counterCaption) => { AppDispatcher.dispatch({ type: actionTypes.INCREMENT, counterCaption: counterCaption, }) }, decrement: (counterCaption) => { AppDispatcher.dispatch({ type: actionTypes.DECREMENT, counterCaption: counterCaption, }) }}
store 取代了 Model 層以及部分 Controller 的邏輯,
const counterData = { "First": 0, "Second": 10, "Third": 20,}const events = []const CounterStore = { incrementCounter: caption => { counterData[caption]++ }, decrementCounter: caption => { counterData[caption]-- }, onChange: callback => { events.push(callback) }, emitChange: () => { for(let i = 0; i < events.length; i++) { events[i]() } }, getDataKeys: () => { return Object.keys(counterData) }, getCounterValue: caption => { return counterData[caption] }, getCounterKeys: () => { return Object.keys(counterData) },}export default CounterStore
這時候發送方和接收方都已經定義好了,就差 dispatcher 這個中間橋樑來傳遞數據了。
import { Dispatcher } from "flux"import * as actionTypes from "../actionTypes"import CounterStore from "../store/CounterStore"const dispatcher = new Dispatcher()CounterStore.dispatchToken = dispatcher.register((action) => { switch(action.type) { case actionTypes.INCREMENT: CounterStore.incrementCounter(action.counterCaption) CounterStore.emitChange() break case actionTypes.DECREMENT: CounterStore.decrementCounter(action.counterCaption) CounterStore.emitChange() break default: break }})
通過 dispatcher.register 來出來視圖發出的 action 對象,這時候 actionTypes 就派上用場,設置這種全局的變數可以減少一些硬編碼量,減低犯錯的可能。
從上述的代碼可以看出,通過判斷 action.type 值來進行不同的操作,但是能操作的可能只有增加或減少數值,其他類型的 action 均為無效操作,這樣就限制了數據發生異常的可能。
當更新了數據後,同樣觸發監聽數據的回調函數,讓監聽數據的組件將全局變數同步更新到局部的狀態,從而觸發視圖的刷新,這部分邏輯就跟 MVC 上的一模一樣了。
另外將原本的 controller 拆分成兩個 Store (CounterStore & SummaryStore),這樣可以做更加細粒度的控制(即可以分開添加回調函數),詳細代碼可以看 SummaryStore 和 dispatch,這裡不再詳細展開。
Flux 優點:
- React 組價依然有自己的狀態,但是已經完全淪為 Store 數據的一個映射,而不是主動變化數據
- 對數據有著更加嚴格的控制,不允許直接對數據進行修改,數據更加不容易出錯。
- 遵循嚴格的單向數據流,想要追溯一個應用的邏輯變得非常容易,更加容易進行維護。
Flux 缺點:
參考:《看漫畫,學 Redux》 —— A cartoon intro to Redux
- store 的代碼無法被熱替換,除非清空當前的狀態
- 每次觸發 action 時狀態對象都被直接改寫了
- 沒有合適的位置引入第三方插件
- 難以進行服務端渲染
問題 1:Store 混淆了邏輯和狀態,當我們需要動態替換一個 Store 的邏輯時,只能把這個 Store 整體替換掉,那也就無法保持 Store 中的存儲狀態。
在一個 store 中同時保存這兩樣東西將會導致代碼熱替換功能出現問題。當你熱替換掉 store 的代碼想要看看新的狀態改變邏輯是否生效時,你就丟失了 store 中保存的當前狀態。
解決方案:
將這兩樣東西分開處理。讓一個對象來保存狀態,這個對象在熱替換代碼的時候不會受到影響。讓另一個對象包含所有改變狀態的邏輯,這個對象可以被熱替換因為它不用關心任何保存狀態相關的事情。
問題 2:時間旅行調試法的特性是:你能掌握狀態對象的每一次變化,這樣的話,你就能輕鬆的跳回到這個對象之前的某個狀態(想像一個撤銷功能)。
所以要想實現時間旅行特性,每一個狀態改變的版本都需要保存在不同的 JavaScript 對象中,這樣你才不會不小心改變了某個歷史版本的狀態。
解決方案:
當一個 action 需要 store 響應時,不要直接修改 store 中的狀態,而是將狀態拷貝一份並在這份拷貝的狀態上做出修改,這樣方便對 store 狀態進行調試回滾。
問題 3:沒有合適的位置引入第三方插件
一個簡單的例子就是日誌。比如說你希望 console.log() 每一個觸發的 action 同時 console.log() 這個 action 被響應完成後的狀態。在 Flux 中,你只能訂閱(subscribe) dispatcher 的更新和每一個 store 的變動。但是這樣就侵入了業務代碼,這樣的日誌功能不是一個第三方插件能夠輕易實現的。
不好的:這樣做就入侵了代碼
const dispatcher = new Dispatcher()CounterStore.dispatchToken = dispatcher.register((action) => { console.log(action); switch(action.type) { //... }})
我們希望以一種插件的方式來引入,比如:
import logger from "xxx-logger"const dispatcher = new Dispatcher({ plugins:[ logger ] }) // something like thisCounterStore.dispatchToken = dispatcher.register((action) => { switch(action.type) { //... }})
解決方案:
在架構原有的功能基礎之上添加了自己的功能。你可以把這種擴展點看做是一個增強器(enhancers)或者高階對象(higher order objects),亦或者中間件(middleware)。
當 dispatch 獲取到 action 和 state 之後,通過層層中間件處理,最後生成新的狀態,可以將 logger 日誌功能看成一個中間件。
四、基本 Redux 架構
基於上面存在的問題,Flux 基本原則是 「單向數據流」,Redux 可以理解為 Flux 的一種實現,其實有很多種 Flux 實現的框架,如:Reflux 等,但是 Reudx 有很多其他框架無法比擬的優勢。
Redux 在單向數據流的基礎上強調三個基礎原理:
- 唯一數據源:即應用的狀態數據應該只存在唯一一個 Store 上。整個應用只保持一個 Store,所有組件的數據源就是這個 Store 上的狀態,每個組件往往只是用樹形對象上的一部分數據。
- 保持狀態只讀。
- 數據改變只能通過純函數完成。
為此,我們可以開始編寫我們先編寫一個基礎的 Redux 實例,然後再想辦法去改進它。
cd controlpanel_with_redux_basic/npm i npm start
重新來看目錄結構,會發現,少了 dispatch,多了 reducers
├── actionCreator│ └── index.js├── actionTypes│ └── index.js├── index.js├── reducers│ └── index.js├── store│ └── index.js└── views ├── ControlPanel.js ├── Counter.js └── Summary.js
reducers 即處理 action 的純函數,通過傳入 action 對象以及舊的 state,返回新的 state。
import * as actionTypes from "../actionTypes"export default (state, action) => { const { counterCaption } = action switch(action.type) { case actionTypes.INCREMENT: return { ...state, [counterCaption]: state[counterCaption] + 1 } case actionTypes.DECREMENT: return { ...state, [counterCaption]: state[counterCaption] - 1 } default: return { ...state } }}
actionCreator 也有所變化,原本包含 dispatch 邏輯,現在只是簡單返回一個 action js 對象。
import * as actionTypes from "../actionTypes"export default { increment: (caption) => { return { type: actionTypes.INCREMENT, counterCaption: caption, } }, decrement: (caption) => { return { type: actionTypes.DECREMENT, counterCaption: caption, } },}
store 的邏輯就很簡單,只要傳入初始化的數據和處理 Action 的純函數即可。
import { createStore } from "redux"import reducer from "../reducers"const initValues = { "First": 0, "Second": 10, "Third": 20}const store = createStore(reducer, initValues)export default store
在視圖組件中,
- 如果需要用到數據,則調用 store.getState 函數即可獲取到當前數據
- 如果需要修改數據,如添加或者減少數值,則僅需要調用 store.dispatch 分發 action,而不用像原來需要使用一整個 Dispatcher 對象
- 一樣的,在組件載入完成後,通過 store.subscribe 去訂閱數據變化的回調函數,一旦數據發生了變化,則觸發回調同步刷新局部變數
所以我們的視圖就變成了這樣:
import store from "../store"import actionCreator from "../actionCreator"export default class Counter extends Component { // ... getOwnState() { return { value: store.getState()[this.props.caption] } } onHandleClickChange = (isIncrement) => { const { caption } = this.props if(isIncrement) { store.dispatch(actionCreator.increment(caption)) }else { store.dispatch(actionCreator.decrement(caption)) } } onCounterUpdate = () => { this.setState({ ...this.getOwnState() }) } componentDidMount() { store.subscribe(this.onCounterUpdate) } // ...}
整個流程看來就像是:點擊了增加按鈕,通過 actionCreator.increment 返回一個 JS 對象,將它傳遞給 store.dispatch 分發出去,這時候交給 store 的 reducers 純函數處理,通過 store.getState() 獲取當前狀態,以及 action 對象,返回一個新的 state,之後再調用 subscribe 的回調函數,將 store 上的變數映射同步更新到局部變數,局部變數通過 setState 即可更新視圖。
Redux 解決了 Flux 所遺留下來的問題:
首先,數據和處理數據的邏輯(reducer)分離了,這樣可以在做一些熱替換的時候可以保留原本的狀態不受影響。
其次,reducer 處理後返回一個新的 state,這樣就有機會保存每次的狀態,你可以跳回之前的某個狀態,方便開發調試工具。
另外如果有多個 reducer 的話,每個 reducer 這變成了一個 store 上的局部變數,就像這樣。
import { createStore, combineReducers } from "redux"import reducerA from "./reducers/reducerA"import reducerB from "./reducers/reducerB"import reducerC from "./reducers/reducerC"const initValues = { "a": 0, "b": 10, "c": 20}const reducers = combineReducers({ "a": reducerA, "b": reducerB, "c": reducerC,})const store = createStore(reducers, initValues)export default store
值得注意的是:
- reducerA 影響的 state 只是 store 上的一個局部狀態,它並無法影響到 store 對象 b 或者 c 上的數據。
- 當 store.dispatch 傳遞 action 對象過來後,store 無法智能地選擇某些相關的 reducer 函數去執行,它只會傻瓜似地將所有 reducer 函數全部執行一遍,重新組裝成一個完成的 store,哪怕只是修改一個簡單的地方,但也要觸發十幾個 reducer 函數去做無用功。
- 所以這時候 actionTypes 就起到作用了,因為 actionType 只是設置成字元串,如果字元串設置的過於簡單就有可能導致重複,導致原本不相干的 reducer 處理了 action,這樣應用就會陷入混亂當中,所以建議使用 Symbol 類型或者將字元串設置成比較具體複雜些的,比如想這樣 export const increment = "counter/increment" 添加不同的前綴來區分。
- 同樣的 store.subscribe 函數也不是智能地區分哪些數據變化,而是通通執行一遍返回新的值,即使大部分數據都沒有發生變化。
另外中間件或者 store 增強器可以在創建 store 的時候進行傳入,比如之前說的 logger 日誌的功能。
import { applyMiddleware, createStore } from "redux";import createLogger from "redux-logger";const logger = createLogger();const initValue = {}const store = createStore( reducer, initValue, applyMiddleware(logger),);
默認它會判斷第二個參數是不是函數,如果是函數,則當初是中間件集合進行傳遞,如果不是函數則當做是初始數據進行傳遞。
整個過程就像前面那幅圖:
遵循中間件先進後出的原則,一一作用於 傳入的 action 和 state 中,其實 reducer 函數可以看做是一個特殊的中間件,被 applyMiddleware 包裹在中間。通過中間件或者 store enhancer 可以定製我們的 store 功能,感興趣的同學可以去網上學習,這裡就不再展開。
五、改進後的 Redux 架構
前面 Redux 方案解決了 Flux 所遺留下來的一些問題,但是僅僅是這樣的話它也存在一些問題,我們可以試著去改變它。
首先,這個 store 在每個需要數據的頁面都需要引入,比如 Counter 和 Summary 組件,這樣則顯得不夠優雅,如果可以將 store 變數掛載在 this 變數上,如果有需要的時候直接訪問 this.xxx.store 即可訪問到,那就更好了。
這裡我們就需要提到一個 context,它可以幫助我們傳遞全局變數,比如在父組件裡面的 this.context 掛載 store 變數,則稍微設置下即可在孫子組件里通過 this.context.store 獲取到 store 變數,一般它是通過創建時候通過構造函數的第二參數傳入的,如: constructor(props, context),所以我們在寫法上應該變成:
// 錯誤的,無意間丟失了祖先組件傳遞的 context 變數constructor(props) { super(props)}// 正確的constructor() { super(...arguments)}// 或者 箭頭函數無 arguments 參數constructor = (...args) => { super(...args);}
為此我們創建了一個包裝組件 Provider,用於將 store 轉化到 context 上。
import { Component } from "react"import PropTypes from "prop-types"class Provider extends Component { getChildContext() { return { store: this.props.store } } render() { return this.props.children }}Provider.propTypes = { store: PropTypes.object.isRequired}Provider.childContextTypes = { store: PropTypes.object}export default Provider
這個組件做的唯一的是就是將 store 通過從傳入的 props 中轉化為 context 的屬性,其它並沒有任何修改,另外需要設置 Provider.childContextTypes,否則可能無法生效。
import React from "react"import { render } from "react-dom"import Provider from "./Provider"import ControlPanel from "./views/ControlPanel"import store from "./store"render( <Provider store={ store } > <ControlPanel /> </Provider>, document.getElementById("root"))
將 store 從根引入,傳遞給 Provider 對象,轉化成 this.context 屬性,以後需要用到 store 數據的只要引用 this.context 即可而不用再次引入 store 了。比如:Summary 組件
class Summary extends Component { constructor() { super(...arguments) this.state = this.getOwnState() } getOwnState = () => { let total = 0 const state = this.context.store.getState() for(let key in state) { if(state.hasOwnProperty(key)) { total += state[key] } } return { value: total } } // ...}// 別忘了設置 contextTypes,否則無法引用到 contextSummary.contextTypes = { store: PropTypes.object}
另外一點是,我們將處理全局變數到局部變數的映射的邏輯跟視圖組件耦合在了一起,不利於維護,我們應該將這其拆成兩個部分:傻瓜組件和容器組件,
傻瓜組件即一個純函數組件,沒有 React 生命周期的事件,通過 props 傳遞的值進行對視圖進行渲染,如:
const Counter = (props) => { const { caption, onHandleClickChange, value } = props return ( <div> <input style={ buttonStyle } type="button" value="-" onClick={ () => onHandleClickChange(false) } /> <input style={ buttonStyle } type="button" value="+" onClick={ () => onHandleClickChange(true) } /> <span> { caption } Count: { value } </span> </div> )}
任人擺布,別人傳遞什麼值我就用什麼值,而容器組件則將所有包含著全局變數到局部變數的映射的邏輯,有自己局部的 state,將自己局部的 state 轉化為 props 傳遞給傻瓜組件進行渲染。
class CounterContainer extends Component { // ... onCounterUpdate = () => { this.setState(this.getOwnState()) } componentDidMount() { this.context.store.subscribe(this.onCounterUpdate) } render() { return ( <Counter caption={ this.props.caption } onHandleClickChange={ this.onHandleClickChange } value={ this.state.value } /> ) }}CounterContainer.contextTypes = { store: PropTypes.object}
這樣我們就將原本組件不同的部分給分離開來了,傻瓜組件僅專註於視圖,容器專註處理數據同步的邏輯。改進後的 redux 例子可以通過:
cd controlpanel_with_redux_promoted/npm i npm start
六、最終的 Redux 架構
我們發現容器的組件的邏輯其實都是一樣的,無非就是兩種,觸發 action 的函數,以及全局變數映射到局部變數的 state,所以如果我們將這部分抽象出來的話那我們以後不是只要寫傻瓜組件就行,而不用每次都還要重複寫類似的容器組件。
cd controlpanel_with_redux_final/npm i npm start
我們新增加一個 connect 目錄,用於構件一個統一通用的容器組件,這裡需要用到一些高階組件的知識:高階組件即函數接受一個 React 組件,返回一個增強後的 React 組件。
// ...export const connect = (mapStateToProps, mapDispatchToProps) => { // ... return (WrappedComponent) => { const HOCComponent = class extends Component { constructor() { super(...arguments) this.state = {}; } onChange = () => { this.setState({}) } componentDidMount() { this.context.store.subscribe(this.onChange) } render() { const store = this.context.store const stateToProps = mapStateToProps(store.getState(), this.props) const newProps = { ...this.props, ...stateToProps, ...mapDispatchToProps(store.dispatch, this.props) } return <WrappedComponent { ...newProps } /> } } HOCComponent.contextTypes = { store: PropTypes.object } return HOCComponent; }}
WrappedComponent 即傳遞的進來的傻瓜組件,connect 接受兩個參數:狀態映射以及函數映射。代碼的核心在於 store.subscribe 了 onChange 函數,通過 setState({}) 從而觸發組件重新執行 render 函數進行 vdom 的比較,這時候 mapStateToProps 函數執行後返回的便是最新全局變數返回的局部映射,我們就將新的值通過 props 的方式傳遞給傻瓜組件,從而引起重新渲染。
const Counter = ({ caption, value, increment, decrement }) => { console.log(caption) return ( <div> <button style={ buttonStyle } onClick={ decrement }>-</button> <button style={ buttonStyle } onClick={ increment }>+</button> { caption } Count: { value } </div> )}const mapStateToProps = (state, ownProps) => { return { caption: ownProps.caption, value: state[ownProps.caption] }}const mapDispatchToProps = (dispatch, ownProps) => { return { increment: () => { dispatch(actionCreator.increment(ownProps.caption)) }, decrement: () => { dispatch(actionCreator.decrement(ownProps.caption)) } }}export default connect(mapStateToProps, mapDispatchToProps)(Counter);
以後我們的視圖只需要寫好純渲染組件,以及 mapStateToProps 以及 mapDispatchToProps 對應的函數即可。值得注意的是:mapStateToProps 傳入的參數分別為當前的 state 和 props,而 mapDispatchToProps 傳遞的是 dispatch 和 ownProps 對象。
這樣還不算完,我們需要做一些性能優化,比如說:我們增加了 First 計時器的值,按理說只有 First 這個組件進行重新 render 了,而現在由於三個 setState({}) 都會觸發高階組件重新進行 render,有點浪費性能,所以這時候 shouldComponentUpdate 這個鉤子函數派上用場,它是通過判斷現在值得跟之前的是否有變化,如果沒有變化,返回 false 後就不會執行後面 render 函數,如果有變化,則交給後面的 render 函數進行 vdom 比較了。
這裡我通過創建一個全局的 Map 來存儲之前的值。
const map = new WeakMap()export const connect = (mapStateToProps, mapDispatchToProps) => { return (WrappedComponent) => { const HOCComponent = class extends Component { shouldComponentUpdate(nextProps, nextState) { return map.get(this).value !== mapStateToProps(this.context.store.getState(), nextProps).value } render() { const store = this.context.store const stateToProps = mapStateToProps(store.getState(), this.props) const newProps = { ...this.props, ...stateToProps, ...mapDispatchToProps(store.dispatch, this.props) } map.set(this, stateToProps) return <WrappedComponent { ...newProps } /> } } }}
每次 render 時候就更新 Map 中對應該組件的值,然後下次執行更新的時候判斷新值中的value 值是否跟之前存儲的一樣,如果一樣說明沒有發生變化,則返回 false 不再繼續,比如更新 First 計數器,那麼只有 First 的 value 更新了,其他的計數器值並沒有變化,那麼只有 First 組件執行了 render 函數,其他的計數器因為 shouldComponentUpdate 返回了 false 則不再執行 render 函數。
七、結合 React-Redux
說了這麼多,只是為了闡述一下它的演變的原理。這些其實在 react-redux 包里已經實現好了,我們不用每次在新的項目里再重新寫一遍 Provider 或者 connect 這樣的代碼。
cd controlpanel_with_react_redux/npm i npm start
區別在於:Provider、connect 都是由 react-redux 提供的
import { Provider } from "react-redux"// ...render( <Provider store={ store } > <ControlPanel /> </Provider>, document.getElementById("root"))``````javascriptimport React from "react"import { connect } from "react-redux"// ...export default connect(mapStateToProps)(Summary)
其它基本就一模一樣了,自此 Redux 結合 React-Redux 的思想我已經介紹完了。
其實如果你一開始就使用 Redux + React-Redux 來編寫代碼的話肯定會覺得寫起來很蹩腳,會很困惑為啥要這麼設計,當你不明白其底層的含義的話就相當於是開始強行使用的話可能會適應的比較慢。希望讀者在看完前面敘述的內容後能夠對 Redux + React-Redux 存在的意義能夠有一定的理解。
八、Mobx 架構
相比於 Redux 體系來說,Mobx 架構就比較容易理解和上手,如果大家使用過 Vue 的話相信對其雙向綁定 MVVM 的思想並不陌生,React + Mobx 相當於是 Vue 全局作用域下的雙向綁定,而 Vue 的狀態管理框架 Vuex 卻是借鑒了 Flux 架構,連尤大都說,似乎有點你中有我,我中有你的關係。
在 React 中,我們通過 setState 來更新數據,從而觸發視圖的更新,嚴格遵循著數據單向流的過程,但是如果你使用 Mobx 而不是用 setState 的話就可以將其變數雙向綁定,就如官網上的例子:
import {observer} from "mobx-react"import {observable} from "mobx"@observer class Timer extends React.Component { @observable secondsPassed = 0 componentWillMount() { setInterval(() => { this.secondsPassed++ }, 1000) } render() { return (<span>Seconds passed: { this.secondsPassed } </span> ) }})React.render(<Timer />, document.body)
單向數據流或者雙向數據流的好壞就見仁見智了,對雙向數據綁定感興趣的話可以看這篇文章:剖析Vue實現原理 - 如何實現雙向綁定mvvm
下面我們重新修改之前的計時器的例子,結合 Mobx 架構來看看會有什麼樣的變化?
cd controlpanel_with_mobx/npm i npm start
先來看一下目錄結構:
├── index.js├── store│ ├── CounterStore│ │ └── index.js│ └── index.js└── views ├── ControlPanel.js ├── Counter.js └── Summary.js
是不是發現簡潔了許多,顯然因為數據雙向綁定了,當數據更新後不用寫一大堆回調函數來更新視圖了。
import { observable, computed, action } from "mobx";class CounterStore { @observable counters = { "First": 0, "Second": 10, "Third": 20, } @computed get totalValue() { let total = 0 for(let key in this.counters) { if(this.counters.hasOwnProperty(key)) { total += this.counters[key] } } return total } @computed get dataKeys() { return Object.keys(this.counters) } @action changeCounter = (caption, type) => { if(type === "increment") { this.counters[caption]++ }else { this.counters[caption]-- } }}const counterStore = new CounterStore()export default counterStoreexport { counterStore }
介紹幾個概念:
- observable:使數據變為可觀測的,響應式的
- computed: 計算屬性,當依賴的數據發生變化時候,自動觸發更新視圖
- action:一個函數,用於觸發數據的更新,這裡使用箭頭函數可以靜態地綁定 this,避免失去上下文。
一樣的 store 需要從根組件向下注入:
import React from "react"import { render } from "react-dom"import ControlPanel from "./views/ControlPanel"import * as stores from "./store"import { Provider } from "mobx-react"render( <Provider { ...stores }> <ControlPanel /> </Provider>, document.getElementById("root"))
store 可以不只有一個,不同的 store 的數據以及對應處理數據的不同邏輯,當組件需要用某個 store 的時候需要通過 inject 注入。
import React, { Component } from "react"import { observer, inject } from "mobx-react";@inject("counterStore")@observerclass Counter extends Component { render() { const store = this.props.counterStore const { caption } = this.props return ( <div> <input style={ buttonStyle } type="button" value="-" onClick={ store.changeCounter.bind(this, caption, "decrement") } /> <input style={ buttonStyle } type="button" value="+" onClick={ store.changeCounter.bind(this, caption, "increment") } /> <span> { caption } Count: { store.counters[caption] } </span> </div> ) }}export default Counter
observer 代碼這個類成為觀察者,inject("counterStore") 注入 counterStore,可以通過 this.props.counterStore 來訪問。是不是十分簡單,使用起來也比較容易上手,相信使用過 Vue 寫代碼的人一定不會感到陌生。
值得一提的是 @ 這個代表 ES7 的裝飾器,如果對其不熟悉的可以看這篇文章:ES7 Decorator 裝飾者模式
自此 React 狀態管理方案就介紹到這裡,我們從傳統的父子間通信,MVC 再到 Flux,Redux,最後止於 Mobx,希望本文能夠對你有所幫助,覺得有用的話請點個 star 鼓勵下,謝謝!
推薦閱讀: