有時候在跟別人聊天的時候,就會聊到redux這個東西到底為什麼需要用到它,不用他的話會如何?因此這篇文章將從Redux改善了什麼以前的問題去做討論。

常討論的問題

為什麼要用Redux不用Context?
我覺得這個問題也可以問,為什麼要用Redux不用Flux

關於Store

Store這個概念是從Flux開始出現的,由於Component都有各自的State,當要嘗試共用的時候就會衍生出很多問題,例如:原本該放在child的State為了共用,需要搬到parent State,最後設計出來的Parent Component你會發現變成了擁有一堆State的巨大怪物。當然,你說這樣不好嗎?其實倒也未必,這樣看團隊怎麼去定義Component的State怎麼設計,但是對有些團隊來說,他們認為這是一個不太自然的設計,child應該擁有自己的State才可以做到元件獨立化。

為什麼要用Redux而不是Flux

讓我們來聽聽作者怎麼說:
Redux is not that different from Flux. Overall it has same architecture, but Redux is able to cut some complexity corners by using functional composition where Flux uses callback registration.
There is not a fundamental difference in Redux, but I find it makes certain abstractions easier, or at least possible to implement, that would be hard or impossible to implement in Flux.

基本上Redux和Flux本質是一樣的,不過開發體驗是不一樣的,Flux使用callback,而Redux使用Functional composition。
首先必須了解,JavaScript是一個functional programming language,因此透過callback操控事件順序也是常見的方式。
以下擷取React Flux介紹:

透過dispatcher調用callback與Store互動的一個單向流。
而React也有提到隨著專案的增長,不同的Store之間會用到也是很正常的,A Store當然要等B Store更新完才能使用,因此dispatcher扮演重要的角色。
這種操作其實也就如作者提到的問題一樣,很明顯的這種操作看起來是有一點不自然而且複雜。
為什麼我們不能讓操作再更簡單一點呢?

於是Redux就提出了Reducers來改善狀態管理,或是你可以稱它為Reducer composition。

所以為什麼要使用?關鍵就是在改善Store的使用
讓我們再來看Redux的部分說明:
As the requirements for JavaScript single-page applications have become increasingly complicated, our code must manage more state than ever before. This state can include server responses and cached data, as well as locally created data that has not yet been persisted to the server. UI state is also increasing in complexity, as we need to manage active routes, selected tabs, spinners, pagination controls, and so on.

隨著專案變大,State管理會變得越來越複雜,來源也可能包含cache, server…UI的互動甚至會有與Store相扣的狀況。
在一開始開發專案使用Store的時候,我們以現實狀況來說,通常不會考量到很深遠的設計,包含SSR, 邏輯優化與整合……
更何況是之後處理非同步與mutation。
在耦合性很高的設計時,有時候處理新需求會進行較高的更動,因此Redux提出的reducer composition改善了這個不自然的開發模式。

所以Redux是從Flux來的嗎

這要看怎麼解釋,是也不是。
不過很明顯的,Flux發揮了很大的影響力,使Redux的解決方案出現了。
Flux主要使用action與store處理邏輯,更新邏輯主要在store處理。
而Redux把更新邏輯轉為在reducer做處理,同時擁有composition與pure function的特性,使開發上更易實作。

為什麼需要使用Redux

讓我們來看官方的介紹
Redux是一個predictable的狀態管理套件,認識Redux之前,你必須要了解為什麼Redux是predictable:

  1. 狀態是immutable Object
  2. 所有的State change都必須經過actions
  3. reducers每次呼叫都是使用當前的State:(state, action) => state

綜合上述,predictable表示你可以輕易地掌握你使用這個action之後,state是如何改變的

基本用法

Redux的用法其實很單純
首先先產生一個Store

1
const store = createStore(counterReducer);

你可以用這個Store使用這幾種操作:

  1. subscribe
  2. dispatch
  3. getState

你可以隨時使用subscribe操作state change,不過通常你不會用到它,因為使用其他redux的middleware的時候都幫你處理好了

1
store.subscribe(() => { ... });

可以透過dispatch發出一個action

1
store.dispatch({ type: 'counter/incremented' })

你也可以在任何時候使用getState獲得當下的State

1
console.log(store.getState());

沒錯,就這樣,概念非常單純,此外redux還有很多便捷的功能,後面會提出一些重點

三大特色

  1. Single source of truth
  2. State is read-only
  3. Changes are made with pure functions

Global state皆存在Single-state tree中

1
store.getState();

State在每次提出時,皆為read-only,唯一會改變State的方式就是使用action發起更新請求

