停止在 React 中过度使用 useEffect钩子

boyanx1个月前技术教程5

React开发者们已经陷入了一个危险的陷阱:过度使用useEffect钩子。

在无数代码库中,我们都能看到useEffect像藤蔓一样蔓延在组件之间,扼杀代码的可读性、性能与可预测性。但如果我告诉你,大多数useEffect的使用不仅多余——甚至是有害的呢?

让我们剖析这个日益严重的问题,理解React文档真正的建议,并探索更优模式的实践案例。

React 官方对 useEffect 的解释

“Effect 是 React 范式的一个逃生舱口。它能让你‘跳出’ React,将你的组件与某些外部系统(比如非 React 的第三方组件、网络请求或浏览器 DOM)同步。如果没有涉及外部系统,你就不应该使用 Effect。”

React 文档中的这段话常常被忽视。它说得很明确:useEffect 的用途是处理副作用,而不是用来同步组件内部状态与 props,或者控制组件行为逻辑。

但为什么许多开发者仍然无视这一原则?

因为 useEffect 就像一把瑞士军刀——它让你能“响应”任何变化,但能用 ≠ 该用

为什么滥用 useEffect 是一种反模式?

  1. 维护难度激增
    每次添加useEffect,你都在创建一个微型的生命周期。开发者不仅要理解组件的渲染逻辑,还要异步追踪渲染后的副作用。久而久之,这些副作用会形成错综复杂的更新网,极难梳理。
  2. 拖慢渲染性能
    useEffect在浏览器绘制完成后执行。如果在副作用中更新状态,会导致DOM已经变更后再次触发渲染——每次都会多出一个渲染周期。当多个组件出现这种情况时,就会造成明显的卡顿。
  3. 极易产生bug
    你一定遇到过这种情况:useEffect中的状态更新导致界面闪烁、显示异常甚至无限循环。这类bug往往难以察觉,特别是当副作用依赖的状态自身也在同一个副作用中被更新时。
  4. 具有传染性
    一旦开始用useEffect根据props更新内部状态,就会形成恶性循环。一个组件需要响应prop变化,另一个组件又依赖这个组件的状态,不知不觉间整个代码库都充斥着副作用。
  5. 范式混淆
    React基于声明式范式构建——组件应该描述UI的最终形态,而非变更步骤。useEffect引入了渲染后执行的命令式逻辑,形成难以理解的混合范式。
  6. 执行顺序不明确
    副作用在渲染后执行,但具体时机难以预测——当多个副作用相互影响时尤为明显。这种时间不确定性会大幅增加调试难度。

既然这个API明确命名为useEffect(副作用钩子),就意味着它应该只用于处理副作用。仅仅在副作用中更新状态并不构成真正的副作用,这种用法恰恰暴露了对设计初衷的误解。

典型反模式示例:

错误做法1:通过useEffect根据props派生状态
这是React开发中最常见也最糟糕的反模式之一:

const Child = ({ prop }) => {
  const [value, setValue] = useState(null);

  useEffect(() => {
    if (prop) {
      const updatedValue = someFuncToCalculate(prop);
      setValue(updatedValue);
    }
  }, [prop]);

  return <div>{value}</div>;
};

为何这种做法很糟糕:

  1. 冗余的状态同步
    你在用useEffect同步一个完全由props派生的状态,这相当于手动建立了本应自动的数据绑定。
  2. 三重性能损耗
  • 初始渲染后触发不必要的副作用
  • 副作用引发额外渲染
  • 可能产生"渲染-副作用-再渲染"的死循环
  1. 违背单一数据源原则
    当派生状态与原始prop不同步时(比如清理函数执行时机问题),会导致难以追踪的数据不一致问题。

正确做法:直接派生计算

当某个值完全基于 props 计算得出时,应该直接计算而非使用状态:

const Child = ({ prop }) => {
  const value = someFuncToCalculate(prop);
  return <div>{value}</div>;
};

无需 useState,无需 useEffect。代码更简洁、性能更高效、逻辑更清晰。

如果某个值仅由 prop 计算得出,那它就不该被存储为状态 —— 道理就这么简单。

错误做法2:从父组件重置子组件状态的常见误区

另一个滥用 useEffect 的典型场景是:开发者试图基于父组件的 prop 来重置子组件的状态:

const Child = ({ shouldReset }) => {
  const [field, setField] = useState('');

  useEffect(() => {
    if (shouldReset) {
      setField('');
    }
  }, [shouldReset]);

  return <input value={field} onChange={e => setField(e.target.value)} />;
};

这种做法会导致紧耦合的代码逻辑,使组件难以维护。不过,我们有更优雅的解决方案:

正确做法:使用KEY prop

