使用选择器派生数据
本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
- 为什么优秀的 Redux 架构应保持状态最小化并派生额外数据
- 使用选择器函数派生数据和封装查询的原则
- 如何使用 Reselect 库编写记忆化选择器进行优化
- Reselect 的高级使用技巧
- 创建选择器的其他工具和库
- 编写选择器的最佳实践
数据派生
我们特别建议 Redux 应用应保持 Redux 状态最小化,并尽可能从中派生出额外值。
这包括计算过滤列表或求和等操作。例如,待办事项应用会在状态中保存原始的任务对象列表,但在状态更新时在外部派生过滤后的任务列表。同样,检查所有任务是否已完成或剩余任务数量也可以在存储外部计算。
这带来多重优势:
-
实际状态更易阅读
-
减少计算这些额外值并保持与其他数据同步所需的逻辑
-
原始状态仍保留作为参考,不会被替换
这同样适用于 React 状态! 许多用户试图定义 useEffect 钩子等待状态值变化,然后设置派生值如 setAllCompleted(allCompleted)。实际上,该值可以在渲染过程中直接派生使用,无需存入状态:
function TodoList() {
const [todos, setTodos] = useState([])
// Derive the data while rendering
const allTodosCompleted = todos.every(todo => todo.completed)
// render with this value
}
使用选择器计算派生数据
在典型的 Redux 应用中,派生数据的逻辑通常通过称为选择器的函数实现。
选择器主要用于:封装从状态查询特定值的逻辑、实现实际派生值的逻辑,以及通过避免不必要的重复计算来提升性能。
虽然不强制要求所有状态查询都使用选择器,但它们是标准模式且被广泛采用。
选择器基础概念
"选择器函数"指任何接受 Redux 存储状态(或其部分)作为参数,并返回基于该状态数据的函数。
选择器无需特殊库实现,使用箭头函数或 function 关键字均可。例如以下都是有效的选择器函数:
// Arrow function, direct lookup
const selectEntities = state => state.entities
// Function declaration, mapping over an array to derive values
function selectItemIds(state) {
return state.items.map(item => item.id)
}
// Function declaration, encapsulating a deep lookup
function selectSomeSpecificField(state) {
return state.some.deeply.nested.field
}
// Arrow function, deriving values from an array
const selectItemsWhoseNamesStartWith = (items, namePrefix) =>
items.filter(item => item.name.startsWith(namePrefix))
选择器函数可自由命名,但建议以 select 开头并拼接被选值描述。典型示例如 selectTodoById、selectFilteredTodos 和 selectVisibleTodos。
若使用过 React-Redux 的 useSelector 钩子,你可能已熟悉选择器函数的基本概念——传递给 useSelector 的函数必须是选择器:
function TodoList() {
// This anonymous arrow function is a selector!
const todos = useSelector(state => state.todos)
}
选择器函数通常在 Redux 应用的两个不同位置定义:
-
在切片文件中,与 reducer 逻辑并列
-
在组件文件中,可定义在组件外部或
useSelector调用内联
选择器函数可在您能访问完整 Redux 根状态值的任何场景使用,包括 useSelector 钩子、connect 的 mapState 函数、中间件、thunks 和 sagas。例如,thunks 和中间件可通过 getState 参数访问状态,因此您可在其中调用选择器:
function addTodosIfAllowed(todoText) {
return (dispatch, getState) => {
const state = getState()
const canAddTodos = selectCanAddTodos(state)
if (canAddTodos) {
dispatch(todoAdded(todoText))
}
}
}
通常无法在 reducer 内部使用选择器,因为切片 reducer 仅能访问自身的 Redux 状态切片,而大多数选择器要求将_整个_ Redux 根状态作为参数传入。
使用选择器封装状态结构
使用选择器函数的首要原因在于处理 Redux 状态结构时的封装性与可复用性。
假设某个 useSelector 钩子需对 Redux 状态进行特定查询:
const data = useSelector(state => state.some.deeply.nested.field)
这段代码合法且能正常运行,但从架构角度并非最佳实践。设想多个组件均需访问该字段:若需调整此状态位置,您将不得不修改_所有_引用该值的 useSelector 钩子。因此,正如我们推荐使用 action creator 封装 action 创建细节一样,我们建议定义可复用的选择器来封装特定状态位置信息。随后,您可在代码库中多次复用该选择器函数,满足应用任意位置对该数据的检索需求。
理想情况下,仅 reducer 函数和选择器应知晓确切状态结构,因此修改状态位置时只需更新这两部分逻辑。
基于此,建议直接在切片文件中定义可复用选择器,而非总是在组件内定义。
选择器常被描述为**"状态查询"**——您无需关心查询如何获取数据,只需关注请求数据后获得结果。
使用记忆化优化选择器
选择器函数常需执行相对"昂贵"的计算,或创建新对象/数组引用的派生值。这可能影响应用性能,原因如下:
-
与
useSelector或mapState配合的选择器会在每次 dispatch action 后重新执行(无论 Redux 根状态哪部分被更新)。当输入状态未变化时重复执行昂贵计算会浪费 CPU 资源,且多数情况下输入值实际并未改变。 -
useSelector和mapState依赖返回值的===引用相等性检查来决定组件是否重渲染。若选择器_总是_返回新引用,即使派生数据实质未变也会强制组件重渲染。这在返回新数组引用的map()、filter()等数组操作中尤为常见。
例如,该组件写法欠佳,因其 useSelector 调用_总是_返回新数组引用。这意味着组件会在_每次_ dispatch action 后重渲染(即使输入 state.todos 切片未变化):
function TodoList() {
// ❌ WARNING: this _always_ returns a new reference, so it will _always_ re-render!
const completedTodos = useSelector(state =>
state.todos.filter(todo => todo.completed)
)
}
另一示例是需执行"昂贵"数据转换的组件:
function ExampleComplexComponent() {
const data = useSelector(state => {
const initialData = state.data
const filteredData = expensiveFiltering(initialData)
const sortedData = expensiveSorting(filteredData)
const transformedData = expensiveTransformation(sortedData)
return transformedData
})
}
类似地,此"昂贵"逻辑会在_每次_ dispatch action 后重新执行。这不仅可能产生新引用,且除非 state.data 实际变化,否则这些计算纯属冗余。
因此,我们需要一种方法来编写优化的选择器,使其在输入相同时避免重新计算。这正是记忆化概念的用武之地。
记忆化是缓存的一种形式。它通过跟踪函数的输入参数,并存储输入值和计算结果以便后续引用。当函数再次以相同参数调用时,可以直接跳过实际计算过程,返回上次相同输入时生成的结果。这种机制通过仅在输入变化时执行计算来优化性能,并在输入相同时返回相同结果引用。
接下来我们将探讨编写记忆化选择器的几种方案。
使用 Reselect 编写记忆化选择器
Redux 生态传统上使用名为 Reselect 的库来创建记忆化选择器函数。此外还有类似的替代库,以及围绕 Reselect 的多种变体和封装方案——我们稍后会讨论这些。
createSelector 概述
Reselect 提供了 createSelector 函数来生成记忆化选择器。createSelector 接收一个或多个"输入选择器"函数,加上一个"输出选择器"函数,并返回一个新的选择器函数供您使用。
createSelector 已作为核心功能集成在 官方 Redux Toolkit 包 中,并重新导出以便使用。
createSelector 可接受多个输入选择器,这些选择器既可以作为独立参数传递,也可以放入数组。所有输入选择器的结果将作为独立参数传递给输出选择器:
const selectA = state => state.a
const selectB = state => state.b
const selectC = state => state.c
const selectABC = createSelector([selectA, selectB, selectC], (a, b, c) => {
// do something with a, b, and c, and return a result
return a + b + c
})
// Call the selector function and get a result
const abc = selectABC(state)
// could also be written as separate arguments, and works exactly the same
const selectABC2 = createSelector(selectA, selectB, selectC, (a, b, c) => {
// do something with a, b, and c, and return a result
return a + b + c
})
调用选择器时,Reselect 会用所有给定参数执行输入选择器,并检查返回值。如果任何结果与之前相比 === 不同,它将重新执行输出选择器并传入这些结果作为参数。如果所有结果都与上次相同,则跳过输出选择器的执行,直接返回之前缓存的结果。
这意味着输入选择器通常只需提取并返回值,而转换工作应由输出选择器完成。
常见错误是编写一个提取值或进行派生的"输入选择器",然后让"输出选择器"直接返回结果:
// ❌ BROKEN: this will not memoize correctly, and does nothing useful!
const brokenSelector = createSelector(
state => state.todos,
todos => todos
)
任何仅返回输入的输出选择器都是错误的! 输出选择器必须包含转换逻辑。
同理,记忆化选择器绝不应使用 state => state 作为输入!这将导致选择器总是重新计算。
在典型 Reselect 用法中,顶层输入选择器应设计为仅返回状态对象中嵌套值的简单函数。随后使用 createSelector 创建记忆化选择器,接收这些值作为输入并生成新派生值:
const selectTodos = state => state.todos.items
const selectCurrentUser = state => state.users.currentUser
const selectTodosForCurrentUser = createSelector(
[selectTodos, selectCurrentUser],
(todos, currentUser) => {
console.log('Output selector running')
return todos.filter(todo => todo.ownerId === currentUser.userId)
}
)
const todosForCurrentUser1 = selectTodosForCurrentUser(state)
// Log: "Output selector running"
const todosForCurrentUser2 = selectTodosForCurrentUser(state)
// No log output
console.log(todosForCurrentUser1 === todosForCurrentUser2)
// true
注意第二次调用 selectTodosForCurrentUser 时,输出选择器并未执行。由于 selectTodos 和 selectCurrentUser 的结果与首次调用相同,selectTodosForCurrentUser 直接返回了首次调用的记忆化结果。
createSelector 行为特性
需特别注意:默认情况下 createSelector 仅记忆最近一组参数。这意味着使用不同输入反复调用选择器时,它仍会返回结果,但需要持续重新执行输出选择器:
const a = someSelector(state, 1) // first call, not memoized
const b = someSelector(state, 1) // same inputs, memoized
const c = someSelector(state, 2) // different inputs, not memoized
const d = someSelector(state, 1) // different inputs from last time, not memoized
此外,选择器可接收多个参数。Reselect 会使用这些确切参数调用所有输入选择器:
const selectItems = state => state.items
const selectItemId = (state, itemId) => itemId
const selectItemById = createSelector(
[selectItems, selectItemId],
(items, itemId) => items[itemId]
)
const item = selectItemById(state, 42)
/*
Internally, Reselect does something like this:
const firstArg = selectItems(state, 42);
const secondArg = selectItemId(state, 42);
const result = outputSelector(firstArg, secondArg);
return result;
*/
因此所有输入选择器必须能够接收相同类型的参数,否则选择器将无法正常工作。
const selectItems = state => state.items
// expects a number as the second argument
const selectItemId = (state, itemId) => itemId
// expects an object as the second argument
const selectOtherField = (state, someObject) => someObject.someField
const selectItemById = createSelector(
[selectItems, selectItemId, selectOtherField],
(items, itemId, someField) => items[itemId]
)
在此示例中,selectItemId 期望其第二个参数为简单值,而 selectOtherField 则期望第二个参数是对象。如果调用 selectItemById(state, 42),selectOtherField 会报错,因为它试图访问 42.someField。
Reselect 使用模式与限制
嵌套选择器
可以将 createSelector 生成的选择器作为其他选择器的输入。在此示例中,selectCompletedTodos 选择器被用作 selectCompletedTodoDescriptions 的输入:
const selectTodos = state => state.todos
const selectCompletedTodos = createSelector([selectTodos], todos =>
todos.filter(todo => todo.completed)
)
const selectCompletedTodoDescriptions = createSelector(
[selectCompletedTodos],
completedTodos => completedTodos.map(todo => todo.text)
)
传递输入参数
Reselect 生成的选择器函数可接受任意数量参数:selectThings(a, b, c, d, e)。但决定是否重新运行输出的关键不是参数数量或参数引用是否更新,而是定义的"输入选择器"及其结果是否变化。同样,"输出选择器"的参数完全取决于输入选择器的返回值。
这意味着若想向输出选择器传递额外参数,必须定义从原始选择器参数中提取这些值的输入选择器:
const selectItemsByCategory = createSelector(
[
// Usual first input - extract value from `state`
state => state.items,
// Take the second arg, `category`, and forward to the output selector
(state, category) => category
],
// Output selector gets (`items, category)` as args
(items, category) => items.filter(item => item.category === category)
)
随后可以这样使用选择器:
const electronicItems = selectItemsByCategory(state, "electronics");
为保持一致性,可考虑将额外参数作为单个对象传递给选择器,例如 selectThings(state, otherArgs),然后从 otherArgs 对象提取值。
选择器工厂
createSelector 的默认缓存大小仅为 1,且每个选择器实例独立生效。当单个选择器函数需在不同位置重用且输入不同时,这会引发问题。
解决方案是创建"选择器工厂"——通过函数运行 createSelector(),每次调用生成新的唯一选择器实例:
const makeSelectItemsByCategory = () => {
const selectItemsByCategory = createSelector(
[state => state.items, (state, category) => category],
(items, category) => items.filter(item => item.category === category)
)
return selectItemsByCategory
}
当多个相似 UI 组件需基于 props 派生不同数据子集时,这种方法特别有用。
其他选择器库
尽管 Reselect 是 Redux 生态中最广泛使用的选择器库,但还有许多其他库能解决类似问题或扩展 Reselect 的功能。
proxy-memoize
proxy-memoize 是相对较新的记忆化选择器库,采用独特实现方式。它依赖 ES2015 Proxy 对象追踪嵌套值的读取操作,后续调用时仅比较这些嵌套值的变化。某些情况下这能提供优于 Reselect 的结果。
典型示例是派生待办事项描述数组的选择器:
import { createSelector } from 'reselect'
const selectTodoDescriptionsReselect = createSelector(
[state => state.todos],
todos => todos.map(todo => todo.text)
)
遗憾的是,当 state.todos 内任意值变化(如切换 todo.completed 标志)时,此选择器会重新计算派生数组。虽然派生数组的_内容_未变,但由于输入的 todos 数组引用更新,必须生成新输出数组。
使用 proxy-memoize 的相同选择器可能如下:
import { memoize } from 'proxy-memoize'
const selectTodoDescriptionsProxy = memoize(state =>
state.todos.map(todo => todo.text)
)
与 Reselect 不同,proxy-memoize 能检测到仅访问了 todo.text 字段,且仅当某个 todo.text 变化时才重新计算。
该库还内置 size 选项,可为单个选择器实例设置所需缓存大小。
相比 Reselect 存在以下差异和权衡:
-
所有值都作为单个对象参数传递
-
要求环境支持 ES2015
Proxy对象(不支持 IE11) -
实现更"魔法化",而 Reselect 更显式
-
基于
Proxy的追踪行为存在边界情况 -
它较新且使用不够广泛
尽管如此,我们仍正式鼓励考虑使用 proxy-memoize 作为 Reselect 的可行替代方案。
re-reselect
https://github.com/toomuchdesign/re-reselect 通过允许定义"键选择器"来增强 Reselect 的缓存行为。它用于在内部管理多个 Reselect 选择器实例,从而简化跨组件的使用。
import { createCachedSelector } from 're-reselect'
const getUsersByLibrary = createCachedSelector(
// inputSelectors
getUsers,
getLibraryId,
// resultFunc
(users, libraryId) => expensiveComputation(users, libraryId)
)(
// re-reselect keySelector (receives selectors' arguments)
// Use "libraryName" as cacheKey
(_state_, libraryName) => libraryName
)
reselect-tools
有时很难追踪多个 Reselect 选择器间的关联关系及其重新计算的原因。https://github.com/skortchmark9/reselect-tools 提供了追踪选择器依赖关系的方法,并自带开发者工具用于可视化这些关联关系和检查选择器值。
redux-views
https://github.com/josepot/redux-views 与 re-reselect 类似,提供为每个项目选择唯一键以实现一致缓存的方法。它被设计为近乎可直接替换 Reselect 的方案,甚至被提议作为 Reselect 第 5 版的备选方案。
Reselect 第 5 版提案
我们在 Reselect 仓库中开启了路线图讨论,旨在探索未来版本的潜在改进,例如优化 API 以支持更大缓存容量、使用 TypeScript 重写代码库等改进方向。欢迎社区在该讨论中提供反馈:
Reselect 第 5 版路线图讨论:目标与 API 设计
在 React-Redux 中使用选择器
调用带参数的选择器
通常需要向选择器函数传递额外参数。但 useSelector 总是以单一参数(Redux 根状态 state)调用选择器。
最简单的解决方案是向 useSelector 传递匿名选择器,并立即用 state 和额外参数调用实际选择器:
import { selectTodoById } from './todosSlice'
function TodoListitem({ todoId }) {
// Captures `todoId` from scope, gets `state` as an arg, and forwards both
// to the actual selector function to extract the result
const todo = useSelector(state => selectTodoById(state, todoId))
}
创建唯一选择器实例
当选择器函数需在多个组件间复用时,若各组件使用不同参数调用选择器,会破坏记忆化机制——选择器连续多次调用不会收到相同参数,因此永远无法返回缓存值。
标准解决方案是在组件内创建记忆化选择器的唯一实例,再将其与 useSelector 配合使用。这样每个组件能始终向其专属选择器实例传递相同参数,该选择器即可正确记忆结果。
对于函数组件,通常使用 useMemo 或 useCallback 实现:
import { makeSelectItemsByCategory } from './categoriesSlice'
function CategoryList({ category }) {
// Create a new memoized selector, for each component instance, on mount
const selectItemsByCategory = useMemo(makeSelectItemsByCategory, [])
const itemsByCategory = useSelector(state =>
selectItemsByCategory(state, category)
)
}
对于使用 connect 的类组件,可通过 mapState 的高级"工厂函数"语法实现:若 mapState 函数首次调用返回新函数,该函数将作为实际 mapState 使用。这创建了可实例化新选择器的闭包:
import { makeSelectItemsByCategory } from './categoriesSlice'
const makeMapState = (state, ownProps) => {
// Closure - create a new unique selector instance here,
// and this will run once for every component instance
const selectItemsByCategory = makeSelectItemsByCategory()
const realMapState = (state, ownProps) => {
return {
itemsByCategory: selectItemsByCategory(state, ownProps.category)
}
}
// Returning a function here will tell `connect` to use it as
// `mapState` instead of the original one given to `connect`
return realMapState
}
export default connect(makeMapState)(CategoryList)
高效使用选择器
虽然选择器是 Redux 应用中的常见模式,但常被误用或误解。以下是正确使用选择器函数的准则。
在 Reducer 旁定义选择器
选择器函数常直接在 useSelector 调用中定义于 UI 层,但这会导致不同文件的选择器存在重复定义,且均为匿名函数。
与其他函数类似,可将匿名函数提取到组件外部并命名:
const selectTodos = state => state.todos
function TodoList() {
const todos = useSelector(selectTodos)
}
但应用多个部分可能需要相同查询逻辑。从设计角度,我们更希望将对 todos 状态组织方式的认知封装在 todosSlice 文件中,作为集中管理的实现细节。
因此,将可复用的选择器定义在对应的 reducer 旁是个好做法。本例中,我们可以从 todosSlice 文件中导出 selectTodos:
import { createSlice } from '@reduxjs/toolkit'
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded(state, action) {
state.push(action.payload)
}
}
})
export const { todoAdded } = todosSlice.actions
export default todosSlice.reducer
// Export a reusable selector here
export const selectTodos = state => state.todos
这样当我们需要更新 todos 切片状态的结构时,相关选择器就在同一位置可同步更新,从而最大限度减少对其他代码的影响。
平衡选择器的使用
应用中可能添加过多选择器。为每个字段单独创建选择器函数并非良策! 这会使 Redux 变得类似带有每个字段 getter/setter 的 Java 类。不仅无法提升代码质量,反而可能恶化代码——维护大量额外选择器需要大量工作,且更难追踪值的使用位置。
同样地,不要为每个选择器都添加记忆化! 仅当选择器每次运行时返回新引用,或执行的计算逻辑开销较大时,才需要记忆化。直接查找并返回值的选择器函数应保持普通函数形式,无需记忆化。
何时需要/不需要记忆化的示例:
// ❌ DO NOT memoize: will always return a consistent reference
const selectTodos = state => state.todos
const selectNestedValue = state => state.some.deeply.nested.field
const selectTodoById = (state, todoId) => 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 => {
return state.items.reduce((result, item) => {
return result + item.total
}, 0)
}
const selectAllCompleted = state => state.todos.every(todo => todo.completed)
// ✅ SHOULD memoize: returns new references when called
const selectTodoDescriptions = state => state.todos.map(todo => todo.text)
按组件需求重塑状态
选择器不限于直接查找——它们可以在内部执行任何必要的转换逻辑。这对于准备特定组件所需数据尤其有价值。
Redux 状态通常以"原始"形式存储数据,因为状态应保持最小化,且多个组件可能需要以不同方式呈现相同数据。选择器不仅可用于提取状态,还能根据当前组件的需求重塑状态。这包括从根状态多个切片提取数据、获取特定值、合并不同数据片段或执行任何有益的转换。
组件中包含部分此类逻辑也无妨,但将转换逻辑抽离为独立选择器能获得更好的可复用性和可测试性。
按需全局化选择器
编写切片 reducer 和选择器存在固有差异:切片 reducer 仅了解自身状态——对 reducer 而言,其 state 就是全部存在(例如 todoSlice 中的待办事项数组)。而选择器通常以整个 Redux 根状态作为参数。这意味着它们必须知道该切片数据在根状态中的位置(如 state.todos),尽管该位置直到创建根 reducer 时才真正定义(通常在应用全局 store 设置逻辑中)。
典型切片文件常同时包含这两种模式。这在中小型应用中是可接受的。但根据应用架构,可能需要进一步抽象选择器,使其无需知道切片状态位置——该位置需显式传递给它们。
我们将此模式称为"全局化"选择器。"全局化"选择器接受 Redux 根状态作为参数,并知晓如何找到对应状态切片执行实际逻辑;"本地化"选择器则仅期望状态片段作为参数,不关心其在根状态中的位置:
// "Globalized" - accepts root state, knows to find data at `state.todos`
const selectAllTodosCompletedGlobalized = state =>
state.todos.every(todo => todo.completed)
// "Localized" - only accepts `todos` as argument, doesn't know where that came from
const selectAllTodosCompletedLocalized = todos =>
todos.every(todo => todo.completed)
通过用知晓如何检索对应状态切片并传递的函数包裹"本地化"选择器,可将其转换为"全局化"选择器。
Redux Toolkit 的 createEntityAdapter API 正是这种模式的典型范例。当不带参数调用 todosAdapter.getSelectors() 时,它会返回一组"局部化"选择器,这些选择器以_实体切片状态_作为参数。而调用 todosAdapter.getSelectors(state => state.todos) 则会返回"全局化"选择器,它们需要以_Redux 根状态_作为调用参数。
"局部化"版本的选择器还可能带来其他优势。例如,在高级场景中,当我们需要在存储中嵌套多个 createEntityAdapter 数据副本时(比如用 chatRoomsAdapter 跟踪聊天室,而每个聊天室定义又包含用于存储消息的 chatMessagesAdapter 状态)。此时无法直接查找每个房间的消息——必须先获取房间对象,再从中选择消息。如果配备消息的"局部化"选择器集合,这一过程将更加便捷。
扩展阅读
-
选择器库:
- Reselect:https://github.com/reduxjs/reselect
proxy-memoize:https://github.com/dai-shi/proxy-memoizere-reselect:https://github.com/toomuchdesign/re-reselectreselect-tools:https://github.com/skortchmark9/reselect-toolsredux-views:https://github.com/josepot/redux-views
-
Randy Coulman 撰写了一系列关于选择器架构和 Redux 选择器全局化方法的优秀博客文章,并分析了不同方案的权衡: