跳至主要內容

Redux 常見問題:不變資料

目錄

不變性的好處是什麼?

不變性可以提升應用程式的效能,並簡化程式設計和除錯,因為永遠不會變更的資料比可以在應用程式中任意變更的資料更容易理解。

特別是,在 Web 應用程式的脈絡中,不變性讓精密的變更偵測技術可以簡單且低成本地實作,確保計算成本高的 DOM 更新只會在絕對必要時發生(這是 React 相較於其他函式庫效能改善的基石)。

進一步資訊

文章

為什麼 Redux 需要不變性?

進一步資訊

文件

討論

為什麼 Redux 使用淺層相等性檢查需要不變性?

如果要正確更新任何已連線的元件,Redux 使用淺層相等性檢查需要不變性。要了解原因,我們需要了解 JavaScript 中淺層和深層相等性檢查的差異。

淺層和深層相等性檢查有何不同?

淺層相等性檢查(或參考相等性)僅檢查兩個不同的變數是否參照同一個物件;相反地,深層相等性檢查(或值相等性)必須檢查兩個物件屬性的每個

因此,淺層相等性檢查就像 a === b 一樣簡單(且快速),而深層相等性檢查則涉及遞迴遍歷兩個物件的屬性,並在每個步驟中比較每個屬性的值。

Redux 使用淺層相等性檢查就是為了提升效能。

進一步資訊

文章

Redux 如何使用淺層相等性檢查?

Redux 在其 combineReducers 函式中使用淺層相等性檢查,以傳回根狀態物件的新變異副本,或者如果沒有進行任何變異,則傳回目前的根狀態物件。

進一步資訊

文件

combineReducers 如何使用淺層相等性檢查?

Redux 儲存的建議結構是將狀態物件依據金鑰分割成多個「區塊」或「網域」,並提供一個獨立的 reducer 函式來管理每個個別的資料區塊。

combineReducers 透過取得定義為包含一組金鑰/值對的雜湊表的 reducers 參數,讓使用這種結構樣式變得更容易,其中每個金鑰都是狀態區塊的名稱,而對應的值則是將作用於該區塊的 reducer 函式。

因此,例如,如果您的狀態形狀為 { todos, counter },則呼叫 combineReducers 的方式如下:

combineReducers({ todos: myTodosReducer, counter: myCounterReducer })

其中

  • 金鑰 todoscounter 各自參照一個獨立的狀態區塊;
  • myTodosReducermyCounterReducer 是 reducer 函式,每個函式作用於由各自金鑰識別的狀態區塊。

combineReducers 會遍歷這些金鑰/值對中的每一個。對於每個反覆運算,它會

  • 建立對每個金鑰參照的目前狀態區塊的參照;
  • 呼叫適當的 reducer 並傳遞區塊給它;
  • 建立一個參考,指向 reducer 傳回的可能已變異的狀態片段。

在繼續進行反覆運算時,combineReducers 會建構一個新的狀態物件,其中包含每個 reducer 傳回的狀態片段。這個新的狀態物件可能與目前的狀態物件不同,也可能相同。combineReducers 在這裡會使用淺層相等性檢查來判斷狀態是否已改變。

更具體來說,在反覆運算的每個階段,combineReducers 會對目前的狀態片段和 reducer 傳回的狀態片段執行淺層相等性檢查。如果 reducer 傳回一個新的物件,淺層相等性檢查就會失敗,而 combineReducers 會將 hasChanged 旗標設定為 true。

在反覆運算完成後,combineReducers 會檢查 hasChanged 旗標的狀態。如果為 true,就會傳回新建構的狀態物件。如果為 false,就會傳回目前的狀態物件。

這一點值得強調:如果所有 reducer 都傳回傳遞給它們的相同 state 物件,則 combineReducers 會傳回目前的根狀態物件,而不是新更新的物件。

進一步資訊

文件

影片

