useReducer 什么时候比 useState 更合适?
一个组件里有多个事件用相似的方式改同一份状态、或者下一个状态要根据多个字段一起算时,把更新逻辑集中到 reducer(state, action) 里,事件处理器只 dispatch 一个描述“发生了什么”的动作,代码更好读、好调试,reducer 还能单独测试。状态简单时 useState 代码更少,两者可以互相转换,也能在同一组件里混用。
简答
比如一个购物车,加商品、删商品、改数量、用优惠券,每个按钮的事件处理器里都在拷贝数组、找对应项、算新值,改状态的代码散落在好几处。
useReducer就是把这些改法集中到一个函数里:reducer(state, action)接收当前状态和一个动作,返回新状态;事件处理器只写dispatch({ type: 'added', id }),说明发生了什么。它适合的情况是:多个事件用相似的方式改同一份状态,或者一个操作要同时改好几个字段。这时用
useState会让组件里塞满更新代码,useReducer把“怎么改”和“发生了什么”分开,组件读起来清楚。好处还有两个。出了 bug,可以在 reducer 里打日志,看到每次状态变化是哪个动作引起的;reducer 是不依赖组件的纯函数,可以单独导出测试。
但它不是越多越好。状态简单时
useState代码更少,useReducer要多写 reducer 和动作。react.dev 的建议是:某个组件经常因为状态更新写错而出 bug、想给它更多结构时再用,两者可以随时互相转换,也能在同一组件里混用。另外 reducer 必须是纯函数,不能发请求、设定时器,也不能直接修改原来的状态。
把购物车的更新逻辑写成 reducer
下面的 cartReducer 是一个普通函数,不依赖 React。示例用数组的 reduce 依次执行一串动作,模拟用户的一系列操作,看最后的状态。
function cartReducer(state, action) {
switch (action.type) {
case 'added': {
const exists = state.items.find((item) => item.id === action.id);
const items = exists
? state.items.map((item) =>
item.id === action.id ? { ...item, qty: item.qty + 1 } : item,
)
: [...state.items, { id: action.id, price: action.price, qty: 1 }];
return { ...state, items };
}
case 'removed':
return { ...state, items: state.items.filter((item) => item.id !== action.id) };
case 'coupon_applied':
return { ...state, discount: action.discount };
default:
throw new Error('未知动作:' + action.type);
}
}
const initial = { items: [], discount: 0 };
const actions = [
{ type: 'added', id: 'apple', price: 5 },
{ type: 'added', id: 'apple', price: 5 },
{ type: 'added', id: 'pear', price: 3 },
{ type: 'coupon_applied', discount: 2 },
{ type: 'removed', id: 'pear' },
];
const final = actions.reduce(cartReducer, initial);
console.log(JSON.stringify(final.items)); // [{"id":"apple","price":5,"qty":2}]
console.log(final.discount); // 2
console.log(initial.items.length); // 0
苹果加了两次,所以 qty 是 2;梨加进去又删掉了;优惠券单独记在 discount 里。最后一行打印 0,说明最初的 initial 没有被改过,每个分支都返回了新对象。
为什么一定要返回新对象?因为 React 用 Object.is 比较新旧状态,如果直接改 state.items.push(...) 再返回原来的 state,引用没变,React 会认为状态没变而跳过渲染。所以分支里都用展开语法创建新对象,并且带上 ...state,否则其他字段会丢失。
为什么可以直接用 reduce 测?这也是 reducer 的好处:它只根据输入算输出,不依赖组件,所以 react.dev 说可以把它单独导出来测试。useReducer 这个名字也来自数组的 reduce,都是“拿当前结果和下一项算出新结果”。
在组件里使用
组件里用 useReducer 接上这个 reducer,事件处理器只负责 dispatch:
import { useReducer } from 'react';
function Cart() {
const [cart, dispatch] = useReducer(cartReducer, { items: [], discount: 0 });
return (
<>
<button onClick={() => dispatch({ type: 'added', id: 'apple', price: 5 })}>加苹果</button>
<button onClick={() => dispatch({ type: 'coupon_applied', discount: 2 })}>用优惠券</button>
<p>共 {cart.items.length} 种商品</p>
</>
);
}
dispatch 和 setState 一样,只更新下一次渲染的状态,调用以后在同一个事件里读 cart 还是旧值。
动作应该怎么设计?react.dev 建议每个动作描述一次用户操作,即使它会改好几处数据。比如表单有五个字段,点“重置”时应该 dispatch 一个 reset_form,而不是五个 set_field。这样把动作日志打出来,就能看出用户按什么顺序做了什么。
reducer 里不能做什么
reducer 在渲染时运行,所以它必须是纯函数:同样的输入总得到同样的输出,不能发请求、设定时器,也不能修改传进来的对象。严格模式下 React 会在开发环境把 reducer 调用两次,帮你发现这类问题,比如在 reducer 里 push 数组,待办就会被加两次。
请求这类副作用放在事件处理器或 Effect 里,结果回来以后再 dispatch 一个动作。
常见追问
初始状态要通过计算得到,怎样避免每次渲染都重新计算?
把计算函数作为第三个参数传进去:
useReducer(reducer, username, createInitialState)。这样 React 只在初始化时调用一次createInitialState(username)。如果写成useReducer(reducer, createInitialState(username)),虽然结果只用一次,函数却每次渲染都会执行。
为什么 dispatch 可以不写进 Effect 的依赖?
因为
dispatch的引用是稳定的,每次渲染都是同一个函数,写不写进依赖都不会让 Effect 多执行。所以把它通过 Context 传给深层组件也很方便,见《Context 与 useReducer 适合管理什么状态?》。
每个分支都写展开语法太啰嗦,有办法简化吗?
可以用 Immer。react.dev 介绍了 use-immer 库的
useImmerReducer,reducer 拿到的是一个草稿对象,可以直接push或赋值,Immer 在背后生成新的状态,所以也不用 return。