跳至主要內容

使用 Immer 編寫 Reducer

Redux Toolkit 的 createReducercreateSlice 會在內部自動使用 Immer,讓您使用「變異」語法撰寫更簡單的不變更新邏輯。這有助於簡化大多數的 reducer 實作。

由於 Immer 本身就是一個抽象層,因此了解 Redux Toolkit 為何使用 Immer 以及如何正確使用它非常重要。

不變性與 Redux

不變性的基礎

「可變」表示「可變更」。如果某個東西是「不變」的,就表示它永遠無法被變更。

JavaScript 物件和陣列在預設情況下都是可變的。如果我建立一個物件,我可以變更其欄位的內容。如果我建立一個陣列,我也能變更其內容

const obj = { a: 1, b: 2 }
// still the same object outside, but the contents have changed
obj.b = 3

const arr = ['a', 'b']
// In the same way, we can change the contents of this array
arr.push('c')
arr[1] = 'd'

這稱為變異物件或陣列。這是記憶體中相同的物件或陣列參考,但現在物件內的內容已變更。

若要以不可變的方式更新值,您的程式碼必須建立現有物件/陣列的副本,然後修改副本.

我們可以使用 JavaScript 的陣列/物件擴散運算子,以及會傳回陣列新副本(而不是變異原始陣列)的陣列方法,手動執行此操作

const obj = {
a: {
// To safely update obj.a.c, we have to copy each piece
c: 3,
},
b: 2,
}

const obj2 = {
// copy obj
...obj,
// overwrite a
a: {
// copy obj.a
...obj.a,
// overwrite c
c: 42,
},
}

const arr = ['a', 'b']
// Create a new copy of arr, with "c" appended to the end
const arr2 = arr.concat('c')

// or, we can make a copy of the original array:
const arr3 = arr.slice()
// and mutate the copy:
arr3.push('c')
想了解更多嗎?

如需有關不可變性如何在 JavaScript 中運作的更多資訊,請參閱

Reducer 和不可變更新

Redux 的主要規則之一是我們的 reducer 絕不允許變異原始/目前狀態值!

危險
// ❌ Illegal - by default, this will mutate the state!
state.value = 123

您不得在 Redux 中變異狀態的原因有幾個

  • 它會導致錯誤,例如 UI 無法正確更新以顯示最新值
  • 它會讓您更難理解狀態已更新的原因和方式
  • 它會讓您更難撰寫測試
  • 它會破壞正確使用「時光旅行除錯」的功能
  • 它違反 Redux 的預期精神和使用模式

因此,如果我們無法變更原始值,我們要如何傳回更新的狀態?

提示

Reducer 只能建立原始值的副本,然後再對副本進行變異。

// ✅ This is safe, because we made a copy
return {
...state,
value: 123,
}

我們已經看到,我們可以使用 JavaScript 的陣列/物件擴充運算子和其他會傳回原始值副本的函式,來手動撰寫不可變的更新。

當資料是巢狀時,這會變得更困難。不可變更新的一個重要規則是,你必須為每個需要更新的巢狀層級建立副本。

以下是一個典型的範例

function handwrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue,
},
},
},
}
}

然而,如果你認為「手動撰寫不可變的更新看起來很難記住且正確執行」... 沒錯,你說對了! :)

手動撰寫不可變的更新邏輯困難,而且在 reducer 中意外變異狀態是 Redux 使用者最常犯的錯誤

使用 Immer 進行不可變更新

Immer 是一個簡化撰寫不可變更新邏輯過程的函式庫。

Immer 提供了一個名為 produce 的函式,它接受兩個引數:你的原始 state 和一個回呼函式。回呼函式會收到該狀態的「草稿」版本,在回呼函式中,撰寫變異草稿值的程式碼是安全的。Immer 會追蹤所有變異草稿值的嘗試,然後使用其不可變等價項重播這些變異,以建立一個安全且不可變更新的結果

import produce from 'immer'

const baseState = [
{
todo: 'Learn typescript',
done: true,
},
{
todo: 'Try immer',
done: false,
},
]

const nextState = produce(baseState, (draftState) => {
// "mutate" the draft array
draftState.push({ todo: 'Tweet about it' })
// "mutate" the nested state
draftState[1].done = true
})

console.log(baseState === nextState)
// false - the array was copied
console.log(baseState[0] === nextState[0])
// true - the first item was unchanged, so same reference
console.log(baseState[1] === nextState[1])
// false - the second item was copied and updated

Redux Toolkit 和 Immer

