createEntityAdapter
概述
一个生成一组预构建的 reducer 和 selector 的函数,用于对包含特定类型数据对象实例的 规范化状态结构 执行 CRUD 操作。这些 reducer 函数可以作为 case reducer 传递给 createReducer
和 createSlice
。它们也可以用作 createReducer
和 createSlice
内部“可变”的辅助函数。
此 API 移植自 NgRx 维护人员创建的 @ngrx/entity
库,但已针对 Redux Toolkit 进行了重大修改。我们要感谢 NgRx 团队最初创建此 API 并允许我们将其移植和调整以满足我们的需求。
术语“实体”用于指代应用程序中一种独特的数据对象类型。例如,在博客应用程序中,您可能拥有User
、Post
和Comment
数据对象,其中每个对象都有许多实例存储在客户端并持久化到服务器上。User
是一个“实体” - 应用程序使用的一种独特的数据对象类型。假设每个实体的唯一实例在特定字段中具有唯一的 ID 值。
与所有 Redux 逻辑一样,仅应将纯 JS 对象和数组传递到存储中 - 不要使用类实例!
出于本参考的目的,我们将使用Entity
来指代由 Redux 状态树特定部分中的 reducer 逻辑副本管理的特定数据类型,并将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
,以便正确推断类型定义。
示例用法
- TypeScript
- JavaScript
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())
import {
createEntityAdapter,
createSlice,
configureStore,
} from '@reduxjs/toolkit'
const booksAdapter = createEntityAdapter({
// Assume IDs are stored in a field other than `book.id`
selectId: (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,
},
})
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((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 数组映射检索实体应导致排序后的实体数组。
如果没有提供,则 `state.ids` 数组将不会排序,并且不保证排序。换句话说,可以预期 `state.ids` 的行为类似于标准的 Javascript 数组。
请注意,排序仅在通过以下 CRUD 函数之一更改状态时生效(例如,`addOne()`、`updateMany()`)。
返回值
一个“实体适配器”实例。实体适配器是一个普通的 JS 对象(不是类),包含生成的 reducer 函数、原始提供的 `selectId` 和 `sortComparer` 回调、生成初始“实体状态”值的方法以及生成一组针对此实体类型的全局和非全局记忆选择器函数的方法。
适配器实例将包含以下方法(包括其他引用的 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` 的对象,如果不存在,则添加它们。 setOne
:接受单个实体并添加或替换它。setMany
:接受实体数组或形状为 `Record` 的对象,并添加或替换它们。 setAll
:接受实体数组或形状为 `Record` 的对象,并用数组中的值替换所有现有实体。 removeOne
:接受单个实体 ID 值,如果存在,则删除具有该 ID 的实体。removeMany
:接受实体 ID 值数组,如果存在,则删除具有这些 ID 的每个实体。removeAll
: 从实体状态对象中移除所有实体。updateOne
: 接受一个“更新对象”,该对象包含一个实体 ID 和一个包含一个或多个新字段值的更新对象,这些新字段值位于changes
字段中,并对相应实体执行浅层更新。updateMany
: 接受一个更新对象的数组,并对所有相应实体执行浅层更新。upsertOne
: 接受单个实体。如果存在具有该 ID 的实体,它将执行浅层更新,并且指定的字段将合并到现有实体中,任何匹配的字段将覆盖现有值。如果实体不存在,它将被添加。upsertMany
: 接受一个实体数组或一个形状为Record<EntityId, T>
的对象,该对象将被浅层 upsert。
所有三个选项都会将新实体插入列表中。但是,它们在处理已存在实体的方式上有所不同。如果实体已经存在
addOne
和addMany
不会对新实体做任何操作setOne
和setMany
将完全用新实体替换旧实体。这也会删除实体上不在新版本实体中的任何属性。upsertOne
和upsertMany
将执行浅层复制以合并旧实体和新实体,覆盖现有值,添加任何不存在的值,并且不触碰新实体中未提供的属性。
每个方法都有一个类似的签名
;(state: EntityState<T>, argument: TypeOrPayloadAction<Argument<T>>) =>
EntityState<T>
换句话说,它们接受一个类似于 {ids: [], entities: {}}
的状态,并计算并返回一个新状态。
这些 CRUD 方法可以以多种方式使用
- 它们可以直接作为 case reducers 传递给
createReducer
和createSlice
。 - 当手动调用时,它们可以用作“可变”辅助方法,例如,如果
state
参数实际上是 ImmerDraft
值,则在现有 case reducer 中对addOne()
进行单独的手写调用。 - 当手动调用时,它们可以用作不可变更新方法,如果
state
参数实际上是普通 JS 对象或数组。
这些方法没有创建相应的 Redux 操作 - 它们只是独立的 reducers / 更新逻辑。完全由您决定在哪里以及如何使用这些方法! 大多数情况下,您会希望将它们传递给 createSlice
或在另一个 reducer 中使用它们。
每个方法都会检查state
参数是否为 Immer 的Draft
。如果是草稿,该方法将假设可以继续对该草稿进行进一步的变异。如果不是草稿,该方法将把纯 JS 值传递给 Immer 的createNextState()
,并返回不可变的更新结果值。
argument
可以是纯值(例如,对于addOne()
的单个Entity
对象或对于addMany()
的Entity[]
数组),也可以是具有相同值的PayloadAction
操作对象作为action.payload
。这使得它们既可以作为辅助函数,也可以作为 reducer 使用。
关于浅层更新的说明:
updateOne
、updateMany
、upsertOne
和upsertMany
仅以可变方式执行浅层更新。这意味着,如果您的更新/插入包含一个包含嵌套属性的对象,则传入更改的值将覆盖整个现有的嵌套对象。这可能是您应用程序的意外行为。一般来说,这些方法最适合用于规范化数据,这些数据没有嵌套属性。
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"}]