useReducer实战指南:简化复杂状态管理的正确姿势

宏春(打工版) 框架 阅读 1,700
赞 13 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月搞一个表单配置器,用户可以在页面上拖拽字段、设置校验规则、调整顺序,最后生成一份结构化配置。一开始我用 useState 管理整个 state,想着不就是个对象嘛,直接 setState 就行了。结果写着写着发现不对劲:表单字段一多,每次改一个字段的某个属性(比如 label),就得深拷贝整个 state,性能肉眼可见地卡顿。

useReducer实战指南:简化复杂状态管理的正确姿势

更头疼的是逻辑分散。比如“删除字段”这个操作,既要删字段本身,又要更新所有后续字段的索引,还得处理关联的校验规则。这些逻辑散落在不同地方,改一处漏一处,测试时总出问题。后来一拍脑袋:这不就是 useReducer 的典型场景吗?状态变更复杂、有明确 action 语义、需要集中管理——赶紧切。

核心代码就这几行

先贴 reducer 函数,这是整个逻辑的核心:

const formReducer = (state, action) => {
  switch (action.type) {
    case 'ADD_FIELD': {
      const newField = {
        id: Date.now().toString(),
        type: action.payload.type,
        label: '',
        required: false,
        // 其他默认属性...
      };
      return {
        ...state,
        fields: [...state.fields, newField],
      };
    }

    case 'UPDATE_FIELD': {
      const { id, updates } = action.payload;
      return {
        ...state,
        fields: state.fields.map(field =>
          field.id === id ? { ...field, ...updates } : field
        ),
      };
    }

    case 'DELETE_FIELD': {
      const { id } = action.payload;
      const indexToDelete = state.fields.findIndex(f => f.id === id);
      if (indexToDelete === -1) return state;

      const newFields = state.fields.filter(f => f.id !== id);
      // 更新后续字段的 displayOrder(如果用了顺序索引)
      const updatedFields = newFields.map((field, idx) => ({
        ...field,
        displayOrder: idx,
      }));

      return {
        ...state,
        fields: updatedFields,
      };
    }

    case 'MOVE_FIELD': {
      const { fromIndex, toIndex } = action.payload;
      const newFields = [...state.fields];
      const [moved] = newFields.splice(fromIndex, 1);
      newFields.splice(toIndex, 0, moved);
      // 同样需要重置 displayOrder
      return {
        ...state,
        fields: newFields.map((f, i) => ({ ...f, displayOrder: i })),
      };
    }

    default:
      return state;
  }
};

然后在组件里用:

const FormEditor = () => {
  const [state, dispatch] = useReducer(formReducer, {
    fields: [],
    // 其他顶层状态...
  });

  const handleAddField = (type) => {
    dispatch({ type: 'ADD_FIELD', payload: { type } });
  };

  const handleUpdateField = (id, updates) => {
    dispatch({ type: 'UPDATE_FIELD', payload: { id, updates } });
  };

  // 其他 handler...
};

看起来挺清爽对吧?但实际开发时坑可不少。

最大的坑:性能问题

切完 useReducer 本以为万事大吉,结果一测发现:每次输入框打字(触发 UPDATE_FIELD)还是会卡!明明只改了一个字段的 label,但整个 fields 数组都重新渲染了。后来用 React DevTools 一看,好家伙,所有字段组件都在 re-render。

原因很简单:reducer 里每次返回的都是新数组([...state.fields, newField]map 生成的新数组)。即使内容没变,引用也变了,React 认为所有子组件都需要更新。

折腾了半天,试了几个方案:

  • 方案1:用 immer。确实能简化不可变操作,但性能问题没解决,因为 immer 底层还是生成新对象。
  • 方案2:缓存字段组件。给每个字段组件加 React.memo,并确保传入的 props 引用稳定。但 handleUpdateField 这种函数每次 render 都会变,得配合 useCallback
