createEntityAdapter

概览

一个能创建一集合关于在 范式化状态结构 执行 CRUD 操作的预设 reducers 和 selectors 的函数,其包含有某种特定数据对象的实例。这些 reducer 函数可以作为 case reducers 传入 createReducer 和 createSlice。它们也可以被用作在 createReducer 和 createSlice 中的 “mutating" helper 函数。

这个 API 是从 NgRx 维护者创建的 the @ngrx/entity library 引入的,但是为契合 Redux 工具包 而被大幅度地改造过。我们特此感谢 NgRx 团队,感谢他们是这个的 API 的原作者同时也允许我们引入并且根据我们的需要进行改良。

注意: "Entity" 这个术语指代的是在一个应用中一种独一无二的数据对象。例如,在一个博客应用中,你可能会有 User, Post, 和 Comment 数据对象,其中他们每个都有许多实例存储在客户端和保持在服务端中。User 是一个 "实体" - 一种在一个应用当中的唯一数据对象类型。每一个实体的独一无二的实例都被假定有一个在某一个具体的字段的唯一 ID 值。

正如所有 Redux 逻辑一样,只有 纯 JS 对象和数组能被传到 store 中 - 没有类实例!

为了捋清这个说法的目的,我们将会使用 Entity 来指代那些被在 Redux 状态树中的某一部分 reducer 逻辑的拷贝管理着的特定数据类型,同时 entity 用来指代单个这个类型的实例。例子:在 state.users 中,Entity 代表着 User 类型,而 state.users.entities[123] 就是单个 entity。

这些被 createEntityAdapter 创建出来的方法,全都会操纵着一个 “实体状态” 结构,形如:

{
// 每个数组元素的唯一 ID。必须是字符串或者数字类型。
ids: []
// 一个映射实体 ID 到对应的实体对象的查找表格
entities: {
}
}

createEntityAdapter 可以在一个应用中被多次调用。如果你结合纯 JavaScript 一起使用的话,你有可能可以复用某个带有多实体类型的单独 adapter 定义,如果他们足够小的话(例如他们都有一个 entity.id 字段)。就 TypeScript 的使用而言,你会需要为了每一个唯一的 Entity 类型,而在不同的时间点调用 createEntityAdapter ,这样类型定义就会被正确地推断出来。

样本用法:

