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

0 阅读32分钟

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 处理异步副作用
createAsyncThunkRTK 提供的标准化异步 thunk 工厂函数,自动管理 pending/fulfilled/rejected 三个阶段的 action
extraReducersRTK slice 中响应外部 action(尤其是 createAsyncThunk 生成的 action)的 reducer 配置区域
rejectWithValuecreateAsyncThunk 提供的工具函数,用于将自定义错误数据放入 action.payload 而非 action.error
AbortController浏览器原生 API,用于取消 fetch 请求;createAsyncThunk 的 thunkAPI.signal 即为其 AbortSignal
竞态条件(Race Condition)多个异步请求同时发起,最终状态由最后完成的请求决定,可能导致展示旧数据的问题
requestIdcreateAsyncThunk 为每次调用自动生成的唯一 ID,用于识别和处理竞态条件

React 性能优化体系

在这里插入图片描述

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

二、底层原理深度解析

2.1 Redux 中间件机制:洋葱模型

要理解 Redux 各种异步方案,必须先理解 Redux 中间件的本质。Redux 中间件采用**函数组合(Function Composition)**的洋葱模型:

在这里插入图片描述

dispatch(action)
    ↓
[中间件 1 外层][中间件 2 外层][中间件 3 外层]reducer(state, action)  ← 核心
    ↑
[中间件 3 内层][中间件 2 内层][中间件 1 内层]
    ↑
返回新 state

Redux 中间件的签名是一个三层柯里化函数:

// 中间件的完整类型签名
type Middleware = (api: MiddlewareAPI) => (next: Dispatch) => (action: Action) => any

// MiddlewareAPI 提供了两个能力:
interface MiddlewareAPI {
  dispatch: Dispatch   // 可以 dispatch 新的 action(形成循环)
  getState: () => S    // 可以读取当前 state
}

applyMiddleware 的实现原理(简化版):

// 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 行

// 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

使用示例:

// 手写 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 解释执行:

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 负责"恢复"执行并注入结果。这使得异步流程可以被测试为纯同步的:

// 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 处理异步流:

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 和竞态问题:

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 的内部工作流程(伪代码):

// 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 的内部逻辑:

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 返回函数的调用结果)。

// 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 的比较逻辑:

// 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 的语法糖:

// 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 年前端工程实践调研,各方案在不同规模项目中的使用分布:

createAsyncThunk(RTK):新项目首选,覆盖率约 60%
  → 适合:中小型项目,团队 Redux 经验一般,快速迭代需求

redux-thunk(手写):遗留项目维护,覆盖率约 20%
  → 适合:轻量级异步需求,不想引入 RTK 的项目

redux-saga:大型复杂项目,覆盖率约 15%
  → 适合:金融交易系统、实时协作工具、复杂工作流

redux-observable:少数 RxJS 深度使用者,覆盖率约 5%
  → 适合:WebSocket 密集型、事件流处理、RxJS 已成熟使用的团队

3.2 createAsyncThunk 的竞态条件处理

在实际生产中,搜索框联想、分页切换等场景高频遇到竞态问题:

// 场景:用户快速切换页码,多个请求同时在途
// 问题:第 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 精确处理竞态:

// 在 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() 获取结果

在某些场景下(如提交表单后跳转路由),需要在组件中等待异步操作完成:

// 使用 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 集成)

// 场景:搜索框实时搜索,每次输入都取消上一次请求
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 的真实使用场景

// 场景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 的生产级应用

// 场景:电商平台商品列表的多维度筛选
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

// 购物车状态管理: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 的区别
// ❌ 错误:不用 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 不处理依赖
// ❌ 陷阱: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 状态冲突
// ❌ 问题:两个不同的 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)
// ❌ 闭包陷阱: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 没有意义
// ❌ 无效优化:子组件未用 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 反而更慢
// ❌ 过度优化:简单组件包裹 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 反而更慢
// ❌ 过度优化:字符串拼接根本不需要 useMemo
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName])
// useMemo 本身的维护开销 > 字符串拼接的开销

// ✅ 直接计算即可
const fullName = `${firstName} ${lastName}`
陷阱 2:依赖数组中的对象/函数引用不稳定
// ❌ 陷阱: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:自定义比较函数逻辑错误
// ❌ 错误:返回值含义搞反了!
// 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
// ❌ 无效:组件的 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 定位性能问题

操作步骤:
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 库:

