跳至主要內容

createEntityAdapter

概觀

一個函式,用於產生一組預建的 reducer 和 selector,以在包含特定類型資料物件實例的正規化狀態結構上執行 CRUD 作業。這些 reducer 函式可以作為案例 reducer 傳遞給 createReducercreateSlice。它們也可以在 createReducercreateSlice 中當作「變異」輔助函式使用。

此 API 移植自 NgRx 維護人員建立的 @ngrx/entity 函式庫,但已大幅修改以搭配 Redux Toolkit 使用。我們要感謝 NgRx 團隊最初建立此 API,並允許我們移植和調整以符合我們的需求。

備註

「實體」一詞用於指稱應用程式中獨特的資料物件類型。例如,在部落格應用程式中,您可能有 UserPostComment 資料物件,其中許多實例儲存在用戶端並保留在伺服器上。User 是「實體」,也就是應用程式使用的獨特資料物件類型。假設實體的每個唯一實例在特定欄位中都有唯一的 ID 值。

與所有 Redux 邏輯一樣,純 JS 物件和陣列應傳遞到儲存體中 - 不包含類別實例!

在本參考中,我們將使用 Entity 指稱 Redux 狀態樹特定區段中由還原器邏輯副本管理的特定資料類型,並使用 entity 指稱該類型的單一實例。範例:在 state.users 中,Entity 指稱 User 類型,而 state.users.entities[123] 會是單一 entity

createEntityAdapter 產生的方法都會操作看起來像這樣的「實體狀態」結構

{
// The unique IDs of each item. Must be strings or numbers
ids: []
// A lookup table mapping entity IDs to the corresponding entity objects
entities: {
}
}

createEntityAdapter 可以在應用程式中呼叫多次。如果您使用純 JavaScript,您可能會在多個實體類型中重複使用單一適配器定義,只要它們夠相似(例如,都有一個 entity.id 欄位)。對於 TypeScript 使用,您需要為每個不同的 Entity 類型分別呼叫 createEntityAdapter,才能正確推斷類型定義。

範例用法

import {
createEntityAdapter,
createSlice,
configureStore,
} from '@reduxjs/toolkit'

type Book = { bookId: string; title: string }

const booksAdapter = createEntityAdapter({
// Assume IDs are stored in a field other than `book.id`
selectId: (book: Book) => book.bookId,
// Keep the "all IDs" array sorted based on book titles
sortComparer: (a, b) => a.title.localeCompare(b.title),
})

const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState(),
reducers: {
// Can pass adapter functions directly as case reducers. Because we're passing this
// as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
bookAdded: booksAdapter.addOne,
booksReceived(state, action) {
// Or, call them as "mutating" helpers in a case reducer
booksAdapter.setAll(state, action.payload.books)
},
},
})

const store = configureStore({
reducer: {
books: booksSlice.reducer,
},
})

type RootState = ReturnType<typeof store.getState>

console.log(store.getState().books)
// { ids: [], entities: {} }

// Can create a set of memoized selectors based on the location of this entity state
const booksSelectors = booksAdapter.getSelectors<RootState>(
(state) => state.books
)

// And then use the selectors to retrieve values
const allBooks = booksSelectors.selectAll(store.getState())

參數

createEntityAdapter 接受單一選項物件參數,其中包含兩個選用欄位。

selectId

接受單一 Entity 執行個體的函式,並傳回其中任何唯一 ID 欄位的數值。如果未提供,預設實作為 entity => entity.id。如果您的 Entity 類型將其唯一 ID 數值保留在 entity.id 以外的欄位中,您必須提供 selectId 函式。

sortComparer

接受兩個 Entity 執行個體的回呼函式,並應傳回標準 Array.sort() 數值結果 (1、0、-1) 以指出其相對排序順序。

如果提供,state.ids 陣列將根據實體物件的比較結果保持已排序順序,以便對 ID 陣列進行對應以透過 ID 擷取實體,應會產生已排序的實體陣列。

如果未提供,state.ids 陣列將不會排序,且不保證排序順序。換句話說,state.ids 可望像標準 JavaScript 陣列一樣運作。

請注意,只有透過下方其中一個 CRUD 函式變更狀態時,才會啟動排序 (例如,addOne()updateMany())。

傳回值

「實體配接器」執行個體。實體配接器是純 JS 物件 (非類別),包含已產生的 reducer 函式、最初提供的 selectIdsortComparer 回呼函式、產生初始「實體狀態」數值的方法,以及產生一組針對此實體類型的全域化和非全域化記憶化選擇器函式的函式。

