Redux异步方案与React性能优化Hooks

Day 10 - Redux 异步方案与 React 性能优化 Hooks 深度解析

本文是尚硅谷 React + TypeScript 实战课程 Day 10 的系统性深度整理。从 Redux 中间件体系的底层原理出发,完整剖析四大异步方案(redux-thunk、redux-saga、redux-observable、createAsyncThunk)的设计哲学与工程取舍,再深入拆解 useCallbackuseMemouseReducerReact.memo 四大性能优化手段的底层机制与实战陷阱,帮助读者建立系统性的 React 性能心智模型。


一、名词解释

在深入原理之前,先对本章所有核心术语做精确定义,建立统一的语言体系。

Redux 异步体系

术语 定义
Middleware(中间件) Redux 中位于 dispatch(action) 与 reducer 执行之间的可插拔函数层,可以拦截、转换、延迟或增强 action 的处理过程
Thunk 一种设计模式,指"被另一个函数包裹、延迟执行的函数"。Redux 语境中特指一个接受 dispatchgetState 为参数的函数
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 的优化完全失效。这就是 useMemouseCallback 存在的根本原因。

2.6 useReducer 与 useState 的底层差异

useState 实际上是 useReducer 的语法糖:

typescript 复制代码
// useState 的内部实现就是特殊的 useReducer
function useState(initialState) {
  return useReducer(
    (state, action) => (typeof action === 'function' ? action(state) : action),
    initialState
  )
}

useReducer 的优势在于:

  1. Reducer 是纯函数,可以独立于组件进行单元测试
  2. Action 的语义化,dispatch 的 action 比直接 setState 更清晰
  3. 批量状态更新,一个 action 可以触发多个状态字段的原子性更新
  4. 与 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 中。序列化过程会尝试提取 namemessagecode 等字段,但不是所有错误信息都能被完整保留(例如自定义错误类的额外字段会丢失)。此时 action.payloadundefined

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 的场景:

  1. 传递给 React.memo 包裹的子组件的回调函数
  2. 作为 useEffect 依赖项的函数
  3. 防抖/节流函数(需要稳定引用)

useMemo 的场景:

  1. 昂贵的计算(对大数组排序/过滤)
  2. 创建需要传给 React.memo 子组件的对象或数组(稳定引用)
  3. 推导状态(从多个 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),但它提供了额外的结构化优势:

技术优势:

  1. 原子性更新:一个 dispatch 调用可以在 reducer 中同时更新多个相关字段,所有字段在同一次渲染中生效,不会出现中间状态
  2. 可测试性:reducer 是纯函数,可以完全脱离 React 独立做单元测试
  3. 状态逻辑分离:将复杂的状态转换逻辑提取到 reducer,组件本身更简洁
  4. 与 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>
    &nbsp;&nbsp;<span class="keyword">const</span> [count, setCount] = useState(0)  <span class="comment">// 父组件自己的状态</span><br>
    &nbsp;&nbsp;<span class="keyword">const</span> [items, setItems] = useState(initialItems)<br>
    &nbsp;&nbsp;<br>
    &nbsp;&nbsp;<span class="comment">// ❌ 每次父组件重渲染都创建新函数引用</span><br>
    &nbsp;&nbsp;<span class="keyword">const</span> handleClick = (item) => console.log(item)<br>
    &nbsp;&nbsp;<br>
    &nbsp;&nbsp;<span class="comment">// ❌ 每次重渲染都重新计算</span><br>
    &nbsp;&nbsp;<span class="keyword">const</span> sortedItems = [...items].sort(...)<br>
    &nbsp;&nbsp;<br>
    &nbsp;&nbsp;<span class="keyword">return</span> &lt;<span class="fn">ItemList</span> items={sortedItems} onClick={handleClick} /&gt;<br>
    &nbsp;&nbsp;<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 → 每次父组件更新都触发重计算

九、参考资料

官方文档

深度阅读

工具


本文完整覆盖了 Day 10 的核心内容。Redux 异步方案的选型核心是:简单用 createAsyncThunk,复杂流程用 redux-saga 。React 性能优化的核心是:先测量再优化,React.memo + useCallback 必须组合使用,useMemo 只用于真正昂贵的计算。记住性能优化的第一原则------没有 Profiler 数据支持的优化就是过早优化。

相关推荐
threerocks1 小时前
什么?我连 A2A、MCP 都没学会,现在又来了 AG-UI、A2UI.
前端·aigc·ai编程
牛奶2 小时前
如何自己写一个浏览器插件?
前端·chrome·浏览器
亿元程序员2 小时前
为什么Cocos都4.0了还有人用2.x?
前端
MomentYY2 小时前
AI 到底是“懂”,还是在“猜”?
前端·人工智能·ai编程
鹏毓网络科技3 小时前
Cursor Rules 文件配置实战:3 个隐藏参数让我每月少写 40% 样板代码
前端·github
没烦恼3013 小时前
无痕模式下 HTTP\-First 拦截引发的“页面刷新”误判
前端
文心快码BaiduComate3 小时前
从个人提效到组织提效:Comate辅助构建自我进化的AI研发系统
前端·程序员
hunterandroid3 小时前
Compose 状态管理:remember、rememberSaveable 与状态提升
前端
星栈4 小时前
Dioxus 接数据库最容易写歪的 3 个地方:sqlx + SQLite 怎么接才顺
前端·rust·前端框架
晴虹4 小时前
vue3-scroll-more:横向滚动条-元素或页签过多滚动显示处理的组件
前端·vue.js