(舞台灯光聚焦,麦克风试音,一阵清脆的“咔哒”声)
大家好,坐好,坐好。别急着划走,我知道你们很多人看到“状态管理”和“reducer”这几个词就头皮发麻,觉得这又是 React 官方搞出来的新式酷刑。但今天,我们不聊那些虚头巴脑的概念,咱们来聊聊怎么从那个把你折磨得死去活来的 useState 手里救回你的大脑。
想象一下,你正在维护一个项目,那个项目是你三个月前写的。你打开代码,发现一个组件里有一百行代码,里面塞了二十个 useState,三十个 useEffect,还有五个事件处理函数。你的手指在键盘上悬停了五分钟,最后你只能对着屏幕,在心里默默流泪。
为什么?因为 useState 在处理复杂逻辑时,就像是用胶带粘乐高积木。逻辑一多,胶带就断了,你的组件就开始抽搐、报错,变成一坨不可名状的“意大利面条代码”。
今天,我们要讲的主角是 useReducer。别被它那个生硬的名字吓到了。它本质上就是一个比 useState 更强壮的“大脑皮层”,专门用来处理那些复杂的、多步骤的、甚至有点混乱的状态逻辑。
我们的核心目标只有一个:可测试性。我们要把逻辑从 UI 里剥离出来,像剥洋葱一样,一层一层,直到只剩下一颗晶莹剔透的纯函数。
准备好了吗?让我们开始这场“大脑重启”之旅。
第一部分:当 useState 遇到“状态爆炸”
首先,让我们看看 useState 在复杂场景下是如何“作妖”的。
假设我们要做一个“高级购物车”组件。这个购物车不仅仅是加加减减那么简单。它需要:
- 跟踪商品列表。
- 跟踪购物车里的数量。
- 根据商品数量计算总价。
- 根据总价判断是否达到满减门槛。
- 处理优惠券逻辑。
- 处理库存限制。
- 处理用户输入的备注。
好,现在请打开你的编辑器,跟着我一起写出那个“经典”的 useState 版本。我会写得非常详尽,因为这就是你每天都在做的噩梦。
import React, { useState, useEffect } from 'react';
const NightmareComponent = ({ products }) => {
// 1. 商品列表状态
const [cartItems, setCartItems] = useState([]);
// 2. 数量状态(虽然可以用 cartItems 推导,但很多人会分开存)
const [quantities, setQuantities] = useState({});
// 3. 优惠券码
const [couponCode, setCouponCode] = useState('');
// 4. 是否使用了优惠券
const [hasCoupon, setHasCoupon] = useState(false);
// 5. 总价(虽然可以计算,但为了性能或者懒,直接存状态)
const [totalPrice, setTotalPrice] = useState(0);
// 6. 满减状态
const [isDiscountActive, setIsDiscountActive] = useState(false);
// 7. 备注信息
const [notes, setNotes] = useState('');
// 8. 优惠券输入框的值
const [inputCoupon, setInputCoupon] = useState('');
// ... 然后是各种处理函数,一个组件文件变得像本小说
const handleAddToCart = (productId) => {
// 逻辑:检查库存 -> 更新数量 -> 更新总价 -> 触发满减判断
// 这里的逻辑写满了30行
};
const handleQuantityChange = (productId, newQty) => {
// 逻辑:边界检查 -> 更新 quantities -> 重新计算总价 -> 更新状态
};
const handleApplyCoupon = () => {
// 逻辑:验证码 -> 更新 hasCoupon -> 重新计算总价
};
const handleSubmit = () => {
// 逻辑:校验备注 -> 提交订单 -> 清空购物车
};
// ... 还有一堆 useEffect 监听依赖,防止价格计算过期
return (
<div className="cart">
{/* 渲染逻辑也是一堆 if/else */}
<h2>购物车</h2>
<div className="items">
{cartItems.map(item => (
<div key={item.id}>
<span>{item.name}</span>
<input
type="number"
value={quantities[item.id] || 0}
onChange={(e) => handleQuantityChange(item.id, e.target.value)}
/>
</div>
))}
</div>
<div className="summary">
<p>总价: {totalPrice}</p>
<button onClick={handleSubmit}>结算</button>
</div>
</div>
);
};
你看,这个组件现在怎么样?它就像一个披着羊皮的狼。表面上它只是在渲染一个购物车,实际上它的体内塞满了业务逻辑。如果你想修改满减规则,你得去翻这个组件的代码;如果你想测试“当输入错误优惠券时总价是否正确”,你得启动 React 组件,模拟点击,模拟输入,甚至还要渲染整个组件树。
这就引出了我们今天的第一个核心痛点:逻辑与视图的耦合。
在 useState 的世界里,状态更新逻辑(handleAddToCart)和 UI 渲染逻辑(return)混杂在一起。这就导致了一个可怕的后果:你很难对这部分逻辑进行单元测试。你想测试 handleAddToCart,但你必须模拟整个 React 环境,甚至还要模拟 DOM 事件。这不仅慢,而且脆弱。
第二部分:useReducer —— 把逻辑从 UI 里“抠”出来
现在,让我们请出 useReducer。它的本质是什么?它是一个分发器。
它接受两个参数:reducer 函数和初始状态。然后它返回一个状态和一个 dispatch 函数。
dispatch 函数就像是一个邮递员。当你想改变状态时,你不需要直接修改状态(state.qty++ 这种操作在 React 里是大忌,因为它会导致不可预测的更新),你只需要给邮递员扔一个信封,信封上写着“这是什么类型的信,里面装了什么数据”。
这个信封,我们就叫它 Action。
让我们重构一下上面的购物车,看看 useReducer 是如何拯救我们的。
import React, { useReducer, useMemo } from 'react';
// 1. 定义初始状态
const initialState = {
items: [],
quantities: {},
coupon: null,
totalPrice: 0,
isDiscountActive: false,
notes: '',
inputCoupon: ''
};
// 2. 定义 Action 类型(常量)
const ADD_ITEM = 'ADD_ITEM';
const UPDATE_QUANTITY = 'UPDATE_QUANTITY';
const APPLY_COUPON = 'APPLY_COUPON';
const REMOVE_ITEM = 'REMOVE_ITEM';
const SET_NOTES = 'SET_NOTES';
// 3. 定义 Reducer 函数(核心逻辑!)
// 注意:这里没有任何 React 代码,没有 DOM,没有 useEffect,只有纯 JS 逻辑
function cartReducer(state, action) {
switch (action.type) {
case ADD_ITEM: {
const product = action.payload;
const currentQty = state.quantities[product.id] || 0;
// 逻辑:处理库存(假设库存无限,这里只是演示逻辑)
// 逻辑:计算新总价
const newTotal = state.totalPrice + (product.price * product.qty);
return {
...state,
items: [...state.items, product],
quantities: {
...state.quantities,
[product.id]: currentQty + product.qty
},
totalPrice: newTotal
};
}
case UPDATE_QUANTITY: {
const { productId, newQty } = action.payload;
const item = state.items.find(i => i.id === productId);
if (!item) return state;
const priceDiff = (newQty - (state.quantities[productId] || 0)) * item.price;
const newTotal = state.totalPrice + priceDiff;
return {
...state,
quantities: {
...state.quantities,
[productId]: newQty
},
totalPrice: newTotal
};
}
case APPLY_COUPON: {
const code = action.payload;
// 逻辑:优惠券验证
if (code === 'SAVE20') {
return {
...state,
coupon: code,
isDiscountActive: true
};
}
// 优惠券错误处理
return {
...state,
coupon: null,
isDiscountActive: false
};
}
case REMOVE_ITEM: {
// ... 类似的逻辑
return state;
}
case SET_NOTES:
return {
...state,
notes: action.payload
};
default:
return state;
}
}
// 4. 组件本身变得无比清爽
const ReducerComponent = ({ products }) => {
const [state, dispatch] = useReducer(cartReducer, initialState);
// 我们可以在这里使用 useMemo 来优化计算(可选)
const grandTotal = useMemo(() => {
return state.items.reduce((sum, item) => {
return sum + (state.quantities[item.id] || 0) * item.price;
}, 0);
}, [state.items, state.quantities]);
// 事件处理函数现在只是简单的 dispatch 调用
const handleAddToCart = (product) => {
dispatch({ type: ADD_ITEM, payload: product });
};
const handleQuantityChange = (productId, newQty) => {
dispatch({ type: UPDATE_QUANTITY, payload: { productId, newQty } });
};
const handleApplyCoupon = (e) => {
e.preventDefault();
dispatch({ type: APPLY_COUPON, payload: state.inputCoupon });
};
return (
<div className="cart">
<h2>Redux... 不,是 Reducer 购物车</h2>
<ul>
{state.items.map(item => (
<li key={item.id}>
<span>{item.name}</span>
<input
type="number"
value={state.quantities[item.id] || 0}
onChange={(e) => handleQuantityChange(item.id, parseInt(e.target.value))}
/>
<button onClick={() => handleAddToCart(item)}>+</button>
</li>
))}
</ul>
<form onSubmit={handleApplyCoupon}>
<input
type="text"
value={state.inputCoupon}
onChange={(e) => dispatch({ type: 'SET_INPUT_COUPON', payload: e.target.value })}
placeholder="输入优惠券码"
/>
<button type="submit">应用</button>
</form>
<div className="summary">
<p>当前总价: {state.totalPrice}</p>
<p>是否打折: {state.isDiscountActive ? '是' : '否'}</p>
<p>备注: {state.notes}</p>
<button>结算</button>
</div>
</div>
);
};
你看,这个组件现在干净多了。它的渲染逻辑(return 里面)只负责把数据展示出来,它不再关心“如果我加一个商品,总价怎么算”。计算逻辑全都在 cartReducer 里面。
这有什么好处?
- 关注点分离:UI 只关心渲染,Reducer 只关心计算。
- 可读性:你一眼就能看出这个组件的状态流转路径。
- 可维护性:修改计算逻辑时,你只需要看
cartReducer,不需要去翻那些散落在组件里的handleXxx函数。
第三部分:可测试性的黄金时代
好了,现在我们到了最激动人心的环节。我们花了这么多篇幅重构代码,到底图什么?图的就是测试。
让我们回到那个 useState 的噩梦版本。如果你想测试“当输入错误优惠券码时,总价是否保持不变且优惠券状态是否变为 false”,你需要写这样的测试:
test('错误优惠券码不生效', () => {
render(<NightmareComponent products={mockProducts} />);
// 1. 模拟输入优惠券
const input = screen.getByPlaceholderText(/优惠券码/i);
fireEvent.change(input, { target: { value: 'WRONG_CODE' } });
// 2. 点击提交
const button = screen.getByText('应用');
fireEvent.click(button);
// 3. 断言状态
// 这里有个问题:NightmareComponent 没有暴露出状态!
// 你需要用 screen.getByText 来断言 UI 显示,但这很脆弱。
// 如果文案改了,测试就挂了。而且,你很难精确断言 state.coupon 是否为 null。
// 你还得确保组件已经挂载,React 事件循环已经跑完。
});
这种测试叫“集成测试”或“端到端测试”。它很慢,很重,而且一旦 UI 变了,它就报错。
现在,看看我们的 ReducerComponent。我们想测试同一个场景,只需要写一个简单的单元测试:
import { useReducer } from 'react';
import { renderHook } from '@testing-library/react';
// 假设我们的 reducer 是导出的
import { cartReducer, initialState } from './cartReducer';
test('错误优惠券码不生效', () => {
// 1. 初始化状态
const { result } = renderHook(() => useReducer(cartReducer, initialState));
// 2. 模拟 Action
result.current[1]({ type: 'SET_INPUT_COUPON', payload: 'WRONG_CODE' });
result.current[1]({ type: 'APPLY_COUPON', payload: 'WRONG_CODE' });
// 3. 断言结果
const currentState = result.current[0];
expect(currentState.coupon).toBeNull();
expect(currentState.isDiscountActive).toBe(false);
expect(currentState.totalPrice).toBe(0); // 假设初始总价为0
});
看懂了吗?
没有 React。没有 DOM。没有 fireEvent。没有复杂的模拟。
我们只是调用了一个纯函数,传入了初始数据和动作,然后检查返回值。
这就是 useReducer 的魔法。它把“状态管理”从“React 的黑盒”变成了“JavaScript 的白盒”。
你可以在任何地方测试这个 reducer。你可以把它放在一个单独的文件里,你可以写成 cartReducer.test.js,你可以用 Jest、Mocha、Jasmine,甚至是 Node.js 的 console.log 来调试它。
第四部分:深入探讨 Reducer 的“灵魂”
现在,我知道你们有些人可能会问:“老哥,你这代码看起来也没什么大不了的,不就是 switch case 吗?我也能写。”
确实,基础的 switch case 很简单。但 useReducer 的强大之处在于扩展性。
1. 复杂逻辑的聚合
在 useState 时代,如果逻辑很复杂,你会写很多个 if-else 或者嵌套函数。而在 reducer 里,你可以把复杂的逻辑封装成子 reducer,或者中间件。
假设我们的购物车逻辑非常复杂,涉及到:
- 库存扣减
- 价格计算
- 优惠券匹配
- 积分抵扣
我们可以这样写:
function cartReducer(state, action) {
switch (action.type) {
case ADD_ITEM: {
// 这是一个纯函数,它内部可以调用其他纯函数
return processAddItem(state, action.payload);
}
// ...
}
}
// 把复杂的计算逻辑抽离出来,单独测试
function processAddItem(state, item) {
const updatedItems = [...state.items, item];
const newQuantities = { ...state.quantities, [item.id]: (state.quantities[item.id] || 0) + 1 };
const newTotal = calculateTotal(updatedItems, newQuantities, state.coupon);
return {
...state,
items: updatedItems,
quantities: newQuantities,
totalPrice: newTotal
};
}
function calculateTotal(items, quantities, coupon) {
let sum = 0;
items.forEach(item => {
sum += (quantities[item.id] || 0) * item.price;
});
return coupon ? sum * 0.8 : sum;
}
现在,calculateTotal 是一个纯粹的数学函数。你想测试它?随便测。
2. 中间件模式
Redux 之所以强大,是因为中间件。useReducer 同样支持这种模式。你可以在 dispatch 之前拦截 action,做一些日志记录、错误捕获,或者异步处理。
function loggerMiddleware(dispatch) {
return function(action) {
console.log('Action:', action);
const result = dispatch(action);
console.log('Next State:', result);
return result;
};
}
// 在组件中使用
const [state, dispatch] = useReducer(cartReducer, initialState);
dispatch = loggerMiddleware(dispatch);
dispatch({ type: 'ADD_ITEM', payload: ... });
虽然 React 官方没有内置 Redux 那么复杂的中间件栈,但这种思想完全可以在 useReducer 中复用。你可以写一个自定义 Hook 来封装这个逻辑。
第五部分:实战演练 —— 一个复杂的表单场景
为了彻底征服大家,我们来搞一个稍微难点的。假设我们要做一个“用户注册/编辑”页面。
这个页面需要:
- 基本信息:姓名、邮箱、年龄。
- 地址信息:省、市、区、详细地址。
- 偏好设置:订阅邮件(布尔值)、偏好主题(单选)。
- 验证逻辑:邮箱格式、年龄范围、必填项。
- 提交逻辑:收集所有数据,发送 API。
如果用 useState,你会写出下面这种怪物:
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState('');
const [province, setProvince] = useState('');
// ... 还有10个状态
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
const val = type === 'checkbox' ? checked : value;
// 这里混杂着验证逻辑!
// 比如:如果邮箱不对,setErrors...
// 如果名字太短,setErrors...
// 然后更新状态
setState(val);
};
现在,让我们用 useReducer 重写这个“怪兽”。
首先,定义 Action 类型:
const UPDATE_FIELD = 'UPDATE_FIELD';
const SET_ERRORS = 'SET_ERRORS';
const START_SUBMIT = 'START_SUBMIT';
const SUBMIT_SUCCESS = 'SUBMIT_SUCCESS';
const SUBMIT_ERROR = 'SUBMIT_ERROR';
然后是 Reducer:
function formReducer(state, action) {
switch (action.type) {
case UPDATE_FIELD: {
const { name, value } = action.payload;
// 在这里,我们可以进行验证!
let error = null;
if (name === 'email') {
const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
if (!emailRegex.test(value)) {
error = 'Invalid email format';
}
}
if (name === 'age' && (value < 18 || value > 100)) {
error = 'Age must be between 18 and 100';
}
return {
...state,
values: {
...state.values,
[name]: value
},
errors: {
...state.errors,
[name]: error
}
};
}
case START_SUBMIT: {
// 验证所有字段
const newErrors = validateAll(state.values);
if (Object.keys(newErrors).length > 0) {
return { ...state, errors: newErrors, isSubmitting: false };
}
return { ...state, isSubmitting: true };
}
case SUBMIT_SUCCESS:
return { ...state, isSubmitting: false, status: 'success' };
case SUBMIT_ERROR:
return { ...state, isSubmitting: false, errors: { ...state.errors, api: action.payload } };
default:
return state;
}
}
看,所有的验证逻辑都在 reducer 里。这意味着,当你点击“提交”按钮时,你不需要在组件里写一大堆 if (name === 'email') ... else if ... 的代码。你只需要 dispatch({ type: 'START_SUBMIT' }),然后 reducer 会告诉你哪里错了。
而且,这个 Reducer 是完全可以测试的!
test('提交无效表单,应该保留错误', () => {
const { result } = renderHook(() => useReducer(formReducer, initialState));
// 模拟输入错误邮箱
result.current[1]({ type: 'UPDATE_FIELD', payload: { name: 'email', value: 'not-an-email' } });
// 提交
result.current[1]({ type: 'START_SUBMIT' });
const state = result.current[0];
expect(state.errors.email).toBeDefined();
expect(state.isSubmitting).toBe(false);
});
这就是纯粹的快乐。
第六部分:误区与陷阱 —— 并不是所有地方都要用 Reducer
虽然我极力推崇 useReducer,但我不是那种让你在 const [count, setCount] = useState(0) 这种简单场景下也用 Reducer 的偏执狂。
什么时候应该用 useState?
- 状态非常简单。
- 状态更新逻辑非常简单,不需要任何复杂判断。
- 状态更新只依赖当前 state。
什么时候必须用 useReducer?
- 状态更新逻辑涉及多个状态字段的相互依赖(例如:购物车总价依赖于数量和优惠券)。
- 状态更新逻辑包含复杂的验证、计算或条件判断。
- 你需要频繁地重置状态到初始值,且逻辑复杂。
- 最重要的一点:你需要对这部分逻辑进行单元测试。
如果你在一个组件里写了 50 行的 if-else 来处理状态更新,或者你的 useEffect 里塞满了处理状态变更的逻辑,请立刻、马上,停止你的手,转而使用 useReducer。
第七部分:进阶技巧 —— Context + Reducer
在大型应用中,我们通常不会把 Reducer 放在组件里,而是把它抽离到 Context 中。这样,任何子组件都可以通过 useContext 获取状态和 dispatch 方法。
这完美地结合了 React 的数据流和 Redux 的思想,但比 Redux 轻量得多。
import React, { useReducer, useContext, createContext } from 'react';
const CartContext = createContext();
const initialState = { items: [], total: 0 };
function cartReducer(state, action) {
// ... 同上
}
export const CartProvider = ({ children }) => {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
};
export const useCart = () => useContext(CartContext);
这样,你可以在任何地方访问购物车状态。你的组件变得超级干净:
const ProductCard = ({ product }) => {
const { state, dispatch } = useCart();
return (
<button onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}>
Add to Cart
</button>
);
};
结语
好了,朋友们,我们的讲座接近尾声。
回顾一下今天我们学到了什么:
useState在复杂逻辑下会变成“意大利面条代码”。useReducer通过将逻辑与 UI 分离,拯救了你的代码结构。- 最重要的是,
useReducer让你的业务逻辑变成了纯函数,从而获得了无敌的可测试性。
从今天开始,当你再遇到一个复杂的组件,看到里面那一堆 useState 和 handleXxx 函数时,请记得提醒自己:“嘿,这地方需要一个 Reducer 了。”
不要害怕 Action 和 Type。不要害怕 switch case。不要害怕写测试。当你看到测试用例像流水一样跑过,而你的代码逻辑被完美地隔离在 React 的渲染周期之外时,你会发现,那种感觉,就像是在炎热的夏天喝下了一杯冰镇可乐,透心凉,心飞扬。
代码不仅仅是写给机器看的,更是写给未来的自己(以及测试人员)看的。保持代码的整洁,保持逻辑的纯粹,保持对测试的热爱。
这就是作为一名资深开发者的必修课。现在,去重构你的那个“噩梦组件”吧!记得,先写测试,再写代码!
谢谢大家!