🔥

The Redux Toolkit

2023/09/05に公開

前言

文章內容為 codecademy 的學習筆記。

到目前為止,會發現說當 Redux 變得愈來愈冗長且複雜的時候,常常會有很多需要規劃的細節會忘記。

為此,Redux 團隊規劃了 Redux Toolkit 工具來改善這些問題。而 Redux Toolkit 為了更好的建構 Redux application,而量身規劃了特別的 package 以及 function,用來簡化 Redux 的任務、避免 common mistakes 以及更容易的寫好 Redux application 的相關邏輯。

在這一篇文章會深入介紹兩個重要的方法,createSlice() 以及 configureStore() 並且學習要怎麼把他們並入 application 中。詳情可以參考 Redux Toolkit docs

而在開始前的第一步,要先記得安裝 Redux Toolkit 的套件:

npm install @reduxjs/toolkit

"Slices" of State

在更深入這篇文章前,先稍微複習之前的 slice of state。slice of state 是 global state 的一部分,每個 slice 都會各別專注於某部分的功能,其中也包含了相關的 data, actions 以及 selectors。

而在下面的範例中,state.todos 以及 state.visibilityFilter 各別代表不同的 slices。

const state = {
  todos: [
    {
      id: 0,
      text: "Learn Redux-React",
      completed: true,
    },
    {
      id: 1,
      text: "Learn Redux Toolkit",
      completed: false,
    }
  ], 
  visibilityFilter: "SHOW_ALL"
}

對每個 slice of the state,通常都會定義對應的 reducer,也就是 slice reducer 的部分。也就是說,每個 reducer 都只負責管理它們自己的 slice。這樣的 modular 簡化了複雜的 application,並且讓 debugging 更容易。

以下可以看看 state.todos 怎麼規劃:

/* todosSlice.js  */
const addTodo = (todo) => {
  return {
    type: 'todos/addTodo',
    payload: todo
  }
}

const toggleTodo = (todo) => {
  return {
    type: 'todos/toggleTodo',
    payload: todo
  }
}

const todos = (state = [], action) => {
 switch (action.type) {
   case 'todos/addTodo':
     return [
       ...state,
       {
         id: action.payload.id,
         text: action.payload.text,
         completed: false
       }
     ]
   case 'todos/toggleTodo':
     return state.map(todo =>
       todo.id === action.payload.id ? { ...todo, completed: !todo.completed } : todo
     )
   default:
     return state
 }
}

現在只有處理 state.todos 的部分,而沒有 state.visibilityFilter 。一次處理一個部分會比較有效率且減少出錯,而若是遇到比較大的專案時,就會把這兩個部分分開成兩個的檔案來處理。

若要在檔案使用 createSlice() ,要先 import:

import { createSlice }  from '@reduxjs/toolkit';

Refactoring with createSlice()

接下來要使用 createSlice() 來定義 slice reducer 以及 相關的 action creators。

/* todosSlice.js  */
const addTodo = (todo) => {
 // logic omitted...
}

const toggleTodo = (todo) => {
  // logic omitted...
}

const todos = (state = [], action) => {
  // logic omitted...
}

傳統的 Redux 會要求把 action types, action creators 以及 reducers 分開寫。而 createSlice() 則可以把這個過程整合在一個 object 裡。

createSlice() 會有一個 configuration object 的參數,這個 object 有以下幾個性質:

  • name: 用來定義 the slice 名字的字串。
  • initialState: 給 reducer 一個初始 state value。
  • reducers: 是一個 object,每個 key 都代表一個 action type,而這邊的字串定義了 action。而相關的方法叫做 “case reducer”,會描述當 action 被觸發的時候, state 會如何被更新。這些 reducer function 就像一連串指示一樣,會基於被發送的 the type of action 來引導 the state 如何改變。

來看看下面的 action 會如何處理:

/* todosSlice.js */
//Configuration object for createSlice()
const options = {
 name: 'todos', //Name of slice
 initialState: [], //Initial state of slice
 reducers: {
   //Reducer for "addTodo" action
   addTodo: (state, action) => {
     return [
       ...state,
       {
         id: action.payload.id,
         text: action.payload.text,
         completed: false
       }
     ]
   },
   //Reducer for "toggleTodo" action
   toggleTodo: (state, action) => {
     return state.map(todo =>
       (todo.id === action.payload.id) ? { ...todo, completed: !todo.completed } : todo
     )
   }
 }
}