const handleUpdateField = useCallback((id, updates) => {
  dispatch({ type: 'UPDATE_FIELD', payload: { id, updates } });
}, [dispatch]);

这招有效,但治标不治本。如果 fields 数组本身引用不变,根本不需要 memo。于是想到终极方案:只在必要时才生成新数组

改造 UPDATE_FIELD:

case 'UPDATE_FIELD': {
  const { id, updates } = action.payload;
  const fieldIndex = state.fields.findIndex(f => f.id === id);
  if (fieldIndex === -1) return state;

  const oldField = state.fields[fieldIndex];
  const newField = { ...oldField, ...updates };
  
  // 浅比较:如果字段内容没变,直接返回原 state
  let hasChanged = false;
  for (const key in updates) {
    if (newField[key] !== oldField[key]) {
      hasChanged = true;
      break;
    }
  }
  if (!hasChanged) return state;

  const newFields = [...state.fields];
  newFields[fieldIndex] = newField;
  return { ...state, fields: newFields };
}

这样只有当字段真发生变化时,fields 数组才会变。配合 React.memo,输入框打字再也不卡了。不过这个浅比较逻辑有点啰嗦,后来封装了个工具函数。

另一个麻烦:异步操作怎么塞进去

项目里有个需求:保存配置时要调 API,成功后再更新本地 state 的 savedAt 字段。一开始我想在 dispatch 后直接 await,但 dispatch 是同步的,没法等 API 结果。

常见的做法是在组件里处理异步:

const handleSave = async () => {
  try {
    const config = { fields: state.fields };
    await fetch('https://jztheme.com/api/save', {
      method: 'POST',
      body: JSON.stringify(config),
    });
    dispatch({ type: 'SET_SAVED_AT', payload: Date.now() });
  } catch (err) {
    // 处理错误
  }
};

这没问题,但有些复杂逻辑(比如保存失败后回滚)就得在组件里写一堆状态判断。后来参考 Redux 的 middleware 思路,搞了个简单的 thunk 支持:

// 自定义 hook
const useThunkReducer = (reducer, initialState) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  
  const enhancedDispatch = useCallback((action) => {
    if (typeof action === 'function') {
      return action(dispatch, state);
    }
    return dispatch(action);
  }, [state, dispatch]);
  
  return [state, enhancedDispatch];
};

// 使用
const [state, dispatch] = useThunkReducer(formReducer, initialState);

// 在组件里
const handleSave = () => {
  dispatch(async (dispatch, state) => {
    const config = { fields: state.fields };
    const res = await fetch('https://jztheme.com/api/save', {
      method: 'POST',
      body: JSON.stringify(config),
    });
    if (res.ok) {
      dispatch({ type: 'SET_SAVED_AT', payload: Date.now() });
    }
  });
};

这招让异步逻辑也能走 reducer 的流程,代码更集中。不过后来想想,其实对于简单项目可能有点过度设计,直接在组件里处理异步也够用。

回顾与反思

最终效果还不错:状态逻辑集中了,性能问题解决了,连新人接手都说“这代码看得懂”。但有几个地方现在看还能优化:

  • action 类型字符串容易拼错。应该用常量或者 TypeScript 的 enum,但项目是 JS 写的,偷懒没做。
  • reducer 太大。现在所有逻辑挤在一个文件,超过 200 行了。其实可以按功能拆成多个 reducer 再合并,但当时赶工期就没动。
  • 那个浅比较逻辑还是有点糙。如果有嵌套对象更新(比如字段的 options 属性是个数组),现在的比较会失效。不过目前业务没遇到,先不管了。

总的来说,useReducer 在这个项目里救了我一命。它不适合所有场景——简单状态用 useState 更快——但一旦状态变更逻辑复杂起来,useReducer 的收益就非常明显。核心就一点:把“怎么变”从组件里抽出来,变成纯函数,问题就清晰多了

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的优化方案,比如怎么优雅处理嵌套更新,欢迎评论区交流!

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论