###### tags: `Redux` # [week 23] 淺談 Redux:狀態管理是一門學問 > 本篇為 [[FE303] React 的好夥伴:Redux](https://lidemy.com/p/fe303-react-redux) 這門課程的學習筆記。如有錯誤歡迎指正! - 推薦閱讀:[Redux 官方文件](https://redux.js.org/introduction/getting-started) ## 什麼是 Redux? 根據 [Redux 官網](https://redux.js.org/)說明,可知 Redux 是一個「用於 JavaScript 應用程式的**狀態管理**工具」: > A Predictable State Container for JS Apps 雖然 Redux 經常與 React 搭配使用,但其實 Redux 是一種前端的「架構模式」。簡單來說,就是用來實現「狀態管理機制」的套件,並且能套用在任何程式語言。 ## 認識 Redux 之前 再進一步認識 Redux 之前,可先從 Redux 的歷史演化來瞭解。 最早是在 Facebook 提出 React 時,所提出的 Flux 開發架構,目的是解決 MVC 在大型商業網站所存在的問題,例如管理 UI 畫面與資料之間的對應關係。 ### 「狀態管理」是一門學問 從以前到現在,關於「狀態管理」,在不同框架也會有不同處理方式,舉例來說: - jQuery:資料與畫面分離,重點放在「畫面」上 - Vue 與 Angular:資料與畫面「雙向綁定」 以 [Vue.js](https://vuejs.org/v2/guide/) 為例,是透過 `v-model` 語法進行雙向綁定: ```htmlmixed= <!-- 畫面 --> <div id="app-6"> <p>{{ message }}</p> <input v-model="message"> </div> ``` ```javascript= // 資料 var app6 = new Vue({ el: '#app-6', data: { message: 'Hello Vue!' } }) ``` - React:只需要管資料,藉由「狀態」渲染出畫面 ### Flux 簡介 根據 [Flux](https://facebook.github.io/Flux/) 官網說明: > Application architecture for building user interfaces 在傳統的 MVC 架構,Model 和 View 之間可能會呈現複雜關係: ![](https://i.imgur.com/Ourrrv7.png) (圖片來源:[Facebook 介紹影片](https://www.youtube.com/watch?v=nYkdrAPrdcw)) Facebook 當初之所以會提出 React 和 Flux,就是為了解決當 APP 架構變更大,功能變更複雜時遇到的問題。例如 FB 中的 Messager 的提醒通知,程式碼中可能有許多部分會操控到同一 Model,使得狀態過於複雜,不易進行後續追蹤。 Flux 架構流程如下,若想要改變 Store(資料)或 View(畫面),都必須透過 Dispatcher 發出 Action(指令),呈現單向資料流: ![](https://i.imgur.com/vArLgx3.png) 上述架構,在小型專案中其實是多此一舉,直接透過修改 Store 去渲染 View 即可;但在大型專案中,透過像這樣「集中」管理的方式,會更容易進行複雜的狀態管理。 ## Redux 簡介 以下是 Redux 中的 data flow 示意圖: ![](https://i.imgur.com/5a6tNaA.gif) 在 React 當中,其實有個和 Redux 功能類似的內建 Hook:[useReducer](https://reactjs.org/docs/hooks-reference.html#usereducer),同樣可用來管理複雜的狀態。 ### React Hook:useReducer useReducer 可接受三個參數: ```javascript= const [state, dispatch] = useReducer(reducer, initialArg, init); ``` 以下是官網提供的範例: ```javascript= // 初始狀態為 count: 0 const initialState = {count: 0}; // 由 reducer 回傳 state function reducer(state, action) { switch (action.type) { // return 新的 state 並取代原本的 case 'increment': return {count: state.count + 1}; case 'decrement': return {count: state.count - 1}; // 非預期指令時則丟出 Error default: throw new Error(); } } function Counter() { const [state, dispatch] = useReducer(reducer, initialState); return ( <> Count: {state.count} // 由 dispatch 發送指令,{type: 'decrement'} 這個物件就代表一個動作 <button onClick={() => dispatch({type: 'decrement'})}>-</button> <button onClick={() => dispatch({type: 'increment'})}>+</button> </> ); } ``` - reducer 是一個 function,可接收兩個參數:目前的狀態和要執行的操作 - initialState 代表初始狀態 - 使用 useReducer 會得到 state, dispatch 兩個值,可對應到 Redux 中「Store 裡面的狀態」和「由 dispatch 指定 Store 執行哪些事」 ## Redux 實際操作 透過上述簡介,我們可以瞭解到 React 和 Redux 其實並無相依性,兩者均可進行狀態管理,差別在於: - React:把 state 放在 component 裡面 - Redux:把 state 放在 Store 裡面,Store 是一個 JavaScript 物件 ### 安裝套件 首先依照[官方文件](https://redux.js.org/introduction/getting-started)安裝 Redux: 1. 初始 npm 專案 ``` $ npm init ``` 2. 安裝 redux ``` $ npm install redux ``` 3. 新增 app.js 並使用 require 引入 > 官方文件是使用 import,需注意在 node.js 較舊版本無法使用,因此這裡使用 require。 ```javascript= const { createStore } = require("redux"); ``` ### 建立 Store 存放 state 透過以下程式碼,可創建 Redux 中的 Store: ```javascript= const { createStore } = require("redux"); const initialState = { value: 0, }; // reducer 決定狀態要如何變化 function counterReducer(state = initialState, action) { return state; } // 把 reducer 存入 store let store = createStore(counterReducer); console.log(store); ``` 在終端機執行 `node app.js`,可知 store 其實是一個物件: ![](https://i.imgur.com/kjulolO.png) 若改城呼叫物件裡面的函式 `getState()`,會顯示目前的 state,也就是 initialState: ```javascript= console.log(store.getState()); // { value: 0 } ``` ### 透過 disptch 指定要做的事 接著可透過 store.dispatch() 指定要做的事,傳入參數為物件,慣例寫法是 `type: '...'`: ```javascript= const { createStore } = require("redux"); const initialState = { value: 0, }; function counterReducer(state = initialState, action) { console.log("receive action", action); return state; } let store = createStore(counterReducer); store.dispatch({ type: 'plus' }) console.log(store.getState()); ``` 結果如下: ![](https://i.imgur.com/7TA6D2C.png) - `{ type: '@@redux/INIT0.1.0.m.r.p' }`:初始化時 redux 自動建立的 dispatch - `{ type: 'plus' }`:印出 dispatch 的 action 也可以透過 `setTimeout()` 驗證: ```javascript= // 延遲 1 秒 setTimeout(() => { store.dispatch({ type: "plus", }); }, 1000); ``` 結果如下,1 秒後 reducer 才印出傳入的 action: ![](https://i.imgur.com/eZliFre.gif) ### 透過 dispatch 改變 state 改寫如下,傳入 action 會改變 state: ```javascript= function counterReducer(state = initialState, action) { console.log("receive action", action); if (action.type === "plus") { return { value: state.value + 1, // 回傳新的 state }; } return state; // 回傳原本的 state } let store = createStore(counterReducer); console.log("first state", store.getState()); // 傳入 action 改變 state store.dispatch({ type: "plus", }); console.log("second state", store.getState()); ``` 印出結果: ![](https://i.imgur.com/qliRszN.png) ### Reducer 中的判斷式 - if...else 條件式:當 type 變多時不易管理 ```javascript= function counterReducer(state = initialState, action) { if (action.type === "plus") { return { value: state.value + 1, }; } else if (action.type === "minus") { return { value: state.value - 1, }; } return state; } ``` - switch 條件式:較推薦使用 ```javascript= function counterReducer(state = initialState, action) { switch (action.type) { case "plus": { return { value: state.value + 1, }; } case "minus": { return { value: state.value - 1, }; } // 非預期 type 時直接回傳 state default: { return state; } } } ``` ### store.subscribe():當 store 改變時觸發執行 store 其實也有提供類似 addEventLister 的功能,也就是 subscribe(),傳入一個 function 作為參數: ```javascript= // 當 store 改變時執行 store.subscribe(() => { console.log("change!", store.getState()); }); ``` 再改寫上述範例: ```javascript= let store = createStore(counterReducer); store.subscribe(() => { console.log("change!", store.getState()); }); store.dispatch({ type: "plus", }); ``` 結果如下: ![](https://i.imgur.com/wcSbCsN.png) ## 實作簡易的 todolist 以實作新增和刪除功能為例: ### 新增功能 和 React 的 useState 用法類似,因為 reducer 回傳新的 state 會直接覆蓋原有的,必須加上 `...state` 保存原本的 state: ```javascript= const { createStore } = require("redux"); let todoId = 0; const initialState = { email: "123@123", todos: [], }; function counterReducer(state = initialState, action) { console.log("receive action", action); switch (action.type) { case "add_todo": { return { // 保存原本的 state ...state, todos: [ ...state.todos, { id: todoId++, name: action.payload.name, }, ], }; } default: { return state; } } } let store = createStore(counterReducer); store.subscribe(() => { console.log("change!", store.getState()); }); store.dispatch({ type: "add_todo", payload: { name: "todo0", }, }); store.dispatch({ type: "add_todo", payload: { name: "todo1", }, }); ``` ### 刪除功能 新增 delete_todo 這個 dispatch: ```javascript= store.dispatch({ type: "delete_todo", payload: { id: 0, }, }); ``` 並在 reducer 新增條件,寫入要執行的動作: ```javascript= case "delete_todo": { return { ...state, todos: state.todos.filter((todo) => todo.id !== action.payload.id), }; } // ... ``` 結果如下: ![](https://i.imgur.com/cuoNgBT.png) ### Reducer 的優點:方便寫測試 像這樣分開撰寫的好處,就是方便對 reducer 寫測試,可藉由比對結果,驗證邏輯是否正確,如以下範例: ```javascript= expect( counterReducer(initialState, { type: "add_todo", payload: { name: "123", }, }) ).toEqual({ // 比對結果 todos: [{ name: "123" }], }); ``` ## 優化:避免程式開發時出錯 當程式變複雜時,我們可透過「action constants」和「action creator」進行管理,可減少開發時出錯: ### action constants:以物件集中管理 Action Types 在上述範例中,我們是以「字串」來表示 type,但這麼做有個缺點,就是打錯字或發生錯誤時不易 debug,可透過 ActionTypes 物件集中管理: ```javascript= // action constants const ActionTypes = { ADD_TODO: "add_todo", DELETE_TODO: "delete_todo", }; ``` 將原本的字串改用 ActionTypes 物件表示: ```javascript= function counterReducer(state = initialState, action) { switch (action.type) { case ActionTypes.ADD_TODO: { return { ...state, todos: [ ...state.todos, { id: todoId++, name: action.payload.name, }, ], }; } case ActionTypes.DELETE_TODO: { return { ...state, todos: state.todos.filter((todo) => todo.id !== action.payload.id), }; } default: { return state; } } } store.dispatch({ type: ActionTypes.ADD_TODO, payload: { name: "todo1", }, }); store.dispatch({ type: ActionTypes.DELETE_TODO, payload: { id: 0, }, }); ``` ### action creator:透過 function 建立 action ```javascript= function addTodo(name) { return { type: ActionTypes.ADD_TODO, payload: { name, }, }; } function deleteTodo(name) { return { type: ActionTypes.DELETE_TODO, payload: { id: 0, }, }; } store.dispatch(addTodo("todo0")); store.dispatch(addTodo("todo1")); store.dispatch(addTodo("todo2")); store.dispatch(deleteTodo(1)); ```