1
2
3
4
5
6
7
store.dispatch({
type: 'UPDATE_SINGLE_USER',
payload: {
name: 'James',
intro: '123',
}
})

reducer,或是你可以稱pure reducer,將每個更新邏輯都拆分的很細很單純,再加上都是pure的特色,降低了改A壞B的發生率:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function visibilityFilter(state = 'SHOW_ALL', action) {
switch (action.type) {
case 'SET_VISIBILITY_FILTER':
return action.filter
default:
return state
}
}

function todos(state = [], action) {
switch (action.type) {
case 'ADD_TODO':
return [
...state,
{
text: action.text,
completed: false
}
]
case 'COMPLETE_TODO':
return state.map((todo, index) => {
if (index === action.index) {
return Object.assign({}, todo, {
completed: true
})
}
return todo
})
default:
return state
}
}

因為reducer是設計成composition的,因此可以合併成上面提到的Single Global state
這也是為什麼我們習慣在reducer設計一個匯流index集中所有的reducer
每個reducer都有各自的State,隨時可以彈性的擴充

1
2
3
import { combineReducers, createStore } from 'redux'
const reducer = combineReducers({ visibilityFilter, todos })
const store = createStore(reducer)

另外,使用redux的人很常會設計一個initial State來初始化State
每次return保持immutable state
因此上面的例子可以寫成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

const initialState = {
filter: null,
}

function visibilityFilter(state = initialState, action) {
switch (action.type) {
case 'SET_VISIBILITY_FILTER':
return {
...state,
filter: action.filter,
};
default:
return state
}
}

最後,Redux的作者表示這樣的設計可以讓global state擁有predictable and testable的特點
當你遇到這些狀況時,你可能會想用Redux:

  1. You have large amounts of application state that are needed in many places in the app
  2. The app state is updated frequently over time
  3. The logic to update that state may be complex
  4. The app has a medium or large-sized codebase, and might be worked on by many people

這樣是為什麼Redux通常會出現在大型專案上使用。

消化一下

中間整理一下Redux提出什麼,改善了什麼

  1. pure function
  2. composition
  3. predictable state

Composition

例如以作者提供的這個專案當例子來說,Flux在使用function來改變Store的過程其實跟Redux大同小異。

關於非同步

我之前有寫一篇關於討論非同步的redux開發,有興趣的人可以參考看看如何在redux處理非同步操作
如果你要直接在Redux使用非同步操作,你可以考慮自己寫一個callback實作非同步或是參考其他redux middleware。
因為Redux本身更新資料的流程是序列化的(也就是同步的更新)
基於好的設計的前提下,action來我都接招,reducer immutable更新彼此互不影響。
不過現實依然還是有很多非同步的需求的,例如:驗證。
所以以下簡單介紹一下官方推薦的thunk:

關於redux最具有代表性的非同步處理就是使用thunk
thunk表示晚一點處理部分程式的意思,就像是callback使事件晚一點處理。

來看官方的介紹:
For Redux specifically, “thunks” are a pattern of writing functions with logic inside that can interact with a Redux store’s dispatch and getState methods.
Using thunks requires the redux-thunk middleware to be added to the Redux store as part of its configuration.

他們提供很簡單的解決方案提供非同步功能,可參考這裡
這樣的設計就是讓每個動作都包成一個擁有dispatch, getState的function,基於這樣的設計,可以透過callback來實現。

1
2
3
4
5
6
7
8
9
10
11
const store = configureStore({ reducer: counterReducer })

const exampleThunkFunction = (dispatch, getState) => {
const stateBefore = getState();
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(increment());
const stateAfter = getState();
console.log(`Counter after: ${stateAfter.counter}`)
}

store.dispatch(exampleThunkFunction)

那Context呢

我之前有試寫過Context在專案上,我覺得有好處也有壞處。
為什麼這兩個東西爭論這麼久,還是有很多人使用呢?
因為這兩個的設計架構其實是不同的,所以很難去做完整的比較。

解決方案不是狀態管理

讓我們來看看React官方的一段描述:
Context provides a way to pass data through the component tree without having to pass props down manually at every level.

Context是一種Dependency Injection設計,請注意,它並沒有管理狀態,它是一種傳輸的機制
Context和Redux都可以存取資料與追蹤變數的改變,但是Context無法做到更新資料這件事情
他能做到的就是透過props傳遞到Provider

這也是為什麼Context的出現無法取代Redux的原因之一
狀態基本上會分配到其他DOM的State去做管理
React雖然有提出Reducer的功能,但是他的概念還是跟Redux不同
React官方表示Reducer使用的State跟Redux的並不一樣,他們也不希望大家用Redux的角度來看待initial State