import {
createEntityAdapter,
createSlice,
configureStore,
} from '@reduxjs/toolkit'
type Book = { bookId: string; title: string }
const booksAdapter = createEntityAdapter<Book>({
// 假设 ID 是存储在不同于 `book.id` 的一个字段中
selectId: (book) => book.bookId,
// 保持 "all IDs" 数组能根据书本的标题被排好序
sortComparer: (a, b) => a.title.localeCompare(b.title),
})
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState(),
reducers: {
// 可以将 adapter 函数直接作为 case reducers 传入。因为我们把着当作一个值来传递,
// `createSlice` 会自动生成 `bookAdded` 的 action type / creator
bookAdded: booksAdapter.addOne,
booksReceived(state, action) {
// 或者,把他们当作在一个 case reducer 中的 "mutating" helpers
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 类型的把其唯一 ID 值存储在 entity.id 之外的字段,你 必须 提供一个 selectId 函数。

sortComparer

一个接受两个 Entity 实例的回调函数,且会返回标准的 Array.sort() 数字类型结果 (1, 0, -1) ,以示它们排序的相对顺序。

如果提供了,state.ids 数组会根据实例对象的比较,以被排好的顺序保留,这样的话映射 ID 数组以通过读取 ID 的形式来获取这些实体,会得到一个排好序的实体数组。

如果么有提供,state.ids 不会被排序,且关于顺序也不能得到保证。换句话说,state.ids 与标准 JavaScript 数组有几乎相同的特性。

返回值

一个 "entity adapter" 实例。一个 entity adapter 是一个普通 JS 对象(并非一个类), 包含有被生成的 reducer 函数、原始的 selectId 和 sortComparer 回调函数、 一个生成初始 "entity state" 值的方法,还有一些用来生成一组全局化和非全局化 selectors 函数的函数。

这个 adapter 实例会包含一下这些方法(额外的 TypeScript 类型也包括了):

export type EntityId = number | string
export type Comparer<T> = (a: T, b: T) => number
export type IdSelector<T> = (model: T) => EntityId
export interface DictionaryNum<T> {
[id: number]: T | undefined
}
export interface Dictionary<T> extends DictionaryNum<T> {
[id: string]: T | undefined
}
export type Update<T> = { id: EntityId; changes: Partial<T> }
export interface EntityState<T> {
ids: EntityId[]
entities: Dictionary<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
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) => Dictionary<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 函数

一个实体 adapter 的核心内容就是一个 reducer 函数的集合,用于增添、更新以及从实体状态对象中删除实体实例对象:

  • addOne: 接受一个单独实体,并且把其加进来。
  • addMany: 接受一个实体数组,或者一个形如 Record<EntityId, T> 的对象,并且把它们添加进来。
  • setAll: 接受一个实体数组,或者一个形如 Record<EntityId, T> 的对象,并且把它们添加进来,并且已有的实体内容用数组里面的值替换掉。
  • removeOne: 接受一个单独实体的 ID 值,并且移除已有的带有相同 ID 的实体。
  • removeMany: 接受一个带有实体 ID 值的数组,并且移除每一个已有的带有相同 ID 的实体。
  • updateOne: 接受一个 "update object",其包含有一个实体 ID 还有一个带有一个或多个新的字段值用于更新在 changes 字段中做更新操作的对象,并且在对应的实体中做浅更新。
  • updateMany: 接受一个 update objects 的数组,并且对相应的实体做浅更新。
  • upsertOne: 接受一个单独实体。如果一个带有某个 ID 的实体已经存在,它会进行一次浅更新,且指定的字段会被合并到已有的实体中,其中任何匹配上的字段都会重写已有值。如果该实体不存在,那么它会被添加进来。
  • upsertMany: 接受一个实体数组,或者一个形如 Record<EntityId, T> 的对象,并且把它们进行更新和插入进来。

每一个方法的函数签名长这样:

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

换句话说,这些方法接受一个 state 对象,形如 {ids: [], entities: {}},然后计算并返回一个新的状态对象。

这些 CRUD 方法有可能会通过如下几种方式被使用起来:

  • 它们可以被当作 case reducers 直接传入到 createReducer 和 createSlice。
  • 当手动调用的时候,它们可以被当作 "mutating" helper 方法来使用,例如一个在已有的 case reducer 中单独手写的 addOne() 方法,如果 state 参数是一个 Immer Draft 值的话。
  • 当手动调用的时候,它们可以被当作 immutable 更新方法,如果 state 参数是一个普通 JS 对象或者数组的话。

注意: 这些方法 没有 对应的被创建出来 Redux actions - 它们只是单独的 reducers / update 逻辑. 这完全取决于你在哪里以及如何使用这些方法! 大多数情况下, 你依然想要把它们传入到 createSlice 或者在另外 reducer 中使用它们。

每一个方法都会检查 state 参数是不是一个 Immer Draft。如果它是一个 draft 类型,这些方法会假定继续更改这些 draft 是安全的。如果不是,这些方法会把普通 JS 值传到 Immer 的 createNextState(),然后返回不可更改的更新后的结果。

argument 可以是一个普通的值(比如一个单独的传到 addOne() 的 Entity 对象,或者是一个传到 addMany() 的 Entity[] 数组),又或者是一个跟 action.payload 有相同的值的 PayloadAction action 对象。这样的话,我们就可以同时使用 helper 函数和 reducers。

浅更新的注意事项: updateOne, updateMany, upsertOne, 还有 upsertMany 仅通过一个可变的方式进行浅更新。这意味着如果你的 update/upsert 操作是由一个嵌套属性组成的对象,新的值会重写 整个 已有的嵌套对象。对于你的应用用来说,这个有可能不是你想要的。总的原则是,这些方法最好配合 没有 嵌套属性的 normalized data 一起使用。

getInitialState

返回一个全新的形如 {ids: [], entities: {}} 的状态对象。

它接受一个可选的对象作为参数。这个对象里面的字段会被合并到返回的初始 state 值中去。例如,或许你想让你的分片也能追踪到一些加载状态:

const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState({
loading: 'idle'
}),
reducers: {
booksLoadingStarted(state, action) {
// 可以更新额外的 state 字段
state.loading = 'pending'
}
}
})

