Redux 簡(jiǎn)明教程
原文鏈接(保持更新):https://github.com/kenberkeley/redux-simple-tutorial
寫在前面
本教程深入淺出,配套 簡(jiǎn)明教程、進(jìn)階教程(源碼精讀)以及文檔注釋豐滿的 Demo 等一條龍服務(wù)
§ 為什么要用 Redux
拋開(kāi)需求講實(shí)用性都是耍流氓,因此下面由我扮演您那可親可愛(ài)的產(chǎn)品經(jīng)理
⊙ 需求 1:在控制臺(tái)上記錄用戶的每個(gè)動(dòng)作
不知道您是否有后端的開(kāi)發(fā)經(jīng)驗(yàn),后端一般會(huì)有記錄訪問(wèn)日志的中間件
例如,在 Express 中實(shí)現(xiàn)一個(gè)簡(jiǎn)單的 Logger 如下:
var loggerMiddleware = function(req, res, next) {
console.log('[Logger]', req.method, req.originalUrl)
next()
}
...
app.use(loggerMiddleware)
每次訪問(wèn)的時(shí)候,都會(huì)在控制臺(tái)中留下類似下面的日志便于追蹤調(diào)試:
[Logger] GET /
[Logger] POST /login
[Logger] GET /user?uid=10086
...
如果我們把場(chǎng)景轉(zhuǎn)移到前端,請(qǐng)問(wèn)該如何實(shí)現(xiàn)用戶的動(dòng)作跟蹤記錄?
我們可能會(huì)這樣寫:
/** jQuery **/
$('#loginBtn').on('click', function(e) {
console.log('[Logger] 用戶登錄')
...
})
$('#logoutBtn').on('click', function() {
console.log('[Logger] 用戶退出登錄')
...
})
/** MVC / MVVM 框架(這里以純 Vue 舉例) **/
methods: {
handleLogin () {
console.log('[Logger] 用戶登錄')
...
},
handleLogout () {
console.log('[Logger] 用戶退出登錄')
...
}
}
上述 jQuery 與 MV* 的寫法并沒(méi)有本質(zhì)上的區(qū)別
記錄用戶行為代碼的侵入性極強(qiáng),可維護(hù)性與擴(kuò)展性堪憂
⊙ 需求 2:在上述需求的基礎(chǔ)上,記錄用戶的操作時(shí)間
哼!最討厭就是改需求了,這種簡(jiǎn)單的需求難道不是應(yīng)該一開(kāi)始就想好的嗎?
呵呵,如果每位產(chǎn)品經(jīng)理都能一開(kāi)始就把需求完善好,我們就不用加班了好伐
顯然地,前端的童鞋又得一個(gè)一個(gè)去改(當(dāng)然 編輯器 / IDE 都支持全局替換):
/** jQuery **/
$('#loginBtn').on('click', function(e) {
console.log('[Logger] 用戶登錄', new Date())
...
})
$('#logoutBtn').on('click', function() {
console.log('[Logger] 用戶退出登錄', new Date())
...
})
/** MVC / MVVM 框架(這里以 Vue 舉例) **/
methods: {
handleLogin () {
console.log('[Logger] 用戶登錄', new Date())
...
},
handleLogout () {
console.log('[Logger] 用戶退出登錄', new Date())
...
}
}
而后端的童鞋只需要稍微修改一下原來(lái)的中間件即可:
var loggerMiddleware = function(req, res, next) {
console.log('[Logger]', new Date(), req.method, req.originalUrl)
next()
}
...
app.use(loggerMiddleware)
⊙ 需求 3:正式上線的時(shí)候,把控制臺(tái)中有關(guān) Logger 的輸出全部去掉
難道您以為有了 UglifyJS,配置一個(gè) drop_console: true
就好了嗎?圖樣圖森破,拿衣服!
請(qǐng)看清楚了,僅僅是去掉有關(guān) Logger 的 console.log
,其他的要保留哦親~~~
于是前端的童鞋又不得不乖乖地一個(gè)一個(gè)注釋掉(當(dāng)然也可以設(shè)置一個(gè)環(huán)境變量判斷是否輸出,甚至可以重寫 console.log
)
而我們后端的童鞋呢?只需要注釋掉一行代碼即可:// app.use(loggerMiddleware)
,真可謂是不費(fèi)吹灰之力
⊙ 需求 4:正式上線后,自動(dòng)收集 bug,并還原出當(dāng)時(shí)的場(chǎng)景
收集用戶報(bào)錯(cuò)還是比較簡(jiǎn)單的,利用 window.error
事件,然后根據(jù) Source Map 定位到源碼(但一般查不出什么)
但要完全還原出當(dāng)時(shí)的使用場(chǎng)景,幾乎是不可能的。因?yàn)槟恢肋@個(gè)報(bào)錯(cuò),用戶是怎么一步一步操作得來(lái)的
就算知道用戶是如何操作得來(lái)的,但在您的電腦上,測(cè)試永遠(yuǎn)都是通過(guò)的(不是我寫的程序有問(wèn)題,是用戶用的方式有問(wèn)題)
相對(duì)地,后端的報(bào)錯(cuò)的收集、定位以及還原卻是相當(dāng)簡(jiǎn)單。只要一個(gè) API 有 bug,那無(wú)論用什么設(shè)備訪問(wèn),都會(huì)得到這個(gè) bug
還原 bug 也是相當(dāng)簡(jiǎn)單:把數(shù)據(jù)庫(kù)備份導(dǎo)入到另一臺(tái)機(jī)器,部署同樣的運(yùn)行環(huán)境與代碼。如無(wú)意外,bug 肯定可以完美重現(xiàn)
在這個(gè)問(wèn)題上拿后端跟前端對(duì)比,確實(shí)有失公允。但為了鼓吹 Redux 的優(yōu)越,只能勉為其難了
實(shí)際上 jQuery / MV* 中也能實(shí)現(xiàn)用戶動(dòng)作的跟蹤,用一個(gè)數(shù)組往里面
push
用戶動(dòng)作即可
但這樣操作的意義不大,因?yàn)閮H僅只有動(dòng)作,無(wú)法反映動(dòng)作前后,應(yīng)用狀態(tài)的變動(dòng)情況
※ 小結(jié)
為何前后端對(duì)于這類需求的處理竟然大相徑庭?后端為何可以如此優(yōu)雅?
原因在于,后端具有統(tǒng)一的入口與統(tǒng)一的狀態(tài)管理(數(shù)據(jù)庫(kù)),因此可以引入中間件機(jī)制來(lái)統(tǒng)一實(shí)現(xiàn)某些功能
多年來(lái),前端工程師忍辱負(fù)重,操著賣白粉的心,賺著買白菜的錢,一直處于程序員鄙視鏈的底層
于是有大牛就把后端 MVC 的開(kāi)發(fā)思維搬到前端,將應(yīng)用中所有的動(dòng)作與狀態(tài)都統(tǒng)一管理,讓一切有據(jù)可循
使用 Redux,借助 Redux DevTools 可以實(shí)現(xiàn)出“華麗如時(shí)光旅行一般的調(diào)試效果”
實(shí)際上就是開(kāi)發(fā)調(diào)試過(guò)程中可以撤銷與重做,并且支持應(yīng)用狀態(tài)的導(dǎo)入和導(dǎo)出(就像是數(shù)據(jù)庫(kù)的備份)
而且,由于可以使用日志完整記錄下每個(gè)動(dòng)作,因此做到像 Git 般,隨時(shí)隨地恢復(fù)到之前的狀態(tài)
由于可以導(dǎo)出和導(dǎo)入應(yīng)用的狀態(tài)(包括路由狀態(tài)),因此還可以實(shí)現(xiàn)前后端同構(gòu)(服務(wù)端渲染)
當(dāng)然,既然有了動(dòng)作日志以及動(dòng)作前后的狀態(tài)備份,那么還原用戶報(bào)錯(cuò)場(chǎng)景還會(huì)是一個(gè)難題嗎?
§ Store
首先要區(qū)分 store
和 state
state
是應(yīng)用的狀態(tài),一般本質(zhì)上是一個(gè)普通對(duì)象
例如,我們有一個(gè) Web APP,包含 計(jì)數(shù)器 和 待辦事項(xiàng) 兩大功能
那么我們可以為該應(yīng)用設(shè)計(jì)出對(duì)應(yīng)的存儲(chǔ)數(shù)據(jù)結(jié)構(gòu)(應(yīng)用初始狀態(tài)):
/** 應(yīng)用初始 state,本代碼塊記為 code-1 **/
{
counter: 0,
todos: []
}
store
是應(yīng)用狀態(tài) state
的管理者,包含下列四個(gè)函數(shù):
getState() # 獲取整個(gè) state
dispatch(action) # ※ 觸發(fā) state 改變的【唯一途徑】※
subscribe(listener) # 您可以理解成是 DOM 中的 addEventListener
replaceReducer(nextReducer) # 一般在 Webpack Code-Splitting 按需加載的時(shí)候用
二者的關(guān)系是:state = store.getState()
Redux 規(guī)定,一個(gè)應(yīng)用只應(yīng)有一個(gè)單一的 store
,其管理著唯一的應(yīng)用狀態(tài) state
Redux 還規(guī)定,不能直接修改應(yīng)用的狀態(tài) state
,也就是說(shuō),下面的行為是不允許的:
var state = store.getState()
state.counter = state.counter + 1 // 禁止在業(yè)務(wù)邏輯中直接修改 state
若要改變 state
,必須 dispatch
一個(gè) action
,這是修改應(yīng)用狀態(tài)的不二法門
現(xiàn)在您只需要記住
action
只是一個(gè)包含type
屬性的普通對(duì)象即可
例如{ type: 'INCREMENT' }
上面提到,state
是通過(guò) store.getState()
獲取,那么 store
又是怎么來(lái)的呢?
想生成一個(gè) store
,我們需要調(diào)用 Redux 的 createStore
:
import { createStore } from 'redux'
...
const store = createStore(reducer, initialState) // store 是靠傳入 reducer 生成的哦!
現(xiàn)在您只需要記住
reducer
是一個(gè) 函數(shù),負(fù)責(zé)更新并返回一個(gè)新的state
而initialState
主要用于前后端同構(gòu)的數(shù)據(jù)同步(詳情請(qǐng)關(guān)注 React 服務(wù)端渲染)
§ Action
上面提到,action
(動(dòng)作)實(shí)質(zhì)上是包含 type
屬性的普通對(duì)象,這個(gè) type
是我們實(shí)現(xiàn)用戶行為追蹤的關(guān)鍵
例如,增加一個(gè)待辦事項(xiàng) 的 action
可能是像下面一樣:
/** 本代碼塊記為 code-2 **/
{
type: 'ADD_TODO',
payload: {
id: 1,
content: '待辦事項(xiàng)1',
completed: false
}
}
當(dāng)然,action
的形式是多種多樣的,唯一的約束僅僅就是包含一個(gè) type
屬性罷了
也就是說(shuō),下面這些 action
都是合法的:
/** 如下都是合法的,但就是不夠規(guī)范 **/
{
type: 'ADD_TODO',
id: 1,
content: '待辦事項(xiàng)1',
completed: false
}
{
type: 'ADD_TODO',
abcdefg: {
id: 1,
content: '待辦事項(xiàng)1',
completed: false
}
}
雖說(shuō)沒(méi)有約束,但最好還是遵循規(guī)范
如果需要新增一個(gè)代辦事項(xiàng),實(shí)際上就是將 code-2
中的 payload
“寫入” 到 state.todos
數(shù)組中(如何“寫入”?在此留個(gè)懸念):
/** 本代碼塊記為 code-3 **/
{
counter: 0,
todos: [{
id: 1,
content: '待辦事項(xiàng)1',
completed: false
}]
}
刨根問(wèn)底,action
是誰(shuí)生成的呢?
⊙ Action Creator
Action Creator 可以是同步的,也可以是異步的
顧名思義,Action Creator 是 action
的創(chuàng)造者,本質(zhì)上就是一個(gè)函數(shù),返回值是一個(gè) action
(對(duì)象)
例如下面就是一個(gè) “新增一個(gè)待辦事項(xiàng)” 的 Action Creator:
/** 本代碼塊記為 code-4 **/
var id = 1
function addTodo(content) {
return {
type: 'ADD_TODO',
payload: {
id: id++,
content: content, // 待辦事項(xiàng)內(nèi)容
completed: false // 是否完成的標(biāo)識(shí)
}
}
}
將該函數(shù)應(yīng)用到一個(gè)表單(假設(shè) store
為全局變量,并引入了 jQuery ):
<--! 本代碼塊記為 code-5 -->
<input type="text" id="todoInput" />
<button id="btn">提交</button>
<script>
$('#btn').on('click', function() {
var content = $('#todoInput').val() // 獲取輸入框的值
var action = addTodo(content) // 執(zhí)行 Action Creator 獲得 action
store.dispatch(action) // 改變 state 的不二法門:dispatch 一個(gè) action!!!
})
</script>
在輸入框中輸入 “待辦事項(xiàng)2” 后,點(diǎn)擊一下提交按鈕,我們的 state
就變成了:
/** 本代碼塊記為 code-6 **/
{
counter: 0,
todos: [{
id: 1,
content: '待辦事項(xiàng)1',
completed: false
}, {
id: 2,
content: '待辦事項(xiàng)2',
completed: false
}]
}
通俗點(diǎn)講,Action Creator 用于綁定到用戶的操作(點(diǎn)擊按鈕等),其返回值
action
用于之后的dispatch(action)
剛剛提到過(guò),action
明明就沒(méi)有強(qiáng)制的規(guī)范,為什么 store.dispatch(action)
之后,
Redux 會(huì)明確知道是提取 action.payload
,并且是對(duì)應(yīng)寫入到 state.todos
數(shù)組中?
又是誰(shuí)負(fù)責(zé)“寫入”的呢?懸念即將揭曉...
§ Reducer
Reducer 必須是同步的純函數(shù)
用戶每次 dispatch(action)
后,都會(huì)觸發(fā) reducer
的執(zhí)行
reducer
的實(shí)質(zhì)是一個(gè)函數(shù),根據(jù) action.type
來(lái)更新 state
并返回 nextState
最后會(huì)用 reducer
的返回值 nextState
完全替換掉原來(lái)的 state
注意:上面的這個(gè) “更新” 并不是指
reducer
可以直接對(duì)state
進(jìn)行修改
Redux 規(guī)定,須先復(fù)制一份state
,在副本nextState
上進(jìn)行修改操作
例如,可以使用 lodash 的deepClone
,也可以使用Object.assign / map / filter/ ...
等返回副本的函數(shù)
在上面 Action Creator 中提到的 待辦事項(xiàng)的 reducer
大概是長(zhǎng)這個(gè)樣子 (為了容易理解,在此不使用 ES6 / Immutable.js):
/** 本代碼塊記為 code-7 **/
var initState = {
counter: 0,
todos: []
}
function reducer(state, action) {
// ※ 應(yīng)用的初始狀態(tài)是在第一次執(zhí)行 reducer 時(shí)設(shè)置的(除非是服務(wù)端渲染) ※
if (!state) state = initState
switch (action.type) {
case 'ADD_TODO':
var nextState = _.deepClone(state) // 用到了 lodash 的深克隆
nextState.todos.push(action.payload)
return nextState
default:
// 由于 nextState 會(huì)把原 state 整個(gè)替換掉
// 若無(wú)修改,必須返回原 state(否則就是 undefined)
return state
}
}
通俗點(diǎn)講,就是
reducer
返回啥,state
就被替換成啥
§ 總結(jié)
-
store
由 Redux 的createStore(reducer)
生成 -
state
通過(guò)store.getState()
獲取,本質(zhì)上一般是一個(gè)存儲(chǔ)著整個(gè)應(yīng)用狀態(tài)的對(duì)象 -
action
本質(zhì)上是一個(gè)包含type
屬性的普通對(duì)象,由 Action Creator (函數(shù)) 產(chǎn)生 - 改變
state
必須dispatch
一個(gè)action
-
reducer
本質(zhì)上是根據(jù)action.type
來(lái)更新state
并返回nextState
的函數(shù) -
reducer
必須返回值,否則nextState
即為undefined
- 實(shí)際上,
state
就是所有reducer
返回值的匯總(本教程只有一個(gè)reducer
,主要是應(yīng)用場(chǎng)景比較簡(jiǎn)單)
Action Creator =>
action
=>store.dispatch(action)
=>reducer(state, action)
=>原 state
state = nextState
⊙ Redux 與傳統(tǒng)后端 MVC 的對(duì)照
Redux | 傳統(tǒng)后端 MVC |
---|---|
store |
數(shù)據(jù)庫(kù)實(shí)例 |
state |
數(shù)據(jù)庫(kù)中存儲(chǔ)的數(shù)據(jù) |
dispatch(action) |
用戶發(fā)起請(qǐng)求 |
action: { type, payload } |
type 表示請(qǐng)求的 URL,payload 表示請(qǐng)求的數(shù)據(jù) |
reducer |
路由 + 控制器(handler) |
reducer 中的 switch-case 分支 |
路由,根據(jù) action.type 路由到對(duì)應(yīng)的控制器 |
reducer 內(nèi)部對(duì) state 的處理 |
控制器對(duì)數(shù)據(jù)庫(kù)進(jìn)行增刪改操作 |
reducer 返回 nextState
|
將修改后的記錄寫回?cái)?shù)據(jù)庫(kù) |
§ 最簡(jiǎn)單的例子 ( 在線演示 )
<!DOCTYPE html>
<html>
<head>
<script src="http://cdn.bootcss.com/redux/3.5.2/redux.min.js"></script>
</head>
<body>
<script>
/** Action Creators */
function inc() {
return { type: 'INCREMENT' };
}
function dec() {
return { type: 'DECREMENT' };
}
function reducer(state, action) {
// 首次調(diào)用本函數(shù)時(shí)設(shè)置初始 state
state = state || { counter: 0 };
switch (action.type) {
case 'INCREMENT':
return { counter: state.counter + 1 };
case 'DECREMENT':
return { counter: state.counter - 1 };
default:
return state; // 無(wú)論如何都返回一個(gè) state
}
}
var store = Redux.createStore(reducer);
console.log( store.getState() ); // { counter: 0 }
store.dispatch(inc());
console.log( store.getState() ); // { counter: 1 }
store.dispatch(inc());
console.log( store.getState() ); // { counter: 2 }
store.dispatch(dec());
console.log( store.getState() ); // { counter: 1 }
</script>
</body>
</html>
由上可知,Redux 并不一定要搭配 React 使用。Redux 純粹只是一個(gè)狀態(tài)管理庫(kù),幾乎可以搭配任何框架使用
(上述例子連 jQuery 都沒(méi)用哦親)