更多資訊可參考React Context for Dependency Injection Not State Management

關於Props drilling/threading

Context和Redux都是從root設置Provider,不過Context可以從任何元件使用Consumer或是context hook來提出State。
這樣可以避免使用過多的Props傳遞導致改A壞B的問題,不過這個狀況就要看如何設計元件了。

在過去幾年,props drilling曾經被視為是一種不好的設計,因為它耦合了每一個level的Component
但是最近被一些人認為這個設計其實是很好的,因為可以根據自己的需求隨時提出props使用
這種設計可以避免使用到Global,有可能開發出非預期的程式邏輯
別忘了,保持immutable的props可是很有幫助的
至於耦合的問題,他們認為parent和child本身就有耦合的必要性了,大不了就是不拿來用
有興趣的話可以參考這篇文章Why is prop drilling good?

至於這個設計好不好,你認為呢?

適合傳遞level較深的元件

特別是現在可以使用context hook的狀況下,只需要用hook就可以輕易提出你要用的State。
Redux的話,不管是多一個接口還是props drilling,某種程度上都會增加不少困擾。

什麼時候你不會考慮用

專案逐漸變大的時候,可能會頻繁的更新State,但是我們知道多次的更新state會不斷地觸發render。
而許多人開發Context遇到最大的問題都是因為時常觸發多次的render。
迷:明明只有dispatch一次呀,為什麼會render2次?
只要Context裏面的context subscribers有更新的動作,都會觸發render

你可能會問:這麼奇怪的設計,為什麼還要給大家用?
不,React官方認為提升開發體驗比起效能來說還來得重要
而且他們提供很多方法來避免額外效能耗損的問題,如:React.memo。

所以如果使用Context,建議多使用useMemo與useCallback來避免過多的資源消耗。
因此如果網站有大量更新State的需求時,Context可能不會是一種好選擇。

舊版與新版的Context

舊版的Context

上面提到的props drilling指的就是舊版Context API所要解決的問題
但是舊版Context的缺陷在於,如果元件透過shouldComponentUpdate來決定是否更新元件時
下面的子元件都會無法更新,也就是無法拿到更新後的props

新版的Context

新版的Context出現後,彷彿拯救了以往設計上的亂鬥,同時也縮減了部分的程式量

Context用法

  1. 創建Context用法
1
const MyContext = React.createContext();
  1. 使用Provider傳遞值
1
<MyContext.Provider value={someValue}>
  1. 取出要用的值
    取用有兩種方式,一種是Consumer,另一種是hook(事實上背後做的一樣是Consumer)
1
2
3
4
5
// 1
<MyContext.Consumer>{(someValue)=> ....}</MyContext.Consumer>

// 2
useContext(MyContext)

Context API的出現,起初是為了解決靜態資料 - props的問題,並不是狀態管理
後來也衍生出了Context API + useReducer 類似Redux的動態狀態使用方式

hook的出現與Context

你會發現只要談到hook的設計,都是基於狀態構造單純的前提下,使程式碼變得簡單易用易維護
例如透過useState可以把狀態做個別獨立化,不用再像以前一樣寫一坨state去做管理
當遇到複雜的狀態時,Context通常會搭配useReducer去處理狀態

舊版與新版的Context差異

Xstate作者David Khourshid說過一句話:
State management is how state changes over time.
要做出好的狀態管理,你需要知道這個狀態:

  1. 什麼時候
  2. 什麼地方
  3. 為什麼
  4. 發生了什麼

回到Context API

  1. Context是為了解決prop drilling而出現的
  2. subscribe了context的元件會被強制更新,但是使用redux則只會更新特定的元件而已

Context API + useReducer

  1. 如上
  2. Context本身不具備狀態管理,需搭配useReducer hook來使用狀態

哪個比較好

沒有一定的答案,要看場合
Redux的團隊成員Mark Erikson曾經說過:
if you get past 2-3 state-related contexts in an application, you’re re-inventing a weaker version of React-Redux and should just switch to using Redux.

你可能不知道的Redux

中大型專案的邏輯設計規模逐漸變大,更新狀態的邏輯較複雜適合使用
而且你知道嗎?其實當你透過react-redux套件來使用redux時,其實背後的運作原理是用Context唷
讓我們來看看react-redux的source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import React, { Context, ReactNode, useMemo } from 'react'
import { ReactReduxContext, ReactReduxContextValue } from './Context'
import { createSubscription } from '../utils/Subscription'
import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect'
import type { FixTypeLater } from '../types'
import { Action, AnyAction, Store } from 'redux'

