使用 Redux 實現 JavaScript 的不變性
已發表: 2022-03-11在一個由豐富而復雜的 JavaScript 應用程序組成的不斷增長的生態系統中,需要管理的狀態比以往任何時候都多:當前用戶、加載的帖子列表等。
任何需要事件歷史的數據集都可以被認為是有狀態的。 管理狀態可能很困難且容易出錯,但使用不可變數據(而不是可變數據)和某些支持技術(即本文的目的的 Redux)會有很大幫助。
不可變數據有限制,即一旦創建就不能更改,但它也有很多好處,特別是在引用與值相等方面,這可以大大加快依賴於頻繁比較數據的應用程序(檢查是否需要更新, 例如)。
使用不可變狀態允許我們編寫可以快速判斷狀態是否已更改的代碼,而無需對數據進行遞歸比較,這通常要快得多。
本文將介紹 Redux 在通過 action creators、純函數、組合 reducer、使用 Redux-saga 和 Redux Thunk 管理狀態時的實際應用,最後是使用 Redux 和 React。 也就是說,Redux 有很多替代品,例如 MobX、Relay 和基於 Flux 的庫。
為什麼選擇 Redux?
將 Redux 與大多數其他狀態容器(例如 MobX、Relay 和大多數其他基於 Flux 的實現)區分開來的關鍵方面是 Redux 有一個只能通過“動作”(純 JavaScript 對象)修改的單一狀態,這些動作被分派到Redux 商店。 大多數其他數據存儲具有包含在 React 組件本身中的狀態,允許您擁有多個存儲和/或使用可變狀態。
這反過來會導致 store 的 reducer(一個對不可變數據進行操作的純函數)執行並可能更新狀態。 此過程強制執行單向數據流,這更易於理解且更具確定性。
由於 Redux reducer 是對不可變數據進行操作的純函數,因此它們總是在給定相同輸入的情況下產生相同的輸出,從而使它們易於測試。 這是一個減速器的例子:
import Immutable from 'seamless-immutable' const initialState = Immutable([]) // create immutable array via seamless-immutable /** * a reducer takes a state (the current state) and an action object (a plain JavaScript object that was dispatched via dispatch(..) and potentially returns a new state. */ function addUserReducer(state = initialState, action) { if (action.type === 'USERS_ADD') { return state.concat(action.payload) } return state // note that a reducer MUST return a value } // somewhere else... store.dispatch({ type: 'USERS_ADD', payload: user }) // dispatch an action that causes the reducer to execute and add the user
處理純函數可以讓 Redux 輕鬆支持許多通常不容易使用 mutative state 完成的用例,例如:
- 時間旅行(回到以前的狀態)
- 日誌記錄(跟踪每一個操作以找出導致商店突變的原因)
- 協作環境(例如 GoogleDocs,其中操作是純 JavaScript 對象,可以序列化、通過網絡發送並在另一台機器上重放)
- 簡單的錯誤報告(只需發送已調度的操作列表,然後重播它們以獲得完全相同的狀態)
- 優化渲染(至少在將虛擬 DOM 渲染為狀態函數的框架中,例如 React:由於不變性,您可以通過比較引用輕鬆判斷某些內容是否已更改,而不是遞歸比較對象)
- 輕鬆測試您的 reducer,因為可以輕鬆對純函數進行單元測試
動作創作者
Redux 的 action creators 有助於保持代碼的清潔和可測試性。 請記住,Redux 中的“動作”只不過是描述應該發生的突變的普通 JavaScript 對象。 話雖如此,一遍又一遍地寫出相同的對像是重複的並且容易出錯。
Redux 中的 action creator 只是一個幫助函數,它返回一個描述突變的純 JavaScript 對象。 這有助於減少重複代碼,並將您的所有操作保存在一個地方:
export function usersFetched(users) { return { type: 'USERS_FETCHED', payload: users, } } export function usersFetchFailed(err) { return { type: 'USERS_FETCH_FAILED', payload: err, } } // reducer somewhere else... const initialState = Immutable([]) // create immutable array via seamless-immutable /** * a reducer takes a state (the current state) and an action object (a plain JavaScript object that was dispatched via dispatch(..) and potentially returns a new state. */ function usersFetchedReducer(state = initialState, action) { if (action.type === 'USERS_FETCHED') { return Immutable(action.payload) } return state // note that a reducer MUST return a value }
將 Redux 與不可變庫一起使用
雖然 reducer 和 action 的本質使它們易於測試,但如果沒有不變性幫助程序庫,則沒有什麼可以保護您免受變異對象的影響,這意味著所有 reducer 的測試必須特別健壯。
考慮以下代碼示例,您將在沒有庫保護您的情況下遇到問題:
const initialState = [] function addUserReducer(state = initialState, action) { if (action.type === 'USERS_ADD') { state.push(action.payload) // NOTE: mutating action!! return state } return state // note that a reducer MUST return a value }
在此代碼示例中,時間旅行將被破壞,因為先前的狀態現在與當前狀態相同,純組件可能不會更新(或重新渲染),因為對狀態的引用沒有改變,即使它的數據contains 發生了變化,並且突變更難以推理。
如果沒有不變性庫,我們就會失去 Redux 提供的所有好處。 因此,強烈建議使用不可變幫助程序庫,例如 immutable.js 或 seamless-immutable,尤其是在多手接觸代碼的大型團隊中工作時。
不管你使用哪個庫,Redux 的行為都是一樣的。 讓我們比較兩者的優缺點,以便您能夠選擇最適合您的用例的一個:
不可變的.js
Immutable.js 是一個由 Facebook 構建的庫,在數據結構(例如 Maps、Lists、Sets 和 Sequences)上具有更實用的風格。 它的不可變持久數據結構庫在不同狀態之間執行盡可能少的複制。
優點:
- 結構共享
- 更新效率更高
- 內存效率更高
- 有一套幫助方法來管理更新
缺點:
- 不能與現有的 JS 庫(即 lodash、ramda)無縫協作
- 需要在 (toJS / fromJS) 之間進行轉換,尤其是在水合/脫水和渲染期間
無縫不可變
Seamless-immutable 是一個用於不可變數據的庫,它一直向後兼容到 ES5。
它基於 ES5 屬性定義函數,例如defineProperty(..)
來禁用對象的突變。 因此,它與 lodash 和 Ramda 等現有庫完全兼容。 它也可以在生產版本中禁用,從而提供潛在的顯著性能提升。
優點:
- 與現有的 JS 庫(即 lodash、ramda)無縫協作
- 不需要額外的代碼來支持轉換
- 可以在生產版本中禁用檢查,從而提高性能
缺點:
- 沒有結構共享 - 對象/數組是淺拷貝的,使得大型數據集的速度變慢
- 內存效率不高
Redux 和多個 Reducer
Redux 的另一個有用特性是能夠將 reducer 組合在一起。這允許您創建更複雜的應用程序,並且在任何可感知大小的應用程序中,您將不可避免地具有多種類型的狀態(當前用戶、加載的帖子列表、等等)。 Redux 通過自然地提供函數combineReducers
來支持(並鼓勵)這個用例:
import { combineReducers } from 'redux' import currentUserReducer from './currentUserReducer' import postsListReducer from './postsListReducer' export default combineReducers({ currentUser: currentUserReducer, postsList: postsListReducer, })
使用上面的代碼,您可以擁有一個依賴於currentUser
的組件和另一個依賴於postsList
的組件。 這也提高了性能,因為任何單個組件都只會訂閱與它們相關的樹的任何分支。
Redux 中的不純操作
默認情況下,您只能將純 JavaScript 對象分派給 Redux。 然而,使用中間件,Redux 可以支持不純的操作,例如獲取當前時間、執行網絡請求、將文件寫入磁盤等。
“中間件”是用於可以攔截正在調度的操作的函數的術語。 一旦被攔截,它就可以做一些事情,比如轉換動作或分派一個異步動作,就像其他框架(如 Express.js)中的中間件。
兩個非常常見的中間件庫是 Redux Thunk 和 Redux-saga。 Redux Thunk 以命令式風格編寫,而 Redux-saga 以函數式風格編寫。 讓我們比較一下。
Redux 重擊
Redux Thunk 通過使用 thunk 支持 Redux 中的不純操作,這些函數返回其他可鏈接的函數。 要使用 Redux-Thunk,必須先將 Redux Thunk 中間件掛載到 store:
import { createStore, applyMiddleware } from 'redux' import thunk from 'redux-thunk' const store = createStore( myRootReducer, applyMiddleware(thunk), // here, we apply the thunk middleware to R )
現在我們可以通過向 Redux 存儲發送 thunk 來執行不純的操作(例如執行 API 調用):
store.dispatch( dispatch => { return api.fetchUsers() .then(users => dispatch(usersFetched(users)) // usersFetched is a function that returns a plain JavaScript object (Action) .catch(err => dispatch(usersFetchError(err)) // same with usersFetchError } )
需要注意的是,使用 thunk 會使您的代碼難以測試,並且難以通過代碼流進行推理。

Redux-saga
Redux-saga 通過稱為生成器的 ES6 (ES2015) 特性和函數式/純助手庫支持不純操作。 生成器的偉大之處在於它們可以恢復和暫停,並且它們的 API 契約使它們非常容易測試。
讓我們看看如何使用 sagas 提高之前 thunk 方法的可讀性和可測試性!
首先,讓我們將 Redux-saga 中間件掛載到我們的商店:
import { createStore, applyMiddleware } from 'redux' import createSagaMiddleware from 'redux-saga' import rootReducer from './rootReducer' import rootSaga from './rootSaga' // create the saga middleware const sagaMiddleware = createSagaMiddleware() // mount the middleware to the store const store = createStore( rootReducer, applyMiddleware(sagaMiddleware), ) // run our saga! sagaMiddleware.run(rootSaga)
請注意,必須使用 saga 調用run(..)
函數才能開始執行。
現在讓我們創建我們的 saga:
import { call, put, takeEvery } from 'redux-saga/effects' // these are saga effects we'll use export function *fetchUsers(action) { try { const users = yield call(api.fetchUsers) yield put(usersFetched(users)) } catch (err) { yield put(usersFetchFailed(err)) } } export default function *rootSaga() { yield takeEvery('USERS_FETCH', fetchUsers) }
我們定義了兩個生成器函數,一個獲取用戶列表和rootSaga
。 請注意,我們沒有直接調用api.fetchUsers
,而是在調用對像中生成了它。 這是因為 Redux-saga 攔截了調用對象並執行其中包含的函數以創建一個純環境(就您的生成器而言)。
rootSaga
產生一個對名為takeEvery,
該函數接受以一種USERS_FETCH
類型調度的每個動作,並使用它所採取的動作調用fetchUsers
saga。 正如我們所看到的,這為 Redux 創建了一個非常可預測的副作用模型,這使得測試變得容易!
測試傳奇
讓我們看看生成器如何使我們的 sagas 易於測試。 在這一部分中,我們將使用 mocha 來運行我們的單元測試和 chai 進行斷言。
因為 sagas 產生純 JavaScript 對象並在生成器中運行,我們可以輕鬆測試它們是否執行正確的行為,而無需任何模擬! 請記住, call
、 take
、 put
等只是被 Redux-saga 中間件攔截的純 JavaScript 對象。
import { take, call } from 'redux-saga/effects' import { expect } from 'chai' import { rootSaga, fetchUsers } from '../rootSaga' describe('saga unit test', () => { it('should take every USERS_FETCH action', () => { const gen = rootSaga() // create our generator iterable expect(gen.next().value).to.be.eql(take('USERS_FETCH')) // assert the yield block does have the expected value expect(gen.next().done).to.be.equal(false) // assert that the generator loops infinitely }) it('should fetch the users if successful', () => { const gen = fetchUsers() expect(gen.next().value).to.be.eql(call(api.fetchUsers)) // expect that the call effect was yielded const users = [ user1, user2 ] // some mock response expect(gen.next(users).value).to.be.eql(put(usersFetched(users)) }) it('should fail if API fails', () => { const gen = fetchUsers() expect(gen.next().value).to.be.eql(call(api.fetchUsers)) // expect that the call effect was yielded const err = { message: 'authentication failed' } // some mock error expect(gen.throw(err).value).to.be.eql(put(usersFetchFailed(err)) }) })
使用反應
雖然 Redux 沒有綁定到任何特定的配套庫,但它與 React.js 配合得特別好,因為 React 組件是純函數,它們將狀態作為輸入並生成虛擬 DOM 作為輸出。
React-Redux 是 React 和 Redux 的幫助庫,它消除了連接兩者的大部分艱苦工作。 為了最有效地使用 React-Redux,讓我們回顧一下展示組件和容器組件的概念。
展示組件描述事物的視覺外觀,完全取決於它們要渲染的道具; 他們從 props 調用回調來調度動作。 它們是手工編寫的,完全純粹,並且不依賴於像 Redux 這樣的狀態管理系統。
另一方面,容器組件描述了事物應該如何運作,了解 Redux,直接調度 Redux 操作以執行突變,並且通常由 React-Redux 生成。 它們通常與一個展示組件配對,提供它的道具。
讓我們編寫一個展示組件並通過 React-Redux 將其連接到 Redux:
const HelloWorld = ({ count, onButtonClicked }) => ( <div> <span>Hello! You've clicked the button {count} times!</span> <button onClick={onButtonClicked}>Click me</button> </div> ) HelloWorld.propTypes = { count: PropTypes.number.isRequired, onButtonClicked: PropTypes.func.isRequired, }
請注意,這是一個完全依賴其 props 來運行的“愚蠢”組件。 這很棒,因為它使 React 組件易於測試和編寫。 現在讓我們看看如何將這個組件連接到 Redux,但首先讓我們了解一下高階組件是什麼。
高階組件
React-Redux 提供了一個名為connect( .. )
的輔助函數,它從一個知道 Redux 的“啞” React 組件創建一個高階組件。
React 通過組合強調可擴展性和可重用性,也就是將組件包裝在其他組件中。 包裝這些組件可以改變它們的行為或添加新功能。 讓我們看看如何從感知 Redux 的展示組件(容器組件)中創建一個高階組件。
這是你如何做到的:
import { connect } from 'react-redux' const mapStateToProps = state => { // state is the state of our store // return the props that we want to use for our component return { count: state.count, } } const mapDispatchToProps = dispatch => { // dispatch is our store dispatch function // return the props that we want to use for our component return { onButtonClicked: () => { dispatch({ type: 'BUTTON_CLICKED' }) }, } } // create our enhancer function const enhancer = connect(mapStateToProps, mapDispatchToProps) // wrap our "dumb" component with the enhancer const HelloWorldContainer = enhancer(HelloWorld) // and finally we export it export default HelloWorldContainer
請注意,我們定義了兩個函數, mapStateToProps
和mapDispatchToProps
。
mapStateToProps
是 (state: Object) 的純函數,它返回從 Redux 狀態計算的對象。 該對象將與傳遞給包裝組件的道具合併。 這也稱為選擇器,因為它選擇部分 Redux 狀態以合併到組件的 props 中。
mapDispatchToProps
也是一個純函數,但是 (dispatch: (Action) => void) 之一,它返回從 Redux 調度函數計算的對象。 該對象同樣會與傳遞給包裝組件的道具合併。
現在要使用我們的容器組件,我們必須使用 React-Redux 中的Provider
組件來告訴容器組件使用哪個存儲:
import { Provider } from 'react-redux' import { render } from 'react-dom' import store from './store' // where ever your Redux store resides import HelloWorld from './HelloWorld' render( ( <Provider store={store}> <HelloWorld /> </Provider> ), document.getElementById('container') )
Provider
組件將 store 傳播到訂閱 Redux store 的任何子組件,將所有內容保存在一個地方並減少錯誤或突變點!
使用 Redux 建立代碼信心
借助 Redux 的這些新知識、眾多支持庫以及與 React.js 的框架連接,您可以通過狀態控制輕鬆限制應用程序中的突變數量。 反過來,強大的狀態控制可以讓您更快地移動並更有信心地創建可靠的代碼庫。