配接器執行個體將包含下列方法 (包含額外的參考 TypeScript 類型)

export type EntityId = number | string

export type Comparer<T> = (a: T, b: T) => number

export type IdSelector<T> = (model: T) => EntityId

export type Update<T> = { id: EntityId; changes: Partial<T> }

export interface EntityState<T> {
ids: EntityId[]
entities: Record<EntityId, T>
}

export interface EntityDefinition<T> {
selectId: IdSelector<T>
sortComparer: false | Comparer<T>
}

export interface EntityStateAdapter<T> {
addOne<S extends EntityState<T>>(state: S, entity: T): S
addOne<S extends EntityState<T>>(state: S, action: PayloadAction<T>): S

addMany<S extends EntityState<T>>(state: S, entities: T[]): S
addMany<S extends EntityState<T>>(state: S, entities: PayloadAction<T[]>): S

setOne<S extends EntityState<T>>(state: S, entity: T): S
setOne<S extends EntityState<T>>(state: S, action: PayloadAction<T>): S

setMany<S extends EntityState<T>>(state: S, entities: T[]): S
setMany<S extends EntityState<T>>(state: S, entities: PayloadAction<T[]>): S

setAll<S extends EntityState<T>>(state: S, entities: T[]): S
setAll<S extends EntityState<T>>(state: S, entities: PayloadAction<T[]>): S

removeOne<S extends EntityState<T>>(state: S, key: EntityId): S
removeOne<S extends EntityState<T>>(state: S, key: PayloadAction<EntityId>): S

removeMany<S extends EntityState<T>>(state: S, keys: EntityId[]): S
removeMany<S extends EntityState<T>>(
state: S,
keys: PayloadAction<EntityId[]>,
): S

removeAll<S extends EntityState<T>>(state: S): S

updateOne<S extends EntityState<T>>(state: S, update: Update<T>): S
updateOne<S extends EntityState<T>>(
state: S,
update: PayloadAction<Update<T>>,
): S

updateMany<S extends EntityState<T>>(state: S, updates: Update<T>[]): S
updateMany<S extends EntityState<T>>(
state: S,
updates: PayloadAction<Update<T>[]>,
): S

upsertOne<S extends EntityState<T>>(state: S, entity: T): S
upsertOne<S extends EntityState<T>>(state: S, entity: PayloadAction<T>): S

upsertMany<S extends EntityState<T>>(state: S, entities: T[]): S
upsertMany<S extends EntityState<T>>(
state: S,
entities: PayloadAction<T[]>,
): S
}

export interface EntitySelectors<T, V> {
selectIds: (state: V) => EntityId[]
selectEntities: (state: V) => Record<EntityId, T>
selectAll: (state: V) => T[]
selectTotal: (state: V) => number
selectById: (state: V, id: EntityId) => T | undefined
}

export interface EntityAdapter<T> extends EntityStateAdapter<T> {
selectId: IdSelector<T>
sortComparer: false | Comparer<T>
getInitialState(): EntityState<T>
getInitialState<S extends object>(state: S): EntityState<T> & S
getSelectors(): EntitySelectors<T, EntityState<T>>
getSelectors<V>(
selectState: (state: V) => EntityState<T>,
): EntitySelectors<T, V>
}

CRUD 函式

實體配接器的主要內容是一組已產生的 reducer 函式,用於將實體執行個體新增至實體狀態物件、更新實體狀態物件,以及從實體狀態物件中移除實體執行個體

  • addOne:接受單一實體,並在實體尚未存在時新增實體。
  • addMany:接受陣列形式的實體或 Record<EntityId, T> 形狀的物件,並在實體尚未存在時新增實體。
  • setOne:接受單一實體並新增或取代它
  • setMany:接受實體陣列或形狀為 Record<EntityId, T> 的物件,並新增或取代它們。
  • setAll:接受實體陣列或形狀為 Record<EntityId, T> 的物件,並用陣列中的值取代所有現有實體。
  • removeOne:接受單一實體 ID 值,並移除具有該 ID 的實體(如果存在)。
  • removeMany:接受實體 ID 值陣列,並移除具有這些 ID 的每個實體(如果存在)。
  • removeAll:從實體狀態物件中移除所有實體。
  • updateOne:接受包含實體 ID 和包含一個或多個要更新的新欄位值的物件(在 changes 欄位中),並對應實體執行淺層更新。
  • updateMany:接受更新物件陣列,並對所有對應實體執行淺層更新。
  • upsertOne:接受單一實體。如果具有該 ID 的實體存在,它將執行淺層更新,且指定的欄位將合併到現有實體中,任何匹配的欄位都會覆寫現有值。如果實體不存在,它將被新增。
  • upsertMany:接受形狀為 Record<EntityId, T> 的實體陣列或物件,將淺層更新。
