Skip to content

Latest commit

 

History

History
529 lines (395 loc) · 11.8 KB

week23_淺談Redux:狀態管理是一門學問.md

File metadata and controls

529 lines (395 loc) · 11.8 KB
tags: Redux

[week 23] 淺談 Redux:狀態管理是一門學問

本篇為 [FE303] React 的好夥伴:Redux 這門課程的學習筆記。如有錯誤歡迎指正!

什麼是 Redux?

根據 Redux 官網說明,可知 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 為例,是透過 v-model 語法進行雙向綁定:

<!-- 畫面 -->
<div id="app-6">
  <p>{{ message }}</p>
  <input v-model="message">
</div>
// 資料
var app6 = new Vue({
  el: '#app-6',
  data: {
    message: 'Hello Vue!'
  }
})
  • React:只需要管資料,藉由「狀態」渲染出畫面

Flux 簡介

根據 Flux 官網說明:

Application architecture for building user interfaces

在傳統的 MVC 架構,Model 和 View 之間可能會呈現複雜關係:

(圖片來源:Facebook 介紹影片

Facebook 當初之所以會提出 React 和 Flux,就是為了解決當 APP 架構變更大,功能變更複雜時遇到的問題。例如 FB 中的 Messager 的提醒通知,程式碼中可能有許多部分會操控到同一 Model,使得狀態過於複雜,不易進行後續追蹤。

Flux 架構流程如下,若想要改變 Store(資料)或 View(畫面),都必須透過 Dispatcher 發出 Action(指令),呈現單向資料流:

上述架構,在小型專案中其實是多此一舉,直接透過修改 Store 去渲染 View 即可;但在大型專案中,透過像這樣「集中」管理的方式,會更容易進行複雜的狀態管理。

Redux 簡介

以下是 Redux 中的 data flow 示意圖:

在 React 當中,其實有個和 Redux 功能類似的內建 Hook:useReducer,同樣可用來管理複雜的狀態。

React Hook:useReducer

useReducer 可接受三個參數:

const [state, dispatch] = useReducer(reducer, initialArg, init);

以下是官網提供的範例:

// 初始狀態為 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 物件

安裝套件

首先依照官方文件安裝 Redux:

  1. 初始 npm 專案
$ npm init
  1. 安裝 redux
$ npm install redux
  1. 新增 app.js 並使用 require 引入

官方文件是使用 import,需注意在 node.js 較舊版本無法使用,因此這裡使用 require。

const { createStore } = require("redux");

建立 Store 存放 state

透過以下程式碼,可創建 Redux 中的 Store:

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 其實是一個物件:

若改城呼叫物件裡面的函式 getState(),會顯示目前的 state,也就是 initialState:

console.log(store.getState());
// { value: 0 }

透過 disptch 指定要做的事

接著可透過 store.dispatch() 指定要做的事,傳入參數為物件,慣例寫法是 type: '...'

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());

結果如下:

  • { type: '@@redux/INIT0.1.0.m.r.p' }:初始化時 redux 自動建立的 dispatch
  • { type: 'plus' }:印出 dispatch 的 action

也可以透過 setTimeout() 驗證:

// 延遲 1 秒
setTimeout(() => {
  store.dispatch({
    type: "plus",
  });
}, 1000);

結果如下,1 秒後 reducer 才印出傳入的 action:

透過 dispatch 改變 state

改寫如下,傳入 action 會改變 state:

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());

印出結果:

Reducer 中的判斷式

  • if...else 條件式:當 type 變多時不易管理
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 條件式:較推薦使用
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 作為參數:

// 當 store 改變時執行
store.subscribe(() => {
  console.log("change!", store.getState());
});

再改寫上述範例:

let store = createStore(counterReducer);

store.subscribe(() => {
  console.log("change!", store.getState());
});

store.dispatch({
  type: "plus",
});

結果如下:

實作簡易的 todolist

以實作新增和刪除功能為例:

新增功能

和 React 的 useState 用法類似,因為 reducer 回傳新的 state 會直接覆蓋原有的,必須加上 ...state 保存原本的 state:

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:

store.dispatch({
  type: "delete_todo",
  payload: {
    id: 0,
  },
});

並在 reducer 新增條件,寫入要執行的動作:

    case "delete_todo": {
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.payload.id),
      };
    }
// ...

結果如下:

Reducer 的優點:方便寫測試

像這樣分開撰寫的好處,就是方便對 reducer 寫測試,可藉由比對結果,驗證邏輯是否正確,如以下範例:

expect(
  counterReducer(initialState, {
    type: "add_todo",
    payload: {
      name: "123",
    },
  })
).toEqual({                  // 比對結果
  todos: [{ name: "123" }], 
});

優化:避免程式開發時出錯

當程式變複雜時,我們可透過「action constants」和「action creator」進行管理,可減少開發時出錯:

action constants:以物件集中管理 Action Types

在上述範例中,我們是以「字串」來表示 type,但這麼做有個缺點,就是打錯字或發生錯誤時不易 debug,可透過 ActionTypes 物件集中管理:

// action constants
const ActionTypes = {
  ADD_TODO: "add_todo",
  DELETE_TODO: "delete_todo",
};

將原本的字串改用 ActionTypes 物件表示:

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

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));