React useReducer 状态管理:在复杂逻辑组件中利用 reducer 模式替代 useState 的可测试性优势

(舞台灯光聚焦,麦克风试音,一阵清脆的“咔哒”声)

大家好,坐好,坐好。别急着划走,我知道你们很多人看到“状态管理”和“reducer”这几个词就头皮发麻,觉得这又是 React 官方搞出来的新式酷刑。但今天,我们不聊那些虚头巴脑的概念,咱们来聊聊怎么从那个把你折磨得死去活来的 useState 手里救回你的大脑。

想象一下,你正在维护一个项目,那个项目是你三个月前写的。你打开代码,发现一个组件里有一百行代码,里面塞了二十个 useState,三十个 useEffect,还有五个事件处理函数。你的手指在键盘上悬停了五分钟,最后你只能对着屏幕,在心里默默流泪。

为什么?因为 useState 在处理复杂逻辑时,就像是用胶带粘乐高积木。逻辑一多,胶带就断了,你的组件就开始抽搐、报错,变成一坨不可名状的“意大利面条代码”。

今天,我们要讲的主角是 useReducer。别被它那个生硬的名字吓到了。它本质上就是一个比 useState 更强壮的“大脑皮层”,专门用来处理那些复杂的、多步骤的、甚至有点混乱的状态逻辑。

我们的核心目标只有一个:可测试性。我们要把逻辑从 UI 里剥离出来,像剥洋葱一样,一层一层,直到只剩下一颗晶莹剔透的纯函数。

准备好了吗?让我们开始这场“大脑重启”之旅。

第一部分:当 useState 遇到“状态爆炸”

首先,让我们看看 useState 在复杂场景下是如何“作妖”的。

假设我们要做一个“高级购物车”组件。这个购物车不仅仅是加加减减那么简单。它需要:

  1. 跟踪商品列表。
  2. 跟踪购物车里的数量。
  3. 根据商品数量计算总价。
  4. 根据总价判断是否达到满减门槛。
  5. 处理优惠券逻辑。
  6. 处理库存限制。
  7. 处理用户输入的备注。

好,现在请打开你的编辑器,跟着我一起写出那个“经典”的 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 里面。

这有什么好处?

  1. 关注点分离:UI 只关心渲染,Reducer 只关心计算。
  2. 可读性:你一眼就能看出这个组件的状态流转路径。
  3. 可维护性:修改计算逻辑时,你只需要看 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 来封装这个逻辑。

第五部分:实战演练 —— 一个复杂的表单场景

为了彻底征服大家,我们来搞一个稍微难点的。假设我们要做一个“用户注册/编辑”页面。

这个页面需要:

  1. 基本信息:姓名、邮箱、年龄。
  2. 地址信息:省、市、区、详细地址。
  3. 偏好设置:订阅邮件(布尔值)、偏好主题(单选)。
  4. 验证逻辑:邮箱格式、年龄范围、必填项。
  5. 提交逻辑:收集所有数据,发送 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>
  );
};

结语

好了,朋友们,我们的讲座接近尾声。

回顾一下今天我们学到了什么:

  1. useState 在复杂逻辑下会变成“意大利面条代码”。
  2. useReducer 通过将逻辑与 UI 分离,拯救了你的代码结构。
  3. 最重要的是useReducer 让你的业务逻辑变成了纯函数,从而获得了无敌的可测试性

从今天开始,当你再遇到一个复杂的组件,看到里面那一堆 useStatehandleXxx 函数时,请记得提醒自己:“嘿,这地方需要一个 Reducer 了。”

不要害怕 Action 和 Type。不要害怕 switch case。不要害怕写测试。当你看到测试用例像流水一样跑过,而你的代码逻辑被完美地隔离在 React 的渲染周期之外时,你会发现,那种感觉,就像是在炎热的夏天喝下了一杯冰镇可乐,透心凉,心飞扬。

代码不仅仅是写给机器看的,更是写给未来的自己(以及测试人员)看的。保持代码的整洁,保持逻辑的纯粹,保持对测试的热爱。

这就是作为一名资深开发者的必修课。现在,去重构你的那个“噩梦组件”吧!记得,先写测试,再写代码!

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注