我應該新增、設定或更新我的實體?

所有這三個選項都會將新的實體插入清單中。然而,它們在處理已存在的實體方式上有所不同。如果實體已存在

  • addOneaddMany 將不會對新實體做任何事
  • setOnesetMany 將完全用新的實體取代舊實體。這也會移除實體上任何在該實體的新版本中不存在的屬性。
  • upsertOneupsertMany 會進行淺層複製,以合併舊有和新的實體,覆寫現有值,新增任何不存在的值,且不變更新實體中未提供的屬性。

每個方法都有類似 {ids: [], entities: {}} 的簽章,並計算並傳回新的狀態。

;(state: EntityState<T>, argument: TypeOrPayloadAction<Argument<T>>) =>
EntityState<T>

換句話說,它們接受類似 {ids: [], entities: {}} 的狀態,並計算並傳回新的狀態。

這些 CRUD 方法可以用多種方式

  • 它們可以作為案例還原器直接傳遞給 createReducercreateSlice
  • 手動呼叫時,它們可以用作「變異」輔助方法,例如在現有的案例還原器內部單獨手寫呼叫 addOne(),如果 state 參數實際上是 Immer Draft 值。
  • 手動呼叫時,它們可以用作不可變更新方法,如果 state 參數實際上是純 JS 物件或陣列。
備註

這些方法沒有對應的 Redux 動作建立 - 它們只是獨立的還原器/更新邏輯。由您完全決定在哪裡以及如何使用這些方法!大多數時候,您會想要將它們傳遞給 createSlice 或在另一個還原器內部使用它們。

每個方法都會檢查 state 參數是否為 Immer Draft。如果是草稿,該方法將假設繼續變異該草稿是安全的。如果不是草稿,該方法將傳遞純 JS 值給 Immer 的 createNextState(),並傳回不可變更新的結果值。

argument 可以是純值(例如 addOne() 的單一 Entity 物件或 addMany()Entity[] 陣列),或具有相同值的 PayloadAction 動作物件,作為 action.payload。這可以使用它們作為輔助函式和還原器。

關於淺層更新的注意事項: updateOneupdateManyupsertOneupsertMany 僅以可變方式執行淺層更新。這表示如果您的更新/upsert 包含包含巢狀屬性的物件,則傳入變更的值將覆寫整個現有巢狀物件。這可能是您的應用程式的意外行為。一般來說,這些方法最適合用於沒有巢狀屬性的正規化資料

getInitialState

傳回新的實體狀態物件,例如 {ids: [], entities: {}}

它接受一個選用的物件作為參數。該物件中的欄位會合併到傳回的初始狀態值中。例如,也許您希望您的區段也追蹤一些載入狀態

const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState({
loading: 'idle',
}),
reducers: {
booksLoadingStarted(state, action) {
// Can update the additional state field
state.loading = 'pending'
},
},
})

您也可以傳入陣列實體或 Record<EntityId, T> 物件,以使用一些實體預先填入初始狀態

const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState(
{
loading: 'idle',
},
[
{ id: 'a', title: 'First' },
{ id: 'b', title: 'Second' },
],
),
reducers: {},
})

這等同於呼叫

const initialState = booksAdapter.getInitialState({
loading: 'idle',
})

const prePopulatedState = booksAdapter.setAll(initialState, [
{ id: 'a', title: 'First' },
{ id: 'b', title: 'Second' },
])

如果不需要其他屬性,第一個參數可以是 undefined

選取器函式

實體轉接器將包含一個 getSelectors() 函式,它會傳回一組選取器,這些選取器知道如何讀取實體狀態物件的內容。

  • selectIds:傳回 state.ids 陣列。
  • selectEntities:傳回 state.entities 查詢表。
  • selectAll:對 state.ids 陣列進行對應,並傳回相同順序的實體陣列。
  • selectTotal:傳回儲存在此狀態中的實體總數。
  • selectById:給定狀態和實體 ID,傳回具有該 ID 的實體或 undefined

每個選取器函式都將使用 Reselect 中的 createSelector 函式建立,以啟用結果的記憶化計算。

