Day 10 - Redux 异步方案与 React 性能优化 Hooks 深度解析
本文是尚硅谷 React + TypeScript 实战课程 Day 10 的系统性深度整理。从 Redux 中间件体系的底层原理出发,完整剖析四大异步方案(redux-thunk、redux-saga、redux-observable、createAsyncThunk)的设计哲学与工程取舍,再深入拆解
useCallback、useMemo、useReducer、React.memo四大性能优化手段的底层机制与实战陷阱,帮助读者建立系统性的 React 性能心智模型。
一、名词解释
在深入原理之前,先对本章所有核心术语做精确定义,建立统一的语言体系。
Redux 异步体系
| 术语 | 定义 |
|---|---|
| Middleware(中间件) | Redux 中位于 dispatch(action) 与 reducer 执行之间的可插拔函数层,可以拦截、转换、延迟或增强 action 的处理过程 |
| Thunk | 一种设计模式,指"被另一个函数包裹、延迟执行的函数"。Redux 语境中特指一个接受 dispatch 和 getState 为参数的函数 |
| redux-thunk | 最简单的 Redux 中间件,允许 dispatch 一个函数(thunk)而非普通 action 对象,RTK 默认内置 |
| redux-saga | 基于 ES6 Generator 函数的 Redux 副作用管理库,用声明式的 Effect 描述异步流程 |
| redux-observable | 基于 RxJS 的 Redux 中间件,用 Observable 流和 Epic 处理异步副作用 |
| createAsyncThunk | RTK 提供的标准化异步 thunk 工厂函数,自动管理 pending/fulfilled/rejected 三个阶段的 action |
| extraReducers | RTK slice 中响应外部 action(尤其是 createAsyncThunk 生成的 action)的 reducer 配置区域 |
| rejectWithValue | createAsyncThunk 提供的工具函数,用于将自定义错误数据放入 action.payload 而非 action.error |
| AbortController | 浏览器原生 API,用于取消 fetch 请求;createAsyncThunk 的 thunkAPI.signal 即为其 AbortSignal |
| 竞态条件(Race Condition) | 多个异步请求同时发起,最终状态由最后完成的请求决定,可能导致展示旧数据的问题 |
| requestId | createAsyncThunk 为每次调用自动生成的唯一 ID,用于识别和处理竞态条件 |
React 性能优化体系

| 术语 | 定义 |
|---|---|
| 引用相等(Referential Equality) | JavaScript 中两个对象/函数/数组只有指向同一内存地址时才相等(===),字面量 {} 每次都是新引用 |
| 浅比较(Shallow Compare) | 对对象的第一层属性逐一做 === 比较,不递归深层属性;React.memo 默认采用浅比较 |
| 记忆化(Memoization) | 缓存函数的输入与输出,当相同输入再次出现时直接返回缓存结果,避免重复计算 |
| useCallback | React Hook,返回一个记忆化的函数引用;依赖不变时返回同一函数对象 |
| useMemo | React Hook,返回一个记忆化的计算结果值;依赖不变时跳过重新计算 |
| useReducer | React Hook,以 reducer 函数管理复杂状态,类似组件级别的 Redux |
| React.memo | 高阶组件(HOC),包裹函数组件,对 props 进行浅比较,props 不变则跳过重渲染 |
| PureComponent | 类组件版本的 React.memo,重写 shouldComponentUpdate 做 props/state 浅比较 |
| Wasted Render | 组件执行了渲染(调用了 render 函数)但输出与上次完全相同的无效渲染,是优化的主要目标 |
| React DevTools Profiler | Chrome 扩展工具,记录 React 应用渲染火焰图,识别性能瓶颈 |
| why-did-you-render | 第三方库,在控制台输出组件重渲染原因(哪个 prop/state 改变触发了渲染) |
| 闭包陷阱(Stale Closure) | useCallback/useMemo 的依赖数组遗漏变量导致函数/值捕获了过期的闭包变量 |
二、底层原理深度解析
2.1 Redux 中间件机制:洋葱模型
要理解 Redux 各种异步方案,必须先理解 Redux 中间件的本质。Redux 中间件采用**函数组合(Function Composition)**的洋葱模型:

scss
dispatch(action)
↓
[中间件 1 外层]
↓
[中间件 2 外层]
↓
[中间件 3 外层]
↓
reducer(state, action) ← 核心
↑
[中间件 3 内层]
↑
[中间件 2 内层]
↑
[中间件 1 内层]
↑
返回新 state
Redux 中间件的签名是一个三层柯里化函数:
typescript
// 中间件的完整类型签名
type Middleware = (api: MiddlewareAPI) => (next: Dispatch) => (action: Action) => any
// MiddlewareAPI 提供了两个能力:
interface MiddlewareAPI {
dispatch: Dispatch // 可以 dispatch 新的 action(形成循环)
getState: () => S // 可以读取当前 state
}
applyMiddleware 的实现原理(简化版):
typescript
// Redux applyMiddleware 的核心逻辑(简化)
function applyMiddleware(...middlewares) {
return (createStore) => (reducer, preloadedState) => {
const store = createStore(reducer, preloadedState)
// 将所有中间件与 store 的 dispatch/getState 绑定
const chain = middlewares.map(m => m({ getState: store.getState, dispatch: (...args) => dispatch(...args) }))
// 用 compose 将所有中间件串联起来
// compose(f, g, h)(x) = f(g(h(x)))
const dispatch = compose(...chain)(store.dispatch)
return { ...store, dispatch }
}
}
这就是为什么 redux-thunk 可以让 dispatch 接受函数:它在中间件层拦截了函数类型的 action,执行后再 dispatch 真正的对象 action。
2.2 四大异步方案原理对比