const todosSlice = createSlice(options);

在這個例子中,這個 options object 會被傳遞到 createSlice() ,並產生 component 來管理 slice。如此一來,就可以大量減少需要 boilerplate code 的數量。

Writing "Mutable" Code with Immer

Redux reducers 其中一個最重要的原則就是要避免直接改變 state。這就代表說必須去更新被複製的每一層巢狀結構。通常可以透過 JavaScript’s array, object spread operators 以及其他可以複製 original value 的 function 來達成。

但要遵守這一個原則會變得相當複雜,而最常見的錯誤就是會不小心改到 reducer 裡的 state。

這一個問題 Redux Toolkit 其實有一個解決方法,createSlice() 可以用一個叫 immer 的 library 來避免這類型的錯誤。

Immer 是如何運作的呢?

Image.png

from Introduction to Immer

Immer 會利用一個稱作 Proxy 的 JS object 來包住你提供的 data,並且可以讓你寫可以改變 wrapped data 的程式。而 Immer 可以做到這些事,是因為它可以追蹤你所做的更改,並利用更改的 list 來回傳一個不可變的更新值。

詳細說的話,Immer 最基礎的觀念就是它會把你所有做的改變都存到一個 temporary draft 中,可以把它視爲是 the current state 的副本。而一旦完成了這一階段的更改時,Immer 會基於你在 draft 上的更改,來產生下一個 State。

Without Immer:

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo: (state, action) => {
      return [
        ...state,
        {
          ...action.payload,
          completed: false
        }
      ]
    },
    toggleTodo: (state, action) => {
      return state.map(todo =>
        todo.id === action.payload.id ? { ...todo, completed: !todo.completed } : todo
      )
    }
  }
})

With Immer:

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo: (state, action) => {
      state.push({ 
        ...action.payload, 
        completed: false 
      })
    },
    toggleTodo: (state, action) => {
      const todo = state.find(todo => todo.id === action.payload.id)
      if (todo) {
        todo.completed = !todo.completed
      }
    }
  }
})

事實上,上面的方法 addTodo 以及 toggleTodo 都有改變到原始的值,但 Immer 會讓這部分可以順利運行。

Returned Objects and Auto-Generated Actions

到這邊,大概講了 object 怎麼傳遞到 createSlice() 的。現在要更深入探討 function 實際回傳了什麼?

下面還是以 todosSlice 為例子,來看看當使用 createSlice() 時,它給你的 object 會是什麼樣子?

const todosSlice = createSlice({
 name: 'todos',
 initialState: [],
 reducers: {
   addTodo(state, action) {
     const { id, text } = action.payload
     state.push({ id, text, completed: false })
   },
   toggleTodo(state, action) {
     const todo = state.find(todo => todo.id === action.payload)
     if (todo) {
       todo.completed = !todo.completed
     }
   }
 }
})

/* Object returned by todosSlice */
{
 name: 'todos',
 reducer: (state, action) => newState,
 actions: {
   addTodo: (payload) => ({type: 'todos/addTodo', payload}),
   toggleTodo: (payload) => ({type: 'todos/toggleTodo', payload})
 },
 // case reducers field omitted
}

分開來說明一下:

  • name : 生成 action types 的字串。
  • reducer : 這是已完成的 reducer function。
  • actions : 會自動產生 action creators。

所以自動產生的action object 會是什麼?

預設的話,每個 action creator 會接受一個參數,它之後會變成 action.payload。而 action.type 的字串是靠結合 the slice’s name 以及 the case reducer function’s name 來產生。

舉例來說:

console.log(todosSlice.actions.addTodo('walk dog'))
// {type: 'todos/addTodo', payload: 'walk dog'}

有了自動產生的 action creators,可以匯出它們,並且把它們用在其他檔案中,理論上可以匯出被 createSlice() 回傳的整個 slice object。但如果要 follow Redux community’s “ducks” pattern pattern,會建議分別從 reducer 中命名 action creators 來匯出:

