Redux 簡明教程
原文連結(保持更新):https://github.com/kenberkele…
寫在前面
§ 為什麼要用 Redux
拋開需求講實用性都是耍流氓,因此下面由我扮演您那可親可愛的產品經理
⊙ 需求 1:在控制檯上記錄使用者的每個動作
不知道您是否有後端的開發經驗,後端一般會有記錄訪問日誌的中介軟體
例如,在 Express 中實現一個簡單的 Logger 如下:
var loggerMiddleware = function(req, res, next) {
console.log(`[Logger]`, req.method, req.originalUrl)
next()
}
...
app.use(loggerMiddleware)
每次訪問的時候,都會在控制檯中留下類似下面的日誌便於追蹤除錯:
[Logger] GET /
[Logger] POST /login
[Logger] GET /user?uid=10086
...
如果我們把場景轉移到前端,請問該如何實現使用者的動作跟蹤記錄?
我們可能會這樣寫:
/** 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* 的寫法並沒有本質上的區別
記錄使用者行為程式碼的侵入性極強,可維護性與擴充套件性堪憂
⊙ 需求 2:在上述需求的基礎上,記錄使用者的操作時間
哼!最討厭就是改需求了,這種簡單的需求難道不是應該一開始就想好的嗎?
呵呵,如果每位產品經理都能一開始就把需求完善好,我們就不用加班了好伐
顯然地,前端的童鞋又得一個一個去改(當然 編輯器 / 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())
...
}
}
而後端的童鞋只需要稍微修改一下原來的中介軟體即可:
var loggerMiddleware = function(req, res, next) {
console.log(`[Logger]`, new Date(), req.method, req.originalUrl)
next()
}
...
app.use(loggerMiddleware)
⊙ 需求 3:正式上線的時候,把控制檯中有關 Logger 的輸出全部去掉
難道您以為有了 UglifyJS,配置一個 drop_console: true
就好了嗎?圖樣圖森破,拿衣服!
請看清楚了,僅僅是去掉有關 Logger 的 console.log
,其他的要保留哦親~~~
於是前端的童鞋又不得不乖乖地一個一個註釋掉(當然也可以設定一個環境變數判斷是否輸出,甚至可以重寫 console.log
)
而我們後端的童鞋呢?只需要註釋掉一行程式碼即可:// app.use(loggerMiddleware)
,真可謂是不費吹灰之力
⊙ 需求 4:正式上線後,自動收集 bug,並還原出當時的場景
收集使用者報錯還是比較簡單的,利用 window.error
事件,然後根據 Source Map 定位到原始碼(但一般查不出什麼)
但要完全還原出當時的使用場景,幾乎是不可能的。因為您不知道這個報錯,使用者是怎麼一步一步操作得來的
就算知道使用者是如何操作得來的,但在您的電腦上,測試永遠都是通過的(不是我寫的程式有問題,是使用者用的方式有問題)
相對地,後端的報錯的收集、定位以及還原卻是相當簡單。只要一個 API 有 bug,那無論用什麼裝置訪問,都會得到這個 bug
還原 bug 也是相當簡單:把資料庫備份匯入到另一臺機器,部署同樣的執行環境與程式碼。如無意外,bug 肯定可以完美重現
在這個問題上拿後端跟前端對比,確實有失公允。但為了鼓吹 Redux 的優越,只能勉為其難了
實際上 jQuery / MV* 中也能實現使用者動作的跟蹤,用一個陣列往裡面
push
使用者動作即可
但這樣操作的意義不大,因為僅僅只有動作,無法反映動作前後,應用狀態的變動情況
※ 小結
為何前後端對於這類需求的處理竟然大相徑庭?後端為何可以如此優雅?
原因在於,後端具有統一的入口與統一的狀態管理(資料庫),因此可以引入中介軟體機制來統一實現某些功能
多年來,前端工程師忍辱負重,操著賣白粉的心,賺著買白菜的錢,一直處於程式設計師鄙視鏈的底層
於是有大牛就把後端 MVC 的開發思維搬到前端,將應用中所有的動作與狀態都統一管理,讓一切有據可循
使用 Redux,藉助 Redux DevTools 可以實現出“華麗如時光旅行一般的除錯效果”
實際上就是開發除錯過程中可以撤銷與重做,並且支援應用狀態的匯入和匯出(就像是資料庫的備份)
而且,由於可以使用日誌完整記錄下每個動作,因此做到像 Git 般,隨時隨地恢復到之前的狀態
由於可以匯出和匯入應用的狀態(包括路由狀態),因此還可以實現前後端同構(服務端渲染)
當然,既然有了動作日誌以及動作前後的狀態備份,那麼還原使用者報錯場景還會是一個難題嗎?
§ Store
首先要區分 store
和 state
state
是應用的狀態,一般本質上是一個普通物件
例如,我們有一個 Web APP,包含 計數器 和 待辦事項 兩大功能
那麼我們可以為該應用設計出對應的儲存資料結構(應用初始狀態):
/** 應用初始 state,本程式碼塊記為 code-1 **/
{
counter: 0,
todos: []
}
store
是應用狀態 state
的管理者,包含下列四個函式:
-
getState() # 獲取整個 state
-
dispatch(action) # ※ 觸發 state 改變的【唯一途徑】※
-
subscribe(listener) # 您可以理解成是 DOM 中的 addEventListener
-
replaceReducer(nextReducer) # 一般在 Webpack Code-Splitting 按需載入的時候用
二者的關係是:state = store.getState()
Redux 規定,一個應用只應有一個單一的 store
,其管理著唯一的應用狀態 state
Redux 還規定,不能直接修改應用的狀態 state
,也就是說,下面的行為是不允許的:
var state = store.getState()
state.counter = state.counter + 1 // 禁止在業務邏輯中直接修改 state
若要改變 state
,必須 dispatch
一個 action
,這是修改應用狀態的不二法門
現在您只需要記住
action
只是一個包含type
屬性的普通物件即可
例如{ type: `INCREMENT` }
上面提到,state
是通過 store.getState()
獲取,那麼 store
又是怎麼來的呢?
想生成一個 store
,我們需要呼叫 Redux 的 createStore
:
import { createStore } from `redux`
...
const store = createStore(reducer, initialState) // store 是靠傳入 reducer 生成的哦!
現在您只需要記住
reducer
是一個 函式,負責更新並返回一個新的state
而initialState
主要用於前後端同構的資料同步(詳情請關注 React 服務端渲染)
§ Action
上面提到,action
(動作)實質上是包含 type
屬性的普通物件,這個 type
是我們實現使用者行為追蹤的關鍵
例如,增加一個待辦事項 的 action
可能是像下面一樣:
/** 本程式碼塊記為 code-2 **/
{
type: `ADD_TODO`,
payload: {
id: 1,
content: `待辦事項1`,
completed: false
}
}
當然,action
的形式是多種多樣的,唯一的約束僅僅就是包含一個 type
屬性罷了
也就是說,下面這些 action
都是合法的:
/** 如下都是合法的,但就是不夠規範 **/
{
type: `ADD_TODO`,
id: 1,
content: `待辦事項1`,
completed: false
}
{
type: `ADD_TODO`,
abcdefg: {
id: 1,
content: `待辦事項1`,
completed: false
}
}
雖說沒有約束,但最好還是遵循規範
如果需要新增一個代辦事項,實際上就是將 code-2
中的 payload
“寫入” 到 state.todos
陣列中(如何“寫入”?在此留個懸念):
/** 本程式碼塊記為 code-3 **/
{
counter: 0,
todos: [{
id: 1,
content: `待辦事項1`,
completed: false
}]
}
刨根問底,action
是誰生成的呢?
⊙ Action Creator
Action Creator 可以是同步的,也可以是非同步的
顧名思義,Action Creator 是 action
的創造者,本質上就是一個函式,返回值是一個 action
(物件)
例如下面就是一個 “新增一個待辦事項” 的 Action Creator:
/** 本程式碼塊記為 code-4 **/
var id = 1
function addTodo(content) {
return {
type: `ADD_TODO`,
payload: {
id: id++,
content: content, // 待辦事項內容
completed: false // 是否完成的標識
}
}
}
將該函式應用到一個表單(假設 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) // 執行 Action Creator 獲得 action
store.dispatch(action) // 改變 state 的不二法門:dispatch 一個 action!!!
})
</script>
在輸入框中輸入 “待辦事項2” 後,點選一下提交按鈕,我們的 state
就變成了:
/** 本程式碼塊記為 code-6 **/
{
counter: 0,
todos: [{
id: 1,
content: `待辦事項1`,
completed: false
}, {
id: 2,
content: `待辦事項2`,
completed: false
}]
}
通俗點講,Action Creator 用於繫結到使用者的操作(點選按鈕等),其返回值
action
用於之後的dispatch(action)
剛剛提到過,action
明明就沒有強制的規範,為什麼 store.dispatch(action)
之後,
Redux 會明確知道是提取 action.payload
,並且是對應寫入到 state.todos
陣列中?
又是誰負責“寫入”的呢?懸念即將揭曉…
§ Reducer
Reducer 必須是同步的純函式
使用者每次 dispatch(action)
後,都會觸發 reducer
的執行 reducer
的實質是一個函式,根據 action.type
來更新 state
並返回 nextState
最後會用 reducer
的返回值 nextState
完全替換掉原來的 state
注意:上面的這個 “更新” 並不是指
reducer
可以直接對state
進行修改
Redux 規定,須先複製一份state
,在副本nextState
上進行修改操作
例如,可以使用 lodash 的deepClone
,也可以使用Object.assign / map / filter/ ...
等返回副本的函式
在上面 Action Creator 中提到的 待辦事項的 reducer
大概是長這個樣子 (為了容易理解,在此不使用 ES6 / Immutable.js):
/** 本程式碼塊記為 code-7 **/
var initState = {
counter: 0,
todos: []
}
function reducer(state, action) {
// ※ 應用的初始狀態是在第一次執行 reducer 時設定的(除非是服務端渲染) ※
if (!state) state = initState
switch (action.type) {
case `ADD_TODO`:
var nextState = _.deepClone(state) // 用到了 lodash 的深克隆
nextState.todos.push(action.payload)
return nextState
default:
// 由於 nextState 會把原 state 整個替換掉
// 若無修改,必須返回原 state(否則就是 undefined)
return state
}
}
通俗點講,就是
reducer
返回啥,state
就被替換成啥
§ 總結
-
store
由 Redux 的createStore(reducer)
生成 -
state
通過store.getState()
獲取,本質上一般是一個儲存著整個應用狀態的物件 -
action
本質上是一個包含type
屬性的普通物件,由 Action Creator (函式) 產生 -
改變
state
必須dispatch
一個action
-
reducer
本質上是根據action.type
來更新state
並返回nextState
的函式 -
reducer
必須返回值,否則nextState
即為undefined
-
實際上,
state
就是所有reducer
返回值的彙總(本教程只有一個reducer
,主要是應用場景比較簡單)
Action Creator =>
action
=>store.dispatch(action)
=>reducer(state, action)
=>原 state
state = nextState
⊙ Redux 與傳統後端 MVC 的對照
Redux | 傳統後端 MVC |
---|---|
store |
資料庫例項 |
state |
資料庫中儲存的資料 |
dispatch(action) |
使用者發起請求 |
action: { type, payload } |
type 表示請求的 URL,payload 表示請求的資料 |
reducer |
路由 + 控制器(handler) |
reducer 中的 switch-case 分支 |
路由,根據 action.type 路由到對應的控制器 |
reducer 內部對 state 的處理 |
控制器對資料庫進行增刪改操作 |
reducer 返回 nextState
|
將修改後的記錄寫回資料庫 |
§ 最簡單的例子 ( 線上演示 )
<!DOCTYPE html>
<html>
<head>
<script src="//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) {
// 首次呼叫本函式時設定初始 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; // 無論如何都返回一個 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 純粹只是一個狀態管理庫,幾乎可以搭配任何框架使用
(上述例子連 jQuery 都沒用哦親)