方案一:redux-thunk(最简单)
redux-thunk 的完整源码只有 14 行:
typescript
// redux-thunk 完整源码(v2.4.2)
function createThunkMiddleware(extraArgument?: any) {
const middleware: ThunkMiddleware = ({ dispatch, getState }) => next => action => {
// 核心判断:如果 action 是函数,执行它并传入 dispatch/getState
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument)
}
// 否则,正常传给下一个中间件
return next(action)
}
return middleware
}
export const thunk = createThunkMiddleware()
// 支持注入额外参数(如 API 服务实例)
export const thunkExtraArgument = createThunkMiddleware.withExtraArgument
使用示例:
typescript
// 手写 thunk(不用 createAsyncThunk)
const fetchUsersThunk = (keyword: string) => async (dispatch: AppDispatch) => {
dispatch({ type: 'users/loading' })
try {
const data = await api.searchUsers(keyword)
dispatch({ type: 'users/success', payload: data })
} catch (err) {
dispatch({ type: 'users/error', payload: err.message })
}
}
// 组件中使用
dispatch(fetchUsersThunk('react')) // dispatch 一个函数!
优点 :极简,零学习成本,RTK 内置
缺点:无法取消、无法防竞态、复杂流程代码混乱
方案二:redux-saga(复杂流程控制)
redux-saga 基于 Generator 函数,核心思想是将副作用描述为声明式的 Effect 对象,由 saga middleware 解释执行:
typescript
import { call, put, takeLatest, select, cancel } from 'redux-saga/effects'
// Watcher Saga:监听特定 action
function* watchFetchUsers() {
// takeLatest:自动取消上一次未完成的 saga(防竞态的关键!)
yield takeLatest('users/fetchUsers', fetchUsersSaga)
}
// Worker Saga:执行具体的异步逻辑
function* fetchUsersSaga(action: PayloadAction<string>) {
try {
yield put({ type: 'users/setLoading', payload: true })
// call 是一个 Effect 描述符,不是真正的调用
// saga middleware 读取这个描述符后才执行真正的 API 调用
const data = yield call(api.searchUsers, action.payload)
yield put({ type: 'users/setList', payload: data.items })
} catch (error) {
yield put({ type: 'users/setError', payload: error.message })
} finally {
yield put({ type: 'users/setLoading', payload: false })
}
}
Generator 的本质是暂停与恢复 :每个 yield 点都是一个"暂停点",saga middleware 负责"恢复"执行并注入结果。这使得异步流程可以被测试为纯同步的:
typescript
// saga 的测试极其简单------只需比较 Effect 描述符
import { call, put } from 'redux-saga/effects'
test('fetchUsersSaga 成功流程', () => {
const gen = fetchUsersSaga({ payload: 'react' })
// 验证第一步:设置 loading
expect(gen.next().value).toEqual(put({ type: 'users/setLoading', payload: true }))
// 验证第二步:发起 API 调用
expect(gen.next().value).toEqual(call(api.searchUsers, 'react'))
// 模拟 API 返回
const mockData = { items: [{ id: 1 }] }
expect(gen.next(mockData).value).toEqual(put({ type: 'users/setList', payload: [{ id: 1 }] }))
})
优点 :强大的流程控制(takeLatest/takeEvery/race/all),可测试性极强
缺点:Generator 语法学习成本高,调试困难,包体积较大
方案三:redux-observable(RxJS 响应式)
redux-observable 使用 RxJS 的 Observable 和 Epic 处理异步流:
typescript
import { ofType } from 'redux-observable'
import { switchMap, map, catchError, debounceTime } from 'rxjs/operators'
import { from, of } from 'rxjs'
// Epic:接收 action$ 流,返回新的 action$ 流
const fetchUsersEpic = (action$) =>
action$.pipe(
ofType('users/fetchUsers'), // 过滤特定类型的 action
debounceTime(300), // 防抖,300ms 内只处理最后一次
switchMap(action => // switchMap:自动取消前一个请求(防竞态)
from(api.searchUsers(action.payload)).pipe(
map(data => ({ type: 'users/setList', payload: data.items })),
catchError(err => of({ type: 'users/setError', payload: err.message }))
)
)
)
优点 :RxJS 操作符极其强大(防抖、节流、合并流),适合事件流密集型应用
缺点:RxJS 学习曲线陡峭,不熟悉响应式编程的团队难以维护
方案四:createAsyncThunk(RTK 推荐,最实用)
createAsyncThunk 是对 redux-thunk 的标准化封装,自动处理三态 action 和竞态问题:
typescript
import { createAsyncThunk } from '@reduxjs/toolkit'
// 完整的 thunkAPI 参数解构
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async (keyword: string, thunkAPI) => {
const {
dispatch, // 可以 dispatch 其他 action
getState, // 可以读取当前 state
rejectWithValue, // 返回自定义错误 payload
signal, // AbortController 的 signal,用于取消请求
requestId, // 本次调用的唯一 ID,用于处理竞态
} = thunkAPI
try {
// signal 传给 fetch,当请求被取消时自动 abort
const response = await fetch(`/api/users?q=${keyword}`, { signal })
if (!response.ok) {
return rejectWithValue(`HTTP Error: ${response.status}`)
}
return await response.json()
} catch (err: any) {
if (err.name === 'AbortError') {
// 请求被取消,不需要 reject
return thunkAPI.rejectWithValue('REQUEST_CANCELLED')
}
return rejectWithValue(err.message || '未知错误')
}
}
)
2.3 createAsyncThunk 内部实现原理
createAsyncThunk 的内部工作流程(伪代码):
typescript
// createAsyncThunk 内部实现(简化版)
function createAsyncThunk(typePrefix, payloadCreator) {
// 预生成三个 action creator
const pending = createAction(`${typePrefix}/pending`)
const fulfilled = createAction(`${typePrefix}/fulfilled`)
const rejected = createAction(`${typePrefix}/rejected`)
// 返回一个 thunk creator(接受参数,返回 thunk 函数)
function actionCreator(arg) {
// 这里返回的是一个 thunk 函数(会被 redux-thunk 中间件处理)
return async (dispatch, getState) => {
const requestId = nanoid() // 生成唯一 ID
const abortController = new AbortController()
// 1. dispatch pending action
dispatch(pending({ requestId, arg }))
try {
// 2. 执行 payload creator,传入 thunkAPI
const result = await payloadCreator(arg, {
dispatch,
getState,
requestId,
signal: abortController.signal,
rejectWithValue: (value) => new RejectWithValue(value),
})
// 3a. 检查是否是 rejectWithValue 的返回值
if (result instanceof RejectWithValue) {
dispatch(rejected({ requestId, payload: result.value }))
return rejected({ requestId, payload: result.value })
}
// 3b. 成功:dispatch fulfilled action
dispatch(fulfilled({ requestId, payload: result }))
return fulfilled({ requestId, payload: result })
} catch (err) {
// 4. 失败:dispatch rejected action
dispatch(rejected({ requestId, error: serializeError(err) }))
return rejected({ requestId, error: serializeError(err) })
}
}
}
// 附加 action creators 和 typePrefix
actionCreator.pending = pending
actionCreator.fulfilled = fulfilled
actionCreator.rejected = rejected
actionCreator.typePrefix = typePrefix
return actionCreator
}
2.4 useCallback 底层实现原理