Redux Toolkit 的 createReducer API 會在內部自動使用 Immer。因此,在傳遞給 createReducer 的任何 case reducer 函式中「變異」狀態是安全的

const todosReducer = createReducer([], (builder) => {
builder.addCase('todos/todoAdded', (state, action) => {
// "mutate" the array by calling push()
state.push(action.payload)
})
})

反過來,createSlice 會在內部使用 createReducer,因此在那裡「變異」狀態也是安全的

const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded(state, action) {
state.push(action.payload)
},
},
})

即使 case reducer 函式定義在 createSlice/createReducer 呼叫之外,這也適用。例如,你可以有一個可重複使用的 case reducer 函式,它預期會「變異」其狀態,並視需要包含它

const addItemToArray = (state, action) => {
state.push(action.payload)
}

const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded: addItemToArray,
},
})

這是因為「變異」邏輯在執行時會內部封裝在 Immer 的 produce 方法中。

注意

請記住,「變異」邏輯只有在封裝在 Immer 中時才能正確運作!否則,該程式碼真的會變異資料。

Immer 使用模式

在 Redux Toolkit 中使用 Immer 時,有幾個有用的模式需要了解,以及需要注意的陷阱。

變異並傳回狀態

Immer 的運作方式是追蹤嘗試變異現有草稿狀態值,無論是透過指定到巢狀欄位或呼叫變異該值的函式。這表示state 必須是 JS 物件或陣列,Immer 才能看到嘗試的變更。(切片狀態仍然可以是原始值,例如字串或布林,但由於原始值永遠無法變異,所以你只能傳回新值。)

在任何給定的簡化器中,Immer 預期你會變異現有狀態,自己建構新的狀態值並傳回,但不要在同一個函式中同時進行這兩個動作!例如,這兩個都是使用 Immer 的有效簡化器

const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded(state, action) {
// "Mutate" the existing state, no return value needed
state.push(action.payload)
},
todoDeleted(state, action.payload) {
// Construct a new result array immutably and return it
return state.filter(todo => todo.id !== action.payload)
}
}
})

然而,可以使用不可變更新來完成部分工作,然後透過「變異」來儲存結果。這方面的範例可能是過濾巢狀陣列

const todosSlice = createSlice({
name: 'todos',
initialState: {todos: [], status: 'idle'}
reducers: {
todoDeleted(state, action.payload) {
// Construct a new array immutably
const newTodos = state.todos.filter(todo => todo.id !== action.payload)
// "Mutate" the existing state to save the new array
state.todos = newTodos
}
}
})

請注意,在具有隱式傳回值的箭頭函式中變異狀態會違反此規則並導致錯誤!這是因為陳述式和函式呼叫可能會傳回值,而 Immer 會看到嘗試的變異新的傳回值,並不知道要將哪一個用作結果。一些可能的解決方案是使用 void 關鍵字來略過傳回值,或使用大括號來提供箭頭函式一個主體,而沒有傳回值

const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
// ❌ ERROR: mutates state, but also returns new array size!
brokenReducer: (state, action) => state.push(action.payload),
// ✅ SAFE: the `void` keyword prevents a return value
fixedReducer1: (state, action) => void state.push(action.payload),
// ✅ SAFE: curly braces make this a function body and no return
fixedReducer2: (state, action) => {
state.push(action.payload)
},
},
})

雖然撰寫巢狀不可變更新邏輯很困難,但有時執行物件擴充運算來一次更新多個欄位會比較簡單,而不是指定個別欄位

function objectCaseReducer1(state, action) {
const { a, b, c, d } = action.payload
return {
...state,
a,
b,
c,
d,
}
}

function objectCaseReducer2(state, action) {
const { a, b, c, d } = action.payload
// This works, but we keep having to repeat `state.x =`
state.a = a
state.b = b
state.c = c
state.d = d
}

或者,你可以使用 Object.assign 一次變更多個欄位,因為 Object.assign 永遠會變更給它的第一個物件

function objectCaseReducer3(state, action) {
const { a, b, c, d } = action.payload
Object.assign(state, { a, b, c, d })
}

重設和取代狀態

有時候你可能想要取代整個現有的 state,可能是因為你載入了一些新資料,或者你想要將狀態重設回其初始值。

危險

一個常見錯誤是嘗試直接指定 state = someValue。這不會成功!這只會將本機 state 變數指向不同的參照。這既不會變更現有的 state 物件/陣列於記憶體中,也不會傳回一個全新的值,所以 Immer 沒有做出任何實際變更。

