React-Redux如何决定重新渲染:选择器、相等性判断与类型化钩子
React-Redux内部机制学习指南:Provider、useSelector相等性判断、记忆化选择器、connect()函数、带有withTypes()的类型化钩子以及罕见的边缘情况。
React-Redux 的表面看起来很简单:一个提供者组件和几个钩子函数。然而在庞大的代码库中,同样的问题不断出现。为什么每次触发动作时这个组件都会重新渲染?为什么返回对象的选择器会与 mapStateToProps 表现不同?何时该使用 shallowEqual,RootState 和 AppDispatch 实际上描述了什么,而在 React 18 中是否仍然需要 batch()?
本指南通过一条主线来回答这些问题:从动作被触发到组件重新渲染之间发生了什么。一旦理清了这条路径,各个 API 就不再是需要记忆的列表,而是成为你可以理解、调试,并在代码审查或面试中解释的整体系统的一部分。
useSelector()
useDispatch()
<Provider />
当前建议:React-Redux 的维护者推荐将 Hooks API 作为组件的默认选择。
connect()功能仍然被支持,且值得了解,因为许多现有代码仍依赖它。
什么是 React-Redux以及它的定位
Redux 负责管理状态、运行 reducer 并通知监听器;React 则负责渲染 UI。React-Redux 是官方提供的绑定机制,它让组件能够从存储中读取数据并触发更新,同时由 React 负责渲染工作。
Redux
│
│ Application State
▼
React-Redux
│
│ Integration
▼
React
│
│ UI
▼
User
具体而言,这种绑定机制为组件提供了四种功能:
- 从 Redux 状态中读取值
- 监听这些值的变更
- 向存储发送动作
- 将存储的更新集成到 React 的渲染周期中
实现这些功能的现代入口是三个 Hooks:
useSelector()
useDispatch()
useStore()
还有让存储可访问的组件:
<Provider />
对于较旧的代码,还有高阶组件 API,它仍然得到完全支持:
connect()
Redux 和 React-Redux 是不同的包
Redux 本身是状态容器,包含以下概念:
Store
State
Actions
Reducers
Dispatch
Middleware
Selectors
React-Redux 只是桥梁,它导出的所有内容都是用于与 React 交互的:
Provider
useSelector
useDispatch
useStore
connect
将这些层次结构叠加在一起,看起来是这样的:
Redux
┌───────────────┐
│ Store │
│ State │
│ Reducers │
│ Actions │
│ Middleware │
└───────┬───────┘
│
▼
React-Redux
┌───────────────┐
│ Provider │
│ useSelector │
│ useDispatch │
│ useStore │
│ connect │
└───────┬───────┘
│
▼
React
如果问题涉及状态如何变化(还原器、中间件、动作格式),则属于 Redux 的范畴;如果问题涉及组件何时检测到变化或进行渲染,则属于 React-Redux 的范畴。
需要牢记的核心循环
在使用任何 API 之前,先牢记这个流程:遍历选择器,根据交互触发操作,将状态更新为新值,再次运行选择器,只有当选中的值发生变化时才进行渲染。
React Component
│
│ useSelector()
▼
Redux Store
│
│ current state
▼
Component
│
│ user interaction
▼
useDispatch()
│
│ dispatch(action)
▼
Reducer
│
▼
New Redux State
│
▼
useSelector()
│
▼
Re-render if
selected value
changed
几乎所有关于 React-Redux 的性能问题都与该流程的最后一步有关。
Provider:让存储可被访问
Hook 只能与在其层级结构中更高位置的存储进行交互。<Provider>的作用就是将存储放置在该位置。通常只需为应用的根元素包裹一次该组件:
import { Provider } from 'react-redux'
import { store } from './store'
function AppRoot() {
return (
<Provider store={store}>
<App />
</Provider>
)
}
在底层实现中,Provider 会将存储放入 React 上下文。这样,无论多深的子组件都可以无需通过 prop 逐层传递即可访问到该存储:
<Provider store={store}>
│
├── App
│ ├── Header
│ ├── Dashboard
│ ├── UserProfile
│ └── Settings
│
└── Every descendant
can use Redux
如果某个组件在 Provider 外部调用钩子,那么就没有可读取的存储,该钩子会失败(实际上会抛出错误,提示缺少上下文值):
useSelector(...)
useDispatch(...)
这种情况常见于那些没有用测试 Provider 包装组件就直接进行渲染的单元测试中。
useSelector:组件如何读取状态
useSelector() 是你会最常使用的钩子。你需要为它提供一个函数,该函数接收整个 Redux 状态并返回组件所需的那一部分数据:
const count = useSelector(
state => state.counter.value
)
数据流的结构很简单:
Redux State
│
▼
useSelector()
│
▼
Selected Value
│
▼
React Component
由于 React-Redux 可能会比你预期的更频繁地调用你的选择器(在渲染时、派发动作后、开发调试期间),因此选择器必须是纯函数:相同的输入对应相同的输出,且不能有副作用。
每次渲染和派发动作时钩子的作用
使用一个用于选择当前用户的选择器:
const user = useSelector(
state => state.auth.user
)
在这一行代码的背后,React-Redux会执行一系列简单操作:
- 读取当前的存储状态
- 使用该状态调用选择器
- 将返回的值作为“上次选中”的结果保存下来
- 为该组件注册对存储状态的监听
- 在每次发送动作后重新运行选择器
- 将之前的结果与新的结果进行比较
- 只有当两者不同时才安排重新渲染
几乎所有令人意外的行为都源自第6步。
默认的比较方式是严格引用相等性
默认情况下,该钩子会使用
useSelector()
===来比较结果:
previousResult === newResult
当比较结果为
true
该选择器让 React-Redux 没有理由再次渲染该组件。当它返回特定值时
false
该组件就会被安排重新渲染。
这与 connect() 有明显区别,因为 connect() 仅会对 mapStateToProps 返回的对象进行浅比较。在 connect() 下表现正常的代码,若直接迁移到钩子函数且不调整选择器,就会在每个动作触发时都开始重新渲染。
为何每次返回新对象都会导致重新渲染
最初尝试读取两个值时的常见写法如下:
const data = useSelector(state => ({
user: state.user,
count: state.count
}))
这些值本身没有问题,但箭头函数每次执行时都会创建一个全新的对象字面量:
{
user: ...,
count: ...
}
即使两个对象字面量的内容完全相同,它们仍然是两个不同的对象,因此该检查
oldObject === newObject
的结果始终为
false
其后果是,应用中的每个操作都会触发该链式反应,包括那些与用户或计数无关的操作:
Action dispatched
↓
Selector executes
↓
New object created
↓
Different reference
↓
Component re-renders
这是 React-Redux 中最常见的性能问题之一。文档列出了三种解决方案:使用独立的选择器、自定义相等性函数如 shallowEqual,或是使用记忆化选择器。
解决方案一:每个值只调用一次 useSelector
不要将多个值打包到一个对象中,
const data = useSelector(state => ({
user: state.user,
count: state.count
}))
而应将读取操作拆分为独立的钩子函数:
const user = useSelector(
state => state.user
)
const count = useSelector(
state => state.count
)
现在每个选择器返回的都是存储中已存在的值,而非该值的新的包装对象。当存储这些值的状态没有发生变化时
user unchanged
count unchanged
它们的引用是相同的,因此 === 比较会通过。
在同一个组件中多次调用钩子函数并不会产生任何惩罚。如果单次调度改变了多个选定的值,React-Redux会批量处理这些更新,因此该组件仍只会因这次调度而渲染一次,而非每次调用钩子都重新渲染。
解决方案二:为特定对象使用 shallowEqual
有时返回对象确实是最佳选择,比如当组件需要使用一组相关的字段时。这种情况下可以将shallowEqual作为比较函数传入:
import {
useSelector,
shallowEqual
} from 'react-redux';
const data = useSelector(
state => ({
user: state.user,
count: state.count
}),
shallowEqual
)
通过这种方式,React-Redux会使用===来比较旧对象和新对象的每个顶层字段,而非直接比较两个对象的引用。只要字段值相同,新的包装对象就被视为未发生变化。
最新版本还允许通过选项对象来指定比较方式:
const data = useSelector(
selector,
{
equalityFn: shallowEqual
}
)
何时适合使用 shallowEqual
当对值进行分组能让组件更易理解时,可使用 shallowEqual。切勿将其视为会自动应用于每次调用的默认选项:
useSelector(selector, shallowEqual)
更好的首要问题是该组件是否可以直接单独选取每个值。大多数情况下是可以的,这样处理后的结果也更便于查看:
const user = useSelector(
state => state.auth.user
)
const permissions = useSelector(
state => state.auth.permissions
)
需记住 shallowEqual 只会比较一层深度。如果返回对象中的某个字段本身又是新生成的数组或对象,比较仍会失败。
用于获取数据的选择器
选择器的功能不仅限于提取字段,还可以用于计算派生数据,比如过滤后的列表:
const selectCompletedTodos = state =>
state.todos.filter(
todo => todo.completed
)
问题在于
filter()
总会分配一个新的数组。即便待办列表没有发生任何实质性的变化,也是如此。
oldArray !== newArray
该组件在每次操作后都会重新渲染。记忆化通过将输出结果与输入进行缓存来解决这个问题。当输入对象与上次相同,就会直接返回缓存的计算结果:
Same inputs
↓
Return cached result
当输入发生变化时,就会重新执行计算:
Changed inputs
↓
Recalculate
使用 Reselect 创建 Selector
实现这一功能的标准工具是 Reselect 中的 createSelector 函数(Redux Toolkit 会重新导出它)。你需要列出输入选择器,再定义一个仅在这些输入发生变化时才会被调用的结果函数:
import { createSelector } from 'reselect';
const selectCompletedTodos = createSelector(
state => state.todos,
todos =>
todos.filter(todo => todo.completed)
)
该组件可以像使用其他选择器一样使用它:
const todos = useSelector(
selectCompletedTodos
)
虽然 state.todos 是同一个数组引用,但由于选择器返回的是之前过滤后的数组,因此 === 比较会成立。需要注意的是:经过记忆化的选择器会携带缓存,因此其实例的创建位置很重要。
在模块级别一次性创建仅依赖状态的记忆化选择器
当一个记忆化选择器仅依赖于Redux状态时,
const selectCompletedTodos = createSelector(
state => state.todos,
todos => ...
)
应在任何组件之外声明它:
const selectCompletedTodos = createSelector(...)
然后在组件中引用它:
function TodoList() {
const todos = useSelector(
selectCompletedTodos
)
}
如果将createSelector放在组件内部执行,每次渲染都会创建一个带有空缓存的新选择器,这样记忆化功能就毫无作用。而模块级别的实例则会在多次渲染中保持不变。
同时需要属性的记忆化选择器
那些读取属性的普通、非记忆化选择器并无问题。这类选择器不会保存任何缓存,因此不可能出错:
function TodoListItem({ id }) {
const todo = useSelector(
state => state.todos[id]
)
return <div>{todo.text}</div>
}
一旦记忆化选择器需要同时依赖状态和属性,情况就会变得更为复杂。
Redux State + Component Props
此类选择器会为其最后一个参数缓存结果。如果多个列表项共享同一个实例但每个都有不同的id,它们会不断使彼此的缓存失效。对于单个组件而言,使用useMemo创建选择器通常就足够了;而对于多个组件,则需要了解所使用库的缓存策略(缓存大小或每个组件一个实例)。这在高级面试以及处理长列表时经常会遇到。
选择器必须保持纯函数特性
选择器应当仅仅是状态的简单函数:
State
↓
Selector
↓
Value
绝不能让其他操作影响到选择器的内部逻辑:
State
↓
Selector
↓
API call
↓
Mutation
↓
Side effect
以下是应当避免的那种选择器;日志记录、网络请求和数据修改都不属于选择器的功能范畴:
const selectUser = state => {
console.log('side effect')
// API call ❌
// mutation ❌
return state.user
}
在选择器中使用props
在组件中内联定义的选择器可以直接使用该组件的props:
function TodoItem({ id }) {
const todo = useSelector(
state => state.todos[id]
)
return <div>{todo.text}</div>
}
该值通过闭包从属性传递到选择器中:
id
↓
closure
↓
selector
↓
state.todos[id]
这与mapStateToProps有明显不同,后者会将ownProps作为第二个参数传入。而useSelector()则完全不传递任何属性,因此必须依赖闭包或那些接受额外参数的选择器工厂。
useDispatch:发送动作
useSelector()负责读取数据,useSelector
↓
READ
那么useDispatch()则负责写入数据:
useDispatch
↓
DISPATCH
它返回存储对象的dispatch函数,可在事件处理程序中调用该函数:
const dispatch = useDispatch()
function handleClick() {
dispatch(increment())
}
或直接在JSX中调用:
<button
onClick={() => dispatch(increment())}
>
Increment
</button>
dispatch所触发的操作
从起点到终点追踪一次点击操作,可以进一步印证之前的核心流程:
User clicks button
↓
dispatch(action)
↓
Redux
↓
Reducer
↓
New state
↓
useSelector()
↓
Component updates
带有动作创建器和负载的具体调用方式如下:
dispatch(
addTodo({
id: 1,
text: 'Learn Redux'
})
)
用于记忆化子组件的稳定回调
大多数调度回调并不需要使用 useCallback。例外情况是当某个处理函数被传递给被 React.memo 包装的子组件时:
const increment = () =>
dispatch(incrementAction())
该处理函数会被传递给被 React.memo 包装的子组件:
<MyButton
onIncrement={increment}
/>
由于父组件的每次渲染都会重新创建箭头函数,因此记忆化后的子组件每次都会收到新的属性并继续渲染。将处理函数包裹在 useCallback 中可以解决这个问题:
const increment = useCallback(
() => dispatch(incrementAction()),
[dispatch]
)
与记忆化子组件一起使用:
const MyButton = React.memo(...)
将 dispatch 列为依赖项是安全的:只要 Provider 接收到的存储实例不变,它的标识就不会改变。
useStore:直接访问,很少需要
第三个钩子会将存储对象本身交给你:
const store = useStore()
通过它,你可以调用原始的存储方法:
store.getState()
store.dispatch(...)
store.subscribe(...)
组件几乎不应使用这种访问方式来渲染数据。读取操作会经过
useSelector()
而写入操作则通过
useDispatch()
文档将useStore()视为处理罕见情况的应急手段,比如注入 reducer,而非用于日常的读取操作。
为何在渲染中使用store.getState()会导致数据过时
考虑一个直接从存储中读取数据的组件:
function Component() {
const store = useStore()
const user = store.getState().user
return <div>{user.name}</div>
}
该组件会正确渲染一次,之后就会落后于实际数据。由于getState()只是单次读取且没有订阅机制,后续的数据变化不会触发 React 重新渲染此组件。而使用订阅方式的版本则能保持同步:
const user = useSelector(
state => state.user
)
三个钩子的概览
┌────────────────────────────┐
│ React-Redux Hooks │
├────────────────────────────┤
│ useSelector() │ → READ
│ useDispatch() │ → DISPATCH
│ useStore() │ → STORE ACCESS
└────────────────────────────┘
在日常的组件代码中,前两种方式几乎承担了所有工作:
90%+
useSelector()
useDispatch()
useStore()仅偶尔出现。
connect():高阶组件 API
虽然钩子是推荐的方式,但connect()依然存在,许多长期使用的代码库都是基于它构建的。你可能会遇到这样的代码:
connect(
mapStateToProps,
mapDispatchToProps
)(Component)
connect 如何将存储数据映射为属性
其思维模型是一个包装器,可将存储数据和派发函数转换为普通的属性:
Redux Store
│
▼
connect()
│
├── mapStateToProps
│
└── mapDispatchToProps
│
▼
Component Props
mapStateToProps接收状态并返回一个属性对象:
const mapStateToProps = state => ({
user: state.auth.user,
count: state.counter.value
})
实际上它执行的是这样的转换:
Redux State
↓
Component Props
经过包装的组件仍然只是其属性的普通函数,且并不知道 Redux 的存在:
function User({ user, count }) {
return (
<div>
{user.name}
{count}
</div>
)
}
mapDispatchToProps 会提供回调函数。在其函数形式中,你会收到 dispatch,需要自己构建处理函数:
const mapDispatchToProps =
dispatch => ({
increment: () =>
dispatch(increment())
})
随后组件会将这些处理函数作为属性传入:
props.increment()
mapDispatchToProps 的对象简写形式
这种更简洁的形式会传递一个包含动作创建函数的对象:
const mapDispatchToProps = {
increment,
decrement
}
React-Redux 会为每个动作创建函数绑定上下文,这样调用该属性就能触发相应操作。这通常是更整洁的方案。
四种常见的 connect 使用方式
你会遇到四种不同的形式。如果没有参数,组件只会收到 dispatch 作为属性:
connect()(Component)
仅当存在状态映射器时,组件虽然可以读取数据,但不会收到已绑定的动作创建函数:
connect(
mapStateToProps
)(Component)
当第一个参数为 null 时,该组件不会监听存储状态,仅能接收派发过来的属性:
connect(
null,
mapDispatchToProps
)(Component)
而当两个参数都存在时,它既能读取数据又能派发操作:
connect(
mapStateToProps,
mapDispatchToProps
)(Component)
知道 null 形式会跳过订阅操作这一点很有用:这是一种无需让组件在存储状态变化时重新渲染,就能为其提供派发功能的简单方法。
connect 会返回一个新组件
调用
connect(
mapStateToProps,
mapDispatchToProps
)(MyComponent)
不会修改 MyComponent。它会生成一个独立的包装组件,将原组件渲染在其内部:
MyComponent
│
▼
connect()
│
▼
ConnectedComponent
这就是为什么使用 connect 的模块通常会将包装后的版本作为默认导出,有时还会为测试目的单独导出原始组件。
钩子函数与 connect 的选择
对于新应用而言,答案很明确:
New React application
↓
Hooks
对于已有的实现,实际可行的解决方案是:
Existing connect()
↓
Understand and maintain it
钩子功能能减少样板代码,无需额外的包装组件,还能让 TypeScript 的使用更加便捷,正因如此它们才成为默认选择。现有的 connect() 组件无需重写;反正以后修改这些组件时再将其转换即可。
一行解释相等性的差异
请记住以下对应关系:
useSelector()
↓
=== reference equality
与……相对
connect()
↓
shallow equality
许多“重构前能正常工作”的错误都源于此:在 mapStateToProps 中使用新创建的对象是安全的,但在 useSelector() 中却无法通过 === 比较。
使用 TypeScript 为 React-Redux 添加类型定义
React-Redux 自带类型定义,文档中也介绍了标准的类型配置方式。这种配置主要围绕六个名称展开:
RootState
AppDispatch
AppStore
useAppSelector
useAppDispatch
useAppStore
RootState:从存储中推断状态类型
对于使用 Redux Toolkit 配置的商店,
const store = configureStore({
reducer: {
counter: counterReducer,
users: usersReducer
}
})
可从 getState 返回的值中推导出状态类型:
export type RootState =
ReturnType<typeof store.getState>
这样比手动编写结构更高效,
type RootState = {
counter: CounterState
users: UsersState
}
因为推导出的类型会自动跟随 reducer 的变化。
AppDispatch:包含中间件的调度类型
以相同方式推导调度类型:
export type AppDispatch =
typeof store.dispatch
普通的 Redux Dispatch 类型仅支持普通动作;而推导出的类型会反映你所使用的中间件,这在以下情况非常重要:
- 会改变
dispatch接受参数的中间件 - thunk 函数
- 自定义的调度逻辑
- 各类异步动作
如果没有 AppDispatch,调度 thunk 函数就会导致类型错误。
AppStore:商店本身的类型
商店的类型只需再进行一次推导即可得到:
export type AppStore =
typeof store
这样一来,每个功能模块都有唯一的真实数据来源。状态类型:
RootState
↓
state type
调度类型:
AppDispatch
↓
dispatch type
存储类型:
AppStore
↓
store type
当需要为每个请求或测试创建存储并传递它时,AppStore就显得特别实用。
使用withTypes()的预定义钩子
从React-Redux 9.1.0版本开始,每个钩子都提供了一个.withTypes()方法:
useDispatch.withTypes()
useSelector.withTypes()
useStore.withTypes()
文档中描述的模式允许一次性从中生成特定于应用程序的钩子:
export const useAppDispatch =
useDispatch.withTypes<AppDispatch>()
export const useAppSelector =
useSelector.withTypes<RootState>()
export const useAppStore =
useStore.withTypes<AppStore>()
类型化钩子能为您节省什么
如果没有这些钩子,每个选择器都需要显式的注解:
const user = useSelector(
(state: RootState) =>
state.auth.user
)
使用类型化钩子后,
const user = useAppSelector(
state => state.auth.user
)
编译器已经能够知晓
state = RootState
调度的实现方式也是一样的:
const dispatch = useAppDispatch()
此 dispatch 函数可接收 thunk 以及中间件允许的任何其他内容,并会进行全面检查。
应用程序的 hooks.ts 文件
典型的项目会将这些内容放在一个小型模块中:
import {
useDispatch,
useSelector,
useStore
} from 'react-redux'
import type {
RootState,
AppDispatch,
AppStore
} from './store'
export const useAppDispatch =
useDispatch.withTypes<AppDispatch>()
export const useAppSelector =
useSelector.withTypes<RootState>()
export const useAppStore =
useStore.withTypes<AppStore>()
组件从该模块导入,而非直接从 react-redux 导入:
const user = useAppSelector(
state => state.auth.user
)
const dispatch = useAppDispatch()
用于类型化 connect() 代码的 ConnectedProps
对 connect() 组件进行类型标注的代码库中会包含
ConnectedProps
先将 connect 调用拆分为连接器,然后再提取其注入的属性:
const connector = connect(
mapState,
mapDispatch
)
type PropsFromRedux =
ConnectedProps<typeof connector>
PropsFromRedux 能准确描述连接器注入的内容,因此映射类型不会重复出现。
设计优秀的选择器
人们很容易认为选择器只不过是
state => state.user
在规模较大的应用中,选择器更应被视为一种边界,它将状态的存储格式转换为界面所需的形态:
Redux State
↓
Selector
↓
UI-friendly data
例如,决定哪些待办事项可见的规则可以放在一个有名称的函数中:
const selectVisibleTodos =
state =>
state.todos.filter(
todo => !todo.hidden
)
而组件只需获取该函数的返回结果即可:
const todos = useSelector(
selectVisibleTodos
)
这样组件仅负责呈现功能,而规则本身也可以独立进行测试。
好的选择器应当精确
const selectUserName =
state => state.auth.user.name
它只返回组件实际显示的内容,不会多出任何其他信息,因此只有当该名称发生变化时才会触发重新渲染。
选择整个状态几乎总是错误的
const selectEverything =
state => state
每当有任何 reducer 修改了数据,Redux 都会生成一个新的根状态对象。因此,返回根状态的选择器在几乎每个动作执行后都会返回新的引用:
Anything in Redux changes
↓
Root state reference changes
↓
Selector result changes
↓
Component re-renders
React-Redux的开发模式检查会标记这种模式。
保持选择器尽可能具体
建议使用多个针对性的读取方式:
const count =
useSelector(
state => state.counter.value
)
const user =
useSelector(
state => state.auth.currentUser
)
而非一个通用的读取方式:
const state =
useSelector(state => state)
一个实用的准则:
选择组件实际能够使用的最小状态部分。
开发模式下的选择器检查
最新版本的React-Redux会在开发构建中对选择器进行额外检查。其中有两个值得了解其名称。
稳定性检查
第一项检查会使用相同的状态再次调用选择器,并比较两次的结果:
selector(state)
↓
run again with same state
↓
same result?
如果结果是
same reference
则该选择器是稳定的。如果结果不是
new reference
React-Redux 会输出警告,因为对于相同的输入返回新引用的选择器会在每次存储更新时重新渲染其组件。
常见的问题出在前面提到的对象字面量选择器上:
const data = useSelector(
state => ({
count: state.count,
user: state.user
})
)
由于每次调用都会重新构建该对象,检查机制会检测到:
same input
↓
different object
↓
unstable selector
配置检查运行的频率
你可以在 Provider 上为整个应用设置该频率:
<Provider
store={store}
stabilityCheck="always"
>
<App />
</Provider>
或者为某个特定的钩子调用覆盖该设置:
const count = useSelector(
selectCount,
{
devModeChecks: {
stabilityCheck: 'once'
}
}
)
允许的值为:
never
once
always
默认值为 'once',表示在每个钩子的第一次调用时进行检查。这些功能在生产版本中不会运行。
身份函数检查
第二种检查则是寻找那些返回未改变输入的选择器:
state => state
在组件中,其形式如下:
const state = useSelector(
state => state
)
这使该组件与每一次存储状态变化相关联:
Any Redux change
↓
Root state changes
↓
Component re-renders
文档中将此称为身份函数检查。在早期版本中它被称为noopCheck,在较旧的配置中仍可能看到该名称。
解决方法与之前相同:进行替换
const state = useSelector(
state => state
)
改为读取所需的特定值:
const count = useSelector(
state => state.counter.value
)
const user = useSelector(
state => state.auth.currentUser
)
超越选择器的渲染与性能
父组件的渲染仍会级联
useSelector()仅控制由存储状态更新引发的渲染。对于组件在父组件渲染时自动渲染这一常规React规则,它并无作用:
Parent renders
↓
Child renders
即使Redux状态完全没有变化,这种情况也会发生。当某个子组件的渲染成本较高且其属性保持稳定时,应将其包裹在
React.memo()
这与connect()不同,后者的封装方式类似带记忆功能的组件;而基于hooks的组件则无法免费获得此类功能。
将React.memo与useSelector结合使用
在此示例中,该组件会订阅一个计数器,并且还会根据其name属性进行记忆化处理:
const Counter = ({ name }) => {
const count = useSelector(
state => state.counter.value
)
return (
<div>
{name}: {count}
</div>
)
}
export default React.memo(Counter)
这两种机制分别对应了两种渲染触发源:
Redux selector
+
React.memo
↓
More controlled rendering
记忆化本身也有成本,因此需先进行性能分析。如需了解更多导致React不必要的重新渲染的触发因素,请参阅我们关于导致React不必要的重新渲染的常见模式的指南。
适合展示在单页上的性能模型
在每次操作之后,React-Redux会重新运行已订阅组件的选择器,将每个结果与之前的结果进行比较,仅渲染那些结果有所变化的组件:
Redux action
↓
Store updates
↓
Selectors execute
↓
Selector results compared
↓
Changed?
┌───┴────┐
No Yes
│ │
│ ▼
│ Re-render
│
└── No Redux-triggered render
因此,你的任务是编写这样的选择器:
- 仅返回组件所需的数据
- 当没有相关内容发生变化时,返回稳定的引用
- 无需时不要创建新的对象或数组
- 对计算成本较高的派生数据进行缓存
规则一:绝不要选择根状态
useSelector(state => state)
规则二:避免在选择器中创建包装对象
像这样的选择器会在每次运行时都分配内存:
useSelector(state => ({
user: state.user,
count: state.count
}))
只有在你刻意将其与某物配对时,才返回这样的对象
shallowEqual
或者通过经过缓存的选择器来传递它。
规则三:对计算成本高的派生数据进行缓存
排序、过滤、分组和连接属于
createSelector(...)
规则四:在适当位置使用 React.memo
在对组件进行记忆化处理之前,先查看以下简短清单:
Is the component expensive?
↓
Does it receive stable props?
↓
Does it re-render unnecessarily?
↓
Then consider React.memo()
如果你的代码库使用了 React Compiler,很多这类手动记忆化操作可能已经由它处理完毕。
规则五:在组件允许的范围内尽可能精确地选择
范围过宽:
state => state
更好的做法是:
state => state.auth.user
如果组件仅显示名称,那就更好了:
state => state.auth.user.name
罕见的边缘情况:过时的属性和“僵尸”子元素
大多数应用程序都不会遇到这两个问题,但它们解释了为何养成防御性选择器的习惯是明智的。
过时的属性
要解决过时属性的问题,需要一个依赖于该属性的选择器,以及能够同时改变状态并间接改变该属性的存储更新:
Selector depends on component props
↓
Redux action updates state
↓
Parent would receive new props
↓
Child selector runs first
↓
Selector sees old props
子组件的订阅可能在父组件重新渲染并传递新属性之前就触发。此时,选择器会将最新状态与旧属性结合在一起。典型的情况如下:
const todo = useSelector(
state => state.todos[props.id]
)
如果具有该id的元素刚刚被移除,或者父组件即将传递不同的id,则选择器会暂时读取不再匹配的数据。
编写能够处理缺失数据的選择器
那种脆弱的实现方式假设该元素始终存在:
state.todos[props.id].name
防御性较强的实现方式会先查找该元素,
const todo =
state.todos[props.id]
然后再从中读取数据:
return todo
? todo.name
: undefined
这个判断其实是一个简单的保护机制:
Does data exist?
↓
Yes → use it
No → handle safely
可选链操作(state.todos[id]?.name)可以用一个表达式实现同样的功能。
“僵尸”子组件
僵尸子组件场景涉及一个负责渲染列表的父组件,以及一个订阅了列表中某个项的子组件:
Parent
│
└── Child
具体流程如下:
- 子组件订阅到数据存储。
- 有操作会移除子组件所显示的数据。
- 父组件在下次渲染时将不再渲染该子组件。
- 在此之前,子组件的订阅仍在运行。
- 子组件的选择器会尝试获取已经不存在的数据。
在最后这一步,未经保护的选择器就会抛出错误。React-Redux虽有关于因数据存储更新而产生的选择器错误的处理机制,但使用防御性选择器仍是更为可靠的解决方案。
为何 hooks 比 connect 更容易出问题
connect()会构建一个嵌套的订阅树:每个连接组件只有在其上游连接的组件更新之后才会更新,从而确保自上而下的执行顺序。而钩子则直接连接到存储层,没有这种层级结构,因此顺序保障较弱,这些边缘情况在理论上就可能出现。
可将此视为背景知识,而非避免使用钩子的理由。官方立场是这些问题在实际应用中并不常见。
高级 Provider 功能
为独立存储创建自定义上下文
默认情况下,
<Provider store={store}>
会通过 React-Redux 内置的上下文来发布存储信息。那些内部使用 Redux 的可复用组件库可能会因此与宿主应用程序的存储发生冲突。为避免这种情况,Provider 允许用户传入自定义的上下文:
<Provider
context={MyContext}
store={myStore}
>
绑定到该上下文的钩子来自工厂函数:
createStoreHook()
createDispatchHook()
createSelectorHook()
这在需要复用库且多个存储可能会发生冲突的情况下尤为重要。
batch() 与 React 18 的自动批处理
旧代码通常会将连续的调度操作封装在 batch() 中,这样 React 就只会渲染一次而非两次:
batch(() => {
dispatch(action1())
dispatch(action2())
})
React 18 会自动对更新进行批处理,包括在 Promise 和定时器中的操作,因此典型的 React 18 应用无需为此使用 batch()。最新的 React-Redux 版本主要保留它以保持兼容性;请查阅最新文档,并预期在旧代码中仍会见到它的使用。
SSR 和水合过程中的 serverState
在服务器端渲染时,Provider 会接收一个额外的属性:
<Provider
store={store}
serverState={preloadedState}
>
服务器从初始状态渲染HTML,并将该状态发送给浏览器。serverState确保水合渲染使用相同的快照,从而避免出现不一致:
Server
↓
Initial Redux State
↓
HTML
↓
Browser Hydration
↓
Provider(serverState)
↓
Consistent initial render
典型的项目结构
基于Redux Toolkit构建的TypeScript应用通常按功能模块组织,存储相关的配置集中在一处:
src/
│
├── app/
│ ├── store.ts
│ └── hooks.ts
│
├── features/
│ │
│ ├── counter/
│ │ ├── counterSlice.ts
│ │ └── Counter.tsx
│ │
│ ├── users/
│ │ ├── usersSlice.ts
│ │ └── Users.tsx
│ │
│ └── auth/
│ ├── authSlice.ts
│ └── Login.tsx
│
├── App.tsx
└── main.tsx
store.ts
存储模块负责配置还原器,并导出相应的类型:
const store = configureStore({
reducer: {
counter: counterReducer,
users: usersReducer,
auth: authReducer
}
})
export type RootState =
ReturnType<typeof store.getState>
export type AppDispatch =
typeof store.dispatch
export type AppStore =
typeof store
hooks.ts
钩子模块将这些类型转换为应用程序中的钩子:
export const useAppDispatch =
useDispatch.withTypes<AppDispatch>()
export const useAppSelector =
useSelector.withTypes<RootState>()
export const useAppStore =
useStore.withTypes<AppStore>()
同时使用两者的组件
计数器组件则仅通过这些带类型的钩子来进行读写操作:
function Counter() {
const count = useAppSelector(
state => state.counter.value
)
const dispatch = useAppDispatch()
return (
<>
<span>{count}</span>
<button
onClick={() =>
dispatch(increment())
}
>
+
</button>
</>
)
}
该组件的执行流程与最初的核心循环完全一致:
Component
│
├── useAppSelector()
│ ↓
│ READ
│
└── useAppDispatch()
↓
DISPATCH
↓
Redux
↓
New State
↓
useAppSelector()
↓
Component
Redux Toolkit的适用场景
Redux Toolkit与React-Redux是互补关系,而非替代关系:
Redux Toolkit
+
React-Redux
Redux Toolkit提升了Redux方面的功能:存储配置、还原器、异步逻辑、记忆化选择器以及数据获取。
configureStore
createSlice
createAsyncThunk
createSelector
RTK Query
React-Redux则负责与React的连接:
Provider
useSelector
useDispatch
useStore
connect
官方的React-Redux快速入门指南会同时配置这两者,这种组合也是新项目的默认设置。
一张图表展示完整流程
从Provider开始,一直到相等性检查,将所有部分整合在一起:
React
│
▼
<Provider>
│
▼
Redux Store
│
┌────────┴────────┐
│ │
useSelector() useDispatch()
│ │
│ ▼
│ Action
│ │
│ ▼
│ Reducer
│ │
│ ▼
│ New State
│ │
└─────────┬───────┘
▼
Selector runs
│
▼
Equality check
│
┌──────┴──────┐
│ │
Same Different
│ │
▼ ▼
No Redux Re-render
render
常见错误及识别方法
订阅整个状态
useSelector(state => state)
应使用具体的选择器来替代。
将值包裹在新的对象中
useSelector(state => ({
user: state.user
}))
应将其拆分为独立的钩子,或刻意使用shallowEqual函数。
每次运行时在选择器中进行过滤或映射
useSelector(state =>
state.todos.filter(...)
)
如果此操作频繁执行或处理大量数据列表,应将其移至带记忆功能的选色器中。
通过useStore读取渲染数据
在渲染逻辑中调用
store.getState()
可以无需订阅即可获取值。应使用
useSelector()
这样当值发生变化时组件才会更新。
直接修改状态
像这样的赋值操作
state.user.name = 'John'
会破坏Redux的不可变更新模型:引用不会改变,因此选择器会认为“没有变化”,组件也不会重新渲染。(在Redux Toolkit的createSlice reducer中,由于Immer会将其转换为不可变更新,这种写法是允许的;但在其他地方则属于错误。)
选择器中的副作用
state => {
fetch(...)
return state.user
}
选择器应仅负责计算并返回结果,除此之外不应做其他事情。
条件反射式的缓存
将这类机制到处使用
useMemo()
useCallback()
React.memo()
虽然增加了复杂性并带来了比较开销,但并无证据表明其确实有用。应针对已测量的渲染过程进行优化。
缓存选择器实例的位置错误
带有缓存的选器其行为会因其作用域不同而有所差异。在使用之前,需先确认它属于以下哪种情况:
global
per component
per component instance
使用多个不同参数的共享实例可能永远无法命中其缓存。
简短答案形式的面试问题
什么是 React-Redux,为什么需要 Provider?
它是 Redux 的官方 React 绑定:通过 Provider、钩子函数以及 connect,组件可以读取状态、响应变化并派发动作。Provider 将存储库放入 React 上下文,从而使所有子组件都能访问它。
useSelector 与 useDispatch 的区别?
前者用于:
useSelector
↓
READ Redux state
其他写法:
useDispatch
↓
DISPATCH Redux actions
useSelector 是如何触发重新渲染的?
每次有动作发生时,它都会重新运行选择器,并使用 === 或你传入的相等函数将结果与之前的结果进行比较。只有存在差异时才会安排重新渲染。
为什么这个选择器会不断触发重新渲染,该如何解决?
useSelector(state => ({
user: state.user,
count: state.count
}))
它每次运行时都会创建一个新对象,因此引用检查总是失败。有三种解决方法:
1. Multiple useSelector calls
2. shallowEqual
3. Memoized selector
useSelector 与 connect 的区别?
钩子函数是通过引用比较选择器结果;而 connect() 则会对 mapStateToProps 返回的属性进行浅比较。钩子函数是默认选择,connect() 仍然被支持。
为什么需要使用记忆化选择器?
只有当输入发生变化时,它们才会重新计算派生数据,否则会返回相同的引用。过滤器就是典型的例子:
todos
↓
filter completed
↓
new array
RootState、AppDispatch和withTypes()是什么?
整个状态的推断类型:
type RootState =
ReturnType<typeof store.getState>
包括thunks等中间件在内的派发类型推断结果:
type AppDispatch =
typeof store.dispatch
以及返回已预先绑定到这些类型的钩子的辅助函数:
useDispatch.withTypes<AppDispatch>()
useSelector.withTypes<RootState>()
useStore.withTypes<AppStore>()
为什么需要类型化的钩子?
它们可以替代重复的注解,比如
useSelector(
(state: RootState) =>
state.user
)
用
useAppSelector(
state => state.user
)
为什么选择器必须是纯函数?
对于相同的状态,选择器可能在渲染时、每次执行动作后,以及代码无法控制的时刻被多次调用。任何副作用都可能以不可预测的次数执行。
useStore的作用是什么?
确实需要使用 store 对象的罕见情况。用于渲染的状态读取是通过 useSelector() 来实现的。
什么是僵尸子组件和过时属性?
这两种都是较为少见的排序问题。僵尸子组件会在其父组件卸载之前处理更新并读取已删除的数据;而过时属性则是指依赖特定属性的选择器使用的是最新状态,但对应的属性却是旧的。防御性选择器可以解决这两种问题。
在 React 18 中还需要使用 batch() 吗?
通常不需要,因为 React 18 会自动进行批量处理,但在较旧的代码中仍可能会遇到这种情况。
为什么 connect 和 hooks 的渲染方式会有不同?
这是因为它们的订阅机制以及比较方式不同:
connect()
↓
shallow equality
与
useSelector()
↓
strict === equality
为什么 useSelector 会如此频繁地运行?
它:
- 在渲染过程中执行
- 监听 store 的变化
因此,内联选择器在每次渲染时都会生成新的函数,从而在每次渲染时都被执行;而稳定的选择器引用则能让 React-Redux 跳过此次调用。
按优先级排序的学习内容
第一层级:必须熟练掌握
Provider
useSelector
useDispatch
Redux Store flow
Selectors
=== equality
Re-render behavior
Redux Toolkit + React-Redux
TypeScript
RootState
AppDispatch
.withTypes()
第二层级:具备扎实的实际应用知识
shallowEqual
Memoized selectors
createSelector
useStore
connect
mapStateToProps
mapDispatchToProps
ConnectedProps
React.memo
第三层级:高级主题
Stale props
Zombie children
Selector + props
Selector memoization
Custom context
Development mode checks
SSR serverState
batch()
无需记忆的内容
不必逐行学习文档内容,以下部分可以跳过:
- 订阅机制的内部实现方式
- 很少使用的
connect()选项 - 早已过时的模式
- 源代码的实现细节
重要的是这些规则背后的原理。了解事实本身
useSelector uses ===
并不如能够解答问题那样有用
Why?
具体来说:
Because returning a new object
creates a new reference.
这会形成一系列关联关系
New reference
↓
=== false
↓
re-render
如果你理解了这些关联关系,就能自行推导出大多数其他规则。
需牢记的十条规则
1. 使用 useSelector 读取数据
useSelector()
这是组件读取状态的方式。
2. 使用 useDispatch 发送操作
useDispatch()
这是组件发送动作的方式。
3. 用 Provider 包裹应用
<Provider store={store}>
4. 记住比较规则
useSelector → ===
connect → shallow comparison
5. 永远不要选择根状态
useSelector(state => state)
6. 使用返回对象的选择器时要谨慎
useSelector(state => ({
...
}))
7. 对计算成本高的派生数据进行缓存
Memoized selector
8. 严格规范选择器使用
Pure
Predictable
Granular
9>先定义存储类型,再定义钩子
首先是推断出的类型:
RootState
AppDispatch
AppStore
然后是根据这些类型构建的应用钩子:
useAppSelector
useAppDispatch
useAppStore
10. 使用现代组合方式
Redux Toolkit
+
React-Redux Hooks
所有API内容集中于一页
这是一份简明的参考资料,涵盖了核心钩子、性能优化技巧、TypeScript类型定义、旧版API以及高级主题:
┌───────────────────────────────────────────────┐
│ REACT-REDUX │
├───────────────────────────────────────────────┤
│ │
│ <Provider store={store}> │
│ ↓ │
│ Makes Redux available to React │
│ │
│ useSelector() │
│ ↓ │
│ READ state │
│ ↓ │
│ Default comparison: === │
│ │
│ useDispatch() │
│ ↓ │
│ DISPATCH actions │
│ │
│ useStore() │
│ ↓ │
│ Direct store access │
│ ↓ │
│ Use rarely │
│ │
├───────────────────────────────────────────────┤
│ PERFORMANCE │
├───────────────────────────────────────────────┤
│ │
│ Avoid state => state │
│ Avoid unnecessary object creation │
│ Use granular selectors │
│ Use shallowEqual when appropriate │
│ Use memoized selectors for derived data │
│ Use React.memo when justified │
│ │
├───────────────────────────────────────────────┤
│ TYPESCRIPT │
├───────────────────────────────────────────────┤
│ │
│ RootState = ReturnType<typeof store.getState> │
│ AppDispatch = typeof store.dispatch │
│ AppStore = typeof store │
│ │
│ useAppSelector │
│ useAppDispatch │
│ useAppStore │
│ │
├───────────────────────────────────────────────┤
│ LEGACY / EXISTING APPLICATIONS │
├───────────────────────────────────────────────┤
│ │
│ connect() │
│ mapStateToProps │
│ mapDispatchToProps │
│ ConnectedProps │
│ │
├───────────────────────────────────────────────┤
│ ADVANCED │
├───────────────────────────────────────────────┤
│ │
│ Stale Props │
│ Zombie Children │
│ Custom Context │
│ SSR serverState │
│ Development checks │
│ batch() │
│ │
└───────────────────────────────────────────────┘
此外,还用并列的方式再次展示了读取和写入路径构成的循环结构:
REACT
│
│
<Provider />
│
▼
┌─────────────┐
│ Redux Store │
└──────┬──────┘
│
┌───────────┴───────────┐
│ │
▼ ▲
useSelector() useDispatch()
│ │
│ │
READ ACTION
│ │
│ │
│ ┌────┴─────┐
│ │ Reducer │
│ └────┬─────┘
│ │
│ ▼
│ New State
│ │
└───────────────────────┘
│
▼
Equality Check
│
┌──────┴──────┐
│ │
Same Changed
│ │
▼ ▼
No Redux Re-render
render
对于新的TypeScript项目,推荐的配置结构大致如下:
Redux Toolkit
+
React-Redux
│
┌──────────┴──────────┐
│ │
Store Provider
│ │
│ Application tree
│ │
└──────────┬──────────┘
│
┌────────┴────────┐
│ │
useAppSelector() useAppDispatch()
│ │
READ WRITE
│ │
└────────┬────────┘
│
Redux
│
New State
│
Selector
│
Re-render
总结
一旦不再将React-Redux的各个导出项视为互不相关的工具,使用起来就会容易得多。这份名称列表
Provider
useSelector
useDispatch
connect
shallowEqual
createSelector
useStore
描述了一个每个阶段仅包含一个任务的单一流水线。提供者会暴露存储功能:
Provider
↓
makes Store available
选择器钩子会读取:
useSelector
↓
reads selected state
调度钩子会发送:
useDispatch
↓
sends actions
还原器负责生成下一个状态:
Reducer
↓
creates new state
选择器会将其格式化以适配用户界面:
Selector
↓
derives data
相等性检查用于判断是否有任何相关内容发生了变化:
Equality
↓
decides whether selected data changed
然后由React负责进行渲染:
React
↓
re-renders when necessary
在TypeScript层面,其结构同样呈线性:
Store
↓
RootState
AppDispatch
AppStore
↓
.withTypes()
↓
useAppSelector()
useAppDispatch()
useAppStore()
当组件渲染的内容超出预期时,每次都需要依次考虑这四个问题:
useSelector()
↓
What does my selector return?
↓
Is the reference stable?
↓
Does the selected value actually change?
↓
Should this component re-render?
关键要点:
- 大多数React-Redux性能问题源于返回新引用的选择器,而非Redux本身。
useSelector() 使用 === 进行严格比较;而 connect() 则采用浅层比较。若在不调整选择器的情况下在二者之间迁移代码,会导致行为异常。createSelector 实例对派生数据进行缓存,仅当有意得到对象结果时才使用 shallowEqual。RootState、AppDispatch 和 AppStore,并使用 .withTypes() 创建带类型的钩子函数。batch() 以及 serverState 均值得了解,但它们属于边缘情况,并非日常需要处理的问题。作为自我测试,试着凭记忆解释本指南中的每一个标题,从 Provider 和 equality 开始,再到 typed hooks,以及 serverState,并且在不查阅任何资料的情况下写出基本配置。
有关详细信息,官方参考资料包括 hooks API、Provider API、TypeScript 使用指南、快速入门以及connect() 参考文档。建议先学习核心循环与 equality,再了解选择器、TypeScript、性能相关内容、connect函数以及各种边界情况,这样在研究 API 详情时才有对应的框架。
相关阅读
- React Query与Redux:重新思考大型应用中的服务器状态 — 了解为何某款生产级聊天应用选择使用TanStack Query而非Redux来管理服务器数据,以及Redux在现代React架构中仍能发挥的作用。
- 构建TanStack Query数据层:从queryOptions到回滚机制 — 逐步搭建TanStack Query数据层:共享的queryOptions、键生成器、选择器、分页功能、预加载机制、集中式无效状态处理以及安全的乐观更新方式。