React Hooks 的本质是一个链表。每个组件实例维护一个 "hook 链表",每次渲染时按顺序读取/更新链表节点。useCallback 的内部逻辑:
css
mount 阶段(首次渲染):
创建 hook 节点 = { memoizedState: [fn, deps], queue: null }
update 阶段(后续渲染):
读取上次的 hook 节点(通过链表位置)
比较新旧 deps 数组(Object.is 逐元素比较)
如果 deps 未变:返回 memoizedState[0](旧函数引用)
如果 deps 改变:用新 fn 更新 memoizedState,返回新函数引用
关键细节:useCallback(fn, deps) 等价于 useMemo(() => fn, deps),两者在 React 内部共用同一套记忆化逻辑,只是返回值不同(useCallback 返回函数本身,useMemo 返回函数的调用结果)。
typescript
// React 源码中 useCallback 的简化实现
function updateCallback(callback, deps) {
const hook = updateWorkInProgressHook() // 获取当前 hook 节点
const nextDeps = deps === undefined ? null : deps
const prevState = hook.memoizedState // [prevCallback, prevDeps]
if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps = prevState[1]
if (areHookInputsEqual(nextDeps, prevDeps)) { // 逐元素 Object.is 比较
return prevState[0] // deps 未变:返回旧函数引用
}
}
}
hook.memoizedState = [callback, nextDeps] // deps 变化:缓存新函数
return callback
}
2.5 React.memo 浅比较原理
React.memo 的比较逻辑:
typescript
// React.memo 的 defaultProps 比较函数(简化)
function shallowEqual(objA: mixed, objB: mixed): boolean {
if (Object.is(objA, objB)) return true // 相同引用或基本类型相同
if (typeof objA !== 'object' || typeof objB !== 'object') return false
const keysA = Object.keys(objA)
const keysB = Object.keys(objB)
if (keysA.length !== keysB.length) return false // key 数量不同
for (let i = 0; i < keysA.length; i++) {
const key = keysA[i]
// 只比较第一层,对象/数组/函数只比较引用
if (!Object.is(objA[key], objB[key])) return false
}
return true
}
浅比较的直接后果 :传给 memo 组件的 props 如果含有对象字面量 {}、数组字面量 [] 或函数字面量 () => {},每次父组件渲染时这些值的引用都会改变,导致 memo 的优化完全失效。这就是 useMemo 和 useCallback 存在的根本原因。
2.6 useReducer 与 useState 的底层差异
useState 实际上是 useReducer 的语法糖:
typescript
// useState 的内部实现就是特殊的 useReducer
function useState(initialState) {
return useReducer(
(state, action) => (typeof action === 'function' ? action(state) : action),
initialState
)
}
useReducer 的优势在于:
- Reducer 是纯函数,可以独立于组件进行单元测试
- Action 的语义化,dispatch 的 action 比直接 setState 更清晰
- 批量状态更新,一个 action 可以触发多个状态字段的原子性更新
- 与 Context 组合,替代多个 useState 作为 Context 值,减少 Consumer 的重渲染
三、市面实际应用
3.1 Redux 异步方案的企业选型现状
根据 2023-2024 年前端工程实践调研,各方案在不同规模项目中的使用分布:
erlang
createAsyncThunk(RTK):新项目首选,覆盖率约 60%
→ 适合:中小型项目,团队 Redux 经验一般,快速迭代需求
redux-thunk(手写):遗留项目维护,覆盖率约 20%
→ 适合:轻量级异步需求,不想引入 RTK 的项目
redux-saga:大型复杂项目,覆盖率约 15%
→ 适合:金融交易系统、实时协作工具、复杂工作流
redux-observable:少数 RxJS 深度使用者,覆盖率约 5%
→ 适合:WebSocket 密集型、事件流处理、RxJS 已成熟使用的团队
3.2 createAsyncThunk 的竞态条件处理
在实际生产中,搜索框联想、分页切换等场景高频遇到竞态问题:
typescript
// 场景:用户快速切换页码,多个请求同时在途
// 问题:第 2 页请求先返回,第 1 页请求后返回 → 显示第 1 页内容但 URL 是第 2 页
// RTK 的 condition 选项:请求发起前可以决定是否执行
export const fetchPage = createAsyncThunk(
'list/fetchPage',
async (page: number, { getState }) => {
const data = await api.getList(page)
return { page, data }
},
{
// condition:返回 false 则取消本次 dispatch,不会进入 pending 状态
condition: (page, { getState }) => {
const { list } = getState() as RootState
// 如果当前正在加载且请求的页码与上次相同,不重复发请求
if (list.loading && list.currentPage === page) return false
return true
}
}
)
使用 requestId 精确处理竞态:
typescript
// 在 slice 中存储 currentRequestId,只接受最新请求的结果
const listSlice = createSlice({
name: 'list',
initialState: {
data: [],
loading: false,
currentRequestId: null as string | null,
},
extraReducers: builder => {
builder
.addCase(fetchPage.pending, (state, action) => {
state.loading = true
// 记录最新的 requestId
state.currentRequestId = action.meta.requestId
})
.addCase(fetchPage.fulfilled, (state, action) => {
// 只处理最新请求的结果,丢弃过期的响应
if (state.currentRequestId !== action.meta.requestId) return
state.loading = false
state.data = action.payload.data
})
}
})
3.3 组件中使用 unwrap() 获取结果
在某些场景下(如提交表单后跳转路由),需要在组件中等待异步操作完成:
typescript
// 使用 unwrap() 在组件中 await thunk 结果
function LoginForm() {
const dispatch = useAppDispatch()
const navigate = useNavigate()
const handleSubmit = async (credentials: LoginCredentials) => {
try {
// unwrap() 会在 fulfilled 时返回 payload,在 rejected 时抛出错误
const user = await dispatch(loginUser(credentials)).unwrap()
// 登录成功后跳转
navigate('/dashboard')
toast.success(`欢迎回来,${user.name}!`)
} catch (error) {
// loginUser rejected 时,这里捕获 rejectWithValue 的值
toast.error(typeof error === 'string' ? error : '登录失败,请重试')
}
}
return <form onSubmit={handleSubmit}>{/* ... */}</form>
}
3.4 请求取消(AbortController 集成)
typescript
// 场景:搜索框实时搜索,每次输入都取消上一次请求
function SearchInput() {
const dispatch = useAppDispatch()
const [query, setQuery] = useState('')
useEffect(() => {
if (!query.trim()) return
// dispatch 返回的 promise 上有 abort 方法
const promise = dispatch(fetchUsers(query))
// cleanup 函数:在下一次 effect 执行前调用
return () => {
promise.abort() // 取消正在进行的请求
}
}, [query, dispatch])
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索用户..."
/>
)
}
// Slice 中需要处理 abort 状态
.addCase(fetchUsers.rejected, (state, action) => {
// action.error.name === 'AbortError' 时说明是主动取消
if (action.meta.aborted) {
// 不更新 error 状态,静默处理
state.loading = false
return
}
state.loading = false
state.error = action.payload as string
})
3.5 useCallback 的真实使用场景
typescript
// 场景1:虚拟列表的 renderItem 回调
// 虚拟列表渲染数千条目,renderItem 必须稳定以避免全量重渲染
function VirtualList({ data, onSelect }) {
const renderItem = useCallback((item: DataItem, index: number) => (
<ListItem
key={item.id}
item={item}
onSelect={onSelect}
style={{ height: ITEM_HEIGHT }}
/>
), [onSelect]) // onSelect 本身也应该是 useCallback 包裹的
return <FixedSizeList itemCount={data.length} renderItem={renderItem} />
}
// 场景2:防抖/节流回调
function SearchBox({ onSearch }) {
// debounce 返回新函数,必须用 useCallback 或 useRef 稳定
const debouncedSearch = useCallback(
debounce((value: string) => onSearch(value), 300),
[onSearch]
)
return <input onChange={e => debouncedSearch(e.target.value)} />
}
// 场景3:useEffect 的依赖中包含函数
function DataFetcher({ userId }) {
// 如果 fetchUserData 不稳定,useEffect 会无限循环
const fetchUserData = useCallback(async () => {
const data = await api.getUser(userId)
setUser(data)
}, [userId]) // 只在 userId 变化时重新创建函数
useEffect(() => {
fetchUserData()
}, [fetchUserData]) // 依赖稳定的函数引用
}
3.6 useMemo 的生产级应用
typescript
// 场景:电商平台商品列表的多维度筛选
function ProductList({ products }: { products: Product[] }) {
const [filters, setFilters] = useState<FilterState>({
category: 'all',
priceRange: [0, 10000],
brand: [],
inStockOnly: false,
sortBy: 'relevance',
sortOrder: 'desc',
})
// 每次 products 或 filters 变化才重新计算(可能有 5000+ 商品)
const filteredAndSortedProducts = useMemo(() => {
let result = [...products]
// 分类过滤
if (filters.category !== 'all') {
result = result.filter(p => p.category === filters.category)
}
// 价格区间过滤
result = result.filter(
p => p.price >= filters.priceRange[0] && p.price <= filters.priceRange[1]
)
// 品牌过滤
if (filters.brand.length > 0) {
result = result.filter(p => filters.brand.includes(p.brand))
}
// 库存过滤
if (filters.inStockOnly) {
result = result.filter(p => p.stock > 0)
}
// 排序
result.sort((a, b) => {
const order = filters.sortOrder === 'asc' ? 1 : -1
switch (filters.sortBy) {
case 'price': return (a.price - b.price) * order
case 'sales': return (a.salesCount - b.salesCount) * order
case 'rating': return (a.rating - b.rating) * order
default: return 0
}
})
return result
}, [products, filters])
// 统计面板也用 useMemo,避免每次渲染重新聚合
const stats = useMemo(() => ({
totalCount: filteredAndSortedProducts.length,
priceRange: {
min: Math.min(...filteredAndSortedProducts.map(p => p.price)),
max: Math.max(...filteredAndSortedProducts.map(p => p.price)),
avg: filteredAndSortedProducts.reduce((s, p) => s + p.price, 0)
/ filteredAndSortedProducts.length || 0
}
}), [filteredAndSortedProducts])
return (
<div>
<FilterPanel filters={filters} onChange={setFilters} stats={stats} />
<ProductGrid products={filteredAndSortedProducts} />
</div>
)
}
3.7 useReducer + Context 替代小型 Redux
typescript
// 购物车状态管理:useReducer + Context 组合
interface CartState {
items: CartItem[]
total: number
loading: boolean
}
type CartAction =
| { type: 'ADD_ITEM'; payload: Product }
| { type: 'REMOVE_ITEM'; payload: string } // productId
| { type: 'UPDATE_QTY'; payload: { id: string; qty: number } }
| { type: 'CLEAR_CART' }
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(i => i.id === action.payload.id)
const newItems = existing
? state.items.map(i => i.id === action.payload.id
? { ...i, qty: i.qty + 1 }
: i)
: [...state.items, { ...action.payload, qty: 1 }]
return {
...state,
items: newItems,
total: newItems.reduce((s, i) => s + i.price * i.qty, 0)
}
}
case 'REMOVE_ITEM': {
const newItems = state.items.filter(i => i.id !== action.payload)
return { ...state, items: newItems, total: newItems.reduce((s, i) => s + i.price * i.qty, 0) }
}
default:
return state
}
}
const CartContext = createContext<{
state: CartState
dispatch: Dispatch<CartAction>
} | null>(null)
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0, loading: false })
// 用 useMemo 稳定 context value 引用,避免所有 Consumer 重渲染
const value = useMemo(() => ({ state, dispatch }), [state])
return <CartContext.Provider value={value}>{children}</CartContext.Provider>
}
四、实战要点与常见陷阱
4.1 createAsyncThunk 常见陷阱
陷阱 1:忘记处理 rejected 时 payload vs error 的区别
typescript
// ❌ 错误:不用 rejectWithValue,error 数据在 action.error 里
export const fetchUsers = createAsyncThunk('users/fetch', async (keyword) => {
try {
return await api.searchUsers(keyword)
} catch (err) {
throw err // 抛出异常,RTK 会序列化到 action.error.message
}
})
// Reducer 中取错位置
.addCase(fetchUsers.rejected, (state, action) => {
state.error = action.payload // ❌ payload 是 undefined!
// 正确应该是:state.error = action.error.message
})
// ✅ 正确:用 rejectWithValue,错误数据在 action.payload 里
export const fetchUsers = createAsyncThunk('users/fetch', async (keyword, { rejectWithValue }) => {
try {
return await api.searchUsers(keyword)
} catch (err: any) {
return rejectWithValue(err.message) // payload 统一接口
}
})
.addCase(fetchUsers.rejected, (state, action) => {
state.error = action.payload as string // ✅ payload 有值
})
陷阱 2:在 useEffect 中直接 dispatch thunk 不处理依赖
typescript
// ❌ 陷阱:dispatch 每次渲染都是新引用,导致无限循环
useEffect(() => {
dispatch(fetchUsers(keyword))
}, [dispatch, keyword]) // dispatch 来自 useDispatch,在 RTK 中是稳定的,但自定义 dispatch 可能不稳定
// ✅ 正确:使用 useAppDispatch(RTK 类型化 dispatch,引用稳定)
const dispatch = useAppDispatch() // dispatch 本身引用稳定,可以放在 deps 里
useEffect(() => {
dispatch(fetchUsers(keyword))
}, [keyword]) // dispatch 可以省略,因为它永远不会变化
陷阱 3:并发 dispatch 导致 loading 状态冲突
typescript
// ❌ 问题:两个不同的 thunk 共用同一个 loading 状态
const fetchUserList = createAsyncThunk('users/fetchList', ...)
const fetchUserDetail = createAsyncThunk('users/fetchDetail', ...)
// 如果两个请求同时发起,loading 被第一个完成的置为 false
// 而另一个仍在进行中!
// ✅ 解决:每个 thunk 有独立的 loading 状态
interface UsersState {
listLoading: boolean
detailLoading: boolean
// ...
}
4.2 useCallback 常见陷阱
陷阱 1:闭包陷阱(Stale Closure)
typescript
// ❌ 闭包陷阱:handleDelete 闭包里的 items 永远是初始值
const [items, setItems] = useState(['A', 'B', 'C'])
const handleDelete = useCallback((item: string) => {
// 这里的 items 是创建时的快照:['A', 'B', 'C']
// 即使后来 items 变为 ['A', 'B'],这里读取的还是旧值
const newItems = items.filter(i => i !== item)
setItems(newItems) // 可能会"恢复"被删除的元素
}, []) // ❌ 遗漏了 items 依赖
// ✅ 方案1:正确声明依赖(但函数会频繁更新)
const handleDelete = useCallback((item: string) => {
setItems(items.filter(i => i !== item))
}, [items]) // items 变化时函数重新创建
// ✅ 方案2(更优):使用函数式更新,完全避免对 items 的依赖
const handleDelete = useCallback((item: string) => {
setItems(prev => prev.filter(i => i !== item)) // prev 始终是最新值
}, []) // 真正的无依赖
陷阱 2:单独使用 useCallback 没有意义
typescript
// ❌ 无效优化:子组件未用 React.memo,useCallback 完全没用
const handleClick = useCallback(() => {
console.log('clicked')
}, [])
// 无论 handleClick 是否稳定,ChildComponent 都会在父组件重渲染时重渲染
return <ChildComponent onClick={handleClick} /> // ChildComponent 没有 React.memo!
// ✅ 必须配合 React.memo 才有意义
const ChildComponent = React.memo(({ onClick }) => {
return <button onClick={onClick}>点击</button>
})
// 现在 useCallback 才能发挥作用
陷阱 3:过度使用 useCallback 反而更慢
typescript
// ❌ 过度优化:简单组件包裹 useCallback 反而增加开销
function SimpleCounter() {
const [count, setCount] = useState(0)
// 这个组件没有子组件,useCallback 毫无意义
// 反而多了"存储依赖数组、比较依赖"的开销
const increment = useCallback(() => {
setCount(c => c + 1)
}, [])
return <button onClick={increment}>{count}</button>
}
4.3 useMemo 常见陷阱
陷阱 1:轻量计算用 useMemo 反而更慢
typescript
// ❌ 过度优化:字符串拼接根本不需要 useMemo
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName])
// useMemo 本身的维护开销 > 字符串拼接的开销
// ✅ 直接计算即可
const fullName = `${firstName} ${lastName}`
陷阱 2:依赖数组中的对象/函数引用不稳定
typescript
// ❌ 陷阱:deps 中包含每次渲染都新建的对象
function Component({ userId }) {
const options = { limit: 10, offset: 0 } // 每次渲染都是新对象
// options 引用每次都变,useMemo 每次都重新计算,完全无效!
const data = useMemo(() => processData(userId, options), [userId, options])
// ✅ 方案1:解构出原始值作为依赖
const data2 = useMemo(() => processData(userId, { limit: 10, offset: 0 }),
[userId]) // 只依赖 userId,不依赖对象字面量
// ✅ 方案2:将 options 也用 useMemo 稳定
const stableOptions = useMemo(() => ({ limit: 10, offset: 0 }), [])
const data3 = useMemo(() => processData(userId, stableOptions), [userId, stableOptions])
}
4.4 React.memo 常见陷阱
陷阱 1:自定义比较函数逻辑错误
typescript
// ❌ 错误:返回值含义搞反了!
// React.memo 的第二个参数:返回 true = 相等 = 跳过渲染(与 shouldComponentUpdate 相反!)
const MyComponent = React.memo(
({ user }) => <div>{user.name}</div>,
(prevProps, nextProps) => {
return prevProps.user.id !== nextProps.user.id // ❌ 逻辑反了!
// 这会在 id 不同时"认为相等",在 id 相同时"认为不同"
}
)
// ✅ 正确:返回 true 表示相等(跳过渲染)
const MyComponent = React.memo(
({ user }) => <div>{user.name}</div>,
(prevProps, nextProps) => prevProps.user.id === nextProps.user.id // ✅
)
陷阱 2:对频繁变化的组件使用 React.memo
typescript
// ❌ 无效:组件的 props 每次父渲染都会变化
function Parent() {
const [time, setTime] = useState(Date.now())
useEffect(() => {
const timer = setInterval(() => setTime(Date.now()), 1000)
return () => clearInterval(timer)
}, [])
// Clock 的 time 每秒都变,React.memo 每次都会重渲染,比较开销是纯浪费
return <Clock time={time} />
}
const Clock = React.memo(({ time }) => <span>{new Date(time).toLocaleTimeString()}</span>)
4.5 使用 React DevTools Profiler 定位性能问题
markdown
操作步骤:
1. 打开 Chrome DevTools → React 标签页 → Profiler 子标签
2. 点击"录制"按钮(圆形),执行目标操作,点击"停止"
3. 查看火焰图(Flamegraph):横轴是时间,颜色深浅代表渲染时长
4. 点击某个组件,查看"为什么渲染"(Why did this render?)
5. 关注灰色组件(memo 跳过渲染的)和黄/红色组件(渲染慢的)
常见发现:
- 某个列表的每个 Item 组件都在父组件 state 变化时重渲染
→ 解决:React.memo + useCallback 稳定 callback prop
- 某个排序/过滤计算每次渲染都执行
→ 解决:useMemo 缓存结果
- 整个树因为 Context value 变化重渲染
→ 解决:将 Context 拆分,或用 useMemo 稳定 value
使用 why-did-you-render 库:
typescript
// 开发环境入口文件(index.tsx 最顶部)
import React from 'react'
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render')
whyDidYouRender(React, {
trackAllPureComponents: true, // 追踪所有 memo 组件
// 也可以单独标记某个组件
})
}
// 在具体组件上启用追踪
const ExpensiveComponent = React.memo(({ items }) => {
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>
})
// 添加这行就会在控制台输出"为什么重渲染"的详细信息
ExpensiveComponent.whyDidYouRender = true
五、本章小结
5.1 Redux 异步方案对比表
| 方案 | 核心机制 | 学习成本 | 防竞态 | 可取消 | 测试性 | 适用场景 |
|---|---|---|---|---|---|---|
| redux-thunk(手写) | 函数返回函数 | ★☆☆☆☆ | 手动 | 手动 | 一般 | 简单异步 |
| createAsyncThunk | RTK 封装 thunk | ★★☆☆☆ | requestId | abort() | 良好 | 大多数项目 |
| redux-saga | Generator + Effect | ★★★★☆ | takeLatest | cancel() | 极强 | 复杂业务流程 |
| redux-observable | RxJS + Epic | ★★★★★ | switchMap | unsubscribe | 良好 | 事件流密集型 |
5.2 createAsyncThunk API 速查表
| 参数/属性 | 类型 | 说明 |
|---|---|---|
typePrefix |
string | action 类型前缀,自动生成 /pending、/fulfilled、/rejected |
payloadCreator |
async function | 执行异步操作的函数,返回值成为 fulfilled 的 payload |
thunkAPI.dispatch |
AppDispatch | 可以 dispatch 其他 action |
thunkAPI.getState |
() => RootState | 读取当前 Redux state |
thunkAPI.rejectWithValue |
(value) => RejectWithValue | 自定义 rejected payload,统一错误接口 |
thunkAPI.signal |
AbortSignal | 传给 fetch 实现请求取消 |
thunkAPI.requestId |
string | 本次调用的唯一 ID,用于处理竞态 |
action.meta.requestStatus |
'pending'/'fulfilled'/'rejected' | 当前请求状态 |
action.meta.aborted |
boolean | 请求是否被主动取消 |
dispatch(thunk).unwrap() |
Promise | 组件中 await 结果,fulfilled 返回 payload,rejected 抛出错误 |
dispatch(thunk).abort() |
void | 取消正在进行的请求 |
5.3 React 性能优化 Hooks 对比表
| Hook / API | 缓存目标 | 返回值类型 | 何时重新计算 | 主要配合 | 典型场景 |
|---|---|---|---|---|---|
useCallback(fn, deps) |
函数引用 | Function | deps 变化时 | React.memo | 传给 memo 子组件的回调 |
useMemo(fn, deps) |
计算结果值 | any | deps 变化时 | React.memo、子组件 | 昂贵计算、稳定对象引用 |
React.memo(Component) |
渲染结果 | Component | props 浅比较失败时 | useCallback、useMemo | 包裹接受 callback 的子组件 |
useReducer(reducer, init) |
状态逻辑 | state, dispatch | dispatch action 时 | Context | 复杂状态管理、多字段表单 |
PureComponent |
渲染结果 | class | props/state 浅比较失败时 | - | 类组件版 React.memo |
5.4 选型决策树
makefile
Q: 需要处理异步操作?
├── 是 → 使用 createAsyncThunk(RTK 项目)或 redux-saga(复杂流程)
└── 否 → 只需同步 slice reducers
Q: 子组件有不必要的重渲染?
├── 是 → 先用 React.memo 包裹子组件
│ ├── 有 callback props → 用 useCallback 稳定函数引用
│ └── 有对象/数组 props → 用 useMemo 稳定引用
└── 否 → 不需要优化
Q: 有昂贵的计算逻辑?
├── 是(耗时 > 1ms)→ 用 useMemo 缓存
└── 否(字符串、简单加减)→ 直接计算,不需要 useMemo
Q: 组件状态复杂?
├── 多个相互依赖的字段 → useReducer
├── 需要跨组件共享 → Redux 或 Context + useReducer
└── 简单独立状态 → useState
六、记忆口诀
6.1 createAsyncThunk 三态口诀
ini
"派发异步看三态,P-F-R 要分清楚"
P = Pending(进行中)→ loading = true
F = Fulfilled(成功)→ 拿 payload 更新数据
R = Rejected(失败)→ 拿 payload(rejectWithValue)更新错误
"rejectWithValue 让 payload 有值,不用它则只有 error"
"unwrap 组件中等结果,abort 方法来取消请求"
6.2 useCallback 三要素口诀
javascript
"三个没有不要用 useCallback":
1. 没有传给 memo 子组件 → 没用
2. 子组件没有 React.memo → 没用
3. 依赖几乎每次都变 → 没用
"有函数式更新,deps 不写 state":
setItems(prev => ...) → deps 里不需要写 items
6.3 useMemo 判断口诀
arduino
"计算慢用 memo,计算快不用"
"引用要稳定(传给 memo 子组件),也用 useMemo"
"deps 里有字面量({}、[]),useMemo 白费"
6.4 React.memo 口诀
arduino
"React.memo 第二参数:true = 相等 = 跳过渲染"(与 shouldComponentUpdate 相反!)
"浅比较一层,引用类型看地址,基本类型看值"
"props 频繁变化的组件,memo 是负担不是优化"
6.5 性能优化总口诀
bash
先 Profiler 找瓶颈,再针对性来优化
React.memo 防渲染,配合 callback/useMemo
useCallback 稳函数,useMemo 缓结果
useReducer 管复杂态,dispatch action 更语义化
React 18 并发特性性能优化实战
1. useTransition vs useDeferredValue 选型
两者都用于把"重渲染"标记为低优先级,但使用位置不同:
| Hook | 控制点 | 适用场景 |
|---|---|---|
useTransition |
更新源头(setState) | 你能控制 setState 调用 |
useDeferredValue |
消费端(值传递) | 值来自 props 或第三方 |
2. useTransition 实战:大列表搜索
未优化版(输入卡顿):
tsx
function SearchTable() {
const [keyword, setKeyword] = useState('');
const filtered = useMemo(
() => bigList.filter(item => item.name.includes(keyword)),
[keyword]
);
return (
<>
<Input value={keyword} onChange={e => setKeyword(e.target.value)} />
<Table data={filtered} /> {/* 1万行,filter 慢 */}
</>
);
}
优化版(输入流畅):
tsx
function SearchTable() {
const [keyword, setKeyword] = useState('');
const [deferredKeyword, setDeferredKeyword] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value); // 高优先级:立即更新输入框
startTransition(() => {
setDeferredKeyword(e.target.value); // 低优先级:可被打断
});
};
const filtered = useMemo(
() => bigList.filter(item => item.name.includes(deferredKeyword)),
[deferredKeyword]
);
return (
<>
<Input value={keyword} onChange={handleChange} />
{isPending && <Spinner />}
<Table data={filtered} style={{ opacity: isPending ? 0.5 : 1 }} />
</>
);
}
性能对比(10000 行数据,Chrome Profiler):
| 指标 | 未优化 | 优化后 |
|---|---|---|
| 输入响应延迟 | 200~400ms | 16ms |
| 过滤总耗时 | 200ms | 200ms(不变) |
| 主线程阻塞 | 持续 200ms | 分片,每 5ms 让出 |
3. useDeferredValue 实战:图表组件
tsx
function PriceChart({ symbol }: { symbol: string }) {
// symbol 高频变化(每秒),但图表渲染慢
const deferredSymbol = useDeferredValue(symbol);
const isStale = symbol !== deferredSymbol;
return (
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<ExpensiveChart symbol={deferredSymbol} />
</div>
);
}
4. 配合 React.memo 才有效
useDeferredValue 只有在子组件用 React.memo 包裹时才能跳过子树渲染:
tsx
const ExpensiveChart = memo(function Chart({ symbol }: { symbol: string }) {
// symbol 变了才重渲染
return /* ... */;
});
5. Profiler API 量化性能
tsx
import { Profiler, ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration) => {
if (actualDuration > 16) {
console.warn(`[${id}] ${phase} 耗时 ${actualDuration.toFixed(2)}ms`);
// 上报到监控系统
metrics.recordRenderTime(id, actualDuration);
}
};
function App() {
return (
<Profiler id="HospitalList" onRender={onRender}>
<HospitalList />
</Profiler>
);
}
6. ErrorBoundary + Sentry 集成
tsx
import * as Sentry from '@sentry/react';
import { ErrorBoundary } from 'react-error-boundary';
function FallbackComponent({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert">
<h2>发生错误</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>重试</button>
</div>
);
}
function App() {
return (
<ErrorBoundary
FallbackComponent={FallbackComponent}
onError={(error, info) => {
Sentry.captureException(error, {
contexts: { react: { componentStack: info.componentStack } }
});
}}
onReset={() => {
// 清理可能导致错误的状态
window.location.reload();
}}
>
<Dashboard />
</ErrorBoundary>
);
}
生产部署 Source Map 上传:
bash
# vite.config.ts 启用 sourcemap
build: { sourcemap: 'hidden' }
# CI 中上传到 Sentry
sentry-cli releases new $RELEASE
sentry-cli releases files $RELEASE upload-sourcemaps ./dist
sentry-cli releases finalize $RELEASE
七、面试考点精讲
考点 1:createAsyncThunk 的 rejectWithValue 和默认抛出异常有什么区别?
参考答案:
这是一个考查对 RTK 底层行为理解程度的好问题。
默认行为(抛出异常) :当 payloadCreator 中直接 throw err 时,RTK 会调用内部的 miniSerializeError 函数对错误进行序列化,结果放在 action.error 中。序列化过程会尝试提取 name、message、code 等字段,但不是所有错误信息都能被完整保留(例如自定义错误类的额外字段会丢失)。此时 action.payload 是 undefined。
rejectWithValue 的行为 :返回 rejectWithValue(customData) 时,RTK 识别到这是一个 RejectWithValue 实例,会将 customData 放入 action.payload,并将 action.error 设为 { message: 'Rejected' }(标准占位符)。这样错误数据的位置与 fulfilled 时的 payload 保持一致(都在 action.payload),reducer 和组件代码更统一。
实践建议 :始终使用 rejectWithValue,可以传入结构化的错误对象,如 { code: 401, message: '未登录' },便于组件根据错误码做差异化处理。
typescript
// 统一的错误处理模式
.addCase(fetchUser.rejected, (state, action) => {
const error = action.payload as ApiError
state.error = error?.message ?? '未知错误'
state.errorCode = error?.code
})
考点 2:useCallback 和 useMemo 的关系是什么?什么情况下用哪个?
参考答案:
从 React 源码角度看,useCallback(fn, deps) 等价于 useMemo(() => fn, deps)。两者共用同一套记忆化基础设施,区别只在于返回值:
useCallback返回传入的函数本身(缓存函数引用)useMemo返回传入函数的调用结果(缓存计算值)
使用场景区分:
用 useCallback 的场景:
- 传递给
React.memo包裹的子组件的回调函数 - 作为
useEffect依赖项的函数 - 防抖/节流函数(需要稳定引用)
用 useMemo 的场景:
- 昂贵的计算(对大数组排序/过滤)
- 创建需要传给
React.memo子组件的对象或数组(稳定引用) - 推导状态(从多个 state 计算出的复合值)
共同陷阱:两者都不要过早优化。Kent C. Dodds 指出,对于小列表(< 100 项)的简单过滤,useMemo 本身的维护开销可能超过计算节省的时间。只有在 React DevTools Profiler 证明确实有性能问题时才添加。
考点 3:React.memo 的第二个参数(自定义比较函数)与 shouldComponentUpdate 有什么区别?
参考答案:
这是一个极容易混淆的设计差异,返回值语义完全相反:
React.memo 第二参数 |
shouldComponentUpdate |
|
|---|---|---|
返回 true |
认为 props 相等,跳过渲染 | 认为需要更新,执行渲染 |
返回 false |
认为 props 不同,执行渲染 | 认为不需要更新,跳过渲染 |
React.memo 的参数名叫 areEqual(props 是否相等),shouldComponentUpdate 的语义是"是否应该更新",两者问的是相反的问题。
typescript
// React.memo:areEqual(prevProps, nextProps) → boolean
const Comp = React.memo(({ id }) => <div>{id}</div>, (prev, next) => {
return prev.id === next.id // true = 相等 = 跳过渲染
})
// shouldComponentUpdate:返回 true = 需要更新 = 执行渲染
class Comp extends Component {
shouldComponentUpdate(nextProps) {
return this.props.id !== nextProps.id // true = 需要更新 = 执行渲染
}
}
面试时一定要强调这个区别,它是最常见的 bug 来源之一。
考点 4:useReducer 相比 useState 的优势是什么?何时选择它?
参考答案:
useReducer 本质上是 useState 的超集(useState 内部就是特殊的 useReducer),但它提供了额外的结构化优势:
技术优势:
- 原子性更新:一个 dispatch 调用可以在 reducer 中同时更新多个相关字段,所有字段在同一次渲染中生效,不会出现中间状态
- 可测试性:reducer 是纯函数,可以完全脱离 React 独立做单元测试
- 状态逻辑分离:将复杂的状态转换逻辑提取到 reducer,组件本身更简洁
- 与 Context 组合更高效:当 Context value 中有 dispatch 时,所有 Consumer 只会因 state 变化重渲染,而不会因父组件重渲染
选择原则:
| 情况 | 选择 |
|---|---|
| 1-2 个独立的简单状态 | useState |
| 3+ 个相互依赖的状态字段 | useReducer |
| 状态转换逻辑复杂(有多个分支) | useReducer |
| 需要单独为状态逻辑写单元测试 | useReducer |
| 多步骤表单、向导流程 | useReducer |
| 需要通过 Context 共享状态和更新函数 | useReducer + Context |
考点 5:如何系统性地排查和解决 React 应用的渲染性能问题?
参考答案:
这道题考查的是方法论,要展示系统性的分析思路而不是零散的技巧:
第一步:确认问题存在(量化)
不要凭感觉优化。先用 Chrome Performance Tab 录制,看 FPS 是否低于 60。或者用 React DevTools Profiler 看"wasted renders"。
第二步:定位瓶颈(Profiler)
打开 React DevTools Profiler,点击录制,重现卡顿操作,停止录制:
- 查看火焰图,找出耗时最长(黄/红色)的组件
- 查看每个组件的"Why did this render?"面板
- 重点关注列表中每个 Item 的渲染情况
第三步:分析根因
css
常见根因分类:
A. 子组件接受了不稳定的 prop(函数/对象每次都是新引用)
→ 父组件 useCallback / useMemo,子组件 React.memo
B. 昂贵计算每次渲染都执行
→ useMemo 缓存结果
C. Context value 每次渲染都变化,导致所有 Consumer 重渲染
→ 拆分 Context(稳定值和频繁变化值分开),或 useMemo 稳定 value
D. 组件状态位置不对(状态提升过高)
→ 状态下移,或使用状态共存(Colocation)
E. 渲染的 DOM 节点数量过多(长列表)
→ 虚拟列表(react-window / react-virtual)
第四步:针对性修改,再次测量
修改后用 Profiler 对比数据,确认指标改善。避免"感觉优化了"的心理偏差。
关键原则:不要过度优化。每个 React.memo、useCallback、useMemo 都有维护成本。只优化 Profiler 证明有问题的组件。
八、交互式 HTML 演示
将以下代码保存为 react-performance-demo.html,在浏览器中直接打开即可体验性能优化的直观效果。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React 性能优化 - useCallback / React.memo / useMemo 交互演示</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; }
.app { max-width: 1100px; margin: 0 auto; padding: 24px; }
h1 { text-align: center; font-size: 1.6rem; margin-bottom: 8px; color: #38bdf8; }
.subtitle { text-align: center; font-size: 0.9rem; color: #94a3b8; margin-bottom: 28px; }
.control-panel {
background: #1e293b;
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 24px;
border: 1px solid #334155;
}
.control-panel h2 { font-size: 1rem; color: #7dd3fc; margin-bottom: 16px; }
.controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.toggle-btn {
display: flex; align-items: center; gap: 8px;
background: #0f172a; border: 1px solid #475569;
border-radius: 8px; padding: 10px 16px; cursor: pointer;
font-size: 0.875rem; color: #e2e8f0; transition: all 0.2s;
}
.toggle-btn:hover { border-color: #7dd3fc; }
.toggle-btn.active { background: #0c4a6e; border-color: #38bdf8; color: #bae6fd; }
.toggle-indicator {
width: 36px; height: 20px; border-radius: 10px; background: #475569;
position: relative; transition: background 0.2s;
}
.toggle-indicator.on { background: #0284c7; }
.toggle-indicator::after {
content: ''; position: absolute; width: 14px; height: 14px;
background: white; border-radius: 50%; top: 3px; left: 3px;
transition: transform 0.2s;
}
.toggle-indicator.on::after { transform: translateX(16px); }
.action-btn {
background: #7c3aed; border: none; border-radius: 8px;
padding: 10px 20px; color: white; cursor: pointer;
font-size: 0.875rem; font-weight: 600; transition: all 0.2s;
}
.action-btn:hover { background: #6d28d9; transform: translateY(-1px); }
.action-btn:active { transform: translateY(0); }
.action-btn.secondary { background: #0369a1; }
.action-btn.secondary:hover { background: #0284c7; }
.stats-panel {
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px; margin-bottom: 24px;
}
.stat-card {
background: #1e293b; border-radius: 10px; padding: 16px;
border: 1px solid #334155; text-align: center;
}
.stat-card .label { font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
.stat-card .value { font-size: 1.8rem; font-weight: 700; }
.stat-card .value.danger { color: #f87171; }
.stat-card .value.safe { color: #34d399; }
.stat-card .value.info { color: #60a5fa; }
.stat-card .value.warn { color: #fbbf24; }
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
@media (max-width: 700px) { .comparison { grid-template-columns: 1fr; } }
.panel {
background: #1e293b; border-radius: 12px; padding: 16px;
border: 1px solid #334155;
}
.panel h3 { font-size: 0.875rem; margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
.panel h3 .badge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 0.7rem; font-weight: 700;
}
.badge.red { background: #7f1d1d; color: #fca5a5; }
.badge.green { background: #14532d; color: #86efac; }
.list-container {
height: 320px; overflow-y: auto; border-radius: 8px;
background: #0f172a; border: 1px solid #1e293b;
}
.list-container::-webkit-scrollbar { width: 6px; }
.list-container::-webkit-scrollbar-track { background: #1e293b; }
.list-container::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
.list-item {
display: flex; align-items: center; justify-content: space-between;
padding: 7px 12px; border-bottom: 1px solid #1e293b;
font-size: 0.8rem; transition: background 0.1s;
}
.list-item:hover { background: #1e293b; }
.list-item:last-child { border-bottom: none; }
.item-name { color: #cbd5e1; flex: 1; }
.item-count {
font-weight: 700; font-size: 0.75rem; padding: 2px 8px;
border-radius: 999px; min-width: 70px; text-align: center;
}
.item-count.low { background: #14532d; color: #86efac; }
.item-count.mid { background: #713f12; color: #fcd34d; }
.item-count.high { background: #7f1d1d; color: #fca5a5; }
.flash { animation: flash-anim 0.3s ease-out; }
@keyframes flash-anim {
0% { background: #312e81; }
100% { background: transparent; }
}
.legend {
background: #1e293b; border-radius: 10px; padding: 16px;
border: 1px solid #334155; margin-bottom: 20px;
display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start;
}
.legend h4 { width: 100%; font-size: 0.8rem; color: #94a3b8; margin-bottom: 4px; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 0.78rem; color: #cbd5e1; }
.dot { width: 10px; height: 10px; border-radius: 50%; }
.dot.green { background: #34d399; }
.dot.yellow { background: #fbbf24; }
.dot.red { background: #f87171; }
.code-hint {
background: #020617; border-radius: 8px; padding: 14px 16px;
font-family: 'Consolas', 'Monaco', monospace; font-size: 0.78rem;
line-height: 1.7; border: 1px solid #1e293b; margin-top: 16px;
overflow-x: auto;
}
.code-hint .comment { color: #64748b; }
.code-hint .keyword { color: #7dd3fc; }
.code-hint .string { color: #86efac; }
.code-hint .fn { color: #c4b5fd; }
.progress-bar {
height: 6px; background: #1e293b; border-radius: 3px; overflow: hidden;
margin-top: 8px;
}
.progress-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.tip-box {
background: #0c2340; border: 1px solid #1e40af; border-radius: 10px;
padding: 14px 16px; margin-top: 16px; font-size: 0.82rem; color: #bfdbfe;
line-height: 1.6;
}
.tip-box strong { color: #93c5fd; }
</style>
</head>
<body>
<div class="app">
<h1>React 性能优化 Hooks 交互演示</h1>
<p class="subtitle">通过开关实时感受 useCallback + React.memo + useMemo 的优化效果</p>
<div class="legend">
<h4>渲染次数颜色说明</h4>
<div class="legend-item"><div class="dot green"></div> 1-3 次(正常)</div>
<div class="legend-item"><div class="dot yellow"></div> 4-8 次(偏多)</div>
<div class="legend-item"><div class="dot red"></div> 9+ 次(过度渲染)</div>
</div>
<div class="control-panel">
<h2>控制面板</h2>
<div class="controls">
<button class="toggle-btn" id="btn-memo" onclick="toggleMemo()">
<div class="toggle-indicator" id="ind-memo"></div>
<span>React.memo 优化</span>
</button>
<button class="toggle-btn" id="btn-callback" onclick="toggleCallback()">
<div class="toggle-indicator" id="ind-callback"></div>
<span>useCallback 稳定回调</span>
</button>
<button class="toggle-btn" id="btn-usememo" onclick="toggleUseMemo()">
<div class="toggle-indicator" id="ind-usememo"></div>
<span>useMemo 缓存计算</span>
</button>
<button class="action-btn" onclick="triggerParentUpdate()">
触发父组件更新(不改变列表数据)
</button>
<button class="action-btn secondary" onclick="resetCounts()">
重置渲染计数
</button>
<button class="action-btn secondary" onclick="addItem()">
添加一条数据
</button>
</div>
</div>
<div class="stats-panel">
<div class="stat-card">
<div class="label">父组件更新次数</div>
<div class="value info" id="parent-count">0</div>
</div>
<div class="stat-card">
<div class="label">未优化列表总渲染</div>
<div class="value danger" id="unopt-total">0</div>
</div>
<div class="stat-card">
<div class="label">已优化列表总渲染</div>
<div class="value safe" id="opt-total">0</div>
</div>
<div class="stat-card">
<div class="label">useMemo 计算次数</div>
<div class="value warn" id="memo-calc">0</div>
</div>
<div class="stat-card">
<div class="label">节省的渲染次数</div>
<div class="value safe" id="saved-renders">0</div>
</div>
</div>
<div class="comparison">
<div class="panel">
<h3>
<span class="badge red">未优化</span>
无 React.memo + 无 useCallback
</h3>
<div class="list-container" id="unopt-list"></div>
</div>
<div class="panel">
<h3>
<span class="badge green">已优化</span>
React.memo + useCallback(当前状态)
</h3>
<div class="list-container" id="opt-list"></div>
</div>
</div>
<div class="panel">
<h3>useMemo 演示:过滤 + 排序耗时计算</h3>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<select id="filter-select" style="background:#0f172a;border:1px solid #475569;color:#e2e8f0;padding:6px 10px;border-radius:6px;font-size:0.85rem" onchange="updateFilter()">
<option value="all">全部</option>
<option value="even">偶数项</option>
<option value="odd">奇数项</option>
</select>
<select id="sort-select" style="background:#0f172a;border:1px solid #475569;color:#e2e8f0;padding:6px 10px;border-radius:6px;font-size:0.85rem" onchange="updateFilter()">
<option value="id-asc">ID 升序</option>
<option value="id-desc">ID 降序</option>
<option value="name-asc">名称 A-Z</option>
</select>
</div>
<div id="computed-result" style="background:#0f172a;border-radius:8px;padding:12px;font-size:0.82rem;color:#94a3b8;border:1px solid #1e293b;min-height:48px;"></div>
</div>
<div class="code-hint">
<div class="comment">// 未优化版本(当前设置无优化时的等价代码)</div>
<br>
<span class="keyword">function</span> <span class="fn">Parent</span>() {<br>
<span class="keyword">const</span> [count, setCount] = useState(0) <span class="comment">// 父组件自己的状态</span><br>
<span class="keyword">const</span> [items, setItems] = useState(initialItems)<br>
<br>
<span class="comment">// ❌ 每次父组件重渲染都创建新函数引用</span><br>
<span class="keyword">const</span> handleClick = (item) => console.log(item)<br>
<br>
<span class="comment">// ❌ 每次重渲染都重新计算</span><br>
<span class="keyword">const</span> sortedItems = [...items].sort(...)<br>
<br>
<span class="keyword">return</span> <<span class="fn">ItemList</span> items={sortedItems} onClick={handleClick} /><br>
<span class="comment">// ↑ 父组件 count 变化时,ItemList 也会重渲染(即使 items 没变)</span><br>
}<br>
<br>
<div class="comment">// 已优化版本</div>
<br>
<span class="keyword">const</span> handleClick = <span class="fn">useCallback</span>((item) => console.log(item), []) <span class="comment">// 引用稳定</span><br>
<span class="keyword">const</span> sortedItems = <span class="fn">useMemo</span>(() => [...items].sort(...), [items]) <span class="comment">// 缓存计算</span><br>
<span class="keyword">const</span> <span class="fn">ItemList</span> = React.<span class="fn">memo</span>(({ items, onClick }) => { ... }) <span class="comment">// 浅比较 props</span>
</div>
<div class="tip-box">
<strong>实验说明:</strong>点击"触发父组件更新"会增加一个内部计数器(不修改列表数据)。
在没有任何优化的情况下,列表中每个条目都会重新渲染。
开启 <strong>React.memo</strong> 后,props 未变的列表项会跳过渲染。
但如果不同时开启 <strong>useCallback</strong>,传入的回调函数引用每次都变,
React.memo 的浅比较就会失效,优化等于没做。
<br><br>
<strong>useMemo 演示:</strong>修改上方的过滤/排序选项,观察"useMemo 计算次数"。
开启 useMemo 优化后,触发父组件更新(不改变过滤/排序选项)时,计算次数不会增加。
</div>
</div>
<script>
// ============================
// 状态管理
// ============================
const state = {
memoEnabled: false,
callbackEnabled: false,
useMemoEnabled: false,
parentUpdateCount: 0,
unoptCounts: {}, // itemId -> renderCount
optCounts: {}, // itemId -> renderCount
memoCalcCount: 0,
filter: 'all',
sort: 'id-asc',
items: [],
lastComputedDeps: null, // 模拟 useMemo 的依赖检测
}
// ============================
// 初始化数据
// ============================
function initItems() {
const names = ['React', 'TypeScript', 'Redux', 'Zustand', 'Vite', 'Webpack', 'Node.js',
'Express', 'MongoDB', 'PostgreSQL', 'GraphQL', 'REST API', 'Docker', 'Git',
'Jest', 'Vitest', 'Playwright', 'Tailwind', 'MUI', 'Ant Design']
for (let i = 0; i < 40; i++) {
const id = i + 1
const name = `${names[i % names.length]} - ${Math.floor(i / names.length) + 1}`
state.items.push({ id, name })
state.unoptCounts[id] = 0
state.optCounts[id] = 0
}
}
// ============================
// 渲染列表
// ============================
function renderLists(triggerAll) {
const unoptContainer = document.getElementById('unopt-list')
const optContainer = document.getElementById('opt-list')
// 未优化:父组件更新 → 所有子组件都重渲染
if (triggerAll) {
state.items.forEach(item => { state.unoptCounts[item.id]++ })
}
// 已优化:父组件更新 → 只有 props 真正变化的子组件重渲染
// 模拟 React.memo 的行为:
// - 如果 memo 关闭:等同未优化
// - 如果 memo 开启但 callback 关闭:回调每次都新建,所有子组件仍重渲染
// - 如果 memo + callback 都开启:只有 items 变化时才重渲染
if (triggerAll) {
if (!state.memoEnabled || !state.callbackEnabled) {
// memo 或 callback 任一未开启:全部重渲染
state.items.forEach(item => { state.optCounts[item.id]++ })
}
// 若两者都开启,父组件更新但 items/callback 未变 → 跳过渲染(不增加计数)
}
// 当 items 实际变化时(如添加),优化版也要重渲染受影响的项
// (这里 triggerAll 为 false 时表示 items 真正变化)
if (!triggerAll) {
// 新增项:两边都渲染一次
const newItem = state.items[state.items.length - 1]
if (newItem) {
state.unoptCounts[newItem.id] = (state.unoptCounts[newItem.id] || 0) + 1
state.optCounts[newItem.id] = (state.optCounts[newItem.id] || 0) + 1
}
// 未优化:所有旧项也重渲染(因为列表数组引用变了)
state.items.slice(0, -1).forEach(item => { state.unoptCounts[item.id]++ })
// 已优化且 memo+callback 开启:旧项 props 未变,跳过
if (!state.memoEnabled || !state.callbackEnabled) {
state.items.slice(0, -1).forEach(item => { state.optCounts[item.id]++ })
}
}
// 渲染 DOM
renderList(unoptContainer, state.items, state.unoptCounts)
renderList(optContainer, state.items, state.optCounts)
updateStats()
}
function renderList(container, items, counts) {
const html = items.map(item => {
const count = counts[item.id] || 0
const cls = count <= 3 ? 'low' : count <= 8 ? 'mid' : 'high'
return `<div class="list-item" id="item-${container.id}-${item.id}">
<span class="item-name">${item.name}</span>
<span class="item-count ${cls}">渲染 ${count} 次</span>
</div>`
}).join('')
container.innerHTML = html
}
// ============================
// 控制函数
// ============================
function toggleMemo() {
state.memoEnabled = !state.memoEnabled
document.getElementById('btn-memo').classList.toggle('active', state.memoEnabled)
document.getElementById('ind-memo').classList.toggle('on', state.memoEnabled)
}
function toggleCallback() {
state.callbackEnabled = !state.callbackEnabled
document.getElementById('btn-callback').classList.toggle('active', state.callbackEnabled)
document.getElementById('ind-callback').classList.toggle('on', state.callbackEnabled)
}
function toggleUseMemo() {
state.useMemoEnabled = !state.useMemoEnabled
document.getElementById('btn-usememo').classList.toggle('active', state.useMemoEnabled)
document.getElementById('ind-usememo').classList.toggle('on', state.useMemoEnabled)
}
function triggerParentUpdate() {
state.parentUpdateCount++
document.getElementById('parent-count').textContent = state.parentUpdateCount
renderLists(true)
updateComputedResult(false) // 不改变过滤/排序,测试 useMemo 是否跳过重算
}
function addItem() {
const id = state.items.length + 1
state.items.push({ id, name: `新条目 #${id}` })
state.unoptCounts[id] = 0
state.optCounts[id] = 0
renderLists(false)
updateComputedResult(true) // items 变了,useMemo 需要重算
}
function resetCounts() {
state.items.forEach(item => {
state.unoptCounts[item.id] = 0
state.optCounts[item.id] = 0
})
state.parentUpdateCount = 0
state.memoCalcCount = 0
document.getElementById('parent-count').textContent = '0'
document.getElementById('memo-calc').textContent = '0'
renderLists(false)
updateComputedResult(true)
}
function updateFilter() {
state.filter = document.getElementById('filter-select').value
state.sort = document.getElementById('sort-select').value
updateComputedResult(true) // 过滤/排序条件变了,必须重算
}
// ============================
// useMemo 演示
// ============================
function updateComputedResult(depsChanged) {
const t0 = performance.now()
const deps = `${state.filter}-${state.sort}-${state.items.length}`
// 模拟 useMemo 的依赖比较逻辑
const needsRecalc = state.useMemoEnabled ? depsChanged || state.lastComputedDeps !== deps : true
if (needsRecalc) {
state.memoCalcCount++
state.lastComputedDeps = deps
// 模拟计算:过滤 + 排序
let result = [...state.items]
if (state.filter === 'even') result = result.filter(i => i.id % 2 === 0)
else if (state.filter === 'odd') result = result.filter(i => i.id % 2 !== 0)
if (state.sort === 'id-desc') result.sort((a, b) => b.id - a.id)
else if (state.sort === 'name-asc') result.sort((a, b) => a.name.localeCompare(b.name))
const t1 = performance.now()
const elapsed = (t1 - t0).toFixed(3)
document.getElementById('computed-result').innerHTML =
`<span style="color:#94a3b8">计算结果:</span>
<span style="color:#e2e8f0">共 <strong style="color:#38bdf8">${result.length}</strong> 条</span>
<span style="color:#475569"> | </span>
<span style="color:#94a3b8">耗时 ${elapsed}ms</span>
<span style="color:#475569"> | </span>
<span style="color:#94a3b8">第一条:</span>
<span style="color:#a78bfa">${result[0]?.name ?? '空'}</span>
<span style="color:#475569"> | </span>
<span style="color:${state.useMemoEnabled ? '#34d399' : '#f87171'}">${state.useMemoEnabled ? '✓ useMemo 已缓存(本次因 deps 变化重算)' : '✗ 每次渲染都重算'}</span>`
document.getElementById('memo-calc').textContent = state.memoCalcCount
} else {
document.getElementById('computed-result').innerHTML =
`<span style="color:#34d399">✓ useMemo 命中缓存:本次父组件更新不触发重新计算(deps 未变化)</span>
<span style="color:#475569"> | </span>
<span style="color:#94a3b8">总计算次数:${state.memoCalcCount}</span>`
}
}
// ============================
// 统计更新
// ============================
function updateStats() {
const unoptTotal = Object.values(state.unoptCounts).reduce((s, n) => s + n, 0)
const optTotal = Object.values(state.optCounts).reduce((s, n) => s + n, 0)
const saved = unoptTotal - optTotal
document.getElementById('unopt-total').textContent = unoptTotal
document.getElementById('opt-total').textContent = optTotal
document.getElementById('saved-renders').textContent = Math.max(0, saved)
}
// ============================
// 初始化
// ============================
initItems()
renderLists(false)
updateComputedResult(true)
</script>
</body>
</html>
演示操作指南
markdown
步骤 1:基准测试
- 所有优化开关均关闭
- 多次点击"触发父组件更新"
- 观察:左右两个列表的渲染次数相同且持续增长
步骤 2:开启 React.memo(但不开启 useCallback)
- 开启"React.memo 优化"
- 继续点击"触发父组件更新"
- 观察:右侧列表渲染次数仍在增长!
- 原因:onClick callback 每次父组件渲染都是新引用,React.memo 浅比较失败
步骤 3:同时开启 React.memo + useCallback
- 再开启"useCallback 稳定回调"
- 继续点击"触发父组件更新"
- 观察:右侧列表渲染次数不再增长,"节省的渲染次数"大幅上升
- 结论:React.memo 必须配合 useCallback 才能发挥作用
步骤 4:测试真实 props 变化
- 点击"添加一条数据"
- 观察:新条目在两侧都渲染一次(正常)
- 左侧旧条目也重渲染(因为 items 数组引用变了,未优化版全量重渲染)
- 右侧旧条目不重渲染(已优化版只渲染 props 真正变化的组件)
步骤 5:测试 useMemo
- 开启"useMemo 缓存计算"
- 修改过滤/排序选项 → 计算次数增加(deps 变了)
- 点击"触发父组件更新"(不改变过滤/排序)→ 计算次数不增加(缓存命中)
- 关闭 useMemo → 每次父组件更新都触发重计算
九、参考资料
官方文档
-
Redux Toolkit - createAsyncThunk
官方完整 API 文档,涵盖所有 thunkAPI 参数、condition 选项、TypeScript 类型
-
包含"是否需要 useCallback?"的决策指南
-
官方对"何时跳过重新计算"的详细解释
-
useReducer 完整用法,含惰性初始化(lazy initializer)
-
React.memo 与自定义比较函数的详细说明
深度阅读
-
Kent C. Dodds - When to useMemo and useCallback
详细论证了"不是所有计算都需要 useMemo",是理解性能优化权衡的必读文章
-
Mark Erikson - Redux Essentials
RTK 作者亲著的异步逻辑教程,从设计决策角度解释 createAsyncThunk
-
系统梳理了 React 重渲染的所有触发场景和优化策略
-
了解 Watcher/Worker Saga 模式
-
安装和配置 WDYR 进行渲染追踪
工具
-
必装工具,Profiler 功能是性能分析的首选
-
可视化查看 action 历史、state 变化和 diff
本文完整覆盖了 Day 10 的核心内容。Redux 异步方案的选型核心是:简单用 createAsyncThunk,复杂流程用 redux-saga 。React 性能优化的核心是:先测量再优化,React.memo + useCallback 必须组合使用,useMemo 只用于真正昂贵的计算。记住性能优化的第一原则------没有 Profiler 数据支持的优化就是过早优化。