React-Redux 如何使用淺層相等性檢查?

React-Redux 使用淺層相等性檢查來判斷它封裝的元件是否需要重新渲染。

為此,它假設封裝的元件是純元件;也就是說,這個元件會產生相同的結果,給定相同的 props 和狀態

假設包裝的元件是純元件,它只需要檢查根狀態物件或從 mapStateToProps 回傳的值是否已變更。如果沒有變更,則包裝的元件不需要重新渲染。

它會保留根狀態物件的參考,以及從 mapStateToProps 函式回傳的 props 物件中每個值的參考,藉此來偵測變更。

然後,它會對根狀態物件的參考和傳遞給它的狀態物件執行淺層相等性檢查,並對 props 物件值的每個參考和再次執行 mapStateToProps 函式所回傳的值執行一系列獨立的淺層檢查。

進一步資訊

文件

文章

為什麼 React-Redux 會淺層檢查從 mapStateToProp 回傳的 props 物件中的每個值?

React-Redux 會對 props 物件中的每個執行淺層相等性檢查,而不是 props 物件本身。

它這樣做是因為 props 物件實際上是 prop 名稱和其值的雜湊(或用於擷取或產生值的選擇器函式),例如以下範例

function mapStateToProps(state) {
return {
todos: state.todos, // prop value
visibleTodos: getVisibleTodos(state) // selector
}
}

export default connect(mapStateToProps)(TodoApp)

因此,對從重複呼叫 mapStateToProps 回傳的 props 物件進行淺層相等性檢查將總是失敗,因為每次都會回傳新的物件。

因此,React-Redux 會對回傳的 props 物件中的每個保留獨立的參考。

進一步資訊

文章

React-Redux 如何使用淺層相等性檢查來判斷元件是否需要重新渲染?

每次呼叫 React-Redux 的 connect 函式時,它會對其儲存的根狀態物件參考和從儲存體傳遞給它的目前根狀態物件執行淺層相等性檢查。如果檢查通過,表示根狀態物件尚未更新,因此不需要重新渲染元件,甚至不需要呼叫 mapStateToProps

然而,如果檢查失敗,根狀態物件已經更新,因此 connect 會呼叫 mapStateToProps,以查看已封裝元件的 props 是否已更新。

它會執行此動作,藉由個別對物件中的每個值執行淺層相等性檢查,而且只有在其中一個檢查失敗時,才會觸發重新呈現。

在下列範例中,如果 state.todos 和從 getVisibleTodos() 傳回的值在連續呼叫 connect 時沒有變更,則元件不會重新呈現。

function mapStateToProps(state) {
return {
todos: state.todos, // prop value
visibleTodos: getVisibleTodos(state) // selector
}
}

export default connect(mapStateToProps)(TodoApp)

相反地,在這個下一個範例(如下方)中,元件永遠會重新呈現,因為 todos 的值永遠都是一個新的物件,不論其值是否變更。

// AVOID - will always cause a re-render
function mapStateToProps(state) {
return {
// todos always references a newly-created object
todos: {
all: state.todos,
visibleTodos: getVisibleTodos(state)
}
}
}

export default connect(mapStateToProps)(TodoApp)

如果從 mapStateToProps 傳回的新值與 React-Redux 保留參考的先前值之間的淺層相等性檢查失敗,則會觸發元件的重新呈現。

進一步資訊

文章

討論

為什麼淺層相等性檢查無法對可變物件運作?

如果物件是可變的,則無法使用淺層相等性檢查來偵測函式是否會變異傳入的物件。

這是因為參照相同物件的兩個變數永遠會相等,不論物件的值是否變更,因為它們都參照相同的物件。因此,下列程式碼永遠會傳回 true

function mutateObj(obj) {
obj.key = 'newValue'
return obj
}

const param = { key: 'originalValue' }
const returnVal = mutateObj(param)

param === returnVal
//> true

paramreturnValue 的淺層檢查只檢查兩個變數是否參照相同的物件,而它們的確參照相同的物件。mutateObj() 可能傳回 obj 的變異版本,但它仍然與傳入的物件相同。其值在 mutateObj 中已變更的事實,對淺層檢查完全不重要。

進一步資訊

文章

使用可變物件進行淺層相等性檢查是否會對 Redux 造成問題?

使用可變物件進行淺層相等性檢查不會對 Redux 造成問題,但 它會對依賴儲存體的函式庫(例如 React-Redux)造成問題

具體來說,如果 combineReducers 傳遞給 reducer 的狀態區段是可變物件,reducer 可以直接修改它並傳回它。

如果它這樣做,combineReducers 執行的淺層相等性檢查將始終通過,因為 reducer 傳回的狀態區段的值可能已經變異,但物件本身沒有變異 - 它仍然是傳遞給 reducer 的同一個物件。

因此,combineReducers 即使狀態已經改變,也不會設定其 hasChanged 旗標。如果其他 reducer 沒有傳回新的、已更新的狀態區段,hasChanged 旗標將保持設為 false,導致 combineReducers 傳回現有的根狀態物件。

儲存體仍會使用根狀態的新值更新,但由於根狀態物件本身仍然是同一個物件,因此繫結到 Redux 的函式庫(例如 React-Redux)不會察覺狀態的變異,因此不會觸發包裝元件的重新渲染。

進一步資訊

文件

為什麼 reducer 變異狀態會阻止 React-Redux 重新渲染包裝元件?

如果 Redux reducer 直接變異並傳回傳遞給它的狀態物件,根狀態物件的值將會改變,但物件本身不會改變。

由於 React-Redux 對根狀態物件執行淺層檢查,以確定其包裝元件是否需要重新渲染,因此它無法偵測到狀態變異,因此不會觸發重新渲染。

進一步資訊

文件

為什麼一個選擇器變異並傳回一個持續性物件至 mapStateToProps 會阻止 React-Redux 重新渲染一個包裝的元件?

如果從 mapStateToProps 傳回的 props 物件值之一是一個持續存在於對 connect 的呼叫中的物件(例如,可能是根狀態物件),但卻由一個選擇器函式直接變異並傳回,React-Redux 將無法偵測到變異,因此不會觸發重新渲染包裝的元件。

正如我們所見,由選擇器函式傳回的可變物件中的值可能已變更,但物件本身並未變更,而淺層相等性檢查只會比較物件本身,不會比較它們的值。

例如,下列 mapStateToProps 函式將永遠不會觸發重新渲染

// State object held in the Redux store
const state = {
user: {
accessCount: 0,
name: 'keith'
}
}

// Selector function
const getUser = state => {
++state.user.accessCount // mutate the state object
return state
}

// mapStateToProps
const mapStateToProps = state => ({
// The object returned from getUser() is always
// the same object, so this wrapped
// component will never re-render, even though it's been
// mutated
userRecord: getUser(state)
})

const a = mapStateToProps(state)
const b = mapStateToProps(state)

a.userRecord === b.userRecord
//> true

請注意,相反地,如果使用一個不可變物件,元件可能會在不應該重新渲染時重新渲染

進一步資訊

文章

討論

不可變性如何讓淺層檢查偵測到物件變異?

如果一個物件是不可變的,函式中需要對它進行的任何變更都必須對物件的副本進行。

這個變異的副本是一個獨立的物件,與傳遞至函式的物件不同,因此當它被傳回時,淺層檢查會將它識別為與傳入的物件不同的物件,因此會失敗。

進一步資訊

文章

簡約器中的不可變性如何導致元件不必要地渲染?

您無法變異一個不可變的物件;相反地,您必須變異它的副本,讓原始物件保持完整。

當您變異副本時,這完全沒問題,但在簡約器的背景下,如果您傳回一個變異的副本,Redux 的 combineReducers 函式仍會認為狀態需要更新,因為您傳回了一個與傳入的狀態切片物件完全不同的物件。

