本文是『horseshoe·Redux專題』系列文章之一,後續會有更多專題推出
來我的 GitHub repo 閱讀完整的專題文章
來我的 個人部落格 獲得無與倫比的閱讀體驗
Redux是一套精巧而實用的工具,這也是它在開發者中如此流行的原因。
所以對待Redux,最重要的就是熟練使用它的主要API,一旦將它瞭然於胸,就會對Redux的設計思想有一個全域性的認識,也就能清楚的判斷自己的應用需不需要勞駕Redux出手。
需要注意:我們們預設將Redux和React搭配使用,不過Redux不是非得和它在一起的。
Action
要達成某個目的,開發者首先要描述自己的意圖。Action就是用來描述開發者意圖的。它不是一個函式,而是一個普通的物件,通過宣告的型別來觸發相應的動作。
我們來看一個例子:
{
type: 'ADD_TODO_ITEM',
payload: {
content: '每週看一本書',
done: false,
},
}
複製程式碼
Redux官方定義了欄位的一些規範:一個Action必須包含type
欄位,同時一個Action包含的欄位不應該超過type
、payload
、error
、meta
這四種。
- type宣告Action的型別。一般用全大寫的字串表示,多個字母用下劃線分隔。
- payload在英文中的意思是
有效載荷
。引申到程式中就是有效欄位的意思,也就是說真正用於構建應用的資訊都應該放到payload欄位裡。 - error欄位並不承載錯誤資訊,而是一個出錯的token。只有當值為true時才表示出錯,值為其他或者乾脆沒有該欄位表示程式執行正常。那麼錯誤資訊放哪呢?當然是放payload裡面,因為錯誤資訊也屬於構建應用的有效資訊。
- meta在英文中的意思是
元
。在這裡表示除了payload之外的資訊。
因為意圖是通過型別來定義的,所以type欄位必不可少,稱某個物件為一個Action的標誌就是它有一個type欄位。
除此之外,一個動作可能包含更為豐富的資訊。開發者可以隨意新增欄位,畢竟它就是個普通物件。不過遵守一定的規範便於其他開發者閱讀你的程式碼,可以提升協作效率。
Constants
前面說了type欄位一般用全大寫的字串表示,多個字母用下劃線分隔。不僅如此,大家還有一個約定俗成:用一個結構相同的變數儲存該字串,因為它會在多處用到。
const ADD_TODO_ITEM = 'ADD_TODO_ITEM';
複製程式碼
集中儲存這些變數的檔案就叫Constants.js
。
在此,我提出一點異議。如果你覺得不麻煩,那遵循規範再好不過。但開發者向來覺得Redux過於繁瑣,如果你也這麼覺得,大可不必維護所謂的Constants。維護Constants的好處不過是一處更改處處生效,然而字串和變數是結構相同的,如果字串作了修改,語意上必然大打折扣,況且type欄位一旦定義極少更改,所以視你的協作規模和個人喜好而定,為Redux的繁瑣減負不是麼?
Action Creators
我們知道Action是一個物件,但是如果多次用到這個物件,我們可以寫一個生成Action的函式。
function addTodoItem(content) {
return {
type: ADD_TODO_ITEM,
payload: { content, done: false },
};
}
複製程式碼
同理,如果你覺得繁瑣,這一步是可以免去的。
非同步場景下Action Creators會大有用處,後面會講到。
需要注意的是:所謂的Action更確切的說是一個執行動作的指令,而不是一個動作。或者我們換一種說法,這裡的動作指的是動作描述,而不是動作派發。
Store
Redux的本質不復雜,就是用一個全域性的外部的物件來儲存狀態,然後通過觀察者模式來構建一套狀態更新觸發通知的機制。
這裡的Store就是儲存狀態的容器。
但是呢?它需要開發者動手寫一套邏輯來指導它怎麼處理狀態的更新,這就是後面要講的Reducer,暫且按下不表。
問題是Store怎麼接收這套邏輯呢?
import { createStore } from 'redux';
import reducer from './reducer';
const store = createStore(reducer);
複製程式碼
看到沒有,Redux專門有一個API用來建立Store,它接受三個引數:reducer
、preloadedState
和enhancer
。
reducer就是處理狀態更新的邏輯。
preloadedState是初始狀態,如果你需要讓Store一開始不是空物件,那麼可以從這裡傳進去。
enhancer翻譯成中文是增強器
,是用來裝載第三方外掛以增強Redux的功能的。
怎麼存
我們已經瞭解了Action的作用,但是Action只是對動作的描述,怎麼說它得有個發射器吧。這個發射器就隱藏在Store裡。
執行createStore
返回的物件包含了一個函式dispatch
,傳入Action執行就會發射一個動作。
import React, { Component } from 'react';
import store from './store';
import action from './action';
class App extends Component {
render() {
return (
<button onClick={() => store.dispatch(action)}>dispatch</button>
);
}
}
export default App;
複製程式碼
怎麼取
好了我們已經發射了一個動作,假設現在Store中已經有狀態了,我們怎麼把它取出來呢?
直接store.xxx
麼?
我們先來列印Store這個物件看看:
{
dispatch: ƒ dispatch(action),
getState: ƒ getState(),
replaceReducer: ƒ replaceReducer(nextReducer),
subscribe: ƒ subscribe(listener),
Symbol(observable): ƒ observable(),
}
複製程式碼
列印出來一堆API,這可咋整?
彆著急,茫茫人海中看到一個叫getState
的東西,它就是我們要找的高人吧。插一句,大家注意區分Store和State的區別,Store是儲存State的容器。
Redux隱藏了Store的內部細節,所以開發者只能用getState來獲取狀態。
訂閱
Redux是基於觀察者模式的,所以它開放了一個訂閱的API給開發者,每次發射一個動作,傳入訂閱器的回撥都會執行。通過它開發者就能監聽動作的派發以執行相應的邏輯。
import store from './store';
store.subscribe(() => console.log('有一個動作被髮射了'));
複製程式碼
replaceReducer
顧名思義,替換Reducer,這主要是方便開發者除錯Redux用的。
Reducer
Reducer是Redux的核心概念,因為Redux的作者Dan Abramov這樣解釋Redux
這個名字的由來:Reducer+Flux。
其實Reducer是一個計算機術語,包括JavaScript中也有用於迭代的reduce
函式。所以我們先來聊聊應該怎樣理解Reducer這個概念。
reduce翻譯成中文是減少
,Reducer在計算機中的含義是歸併,也是化多為少的意思。
我們來看JavaScript中reduce的寫法:
const array = [1, 2, 3, 4, 5];
const sum = array.reduce((total, num) => total + num);
複製程式碼
再來看Redux中Reducer的寫法:
function todoReducer(state = [], action) {
switch (action.type) {
case 'ADD_TODO_ITEM':
const { content, done } = action.payload;
return [...state, { content, done }];
case 'REMOVE_TODO_ITEM':
const todos = state.filter(todo => todo.content !== action.content);
return todos;
default:
return state;
}
}
複製程式碼
state引數是一箇舊資料集合,action中包含的payload是一個新的資料項,Reducer要做的就是將新的資料項和舊資料集合歸併到一起,返回給Store。這樣看起來Reducer這個名字起的也沒那麼晦澀了是不是?
一個Reducer接受兩個引數,第一個引數是舊的state,我們返回的資料就是用來替換它的,然後風水輪流轉,這次返回的資料下次就變成舊的state了,如此往復;第二個引數是我們派發的action。
因為Reducer的結構類似,都是根據Action的型別返回相應的資料,所以一般採用switch case
語句,如果沒有變動則返回舊的state,總之它必須有返回值。
純函式
Reducer的作用是歸併,也只能是歸併,所以Redux規定它必須是一個純函式。相同的輸入必須返回相同的輸出,而且不能對外產生副作用。
所以開發者在返回資料的時候不能直接修改原有的state,而是應該在拷貝的副本之上再做修改。
多個Reducer
一個Reducer只應該處理一個動作,可是我們的應用不可能只有一個動作,所以一個典型的Redux應用會有很多Reducer函式。那麼怎麼管理這些Reducer呢?
首先來看只有一個Reducer的情況:
import { createStore } from 'redux';
import reducer from './reducer';
const store = createStore(reducer);
export default store;
複製程式碼
如果只有一個Reducer,那我們只需要將它傳入createStore
這個函式中,就這麼簡單。這時候Reducer返回的狀態就是Store中的全部狀態。
而如果有多個Reducer,我們就要動用Redux的另一個API了:combineReducers
。
const reducers = combineReducers({
userStore: userReducer,
todoStore: todoReducer,
});
複製程式碼
當我們有多個Reducer,就意味著有多個狀態需要交給Store管理,我們就需要子容器來儲存它們,其實就是物件巢狀物件的意思。combineReducers就是用來幹這個的,它把每一個Reducer分門別類的與不同的子容器對應起來,某個Reducer只處理對應的狀態。
{
userStore: {},
todoStore: {},
};
複製程式碼
當我們用getState獲取整個Store的狀態,返回的物件就是上面這樣的。
你猜對了,傳入combineReducers的物件的key就是子容器的名字。
預設值
當開發者呼叫createStore建立Store時,傳入的所有Reducer都會執行一遍。注意,這時開發者還沒有發射任何動作呢,那為什麼會執行一遍?
const randomString = () => Math.random().toString(36).substring(7).split('').join('.');
const ActionTypes = {
INIT: `@@redux/INIT${randomString()}`,
REPLACE: `@@redux/REPLACE${randomString()}`,
PROBE_UNKNOWN_ACTION: () => `@@redux/PROBE_UNKNOWN_ACTION${randomString()}`
};
dispatch({ type: ActionTypes.INIT });
複製程式碼
因為Redux原始碼中,在createStore函式裡面放了這樣一段邏輯,這初始化時的dispatch是Redux自己發射的。
為什麼?
還記得Reducer接受兩個引數嗎?第一個是state,而我們可以給state設定預設值。
聰明的你一定想到了,初始化Store時Redux自己發射一個動作的目的是為了收集這些預設值。Reducer會將這些預設值返回給Store,這樣預設值就儲存到Store中了。
聰明的你大概還想到一個問題:createStore也有預設值,Reducer也有預設值,不會打架麼?
Redux的規矩:createStore的預設值優先順序更高,所以不會打架。
執行
在一個有若干Reducer的應用中,一個動作是怎麼找到對應的Reducer的?
這是一個好問題,答案是挨個找。
假如應用有1000個Reducer,與某個動作對應的Reducer又恰好在最後一個,那要把1000個Reducer都執行一遍,Redux不會這麼傻吧?
Redux還真就這麼傻。
因為當一個動作被派發時,Redux並不知道應該由哪個Reducer來處理,所以只能讓每個Reducer都處理一遍,看看到底是誰的菜。可不可以在設計上將動作與Reducer對應起來呢?當然是可以的,但是Redux為了保證API的簡潔和優美,決定犧牲這一部分效能。
只是一些純函式而已,莫慌。
react-redux
當我們使用Redux時,我們希望每發射一個動作,應用的狀態自動發生改變,從而觸發頁面的重新渲染。
import React, { Component } from 'react';
import store from './store';
class App extends Component {
state = { name: 'Redux' };
render() {
const { name } = this.state;
return (
<div>{name}</div>
);
}
componentDidMount() {
this.unsubscribe = store.subscribe(() => {
const { name } = store.getState();
this.setState({ name });
});
}
componentWillUnmount() {
this.unsubscribe();
}
}
複製程式碼
怎麼辦呢?開發者得手動維護一個訂閱器,才能監聽到狀態變化,從而觸發頁面重新渲染。
但是React最佳實踐告訴我們,一個負責渲染UI的元件不應該有太多的邏輯,那麼有沒有更好的辦法使得開發者可以少寫一點邏輯,同時讓元件更加優雅呢?
別擔心,Redux早就幫開發者做好了,不過它是一個獨立的模組:react-redux
。顧名思義,這個模組的作用是連線React和Redux。
Provider
連線React和Redux的第一步是什麼呢?當然是將Store整合到React元件中,這樣我們就不用每次在元件程式碼中import store
了。多虧了React context的存在,Redux只需要將Store傳入根元件,所有子元件就能通過某種方式獲取傳入的Store。
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store';
import App from './App';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>
,
document.getElementById('root')
);
複製程式碼
connect
老式的context寫法,在子元件中定義contextTypes
就可以接收到傳入的引數。當然,你肯定也想到,Redux把這些細節都封裝好了,這就是connect
。
connect介面的意義主要有三點:
- 封裝用context從根元件獲取資料的細節。
- 封裝Redux訂閱器的細節。
- 作為一個容器元件真正連線React和Redux。
import React from 'react';
import Todo from './Todo';
const App = ({ todos, addTodoItem }) => {
return (
<div>
<button onClick={() => addTodoItem()}>add</button>
{todos.map(todo => <Todo key={todo.id} {...todo} />)}
</div>
);
}
const mapStateToProps = (state, ownProps) => {
return {
todos: state.todoStore,
};
};
const mapDispatchToProps = (dispatch, ownProps) => {
return {
addTodoItem: (todoItem) => dispatch({ type: 'ADD_TODO_ITEM', payload: todoItem }),
};
};
export default connect(mapStateToProps, mapDispatchToProps)(App);
複製程式碼
我們看上面例子,connect接受的兩個引數:mapStateToProps
和mapDispatchToProps
,所謂的map就是對映,意思就是將所有state和dispatch依次對映到props上。如此真正的元件需要的資料和功能都在props上,它就可以安安心心的做一個傻瓜元件。
connect接受四個引數:
- mapStateToProps。也可以寫成mapState,這個引數是用來接收訂閱得到的資料更新的,也就是說如果這個引數傳null或者undefined,則被connect包裹的元件無法收到更新的資料。mapStateToProps必須是一個函式,而且必須返回一個純物件。它接收兩個引數,第一個引數是儲存在Store中完整的state,第二個引數是被connect包裹的元件自身的屬性。假如App元件掛載時寫成這樣:
<App value={value} />
,那麼ownProps就是一個包含value的物件。 - mapDispatchToProps。也可以寫成mapDispatch,這個引數是用來封裝所有發射器的。mapDispatchToProps必須是一個函式,而且必須返回一個純物件。它接收兩個引數,第一個引數是dispatch發射器函式,第二個引數和mapStateToProps的第二個引數相同。
- mergeProps。顧名思義,合併props。現在被connect包裹的元件擁有三種props:由state轉化而來的props,由dispatch轉化而來的props,自身的props。它返回的純物件就是最終元件能接收到的props。預設返回的物件是用
Object.assign()
合併上述三種props。 - options。用來自定義connect的選項。
我們注意到,connect要先執行一次,返回的結果再次執行才傳入開發者定義的元件。它返回一個新的元件,這個新的元件不會修改原元件(除非你操縱了ownProps的返回),而是為元件增加一些新的props。
我們也可以用裝飾器寫法來重寫connect:
import React from 'react';
import Todo from './Todo';
const mapStateToProps = (state, ownProps) => {
return {
todos: state.todoStore,
};
};
const mapDispatchToProps = (dispatch, ownProps) => {
return {
addTodoItem: (todoItem) => dispatch({ type: 'ADD_TODO_ITEM', payload: todoItem }),
};
};
@connect(mapStateToProps, mapDispatchToProps)
const App = ({ todos, addTodoItem }) => {
return (
<div>
<button onClick={() => addTodoItem()}>add</button>
{todos.map(todo => <Todo key={todo.id} {...todo} />)}
</div>
);
}
export default App;
複製程式碼
總結
Redux通過呼叫createStore返回Store,它是一個獨立於應用的全域性物件,通過觀察者模式能讓應用監聽到Store中狀態的變化。最佳實踐是一個應用只有一個Store。
Redux必須通過一個明確的動作來修改Store中的狀態,描述動作的是一個純物件,必須有type欄位,傳遞動作的是Store的屬性方法dispatch。
Store本身並沒有任何處理狀態更新的邏輯,所有邏輯都要通過Reducer傳遞進來,Reducer必須是一個純函式,沒有任何副作用。如果有多個Reducer,則需要利用combineReducers定義相應的子狀態容器。
基於容器元件和展示元件分離的設計原則,也為了提高開發者的程式設計效率,Redux通過一個額外的模組將React和Redux連線起來,使得所有的狀態管理介面都對映到元件的props上。其中,Provider將Store注入應用的根元件,解決的是連線的充分條件;connect將需要用到的state和dispatch都對映到元件的props上,解決的是連線的必要條件。只有被Provider包裹的元件,才能使用connect包裹。
Redux專題一覽