export const { addTodo, toggleTodo } = todosSlice.actions

一旦匯出了這些 action creators,就可以透過 application,以一個較有結構的方式,來發送 actions。

Returned Objects and Reducers

來深入看看被 createSlice() 所回傳的 object reducer

const options = {
  // options fields omitted.
}
const todosSlice = createSlice(options);

/* Object returned by todosSlice */
{
 name: 'todos',
 reducer: (state, action) => newState,
 actions: {
   addTodo: (payload) => ({type: 'todos/addTodo', payload}),
   toggleTodo: (payload) => ({type: 'todos/toggleTodo', payload})
 },
 // case reducers field omitted
}

todosSlice.reducer 是一個完整的 reducer function,它代表的是所有 case reducers 的集合,每個 reducer 都跟 slice 所要處理的不同 actions 有關係。而為了要有效率,它會把 the case reducers 結合成一個,這通常被稱為 “ slice reducer “。

而當有著 type 'todos/addTodo' 的 actions 被發送時,todoSlice 會讓 todosSlice 去確認說,是否 the dispatched action’s type 跟在 todos.actions 中的任何 case reducers 有吻合,若有,這個 case reducer function 就會被執行; 若沒有,就會回傳 the current state。這對應之前使用的 switch / case 模式。

而一旦自動產生了,todosSlice.reducer 會需要被匯出,讓它可以被合併到 the global store 中,並被當成 todos slice of state。

export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer

從 slice 匯出 reducer 像是給 App 每一部分他自己特別的區域,其中都有包含如何處理資料的說明。而這些區域會集中在一起,一起對資料進行管理。

Converting the Store to Use configureStore()

為了簡化 actions 以及 reducers 的邏輯,Redux Toolkit 有一個 configureStore() 的方法來簡化 the store 設定的過程。configureStore() 包含了 createStore() 以及 combineReducers() ,且可以自動地掌控大部分的 store 設定。

看一下下面的例子,它創建並匯出一個 rootReducer

// rootReducer.js

import { combineReducers } from 'redux'

import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'

const rootReducer = combineReducers({
 // Define a top-level state field named `todos`, handled by `todosReducer`
 todos: todosReducer,
 visibilityFilter: visibilityFilterReducer
})

export default rootReducer

以及下面這個,它創建且匯出一個 store

// store.js
        
import { createStore } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import rootReducer from './reducer'
import { fetchTodos } from './actions'; 

const store = createStore(rootReducer, composeWithDevTools())
store.dispatch(fetchTodos());
export default store

那現在來看看如何透過 configureStore() 重構這兩個檔案。

configureStore() 接收單一的 configuration object 為參數。這個被輸入的 object 必須為 reducer property,它定義的是可以用來當作 the root reducer 的 function,或是可以被蒐集成一個 root reducer 的 slice reducers 的 object。

其實在這個 object 有很多可用的性質,但這篇文章只會說明讓 reducer property 如何更有效率。

import { configureStore } from '@reduxjs/toolkit'

import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'

const store = configureStore({
 reducer: {
   // Define a top-level state field named `todos`, handled by `todosReducer`
   todos: todosReducer,
   filters: filtersReducer
 }
})

export default store

來看看呼叫 configureStore() 所做的事情:

  • Reducer: 它在可以控制像是 {todos, filters} 的 root state 的 the root reducer function 中,包含了 todosReducer 以及 filtersReducer,並且省去了呼叫 combineReducers() 的必要。
  • Store: 它利用 root reducer 來創建 Redux store,並移除呼叫 createStore() 的必要。
  • Middleware: 它會自動新增中介軟體來檢查常見的錯誤,例如突變的 state。 若以以往的方式,可能需要自己設定這個。
  • DevTools: 它會自動連結 Redux DevTools 的擴充套件。 若以以往的方式,可能也需要自己設定這個。

由於我們能夠使用 configureStore() 省去一些 boilerplate code,我們可以直接將單個 slice reducers 匯入此檔案,而不是為 the root reducer 來建立一個單獨的檔案並匯出/匯入它。

由於這就像切換 store 的設定程式一樣簡單,因此應用程式的所有現有功能都將正常工作!

文章參考

Learn Redux

Discussion