相反地,要取代現有的狀態,你應該直接傳回新值

const initialState = []
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
brokenTodosLoadedReducer(state, action) {
// ❌ ERROR: does not actually mutate or return anything new!
state = action.payload
},
fixedTodosLoadedReducer(state, action) {
// ✅ CORRECT: returns a new value to replace the old one
return action.payload
},
correctResetTodosReducer(state, action) {
// ✅ CORRECT: returns a new value to replace the old one
return initialState
},
},
})

除錯和檢查已撰寫的狀態

通常會想要記錄 reducer 中正在進行的狀態,以查看它在更新時的外觀,例如 console.log(state)。不幸的是,瀏覽器會以難以閱讀或理解的格式顯示已記錄的 Proxy 執行個體

Logged proxy draft

為了解決這個問題,Immer 包含一個 current 函式,用於萃取已包裝資料的副本,而 RTK 重新匯出 current。如果你需要記錄或檢查正在進行的狀態,可以在 reducer 中使用這個函式

import { current } from '@reduxjs/toolkit'

const todosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState(),
reducers: {
todoToggled(state, action) {
// ❌ ERROR: logs the Proxy-wrapped data
console.log(state)
// ✅ CORRECT: logs a plain JS copy of the current data
console.log(current(state))
},
},
})

正確的輸出應該如下所示

Logged current value

Immer 也提供 originalisDraft 函式,用於擷取未套用任何更新的原始資料,並檢查給定的值是否為 Proxy 包裝的草稿。從 RTK 1.5.1 開始,這兩個函式也從 RTK 重新匯出。

更新巢狀資料

Immer 大幅簡化了更新巢狀資料。巢狀物件和陣列也會包裝在 Proxy 中並撰寫草稿,而且可以安全地將巢狀值拉出到其自己的變數中,然後變更它。

不過,這仍然只適用於物件和陣列。如果我們將基本值拉出到其自己的變數中並嘗試更新它,Immer 沒有東西可以包裝,也無法追蹤任何更新

const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
brokenTodoToggled(state, action) {
const todo = state.find((todo) => todo.id === action.payload)
if (todo) {
// ❌ ERROR: Immer can't track updates to a primitive value!
let { completed } = todo
completed = !completed
}
},
fixedTodoToggled(state, action) {
const todo = state.find((todo) => todo.id === action.payload)
if (todo) {
// ✅ CORRECT: This object is still wrapped in a Proxy, so we can "mutate" it
todo.completed = !todo.completed
}
},
},
})

這裡有一個陷阱。Immer 不會包裝新插入到狀態中的物件。大多數時候這並不重要,但有時你可能想要插入一個值,然後對其進行進一步更新。

與此相關,RTK 的 createEntityAdapter 更新函式 可用作獨立的 reducer 或「變異」更新函式。這些函式透過檢查所提供的狀態是否封裝在草稿中,來決定是否「變異」或傳回新值。如果您在案例 reducer 內部自行呼叫這些函式,請務必知道您傳遞給它們的是草稿值還是純值。

最後,值得注意的是,Immer 不會 自動為您建立巢狀物件或陣列 - 您必須自行建立。例如,假設我們有一個包含巢狀陣列的查詢表,並且我們想要在其中一個陣列中插入項目。如果我們無條件嘗試插入,而沒有檢查該陣列是否存在,則當陣列不存在時,邏輯將會崩潰。相反地,您需要先確保陣列存在。

const itemsSlice = createSlice({
name: 'items',
initialState: { a: [], b: [] },
reducers: {
brokenNestedItemAdded(state, action) {
const { id, item } = action.payload
// ❌ ERROR: will crash if no array exists for `id`!
state[id].push(item)
},
fixedNestedItemAdded(state, action) {
const { id, item } = action.payload
// ✅ CORRECT: ensures the nested array always exists first
if (!state[id]) {
state[id] = []
}

state[id].push(item)
},
},
})

狀態變異的 Linting

許多 ESLint 設定檔包含 https://eslint.dev.org.tw/docs/rules/no-param-reassign 規則,它也可能會針對巢狀欄位的變異發出警告。這可能會導致該規則針對 Immer 強化的 reducer 中的 state 變異發出警告,這沒有幫助。

若要解決此問題,您可以告訴 ESLint 規則僅在區段檔案中忽略變異和指派到名為 state 的參數。