提示

可以替換使用的 createSelector 執行個體,方法是將它作為選項物件(第二個參數)的一部分傳遞。

import {
createDraftSafeSelectorCreator,
weakMapMemoize,
} from '@reduxjs/toolkit'

const createWeakMapDraftSafeSelector =
createDraftSafeSelectorCreator(weakMapMemoize)

const simpleSelectors = booksAdapter.getSelectors(undefined, {
createSelector: createWeakMapDraftSafeSelector,
})

const globalizedSelectors = booksAdapter.getSelectors((state) => state.books, {
createSelector: createWeakMapDraftSafeSelector,
})

如果未傳遞任何執行個體,它將預設為 createDraftSafeSelector

由於選取器函式依賴於知道此特定實體狀態物件在狀態樹中的位置,因此可以透過兩種方式呼叫 getSelectors()

  • 如果在沒有任何參數(或將 undefined 作為第一個參數)的情況下呼叫,它會傳回一組「未全球化」的選取器函式,這些函式假設其 state 參數是要讀取的實際實體狀態物件。
  • 也可以使用接受整個 Redux 狀態樹並傳回正確實體狀態物件的選取器函式來呼叫它。

例如,Book 類型的實體狀態可能會儲存在 Redux 狀態樹中,例如 state.books。您可以使用 getSelectors() 以兩種方式從該狀態讀取

const store = configureStore({
reducer: {
books: booksReducer,
},
})

const simpleSelectors = booksAdapter.getSelectors()
const globalizedSelectors = booksAdapter.getSelectors((state) => state.books)

// Need to manually pass the correct entity state object in to this selector
const bookIds = simpleSelectors.selectIds(store.getState().books)

// This selector already knows how to find the books entity state
const allBooks = globalizedSelectors.selectAll(store.getState())

注意事項

套用多重更新

如果 updateMany() 被呼叫,而多個更新都針對同一個 ID,它們將會合併成單一更新,後面的更新會覆寫前面的更新。

對於 updateOne()updateMany(),將一個現有實體的 ID 變更為另一個現有實體的 ID,會導致第一個實體完全取代第二個實體。

範例

練習多個 CRUD 方法和選擇器

import {
createEntityAdapter,
createSlice,
configureStore,
} from '@reduxjs/toolkit'

// Since we don't provide `selectId`, it defaults to assuming `entity.id` is the right field
const booksAdapter = createEntityAdapter({
// Keep the "all IDs" array sorted based on book titles
sortComparer: (a, b) => a.title.localeCompare(b.title),
})

const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState({
loading: 'idle',
}),
reducers: {
// Can pass adapter functions directly as case reducers. Because we're passing this
// as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
bookAdded: booksAdapter.addOne,
booksLoading(state, action) {
if (state.loading === 'idle') {
state.loading = 'pending'
}
},
booksReceived(state, action) {
if (state.loading === 'pending') {
// Or, call them as "mutating" helpers in a case reducer
booksAdapter.setAll(state, action.payload)
state.loading = 'idle'
}
},
bookUpdated: booksAdapter.updateOne,
},
})

const { bookAdded, booksLoading, booksReceived, bookUpdated } =
booksSlice.actions

const store = configureStore({
reducer: {
books: booksSlice.reducer,
},
})

// Check the initial state:
console.log(store.getState().books)
// {ids: [], entities: {}, loading: 'idle' }

const booksSelectors = booksAdapter.getSelectors((state) => state.books)

store.dispatch(bookAdded({ id: 'a', title: 'First' }))
console.log(store.getState().books)
// {ids: ["a"], entities: {a: {id: "a", title: "First"}}, loading: 'idle' }

store.dispatch(bookUpdated({ id: 'a', changes: { title: 'First (altered)' } }))
store.dispatch(booksLoading())
console.log(store.getState().books)
// {ids: ["a"], entities: {a: {id: "a", title: "First (altered)"}}, loading: 'pending' }

store.dispatch(
booksReceived([
{ id: 'b', title: 'Book 3' },
{ id: 'c', title: 'Book 2' },
]),
)

console.log(booksSelectors.selectIds(store.getState()))
// "a" was removed due to the `setAll()` call
// Since they're sorted by title, "Book 2" comes before "Book 3"
// ["c", "b"]

console.log(booksSelectors.selectAll(store.getState()))
// All book entries in sorted order
// [{id: "c", title: "Book 2"}, {id: "b", title: "Book 3"}]