// 开发环境入口文件(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(手写)函数返回函数★☆☆☆☆手动手动一般简单异步
createAsyncThunkRTK 封装 thunk★★☆☆☆requestIdabort()良好大多数项目
redux-sagaGenerator + Effect★★★★☆takeLatestcancel()极强复杂业务流程
redux-observableRxJS + Epic★★★★★switchMapunsubscribe良好事件流密集型

5.2 createAsyncThunk API 速查表

参数/属性类型说明
typePrefixstringaction 类型前缀,自动生成 /pending/fulfilled/rejected
payloadCreatorasync function执行异步操作的函数,返回值成为 fulfilled 的 payload
thunkAPI.dispatchAppDispatch可以 dispatch 其他 action
thunkAPI.getState() => RootState读取当前 Redux state
thunkAPI.rejectWithValue(value) => RejectWithValue自定义 rejected payload,统一错误接口
thunkAPI.signalAbortSignal传给 fetch 实现请求取消
thunkAPI.requestIdstring本次调用的唯一 ID,用于处理竞态
action.meta.requestStatus'pending'/'fulfilled'/'rejected'当前请求状态
action.meta.abortedboolean请求是否被主动取消
dispatch(thunk).unwrap()Promise组件中 await 结果,fulfilled 返回 payload,rejected 抛出错误
dispatch(thunk).abort()void取消正在进行的请求

5.3 React 性能优化 Hooks 对比表

Hook / API缓存目标返回值类型何时重新计算主要配合典型场景
useCallback(fn, deps)函数引用Functiondeps 变化时React.memo传给 memo 子组件的回调
useMemo(fn, deps)计算结果值anydeps 变化时React.memo、子组件昂贵计算、稳定对象引用
React.memo(Component)渲染结果Componentprops 浅比较失败时useCallback、useMemo包裹接受 callback 的子组件
useReducer(reducer, init)状态逻辑[state, dispatch]dispatch action 时Context复杂状态管理、多字段表单
PureComponent渲染结果classprops/state 浅比较失败时-类组件版 React.memo

5.4 选型决策树

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 三态口诀

"派发异步看三态,P-F-R 要分清楚"
P = Pending(进行中)→ loading = true
F = Fulfilled(成功)→ 拿 payload 更新数据
R = Rejected(失败)→ 拿 payload(rejectWithValue)更新错误

"rejectWithValue 让 payload 有值,不用它则只有 error"
"unwrap 组件中等结果,abort 方法来取消请求"

6.2 useCallback 三要素口诀

"三个没有不要用 useCallback"1. 没有传给 memo 子组件 → 没用
2. 子组件没有 React.memo → 没用
3. 依赖几乎每次都变 → 没用

"有函数式更新,deps 不写 state"setItems(prev => ...)  →  deps 里不需要写 items

6.3 useMemo 判断口诀

"计算慢用 memo,计算快不用"
"引用要稳定(传给 memo 子组件),也用 useMemo"
"deps 里有字面量({}、[]),useMemo 白费"

6.4 React.memo 口诀

"React.memo 第二参数:true = 相等 = 跳过渲染"(与 shouldComponentUpdate 相反!)
"浅比较一层,引用类型看地址,基本类型看值"
"props 频繁变化的组件,memo 是负担不是优化"

6.5 性能优化总口诀

先 Profiler 找瓶颈,再针对性来优化
React.memo 防渲染,配合 callback/useMemo
useCallback 稳函数,useMemo 缓结果
useReducer 管复杂态,dispatch action 更语义化

React 18 并发特性性能优化实战

1. useTransition vs useDeferredValue 选型

两者都用于把"重渲染"标记为低优先级,但使用位置不同:

Hook控制点适用场景
useTransition更新源头(setState)你能控制 setState 调用
useDeferredValue消费端(值传递)值来自 props 或第三方

2. useTransition 实战:大列表搜索

未优化版(输入卡顿):

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 慢 */}
    </>
  );
}

优化版(输入流畅):

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~400ms16ms
过滤总耗时200ms200ms(不变)
主线程阻塞持续 200ms分片,每 5ms 让出

3. useDeferredValue 实战:图表组件

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 包裹时才能跳过子树渲染:

const ExpensiveChart = memo(function Chart({ symbol }: { symbol: string }) {
  // symbol 变了才重渲染
  return /* ... */;
});

5. Profiler API 量化性能

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 集成

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 上传

# 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: '未登录' },便于组件根据错误码做差异化处理。

// 统一的错误处理模式
.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 的语义是"是否应该更新",两者问的是相反的问题。

// 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 的渲染情况

第三步:分析根因

常见根因分类:
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,在浏览器中直接打开即可体验性能优化的直观效果。

<!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>

演示操作指南

步骤 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 数据支持的优化就是过早优化。