使用 Selector 派生数据
- Why good Redux architecture keeps state minimal and derives additional data
- Principles of using selector functions to derive data and encapsulate lookups
- How to use the Reselect library to write memoized selectors for optimization
- How memoized selectors work with
useSelectorand component arguments - Additional tools and libraries for creating selectors
- Best practices for writing selectors
派生数据
我们特别推荐 Redux 应用应当保持 Redux 状态最小化,并尽可能从该状态中派生附加数据。
这包括计算过滤后的列表或汇总数值。例如,一个待办事项 (todo) 应用会在状态中保存一份原始的 todo 对象列表,但在每次状态更新时,在状态之外派生一个过滤后的 todo 列表。类似地,是否所有 todos 都完成的检查,或未完成的 todos 数量,也可以在 store 外部计算。
这有几个好处:
- 实际状态更易于阅读
- 计算这些附加值并保持其与数据同步所需逻辑更少
- 原始状态仍保留作为参考且不会被替换
这对 React 状态同样是一个好原则!许多用户曾尝试定义一个 useEffect 钩子,监听某个状态值变化,然后调用派生的值如 setAllCompleted(allCompleted) 更新状态。其实,这个值可以在渲染过程中直接派生和使用,无需存入状态:
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([])
// 在渲染过程中派生数据
const allTodosCompleted = todos.every(todo => todo.completed)
// 使用该值进行渲染
}
使用 Selectors 计算派生数据
在典型的 Redux 应用中,用于派生数据的逻辑通常写成称为selectors的函数。
Selectors 主要用来封装从 state 中查找特定值的逻辑、派生值的逻辑,以及通过避免不必要的重复计算来提升性能。
你并非_必须_使用 selectors 来查询所有状态数据,但它们是标准模式且被广泛使用。
基础 Selector 概念
“selector 函数”是任何接受 Redux store 状态(或状态的一部分)作为参数、并返回基于该状态的数据的函数。
selectors 不必用特定库编写,也无所谓采用箭头函数或传统 function 关键字。比如,以下都是有效的 selector 函数示例:
// 箭头函数,直接查找
const selectEntities = (state: RootState) => state.entities
// 函数声明,通过遍历数组派生值
function selectItemIds(state: RootState) {
return state.items.map(item => item.id)
}
// 函数声明,封装深层查找
function selectSomeSpecificField(state: RootState) {
return state.some.deeply.nested.field
}
// 箭头函数,从数组派生值
const selectItemsWhoseNamesStartWith = (items: Item[], namePrefix: string) =>
items.filter(item => item.name.startsWith(namePrefix))
selector 函数可以任意命名。但我们推荐给 selector 函数命名加前缀 select,并结合所选择值的描述。典型例子有 selectTodoById、selectFilteredTodos 和 selectVisibleTodos。
如果你用过 React-Redux 的 useSelector 钩子,你可能已经熟悉 selector 函数的基本含义——传给 useSelector 的函数必须是 selectors:
function TodoList() {
// This anonymous arrow function is a selector!
const todos = useAppSelector(state => state.todos)
}
selector 函数通常定义在 Redux 应用的两个不同部分:
- 在 slice 文件中,与 reducer 逻辑并列
- 在组件文件中,在组件外部或直接内联于
useSelector调用
只要能访问完整的 Redux 根状态,就可以在任何地方使用 selector 函数,包括 useSelector hook、middleware、thunk、listener 和 saga。例如,thunk 和 middleware 可以访问 getState 参数,因此也可以在那里调用 selector:
function addTodosIfAllowed(todoText: string): AppThunk {
return (dispatch, getState) => {
const state = getState()
const canAddTodos = selectCanAddTodos(state)
if (canAddTodos) {
dispatch(todoAdded(todoText))
}
}
}
通常不建议在 reducers 内部使用 selectors,因为 slice reducer 只能访问自己的状态分片,而大多数 selectors 都期望得到完整的 Redux 根状态。
只读取一次状态
React 组件通常应该使用 useSelector,因为它会订阅 store,并在选中值变化时更新组件。有时 React 以外的代码只能访问 dispatch,需要读取一次最新状态但不订阅变化。这种情况下,可以派发一个小型 thunk,用 getState() 调用 selector 并返回结果:
const selectFromState = <Selected>(
selector: (state: RootState) => Selected
): AppThunk<Selected> => {
return (_dispatch, getState) => selector(getState())
}
const users = dispatch(selectFromState(selectUsers))
这会在 thunk 运行时读取状态。返回值不会随之后的状态变化而更新,因此如果调用方需要响应后续变化,应使用 useSelector 等订阅 API。
使用 Selector 封装状态结构
使用 selector 函数的首要原因,是封装和复用 Redux 状态结构相关的知识。
假设某个 useSelector 钩子这样执行特定深层状态的查找:
const data = useAppSelector(state => state.some.deeply.nested.field)
这段代码合法且能运行。但从架构角度可能不是最佳实践。想象有多个组件都需要访问该字段。如果状态结构发生改变,你必须修改所有包含该查询的 useSelector 调用。
因此,就像推荐用 action creators 封装创建 action 细节一样,我们建议定义可复用 selector 函数,封装某块状态的获取细节。然后在代码库中任何需要该数据的地方,都使用对应的 selector 函数。
理想情况下,只有 reducer 函数和 selectors 知道确切状态结构;如果状态位置更改,只需更新这两部分逻辑。
基于此,通常建议将可复用 selectors 定义在 slice 文件中,而不是分散定义在组件内。
selector 常被描绘为对状态的**“查询”**——关心的是你请求了数据并得到了结果,而不是查询实现细节。
用缓存优化 Selectors
-
与
useSelector配合的 selector 会在每个 action 派发后重新运行,无论 Redux 根状态的哪个部分实际发生了变化。如果输入状态切片没有变化,却重复执行开销较大的计算,就会浪费 CPU 时间;而大多数时候输入确实并未变化。 -
useSelector依赖返回值的===引用相等性检查来决定组件是否需要重新渲染。如果 selector _总是_返回新引用,即使派生数据实际上与之前相同,也会强制组件重新渲染。map()和filter()等数组操作总会返回新数组引用,因此尤其容易出现此问题。 -
用于
useSelector或mapState的 selectors 在每次派发 action 后都会运行,无论实际更新的是哪个状态分片。重复执行昂贵计算浪费 CPU 时间,而大多数情况下输入数据是未改变的。 -
useSelector和mapState依赖返回值的===引用相等性判断,决定组件是否重新渲染。如果 selector 总是 返回新引用,即使派生数据相同,也会强制组件重新渲染。对数组操作如map()和filter()特别常见,因为它们始终返回新数组引用。
例如,下面这个组件写法不当,useSelector 调用_总是_返回新数组引用,导致组件 每次 派发 action 后重渲染,即使 state.todos 没变:
function TodoList() {
// ❌ 警告:此处_总是_返回新引用,因此组件_总是_会重新渲染!
const completedTodos = useAppSelector(state =>
state.todos.filter(todo => todo.completed)
)
}
另一个示例涉及“昂贵”的数据转换工作:
function ExampleComplexComponent() {
const data = useAppSelector(state => {
const initialData = state.data
const filteredData = expensiveFiltering(initialData)
const sortedData = expensiveSorting(filteredData)
const transformedData = expensiveTransformation(sortedData)
return transformedData
})
}
同样,这些昂贵逻辑会在每次派发 action 后执行,不论 state.data 是否变化。
因此,我们需要通过memoization(记忆化) 执行优化写法。
Memoization 是缓存的一种。它记录函数输入参数,并存储输入与结果。如果函数用相同输入被调用,则跳过实际计算,直接返回缓存结果。这样优化性能,只在输入变化时做工作,且对相同输入稳定返回同一输出引用。
接下来,我们看看如何用 Reselect 编写带缓存的 selectors。
使用 Reselect 编写记忆化 Selector
Redux 生态使用名为 Reselect 的库来创建记忆化 selector 函数。Reselect 是独立的包,但 Redux Toolkit 重新导出了 createSelector 和其他 Reselect API,因此无需单独安装。
本页介绍如何在 Redux 应用中使用记忆化 selector。有关 Reselect 库本身的完整参考,请参阅 Reselect 文档,其中包括记忆化的内部工作方式、createSelector API 及其选项、记忆化函数和常见问题。
createSelector 概览
Reselect 的 createSelector 接受一个或多个“输入 selector”函数以及一个“结果函数”,并返回新的记忆化 selector。
调用生成的 selector 时,Reselect 会使用你传入的参数运行所有输入 selector,并将结果与上次调用的结果比较。如果任一结果的 === 比较不相等,它就会将这些结果作为参数重新运行结果函数。如果所有结果都与上次相同,则跳过结果函数,直接返回之前缓存的结果。
典型用法中,输入 selector 是一些简单函数,用于返回状态对象中嵌套的值;实际的派生计算则由结果函数完成:
import { createSelector } from '@reduxjs/toolkit'
const selectTodos = (state: RootState) => state.todos.items
const selectCurrentUser = (state: RootState) => state.users.currentUser
const selectTodosForCurrentUser = createSelector(
[selectTodos, selectCurrentUser],
(todos, currentUser) => {
console.log('Result function running')
return todos.filter(todo => todo.ownerId === currentUser.userId)
}
)
const todosForCurrentUser1 = selectTodosForCurrentUser(state)
// Log: "Result function running"
const todosForCurrentUser2 = selectTodosForCurrentUser(state)
// No output
console.log(todosForCurrentUser1 === todosForCurrentUser2)
// true
第二次调用 selectTodosForCurrentUser 时,结果函数没有执行。selectTodos 和 selectCurrentUser 的结果与第一次调用时相同,所以 selectTodosForCurrentUser 返回了记忆化结果。
这意味着,输入 selector 应只提取并返回值,转换工作应由结果函数完成。如果结果函数只是原样返回某个输入,或者输入 selector 写成 state => state,就无法实现有效的记忆化。Reselect 文档中的最佳实践和常见错误介绍了这些及其他陷阱;Reselect 的开发模式检查会在 selector 首次运行时对这两种情况发出警告。
createSelector 的行为细节
Reselect 分两层进行记忆化。首先,它会比较本次传给 selector 的参数与上次调用的参数;如果完全相同,就直接返回缓存结果,不运行任何函数。如果参数不同(每次 dispatch 后都会不同,因为根状态对象是新引用),它就运行输入 selector 并比较_它们_的结果。只有输入结果发生变化时,结果函数才会运行。Reselect 文档中的“Reselect 如何工作?”对此有详细说明。
Reselect 5 默认使用 weakMapMemoize 进行记忆化。它会按引用为每组不同参数保留独立的缓存条目,因此连续使用不同输入调用 selector 不会淘汰之前的结果:
const a = someSelector(state, 1) // first call: runs the result function
const b = someSelector(state, 1) // same inputs: cached
const c = someSelector(state, 2) // new inputs: runs the result function
const d = someSelector(state, 1) // still cached from the first call
缓存条目以参数对象作为键保存在 WeakMap 中,因此这些对象被垃圾回收时,相应条目也会释放。无需配置缓存大小上限。
Reselect 4 及更早版本使用 lruMemoize,缓存大小为 1,因此只记住最近的一组参数。在该版本中,c 会淘汰 (state, 1) 的结果,d 需要重新计算。如果需要有界缓存,仍可使用 lruMemoize;下文“Selector 工厂”一节会说明这种方式何时仍有用。
由于每个输入 selector 都会收到完整参数列表,你提供的所有输入 selector 都应接受相同类型的参数:
const selectItems = (state: RootState) => state.items
// 第二个参数应为数字
const selectItemId = (state: RootState, itemId: number) => itemId
// 第二个参数应为对象
const selectOtherField = (
state: RootState,
someObject: { someField: string }
) => someObject.someField
// ❌ 这些输入 selector 对第二个参数的类型要求不一致
const selectItemById = createSelector(
[selectItems, selectItemId, selectOtherField],
(items, itemId, someField) => items[itemId]
)
如果调用 selectItemById(state, 42),selectOtherField 会出错,因为它会尝试访问 42.someField。TypeScript 会报告这个类型不匹配;纯 JavaScript 则会在运行时失败。
Reselect 的用法模式与限制
Selector 嵌套
可以将 createSelector 创建的 selector 用作另一个 selector 的输入。例如:
const selectTodos = (state: RootState) => state.todos
const selectCompletedTodos = createSelector([selectTodos], todos =>
todos.filter(todo => todo.completed)
)
const selectCompletedTodoDescriptions = createSelector(
[selectCompletedTodos],
completedTodos => completedTodos.map(todo => todo.text)
)
传递输入参数
Reselect 生成的 selector 可以接受任意数量的参数,例如 selectThings(a, b, c, d, e)。是否重新运行结果函数,取决于_输入 selector_的结果是否变化,而不是参数本身。因此,如果想将额外参数传给结果函数,就必须定义输入 selector,从原始 selector 参数中提取这些值:
const selectItemsByCategory = createSelector(
[
// 常见的第一个输入:从 `state` 中提取值
(state: RootState) => state.items,
// 获取第二个参数 `category`,并传给结果函数
(state: RootState, category: string) => category
],
// 结果函数接收 (items, category) 作为参数
(items, category) => items.filter(item => item.category === category)
)
const electronicItems = selectItemsByCategory(state, 'electronics')
为了保持一致,可以考虑将额外参数作为单个对象传给 selector,例如 selectThings(state, otherArgs),再从 otherArgs 对象中提取值。另请参阅 Reselect 常见问题中关于接受参数的 selector的说明。
Selector 工厂
在 Reselect 4 中,lruMemoize 默认缓存大小为 1,因此单个 selector 实例只能记住一组参数。如果多个组件使用不同分类调用 selectItemsByCategory(state, category),每次调用都会淘汰上一次结果,并重新运行结果函数。解决办法是使用“selector 工厂”——调用 createSelector() 并为每个组件返回新 selector 实例的函数:
const makeSelectItemsByCategory = () =>
createSelector(
[(state: RootState) => state.items, (state, category: string) => category],
(items, category) => items.filter(item => item.category === category)
)
使用 Reselect 5 默认的 weakMapMemoize 时,一个共享 selector 就会为每组不同参数保留缓存条目,因此通常不需要工厂。如果改用 lruMemoize 设置有界缓存,或希望组件卸载时立即释放缓存(而不是等参数对象被垃圾回收),selector 工厂仍然有用。如何将它与 useSelector 配合使用,请参阅创建唯一 Selector 实例;另请参阅 Reselect 常见问题中关于多个组件实例共享 selector的说明。
Reselect 5
Reselect 5(于 2023 年 12 月发布)使用 TypeScript 编写,并更改了一些值得了解的默认设置:
weakMapMemoize是默认记忆化函数。 如上所述,它会按不同参数组进行缓存,且没有大小上限。若想恢复以前的行为,可以向createSelector传入memoize: lruMemoize(也可传入memoizeOptions: { maxSize: 10 }),或使用createSelectorCreator创建自定义createSelector。createSelector.withTypes<RootState>()会返回一个输入 selector 已预设类型、接收根状态的createSelector,因此无需在每个输入 selector 中标注state类型。- 开发模式检查会对本页前面介绍的两个常见错误发出警告:输入 selector 每次调用都返回新引用,以及结果函数原样返回输入值。检查会在开发环境下每个 selector 首次调用时执行,生产环境中关闭。请参阅仅开发环境检查。
import { createSelector, lruMemoize } from '@reduxjs/toolkit'
import type { RootState } from './store'
export const createAppSelector = createSelector.withTypes<RootState>()
// 输入 selector 会自动接收 `RootState`,无需额外标注
export const selectCompletedTodos = createAppSelector(
[state => state.todos],
todos => todos.filter(todo => todo.completed)
)
// 为单个 selector 改回有界 LRU 缓存
export const selectItemsByCategory = createAppSelector(
[state => state.items, (state, category: string) => category],
(items, category) => items.filter(item => item.category === category),
{ memoize: lruMemoize, memoizeOptions: { maxSize: 10 } }
)
Redux Toolkit 重新导出了 Reselect 的 createSelector、createSelectorCreator、lruMemoize 和 weakMapMemoize,因此无需单独安装 Reselect。Reselect 5 变更摘要列出了所有变化。
其他 Selector 库
虽然 Reselect 是 Redux 中最常用的选择器库,但也有其他库解决类似问题,或增强 Reselect 功能。
proxy-memoize
proxy-memoize 采用了不同的实现方式。它使用 Proxy 对象跟踪 selector 实际读取的嵌套值,之后调用时只比较这些值是否变化。在某些情况下,这比 Reselect 的效果更好。
比如用 Reselect 的选择一个 todo 描述数组:
import { createSelector } from '@reduxjs/toolkit'
const selectTodoDescriptionsReselect = createSelector(
[(state: RootState) => state.todos],
todos => todos.map(todo => todo.text)
)
只要 state.todos 中任意值变化(例如 todo.completed),该 selector 就会重新计算,尽管派生数组内容没有变,因为生成了新的数组引用。
而用 proxy-memoize:
import { memoize } from 'proxy-memoize'
const selectTodoDescriptionsProxy = memoize((state: RootState) =>
state.todos.map(todo => todo.text)
)
与 Reselect 不同,proxy-memoize 可以检测到这里只访问了 todo.text 字段,因此只有某个 todo.text 字段发生变化时才会重新计算。
缺点和区别包括:
- 所有值都通过单个对象参数传入。
- 它的行为更像“魔法”,而 Reselect 更明确。
- 基于
Proxy的跟踪行为存在一些边缘情况。 - 它的使用并不广泛。
如果 selector 只读取大型输入的一小部分,而你希望无关字段变化时避免重新计算,那么 proxy-memoize 是 Reselect 的一个合理替代方案。
re-reselect
re-reselect 对 Reselect 进行了封装,并增加了“key selector”,从 selector 参数中选择缓存键,再为每个键管理独立的 Reselect selector 实例。由于 Reselect 5 的 weakMapMemoize 已经会为每组参数分别缓存,因此它主要适用于希望用明确的键(例如字符串 ID)而不是引用身份来决定使用哪个缓存条目的场景。
import { createCachedSelector } from 're-reselect'
const selectUsersByLibrary = createCachedSelector(
// inputSelectors
selectUsers,
selectLibraryId,
// 结果函数
(users, libraryId) => expensiveComputation(users, libraryId)
)(
// re-reselect keySelector(接收 selector 的参数)
// 使用 "libraryName" 作为 cacheKey
(_state: RootState, libraryName: string) => libraryName
)
在 React-Redux 中使用 Selector
带参数调用选择器
常见需求是向 selector 函数传递额外参数,但 useSelector 只会以 state 作为唯一参数来调用 selector。
最简单的方案是把匿名 selector 传给 useSelector,并立即调用真正的 selector,传入 state 和其他参数:
import { selectTodoById } from './todosSlice'
import { useAppSelector } from '../../app/hooks'
function TodoListItem({ todoId }: { todoId: string }) {
// Captures `todoId` from scope, gets `state` as an arg, and forwards both
// to the actual selector function to extract the result
const todo = useAppSelector(state => selectTodoById(state, todoId))
}
创建唯一的 Selector 实例
记忆化 selector 通常会由多个组件共享,每个组件传入不同参数调用它。使用 Reselect 5 默认的 weakMapMemoize 时,这种用法可直接工作:共享 selector 会为每组不同参数保留缓存条目,因此各组件不会相互淘汰对方的结果。
如果选择了缓存容量较小的 lruMemoize,或希望组件卸载时立即释放其缓存结果,可以结合selector 工厂和 useMemo 为每个组件创建独立的 selector 实例:
import { useMemo } from 'react'
import { makeSelectItemsByCategory } from './categoriesSlice'
import { useAppSelector } from '../../app/hooks'
function CategoryList({ category }: { category: string }) {
// 在挂载时为每个组件实例创建新的记忆化 selector
const selectItemsByCategory = useMemo(makeSelectItemsByCategory, [])
const itemsByCategory = useAppSelector(state =>
selectItemsByCategory(state, category)
)
}
如果仍使用旧版 connect API,对应的做法是 mapStateToProps 的“工厂函数”形式:mapState 第一次调用时返回新的 mapState 函数。
有效使用 Selectors
虽然 selectors 是 Redux 中常用模式,但经常被误用或误解。以下是正确使用的指南。
将 Selector 与 Reducer 放在一起定义
selector 函数通常定义在 UI 层,直接内联在 useSelector。但这可能导致重复定义匿名函数。
可以把匿名函数抽离并命名:
const selectTodos = (state: RootState) => state.todos
function TodoList() {
const todos = useAppSelector(selectTodos)
}
多个地方可能会使用同样的查询。此外,也可能希望把 state 组织细节封装在 todosSlice 文件里,集中管理。
所以,最好把可复用 selector 定义在对应 reducer 的同一个文件中,比如导出 selectTodos:
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '../../app/store'
interface Todo {
id: string
text: string
completed: boolean
}
const initialState: Todo[] = []
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action: PayloadAction<Todo>) {
state.push(action.payload)
}
}
})
export const { todoAdded } = todosSlice.actions
export default todosSlice.reducer
// Export a reusable selector here
export const selectTodos = (state: RootState) => state.todos
这样如果之后要改 todos 状态结构,只需修改这些 selector,其他代码改动最小。
选择性使用 Selector
过度使用 selector 不好。为每个字段都写一个 selector 会让 Redux 像 Java 类里到处都是 getter/setter。这不会提升代码质量,反而会增加维护难度,难以追踪数据使用位置。
同时,不必所有 selector 都进行 memoization。只有每次调用都会返回新引用,或计算昂贵时,才需要缓存。直接查找返回值的 selector 应该是普通函数,不做缓存。
示例:
// ❌ DO NOT memoize: will always return a consistent reference
const selectTodos = (state: RootState) => state.todos
const selectNestedValue = (state: RootState) => state.some.deeply.nested.field
const selectTodoById = (state: RootState, todoId: string) => state.todos[todoId]
// 🤔 MAYBE memoize: deriving data, but will return a consistent result.
// Memoization might be useful if the selector is used in many places
// or the list being iterated over is long.
const selectItemsTotal = (state: RootState) => {
return state.items.reduce((result, item) => {
return result + item.total
}, 0)
}
const selectAllCompleted = (state: RootState) =>
state.todos.every(todo => todo.completed)
// ✅ SHOULD memoize: returns new references when called
const selectTodoDescriptions = (state: RootState) =>
state.todos.map(todo => todo.text)
按需重塑状态
selectors 不必仅限于直接映射查询,也可以做各种转换,尤其方便准备组件所需的数据格式。
Redux 状态通常是“原始”形态,因为状态应当保持最简,而多个组件可能需要不同的呈现形式。你可以使用 selector 名字来抽取、转换多个 slice 数据,或者合并、筛选等。
组件中也可以部分实现转换逻辑,但抽取为 selectors 有利于复用和测试。
需要时全局化 selectors
编写 slice reducer 时,只知道自己的状态片段,对应的 state 就是那片数据(如 todoSlice 中的 todo 数组)。但 selectors 通常接收整个根状态作为参数,必须知道 slice 状态在根状态中的位置,比如 state.todos。
通常 slice 文件里既有局部的 reducer 逻辑,也有“全局化”的 selectors,接收根状态并在内部查找对应 slice。
这种做“全局化”的 selector,叫做“globalized selectors”;而仅期望接受部分状态作为参数的,叫做“localized selectors”:
// "Globalized" - accepts root state, knows to find data at `state.todos`
const selectAllTodosCompletedGlobalized = (state: RootState) =>
state.todos.every(todo => todo.completed)
// "Localized" - only accepts `todos` as argument, doesn't know where that came from
const selectAllTodosCompletedLocalized = (todos: Todo[]) =>
todos.every(todo => todo.completed)
“局部化” selectors 可通过包装成函数,添加查找 slice 的逻辑,变成“全局化”。
Redux Toolkit 的 createEntityAdapter API就是这一模式的例子。如果不带参数调用 todosAdapter.getSelectors(),它会返回一组“局部化” selector,要求传入_实体 slice 状态_作为参数。如果调用 todosAdapter.getSelectors(state => state.todos),则会返回一组“全局化” selector,要求传入 _Redux 根状态_作为参数。
有时“局部化” selectors 更有用。例如,若有多个 createEntityAdapter 嵌套存储,按域划分聊天室和消息数据,要先选聊天室,再取得消息,这时“局部化” selectors 很方便。
更多信息
- Selector libraries:
- Reselect
proxy-memoize: https://github.com/dai-shi/proxy-memoizere-reselect: https://github.com/toomuchdesign/re-reselect
- Randy Coulman has an excellent series of blog posts on selector architecture and different approaches for globalizing Redux selectors, with tradeoffs: