createEntityAdapter
概述
一个生成一组预构建的 reducers 和 selectors 的函数,用于对包含特定类型数据对象实例的规范化状态结构进行 CRUD 操作。这些 reducer 函数可以作为 case reducers 传递给 createReducer
和 createSlice
。它们也可以在 createReducer
和 createSlice
内部用作 "mutating" 辅助函数。
此 API 是从由 NgRx 维护者创建的 @ngrx/entity
库移植过来的,但已经为 Redux Toolkit 的使用进行了大幅修改。我们要感谢 NgRx 团队最初创建了这个 API,并允许我们移植和适应它以满足我们的需求。
术语 "Entity" 用于指代应用中的一种独特类型的数据对象。例如,在一个博客应用中,你可能有 User
、Post
和 Comment
数据对象,每种对象都有许多实例存储在客户端并持久化在服务器上。User
就是一个 "entity" - 应用使用的一种独特类型的数据对象。每个独特的实体实例都被假定在特定字段中有一个独特的 ID 值。
与所有 Redux 逻辑一样,_只有_普通的 JS 对象和数组应该被传入到 store - 不要使用类实例!
为了这个参考的目的,我们将使用 Entity
来指代由 Redux 状态树的特定部分的 reducer 逻辑管理的特定数据类型,使用 entity
来指代该类型的单个实例。例如:在 state.users
中,Entity
将指代 User
类型,state.users.entities[123]
将是一个 entity
。
由 createEntityAdapter
生成的方法都将操作一个看起来像这样的 "entity state" 结构:
{
// 每个项目的唯一 ID。必须是字符串或数字
ids: []
// 一个查找表,将实体 ID 映射到对应的实体对象
entities: {
}
}
createEntityAdapter
可以在一个应用中被多次调用。如果你在使用纯 JavaScript,你可能可以在多个实体类型之间重用一个适配器定义,如果它们足够相似(比如都有一个 entity.id
字段)。对于 TypeScript 使用,你需要为每个不同的 Entity
类型分别调用 createEntityAdapter
,以便正确推断类型定义。
示例用法:
import {
createEntityAdapter,
createSlice,
configureStore,
} from '@reduxjs/toolkit'
type Book = { bookId: string; title: string }
const booksAdapter = createEntityAdapter({
// 假设 ID 存储在 `book.id` 以外的字段中
selectId: (book: Book) => book.bookId,
// 根据书名保持 "所有 ID" 数组排序
sortComparer: (a, b) => a.title.localeCompare(b.title),
})
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState(),
reducers: {
// 可以直接将适配器函数作为 case reducers 传递。因为我们将这个
// 作为一个值传递,`createSlice` 将自动生成 `bookAdded` action 类型 / 创建器
bookAdded: booksAdapter.addOne,
booksReceived(state, action) {
// 或者,在 case reducer 中调用它们作为 "mutating" 辅助函数
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: {} }
// 可以创建一组基于此实体状态位置的 memoized selectors
const booksSelectors = booksAdapter.getSelectors<RootState>(
(state) => state.books,
)
// 然后使用 selectors 来检索值
const allBooks = booksSelectors.selectAll(store.getState())
参数
createEntityAdapter
接受一个单独的选项对象参数,其中有两个可选字段。
selectId
一个接受单个 Entity
实例并返回内部任何唯一 ID 字段的值的函数。如果未提供,默认实现是 entity => entity.id
。如果你的 Entity
类型在 entity.id
以外的字段中保持其唯一 ID 值,你 必须 提供一个 selectId
函数。
sortComparer
一个接受两个 Entity
实例的回调函数,应返回一个标准的 Array.sort()
数字结果(1,0,-1)以指示它们的相对排序顺序。
如果提供了,state.ids
数组将根据实体对象的比较结果保持排序,因此映射 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<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>
的对象,这些对象将被浅插入。
所有三个选项都会将 新 实体插入到列表中。然而,它们在处理已经存在的实体时有所不同。如果一个实体 已经存在:
addOne
和addMany
不会对新实体做任何事情setOne
和setMany
将用新的实体完全替换旧的实体。这也将去除在新版本的实体中不存在的任何属性。upsertOne
和upsertMany
将执行浅复制以合并旧的和新的实体,覆盖现有的值,添加任何不存在的值,并不触及新实体中未提供的属性。
每个方法都有一个看起来像这样的签名:
;(state: EntityState<T>, argument: TypeOrPayloadAction<Argument<T>>) =>
EntityState<T>
换句话说,它们接受一个看起来像 {ids: [], entities: {}}
的状态,并计算并返回一个新的状态。
这些 CRUD 方法可以以多种方式使用:
- 它们可以直接作为 case reducers 传递给
createReducer
和createSlice
。 - 当手动调用时,它们可以作为"变异"帮助方法使用,例如在现有的 case reducer 内部单独手写调用
addOne()
,如果state
参数实际上是一个 ImmerDraft
值。 - 当手动调用时,如果
state
参数实际上是一个普通的 JS 对象或数组,它们可以作为不可变的更新方法使用。
这些方法并没有对应的 Redux 动作创建 - 它们只是独立的 reducers / 更新逻辑。完全由你决定在哪里以及如何使用这些方法! 大多数时候,你会想要将它们传递给 createSlice
或在另一个 reducer 中使用它们。
每个方法都会检查 state
参数是否是一个 Immer Draft
。如果它是一个草稿,该方法将假定继续变异该草稿是安全的。如果它不是一个草稿,该方法将把普通的 JS 值传递给 Immer 的 createNextState()
,并返回不可变更新的结果值。
argument
可以是一个普通值(例如一个单一的 Entity
对象用于 addOne()
或一个 Entity[]
数组用于 addMany()
,或者一个带有相同值作为 action.payload
的 PayloadAction
动作对象。这使得它们既可以作为帮助函数也可以作为 reducers 使用。
关于浅更新的注意事项:
updateOne
、updateMany
、upsertOne
和upsertMany
只以可变的方式执行浅更新。这意味着,如果你的更新/插入包含一个包含嵌套属性的对象,传入的更改的值将覆盖整个现有的嵌套对象。这可能是你的应用程序不期望的行为。作为一般规则,这些方法最好用于标准化数据 ,这些数据 不 包含嵌套属性。
getInitialState
返回一个新的实体状态对象,如 {ids: [], entities: {}}
。
它接受一个可选对象作为参数。该对象中的字段将被合并到返回的初始状态值中。例如,也许你希望你的切片也跟踪一些加载状态:
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState({
loading: 'idle',
}),
reducers: {
booksLoadingStarted(state, action) {
// 可以更新额外的状态字段
state.loading = 'pending'
},
},
})
你也可以传入一个实体数组或一个Record<EntityId, T>
对象来预填充一些实体的初始状态:
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState(
{
loading: 'idle',
},
[
{ id: 'a', title: '第一本' },
{ id: 'b', title: '第二本' },
],
),
reducers: {},
})
这等同于调用:
const initialState = booksAdapter.getInitialState({
loading: 'idle',
})
const prePopulatedState = booksAdapter.setAll(initialState, [
{ id: 'a', title: '第一本' },
{ id: 'b', title: '第二本' },
])
如果不需要额外的属性,第一个参数可以是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)
// 需要手动传入正确的实体状态对象到这个选择器
const bookIds = simpleSelectors.selectIds(store.getState().books)
// 这个选择器已经知道如何找到books实体状态
const allBooks = globalizedSelectors.selectAll(store.getState())
注意事项
应用多个更新
如果updateMany()
被调用并针对同一ID进行多个更新,它们将被合并为一个单一的更新,后面的更新将覆盖前面的更新。
对于updateOne()
和updateMany()
,将一个现有实体的ID更改为与第二个现有实体的ID匹配,将导致第一个完全替换第二个。
示例
演示了一些CRUD方法和选择器的使用:
import {
createEntityAdapter,
createSlice,
configureStore,
} from '@reduxjs/toolkit'
// 由于我们没有提供`selectId`,它默认假设`entity.id`是正确的字段
const booksAdapter = createEntityAdapter({
// 根据书名对"所有ID"数组进行排序
sortComparer: (a, b) => a.title.localeCompare(b.title),
})
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState({
loading: 'idle',
}),
reducers: {
// 可以直接将适配器函数作为case reducers传入。因为我们将这个
// 作为一个值传入,`createSlice`将自动生成`bookAdded`动作类型/创建器
bookAdded: booksAdapter.addOne,
booksLoading(state, action) {
if (state.loading === 'idle') {
state.loading = 'pending'
}
},
booksReceived(state, action) {
if (state.loading === 'pending') {
// 或者,将它们作为"变异"帮助器在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,
},
})
// 检查初始状态:
console.log(store.getState().books)
// {ids: [], entities: {}, loading: 'idle' }
const booksSelectors = booksAdapter.getSelectors((state) => state.books)
store.dispatch(bookAdded({ id: 'a', title: '第一本' }))
console.log(store.getState().books)
// {ids: ["a"], entities: {a: {id: "a", title: "第一本"}}, loading: 'idle' }
store.dispatch(bookUpdated({ id: 'a', changes: { title: '第一本(修改)' } }))
store.dispatch(booksLoading())
console.log(store.getState().books)
// {ids: ["a"], entities: {a: {id: "a", title: "第一本(修改)"}}, loading: 'pending' }
store.dispatch(
booksReceived([
{ id: 'b', title: '第三本' },
{ id: 'c', title: '第二本' },
]),
)
console.log(booksSelectors.selectIds(store.getState()))
// "a"由于`setAll()`调用被移除
// 由于它们按标题排序,"第二本"在"第三本"之前
// ["c", "b"]
console.log(booksSelectors.selectAll(store.getState()))
// 所有书籍条目按排序顺序
// [{id: "c", title: "第二本"}, {id: "b", title: "第三本"}]