combineReducers 然後會將這個新的根狀態物件傳回至儲存。新的物件會與目前的根狀態物件有相同的值,但因為它是一個不同的物件,它會導致儲存被更新,這最終會導致所有連接的元件不必要地重新渲染。

為防止這種情況發生,您必須始終傳回傳遞至簡約器的狀態切片物件,如果簡約器沒有變異狀態。

進一步資訊

文章

mapStateToProps 中的不變性如何導致元件不必要地重新渲染?

某些不變性操作,例如陣列篩選,即使值本身沒有變更,也會永遠傳回新的物件。

如果此類操作用於 mapStateToProps 中的選取器函式,React-Redux 對傳回的 props 物件中每個值執行的淺層相等性檢查將永遠失敗,因為選取器每次都傳回新的物件。

因此,即使新物件的值沒有變更,封裝的元件仍會永遠重新渲染,

例如,下列範例將永遠觸發重新渲染

// A JavaScript array's 'filter' method treats the array as immutable,
// and returns a filtered copy of the array.
const getVisibleTodos = todos => todos.filter(t => !t.completed)

const state = {
todos: [
{
text: 'do todo 1',
completed: false
},
{
text: 'do todo 2',
completed: true
}
]
}

const mapStateToProps = state => ({
// getVisibleTodos() always returns a new array, and so the
// 'visibleToDos' prop will always reference a different array,
// causing the wrapped component to re-render, even if the array's
// values haven't changed
visibleToDos: getVisibleTodos(state.todos)
})

const a = mapStateToProps(state)
// Call mapStateToProps(state) again with exactly the same arguments
const b = mapStateToProps(state)

a.visibleToDos
//> { "completed": false, "text": "do todo 1" }

b.visibleToDos
//> { "completed": false, "text": "do todo 1" }

a.visibleToDos === b.visibleToDos
//> false

請注意,反過來說,如果 props 物件中的值參照可變物件,元件可能不會在應該渲染時渲染

進一步資訊

文章

有哪些處理資料不變性的方法?我一定要使用 Immer 嗎?

您不需要在 Redux 中使用 Immer。如果正確撰寫,純粹的 JavaScript 完全有能力提供不變性,而不需要使用以不變性為重點的函式庫。

但是,使用 JavaScript 保證不變性很困難,而且很容易意外地變異物件,導致應用程式中極難找到的錯誤。因此,使用 Immer 等不變性更新公用程式函式庫可以大幅改善應用程式的可靠性,並讓應用程式的開發變得容易許多。

進一步資訊

討論

使用純粹 JavaScript 進行不變性操作有哪些問題?

JavaScript 從未設計為提供保證的不變性操作。因此,如果您選擇在 Redux 應用程式中使用它進行不變性操作,您需要知道幾個問題。

意外的物件變異

使用 JavaScript 時,你很容易在不知不覺中意外地變異物件(例如 Redux 狀態樹)。例如,更新深度巢狀屬性、建立物件的新參考而不是新物件,或執行淺層複製而不是深度複製,都可能導致無意的物件變異,甚至可能讓經驗最豐富的 JavaScript 程式設計師也感到棘手。

為避免這些問題,請務必遵循建議的不可變更新模式

冗長的程式碼

更新複雜的巢狀狀態樹可能會導致冗長的程式碼,而這些程式碼既難以撰寫又難以除錯。

效能不佳

以不可變的方式操作 JavaScript 物件和陣列可能會很慢,尤其是當你的狀態樹變大時。

請記住,要變更不可變物件,你必須變異其副本,而複製大型物件可能會很慢,因為必須複製每個屬性。

相反地,例如 Immer 等不可變函式庫可以使用結構共享,這會有效地傳回一個新物件,並重複使用從中複製的大部分現有物件。

進一步資訊

文件

文章