// @filename .eslintrc.js
module.exports = {
// add to your ESLint config definition
overrides: [
{
// feel free to replace with your preferred file pattern - eg. 'src/**/*Slice.ts'
files: ['src/**/*.slice.ts'],
// avoid state param assignment
rules: { 'no-param-reassign': ['error', { props: false }] },
},
],
}

為何 Immer 是內建的

我們收到許多要求,希望將 Immer 設為 RTK 的 createSlicecreateReducer API 的選用部分,而不是嚴格要求。

我們的回答始終如一:Immer RTK 中必要的,而且不會改變。

值得探討我們為何認為 Immer 是 RTK 的重要部分,以及我們為何不會讓它成為選用的原因。

Immer 的好處

Immer 有兩個主要好處。首先,Immer 大幅簡化了不可變更新邏輯適當的不可變更新非常冗長。這些冗長的運算整體而言難以閱讀,而且會模糊更新陳述的實際意圖。Immer 消除了所有巢狀展開和陣列切片。程式碼不僅更短且更容易閱讀,而且實際更新應該發生的情況也更清楚。

其次,正確撰寫不可變更新很困難,而且很容易出錯(例如忘記在物件展開集中複製巢狀層級、複製頂層陣列而不是陣列中要更新的項目,或忘記 array.sort() 會改變陣列)。這是 意外變異一直是 Redux 錯誤最常見的原因 的原因之一。Immer 有效地消除了意外變異。不僅沒有更多可能寫錯的展開運算,Immer 也會自動凍結狀態。如果您意外變異,即使在簡約器之外,這也會導致錯誤拋出。消除了 Redux 錯誤的頭號原因是一項巨大的改進。

此外,RTK Query 使用 Immer 的修補功能來啟用 樂觀更新和手動快取更新

權衡和疑慮

與任何工具一樣,使用 Immer 確實有權衡,而使用者對其使用表達了許多疑慮。

Immer 確實會增加整體應用程式套件大小。它大約是 8K 最小值,3.3K 最小值 + gz(參考:Immer 文件:安裝Bundle.js.org 分析)。然而,這個程式庫套件大小會透過縮減應用程式中的簡約器邏輯數量來自行支付。此外,更易於閱讀的程式碼和消除變異錯誤的好處是值得的。

Immer 也會在執行期間效能上增加一些額外負擔。不過,根據 Immer 的「效能」文件頁面,額外負擔在實際上並無意義。此外,在 Redux 應用程式中,reducer 幾乎從來都不是效能瓶頸。相反地,更新 UI 的成本更為重要。

因此,雖然使用 Immer 並非「免費」,但套件和效能成本小到值得使用。

使用 Immer 最實際的痛點在於瀏覽器偵錯工具會以令人困惑的方式顯示 Proxy,這使得在偵錯時難以檢查狀態變數。這當然令人困擾。不過,這並未實際影響執行期間行為,而且我們已經記錄了使用 current 來建立資料的可視化純 JS 版本,如本頁上方所述。(由於 Mobx 和 Vue 3 等函式庫將 Proxy 用於越來越廣泛,因此這也不僅限於 Immer。)

另一個問題是教育和理解。Redux 一直要求 reducer 具有不變性,因此看到「變異」程式碼可能會令人困惑。新的 Redux 使用者很可能會在範例程式碼中看到這些「變異」,假設這是 Redux 使用的常態,然後試圖在 createSlice 外部執行相同的操作。這確實會造成真正的變異和錯誤,因為這超出了 Immer 包裝更新的能力範圍。

我們透過在我們的文件檔中反覆強調不變性的重要性來解決這個問題,包括多個重點部分強調「變異」之所以有效,僅歸功於 Immer 內部的「魔法」,並新增了您現在正在閱讀的這個特定文件檔頁面。

架構和意圖

Immer 不是選配項的還有兩個原因。

一個是 RTK 的架構。createSlicecreateReducer 是透過直接匯入 Immer 來實作的。沒有簡單的方法可以建立它們的版本,並具有假設性的 immer: false 選項。您無法執行選用匯入,而且我們需要在應用程式的初始載入期間立即且同步地取得 Immer。

最後:Immer 預設內建於 RTK,因為我們相信它是我們使用者最佳的選擇!我們希望我們的使用者使用 Immer,並將其視為 RTK 的關鍵不可協商組成部分。更簡單的 reducer 程式碼和防止意外變異等優點遠遠大於相對較小的疑慮。

進一步資訊

請參閱Immer 文件檔,以取得 Immer 的 API、臨界狀況和行為的更多詳細資訊。

有關 Immer 為何為必要性的歷史討論,請參閱下列議題