const Parent = ({ resetCounter }) => {
  return <Child key={resetCounter} />;
};

const Child = () => {
  const [field, setField] = useState('');
  return <input value={field} onChange={e => setField(e.target.value)} />;
};

每当 resetCounter 变化时,React 会将 Child 视为全新的组件并自动重置其状态。完全不需要使用 Effect。

错误做法3:根据父组件 Props 和子组件交互更新状态

你可能会想:“好吧,我这里有一个子组件的状态,它既需要根据父组件的 prop 更新,又要在内部交互(比如点击)时变化。我不能直接把它变成派生值或普通变量——因为子组件也要管理它。所以我别无选择,只能用 useEffect,对吧?”

让我们先看看目前常见的(但不太理想的)做法:

const Child = ({ prop }) => {
  const [value, setValue] = useState(null);

  useEffect(() => {
    if (prop) {
      const updatedValue = someFuncToCalculate(prop);
      setValue(updatedValue);
    }
  }, [prop]);

  const clickHandler = () => {
    setValue(100);
  };

  return (
    <button onClick={clickHandler}>
      Click
    </button>
  );
};


现在来看看这个「更优」方案——可能会让某些开发者感到不适:

const Child = ({ prop }) => {
  const [value, setValue] = useState(null);
  const [prevProp, setPrevProp] = useState(prop);
  
  if (prop !== prevProp) {
    setPrevProp(prop);
    const updatedValue = someFuncToCalculate(prop);
    setValue(updatedValue);
  }  
  
  const clickHandler = () => {
    setValue(100);
  };

  return (
    <button onClick={clickHandler}>
      Click
    </button>
  );
};

等等,什么?在渲染过程中更新状态?
这看起来很危险啊!你竟然在组件主体内调用 setState,而不是在 useEffect 里。这样不会导致无限重新渲染吗?

先别急。

为什么这没看起来那么糟糕?

没错,这看起来确实有风险。但关键在于:这个更新条件是确定性的。它只在 prop !== prevProp 时执行,这意味着这个分支是受控的——它仅在 prop 变化时运行,而这正是 useEffect 原本在做的事情。

但这里有个关键区别:

  • useEffect 方式:在渲染后、DOM 更新后执行
  • 新模式:在渲染期间、DOM 更新前执行

这很重要。例如:

  • useEffect 可能导致 UI 闪烁:因为状态是在 DOM 更新后才被修改的
  • 新模式则不会:状态更新是渲染流程的一部分,不会造成视觉不一致或短暂的 UI 错位

"但你还是多触发了一次渲染啊!"

确实——而这正是关键所在。
让我们拆解一下:

无论哪种方式,当父组件更新 prop 时,都会发生以下流程:

  1. 父组件重新渲染
    父组件状态/属性变更触发渲染流程
  2. 子组件首次渲染
    子组件接收新的props值进行初次渲染
  3. 状态同步阶段
    子组件检测到prop变化后立即同步状态(此时仍在同一渲染周期内)
  4. 子组件二次渲染
    状态更新触发最终渲染确认

无论是使用 useEffect 还是内联逻辑,都无法避免额外的重新渲染。

那为什么这种方式「更好」?因为:

  1. 避免渲染后的状态不一致
    状态更新与渲染同步完成,不会出现中间态闪烁
  2. 不依赖生命周期钩子
    摆脱了 useEffect 的异步时序问题
  3. 保持状态逻辑的内聚性
    所有状态变更都内联在渲染流程中,更易追踪

只需确保更新是受控的(比如通过 prop !== prevProp 检查),就能避免无限循环。

但坦白说——这依然不够理想

无论使用 useEffect 还是这种渲染期状态跟踪模式,你都在处理棘手的问题:既要让子组件状态同步父组件的 prop,又要允许内部修改。

这会带来:

  • 复杂度上升:调试难度增加
  • 数据流混乱:状态变更路径难以追溯

最佳实践是什么?

根本不要让子组件镜像或跟踪 prop 的状态,而是:

不要让子组件在它的状态中镜像或跟踪父组件的属性。相反,你可以:

  1. 将状态提升到父组件,让父子组件都能控制它
  2. 或者在子组件中根据属性动态计算值

你可能会想:“但状态提升不会导致整个子树重新渲染吗?” 是的——但很可能它已经在重新渲染了。如果父组件改变了属性,React 无论如何都会重新渲染父组件和子组件。状态提升通常只是使数据流更可预测,而不是更昂贵。

经验法则 将状态放在正确的位置,不要放在过高或过低的地方。

  1. 如果状态最终由父组件驱动,就不要强行将状态向下推到子组件。
  2. 也不要不必要地向上提升状态,特别是如果只有子组件关心它。