Selector 函数

实体 adapter 会包含一个 getSelectors() 函数,其返回一组 selectors 函数,这些 selectors 知道如何读去实体 state 对象的内容:

  • selectIds: 返回 state.ids 数组。
  • selectEntities: 返回state.entities 查找表。
  • selectAll:映射 state.ids 数组,并且以同样的顺序返回一个实体数组。
  • selectTotal:返回存储到 state 的实体总数。
  • selectById: 基于 state 和实体 ID,返回带有该 ID 的实体或者是 undefined

每一个 selector 函数都会使用 Reselect 中的 createSelector 函数,以开启缓存计算结果的功能。

因为每一个 selector 函数取决于在状态树中,某个具体的实体状态对象所处的位置,getSelectors() 有两种调用方式:

  • 如果调用时没有任何的参数,它会返回一个 "非全局化" 的 selector 函数的集合,其假定它的 state 参数是真正的实体状态对象。

  • 它也可以配合使用一个接受整个 Redux 状态树的 selector 函数进行调用,并且返回正确的实体状态参数。

例如,一个 Book 类型的实体状态在 Redux 状态树被存储为 state.books。你可以使用 getSelectors() ,以两种方式读取该状态:

const store = configureStore({
reducer: {
books: booksReducer
}
})
const simpleSelectors = booksAdapter.getSelectors()
const globalizedSelectors = booksAdapter.getSelectors(state => state.books)
// 需要在这个 selector 中手动传入正确的实体状态对象
const bookIds = simpleSelectors.selectIds(store.getState().books)
// 这个 selector 已经知道如何寻找 books 的实体状态对象
const allBooks = globalizedSelectors.selectAll(store.getState())

注意事项

应用多次更新操作

如果以多个针对与同一个 ID 的更新操作来调用 updateMany() 的话,它们会被合并到一次单独的更新,其中后面的操作会重新前面的。

对于 updateOne() 和 updateMany() 来说,改变一个已有实体的 ID 来匹配第二个已有实体的 ID 会造成第一个实体完全替换掉第二个的情况。

Examples

Exercising several of the CRUD methods and selectors:

import {
createEntityAdapter,
createSlice,
configureStore
} from '@reduxjs/toolkit'
// 因为我们没有提供 `selectId ,默认 `entity.id` 是正确的字段
const booksAdapter = createEntityAdapter({
// 基于 book titles, 对 “全部 ID” 数组进行排序
sortComparer: (a, b) => a.title.localeCompare(b.title)
})
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState({
loading: 'idle'
}),
reducers: {
// 可以把 adapter 函数直接作为 case reducers 传入。因为我们把其作为一个值传入,
// `createSlice` 会自动生成 `bookAdded` 的 action 类型/creator
bookAdded: booksAdapter.addOne,
booksLoading(state, action) {
if (state.loading === 'idle') {
state.loading = 'pending'
}
},
booksReceived(state, action) {
if (state.loading === 'pending') {
// 或者是,在一个 case reducer 中,把它们作为 "mutating" helpers 函数调用
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: '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()))
// 因为 `setAll()` 的调用,"a" 被移除了
// 因为它们是按 title 排序的,"Book 2" 出现在 "Book 3" 之前
// ["c", "b"]
console.log(booksSelectors.selectAll(store.getState()))
// 所有被排序过的条目
// [{id: "c", title: "Book 2"}, {id: "b", title: "Book 3"}]