export interface ProviderProps<A extends Action = AnyAction> {
/**
* The single Redux store in your application.
*/
store: Store<FixTypeLater, A>
/**
* Optional context to be used internally in react-redux. Use React.createContext() to create a context to be used.
* If this is used, generate own connect HOC by using connectAdvanced, supplying the same context provided to the
* Provider. Initial value doesn't matter, as it is overwritten with the internal state of Provider.
*/
context?: Context<ReactReduxContextValue | null>
children: ReactNode
}

function Provider({ store, context, children }: ProviderProps) {
const contextValue = useMemo(() => {
const subscription = createSubscription(store)
subscription.onStateChange = subscription.notifyNestedSubs
return {
store,
subscription,
}
}, [store])

const previousState = useMemo(() => store.getState(), [store])

useIsomorphicLayoutEffect(() => {
const { subscription } = contextValue
subscription.trySubscribe()

if (previousState !== store.getState()) {
subscription.notifyNestedSubs()
}
return () => {
subscription.tryUnsubscribe()
subscription.onStateChange = undefined
}
}, [contextValue, previousState])

const Context = context || ReactReduxContext

return <Context.Provider value={contextValue}>{children}</Context.Provider>
}

export default Provider

這邊有一些值得注意的地方:

  1. 當你使用Redux的時候,Context
  2. 使用useLayoutEffect來執行async render
  3. 使用useMemo hook,在產生store的時候即產生Context value

上面這些是React-redux的部分,再來我們來看redux本身提供了什麼功能給react-redux
那就是Redux可以透過middleware來操作Redux:
Middleware is the suggested way to extend Redux with custom functionality. Middleware lets you wrap the store’s dispatch method for fun and profit. The key feature of middleware is that it is composable. Multiple middleware can be combined together, where each middleware requires no knowledge of what comes before or after it in the chain.

最簡單的例子就是redux的logger,透過middleware加入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { createStore, applyMiddleware } from 'redux'
import todos from './reducers'

function logger({ getState }) {
return next => action => {
console.log('will dispatch', action)

// Call the next dispatch method in the middleware chain.
const returnValue = next(action)

console.log('state after dispatch', getState())

// This will likely be the action itself, unless
// a middleware further in chain changed it.
return returnValue
}
}

const store = createStore(todos, ['Use Redux'], applyMiddleware(logger))

store.dispatch({
type: 'ADD_TODO',
text: 'Understand the middleware'
})
// (These lines will be logged by the middleware:)
// will dispatch: { type: 'ADD_TODO', text: 'Understand the middleware' }
// state after dispatch: [ 'Use Redux', 'Understand the middleware' ]

所以你知道為什麼很多人寧願選擇使用Redux大過於Context了吧
很多事情都幫你處理好了,攏統的講,你可以把React-redux當作是優化版的Context,而且還可以加入middleware

Context API + useReducer整理

中小型專案適合使用,狀態設計的規模不大
當下的需求對渲染的效能要求不高
更改狀態的邏輯需要寫在元件上,但是有些邏輯使用是共用的
如果要把邏輯導出來獨立就必須要另外寫邏輯去封裝它
另外處理非同步議題,Redux的middleware發展較為成熟
基於以上種種因素,那還不如直接用Redux搭配React-redux對吧?
因此Context適合用在parent與child之間簡單的共享狀態,但是對於global的情境並不適合

我的觀點

雖然上面寫了這麼多,不過設計元件沒有一定的答案,主要還是要融入團隊為主。
不過在我業餘跟別人開發的時候,我習慣盡量設計出以props change為主的元件,有需要控制狀態才會用到State。
原因:

  1. props change可以trigger render
  2. 保持immutable原則,避免發生非預期的問題

結論

redux的特色和設計其實很單純,但是也是因為有他的歷史價值使它被更多的開發者使用,我最近在跟一些初學React的交流,其實也會時常發現很多人不懂為什麼會有Redux甚至是Context的出現,因為他們沒有經歷過使用之前套件的過程,所以無法感同身受,當然也沒有那個必要回頭檢視歷史。
所以對我來說,學習網頁設計難的地方並不是開發程式,而是有沒有經驗過這些事情,並且回頭檢視這些帶來的意義是什麼。

參考

Learning Resources
React Context for Dependency Injection Not State Management
Why React Context is Not a “State Management” Tool (and Why It Doesn’t Replace Redux)
Why is prop drilling good?


《【react】react hook运行原理解析》