错误做法4:useEffect 和事件处理程序:停止间接调用

另一个糟糕的模式是基于事件设置的状态在 useEffect 中触发 API 调用:

const Component = () => {
  const [trigger, setTrigger] = useState(false);

  useEffect(() => {
    if (trigger) {
      fetchData();
    }
  }, [trigger]);

  return <button onClick={() => setTrigger(true)}>Load</button>;
};

没有必要间接调用

正确做法:

const Component = () => {
  const handleClick = () => {
    fetchData();
  };

  return <button onClick={handleClick}>Load</button>;
};

JavaScript 事件系统天生是异步的。它不需要通过状态和 Effect 的组合来延迟执行。直接调用函数即可。
JavaScript 的事件系统已经是异步的——点击处理程序通过事件循环排队并运行。将逻辑包装在状态更新和 useEffect 中只会延迟不可避免的执行,增加样板代码,并使流程复杂化。如果你是直接响应用户事件调用某些操作——就直接调用。

为什么要故意增加延迟和间接性呢?

响应式 vs. 命令式控制

当你编写 Effect 来根据外部输入更新内部状态时,你是在将 React 的声明式模型与命令式逻辑混合。这就是导致问题的主要原因。

  • 声明式模型:UI 反映当前状态。
  • 命令式模型:“当 Y 发生时,执行 X。”

大多数 useEffect 的使用方式都是命令式的。它们在声明式系统中创建了过程化代码。难怪会让人困惑。

什么时候应该使用 useEffect?

公平地说,useEffect 是有用的,但仅限于在 React 渲染周期之外处理事务:

合理用例

  • 订阅外部服务(例如 WebSocket、Redux 存储、数据库监听器)
  • 组件出现在屏幕上时获取数据
  • 手动更新 DOM 元素(虽然很少见但合理)
  • 设置或清理定时器、间隔或全局事件监听器

经验法则: 如果逻辑是:

  • 由用户事件触发 → 使用事件处理程序
  • 由组件挂载或卸载触发 → 使用 Effect
  • 从属性或状态派生 → 使用计算值、记忆化或条件渲染,而不是 Effect

最佳实践与总结

  • 除非必要,避免使用状态。如果可以计算,就直接计算。
  • 除非处理副作用,否则避免使用 useEffect。仅仅因为它可用,并不意味着它是最佳选择。
  • 不要将属性同步到状态。如果必须这样做,考虑其他模式,如受控组件、状态提升,甚至通过 key 属性重置组件。
  • 理解重新渲染。useEffect 在 DOM 更新后运行,可能会导致视觉闪烁。
  • 了解组件生命周期。useEffect 不在渲染期间运行,而是在绘制后运行。

简而言之

React 的 useEffect 是为副作用设计的。不是用来根据属性设置状态,不是用来处理内部逻辑,也不是用来响应事件。过度使用它是一种代码异味,表明对 React 范式的误解。

专注于简洁的声明式逻辑。将状态放在正确的位置。信任 React 的渲染模型,你将编写出更可预测、可维护和高性能的组件。

让我们停止像使用带钩子的 jQuery 那样编写 React。
清理我们的组件,停止滥用 useEffect。

标签: jquery confirm

相关文章

Web开发人员最易犯下的十种常见错误

对于如何完成同一项任务,摆在我们面前的方案选项似乎无穷无尽,特别是在开发一套能够运作在现代网络环境之下的网站时。Web开发人员首先需要挑选一套Web托管平台及底层数据存储机制,并利用由提供的工具编写H...

給她每日一封暖心小邮件~

编写代码 网页爬虫这里我们使用到superagent和cheerio组合来实现爬虫:分析网页DOM结构,如下图所示:用superagent来获取指定网页的所有DOM:superagent.get(UR...

一文了解 Telerik Test Studio 测试神器

1. 简介Telerik Test Studio (以下称Test Studio)是一个易于使用的自动化测试工具,可用于Web、WPF应用的界面功能测试,也可以用于API测试,以及负载和性能测试。Te...

如何做好软件测试工作?

在纬创软件从事了三年的软件测试工作,有些内容想跟大家分享,听起来或许会有点像大道理,但这些却也是作为测试人员,实实在在需要面对的。软件测试人员应该居安思危相比较于开发,测试人员的工作更容易被替代,很多...

2025年AI课程避坑指南:这类机构慎选!

2025年AI课程避坑指南:高危机构类型与避雷要点一、虚假宣传与承诺陷阱陷阱类型 典型表现 高危机构特征包就业/高薪承诺 签署就业协议但条款模糊,推荐就业成功率低 线下机构(如A机构)、部分网课平台限...

发表评论    

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。