首页 / 文章

为何 React 的 useEffect 回调函数绝不能是异步函数

传递给useEffect的异步函数会因为返回Promise而违反React的清理约定。在effects中执行异步操作的四种正确方式。

发布者:REACTAPP.TOP阅读约 14 分钟3106 词

来源: React Interview Question Asked in Paytm

这个问题出现在Paytm的前端面试中,其重要性远超表面的简单程度。乍看之下它似乎只是个小的语法陷阱,但实际上它在考察你是否理解效应的生命周期、JavaScript Promises的真实行为、React如何处理效应返回的值,以及如何在组件中以正确的方式获取数据。

大多数候选人的回答在技术上虽正确,但仅触及表面:

“useEffect期望你返回一个清理函数,而异步函数则返回一个Promise。”

这确实是个不错的开场白,但却没有回答那些有趣的问题。React收到那个Promise后会如何处理它?是直接丢弃吗?实际上会导致什么问题出现?更重要的是,应如何在effect中组织异步逻辑才能避免出现问题?

让我们逐步梳理整个情况,将每一项说法与React文档及React源代码本身进行对照验证。

第一部分:useEffect期望的契约

传入useEffect的函数正式名称为设置函数,React文档对其允许返回的值有明确规定。只有两种可接受的结果:

  • 不返回任何值,即值为undefined;或
  • 返回一个清理函数,React会在稍后调用该函数。
useEffect(() => {
  // setup: runs after the component is committed to the screen
  const id = setInterval(tick, 1000);
  // cleanup: React calls this before the next setup run,
  // and one final time when the component unmounts
  return () => clearInterval(id);
}, []);

那个清理函数并非可有可无的附加功能。它是 React 用来销毁在初始化阶段创建的所有对象的机制,这些对象可能包括定时器、订阅项、事件监听器或已建立的 WebSocket 连接。根据 React 文档,React 会在重新执行效应函数之前调用你的清理函数,而在组件彻底卸载时还会再次调用它。

让这个问题变得重要的关键在于:React 需要在初始化函数执行完毕的瞬间立即使用该清理函数。有时清理操作必须几乎立刻完成,甚至可能要在异步函数中的第一个 await 执行完毕之前就完成。如果清理函数迟迟才出现,React 就无法使用它。

第二部分:为何异步操作总是与 Promise 相关

这一部分仅仅是普通的 JavaScript 行为,不存在任何例外情况。根据 MDN 的说明,使用 async 声明的函数总是会返回一个 Promise。即便你返回的并非 Promise 类型,JavaScript 也会自动将其封装成 Promise。没有语法可以绕过这一规则。

async function getCleanup() {
  return () => console.log('cleaning up');
}
const result = getCleanup();console.log(result);
// Promise { <fulfilled>: f }   <- the function is trapped insideconsole.log(typeof result);
// "object"   <- not "function"

仔细看看发生了什么。代码明确返回了一个箭头函数,但调用方仍然没有得到函数本身,而是得到了一个最终会解析为该函数的 Promise。即使是一个完全没有返回语句的异步函数也是如此——它仍然会返回一个 Promise,只不过这个 Promise 会解析为 undefined。

把这两点结合起来:

  1. React 需要的是同步传递的 undefined 或者真正的函数。
  • 无论何种情况,异步函数总会返回一个 Promise。
  • 因此,在你的任何效果逻辑有机会执行之前,契约就已经被违反了。

    // What you think you wrote:
    useEffect(async () => {
      const data = await fetchUser(id);
      setUser(data);
      return () => controller.abort(); // "my cleanup"
    }, [id]);
    
    // What React receives from your setup function:
    // Promise { <pending> }
    // Your cleanup is locked inside a box React never opens.
    

    第三部分:React 实际上如何处理你返回的值

    很多解释在这里就出现了偏差。人们常说“React 会忽略你返回的 Promise”。但源代码揭示了不同的情况。实际上,React 会对该返回值执行三项独立的操作,其中两项还会引发真正的问题。

    3.1 它会将 Promise 作为清理操作存储起来

    在 reconciler 的深处,具体来说是在 commitHookEffectListMount 中——这个函数负责在提交操作后执行相应的效果函数——React 会直接调用你的 setup 函数并存储其返回值,而不做任何询问。在生产版本中,并没有运行时检查来发现类型不匹配的问题:

    // react-reconciler/src/ReactFiberCommitEffects.js (simplified)
    const create = effect.create;   // your setup function
    const destroy = create();       // whatever it returns...
    inst.destroy = destroy;         // ...is stored as the cleanup
    

    如果你传入的 setup 函数是异步的,那么 inst.destroy 就会持有一个挂起的 Promise,而 React 期望的是一个可调用的清理函数。

    3.2 开发版本会给出非常具体的警告

    一旦该值被存储,开发构建时会执行额外的验证步骤。React会检查typeof destroy !== 'function',而且它不仅仅进行常规的类型检查——还会专门查看返回值上是否存在.then方法。这显然表明你传递的是一个Promise而非函数,React会直接指出这一点:

    useEffect must not return anything besides a function, which is used for
    clean-up.
    
    It looks like you wrote useEffect(async () => ...) or returned a Promise.
    Instead, write the async function inside your effect and call it immediately:useEffect(() => {
      async function fetchData() {
        // You can await here
        const response = await MyAPI.getData(someId);
        // ...
      }
      fetchData();
    }, [someId]); // Or [] if effect doesn't need props or stateLearn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching
    

    请注意,这个警告不仅会指出问题,还会在消息中直接提供解决方案,展示如何在effect函数内部声明异步函数并立即调用它的方法。当框架自身的错误提示已经包含解决方案时,这通常意味着面试官期望你之前见过这类情况。

    3.3 清理阶段到来时,React会尝试调用该Promise

    这才是真正危险的地方。当效果的依赖项发生变化,或者组件被卸载时,React 的卸载逻辑只会检查是否有内容被存储为清理函数——它从不验证所存储的值是否可调用:

    // commitHookEffectListUnmount (simplified)
    const destroy = inst.destroy;
    if (destroy !== undefined) {
      safelyCallDestroy(effect, ...); // executes destroy()
    }
    
    // destroy is a Promise here, and a Promise is not callable:
    // TypeError: destroy is not a function
    

    试图将 Promise 当作函数来调用会引发 TypeError。这正是 React 17 中出现著名错误 “Uncaught TypeError: destroy is not a function” 的原因。在当前版本的 React 中,这种调用被包裹在防护逻辑中,因此不会导致整个页面崩溃,而是作为提交阶段的错误被捕获并传递给最近的错误边界。无论如何,这都是真正的运行时故障,而非可以忽视的控制台提示。

    综合来看,最终会出现三种不同的故障:

    1. 实际的清理代码从未被执行,因此定时器、事件监听器和订阅也不会被取消。
    2. 在开发过程中,React会输出警告信息。
    3. 之后当React尝试执行“清理”操作时,它会试图将Promise当作函数调用,从而导致错误。

    异步的setup函数并不能悄无声息地规避规则——它在组件生命周期中的两个不同阶段违反了这些规则。

    第四部分:逐步对比两种执行路径

    将同步的setup函数与异步的版本逐步骤对比会更有帮助。前三个步骤是完全相同的,从第四步开始两者便出现差异。

    同步版本:

    1. 组件函数被执行,JSX会被计算出来。
    2. React会提交更新,浏览器随后进行渲染。
    3. React会调用你的setup函数。
  • 该函数会返回一个实际的清理函数,由 React 存储起来。
  • effect 内部的任何异步逻辑会持续运行,状态更新也能正常应用。
  • 当依赖项发生变化或组件卸载时,React 会调用存储的 cleanup() 函数。
  • 清理操作会取消定时器、移除监听器或设置忽略标志——不会留下任何资源泄漏。
  • 异步版本:

    1. 组件函数被执行,JSX 会被计算出来。
    2. React 会提交状态更新,浏览器随后进行渲染。
    3. React 会调用你的设置函数,由于该函数是异步的,它会立即返回。
    4. 返回值是一个 Promise { <pending> },React 会将该 Promise 当作清理函数来存储。在开发模式下,警告就会在这里触发。
    5. 您的 await 调用仍在后台的某个地方等待处理,而您编写的任何清理逻辑都嵌在那个尚未解决的 Promise 中,因此无法被执行。
    6. 当依赖项发生变化或组件被卸载时,React 会尝试调用存储的值——但它并非函数——从而引发 TypeError。
    7. 从未执行过任何清理操作。定时器会持续触发,过时的响应仍可能引发 setState。

    这一对比也阐明了为何合同要求使用同步方式。效果会在提交之后触发,此时渲染早已完成,而前一个效果的清理工作必须在下一个个效果触发之前立即执行——甚至可能就在下一次渲染时。如果React不得不等待Promise的结果才能确定是否存在清理函数,那么整个提交流程就必须在任意的用户代码处暂停,这与提交阶段的设计理念相悖。这才是该API围绕同步返回值而非让React学会等待Promise而构建的真正原因。

    第5部分:这种模式所隐藏的竞态条件

    异步模式还存在另一个更为隐蔽的危险:如果没有清理函数,就无法取消或忽略那些过时的异步操作。React 的官方文档通过个人资料页的场景来说明这一点,理解这一点非常重要,因为这并非仅仅是风格上的小问题——而是一个可能会被投入生产环境的漏洞。

    具体情况如下:

    1. 你点击“Bob”,应用开始获取 Bob 的个人资料。
    2. 在获取完成之前,你又点击了“Taylor”,从而触发对 Taylor 个人资料的获取请求。
    3. Taylor 的响应先返回,因此界面会显示 Taylor 的个人资料。
    4. 速度较慢的 Bob 的响应随后到达,从而覆盖了当前的状态。
    5. 最终屏幕上显示的仍是 Taylor 作为选中的用户,但下方却显示了 Bob 的个人资料。

    由于网络响应并不一定按照请求发出的顺序返回,这种不匹配确实存在风险。标准的解决方法是使用一个清理函数来设置ignore标志,这样就可以忽略除与最新渲染相关的响应之外的所有响应。但该方案仅在你们的设置函数能同步返回、从而让React有真正的清理回调可执行时才有效。

    因此,异步回调不仅违反了API契约的技术要求,还会破坏防止这种竞态条件影响到用户的机制。

    第6部分:正确的处理方式

    所有有效的解决方案都基于同一个原则:保持设置函数本身为同步执行,并将异步操作交由该函数调用的其他组件来完成。这样React依然能够获得真正的清理回调。

    模式1:在effect内部定义带名称的异步函数(官方推荐方式)

    这是React文档中介绍的方法,它使用了ignore标志来消除上述的竞态条件:

    useEffect(() => {
      let ignore = false;
    
      async function startFetching() {
        const json = await fetchTodos(userId);
        if (!ignore) {
          setTodos(json);
        }
      }  startFetching();  return () => {
        ignore = true;
      };
    }, [userId]);
    

    外层的箭头函数从不使用await,因此return () => { ignore = true; }能为React提供一个真正的清理函数供其使用。而内部带名称的函数则可以自由地等待所需的操作完成。

    模式2:异步IIFE

    这种方法实现的功能相同,只是使用的是会立即执行的匿名函数而非带名称的函数:

    useEffect(() => {
      let ignore = false;
    
      (async () => {
        const data = await fetchUser(id);
        if (!ignore) setUser(data);
      })();  return () => { ignore = true; };
    }, [id]);
    

    模式3:普通的.then()链式调用

    React的参考文档在其自身的数据获取示例中实际上就使用了这种风格:

    useEffect(() => {
      let ignore = false;
      setBio(null);
      fetchBio(person).then(result => {
        if (!ignore) {
          setBio(result);
        }
      });
      return () => {
        ignore = true;
      };
    }, [person]);
    

    模式4:AbortController,用于实现真正的取消功能

    ignore标志可以防止过时的响应覆盖状态,但它无法阻止请求在后台继续执行到完成。使用fetch时,你可以进一步直接取消网络请求。React文档明确指出,清理操作应当要么中止fetch请求,要么忽略其返回结果:

    useEffect(() => {
      const controller = new AbortController();
    
      (async () => {
        try {
          const res = await fetch(`/api/users/${id}`, {
            signal: controller.signal,
          });
          setUser(await res.json());
        } catch (e) {
          if (e.name !== 'AbortError') throw e;
        }
      })();  return () => controller.abort();
    }, [id]);
    

    在面试中应该先介绍哪一个?请从模式1开始——React的开发者模式警告以及官方文档都推荐使用它。明确提到ignore标志,然后补充说明对于fetch调用而言,可以进一步使用AbortController来取消请求,而不仅仅是丢弃其结果。这样结合正确性、竞态条件以及网络流量浪费等问题,三句话就能涵盖整个问题。

    第7部分:StrictMode和TypeScript也能检测到这些问题

    StrictMode。在开发阶段,StrictMode会在初次挂载后立即执行一次额外的初始化后再清理的循环。根据React文档,这样做的目的是测试你的清理逻辑是否真的与初始化逻辑相对应。由于异步效应本质上永远不会返回可用的清理函数,因此必然无法通过这项检查。正是这种强制性的双重调用机制,才会暴露出重复的订阅、重复的定时器以及重复的请求,而这些请求的两个响应最终都会被写入状态中。

    TypeScript。React的类型定义将这一规则直接嵌入到类型系统中:

    // From @types/react (simplified)
    type Destructor = () => void;
    type EffectCallback = () => void | Destructor;
    
    function useEffect(effect: EffectCallback, deps?: DependencyList): void;
    

    异步函数的签名为 () => Promise,而该类型根本无法赋值给 EffectCallback。因此在 TypeScript 项目中,这种错误甚至无法通过编译阶段。这一点值得注意:这一限制极为重要,以至于在类型系统、开发模式警告以及实际运行时行为这三个层面都会得到体现。

    第8部分:值得提及的现代替代方案

    更完善的答案还会指出,在效应函数中手动实现数据获取已不再被视为最佳实践。React 官方文档也提到,在效应函数中直接进行数据获取会导致代码重复,且日后添加缓存和服务器端渲染功能会更加困难。文档推荐了几种替代方案:

    • 框架级加载器。如果项目使用了 Next.js 或 Remix 等框架,其内置的数据获取机制比客户端效果处理方式更佳。
    • 客户端缓存库。React 文档特别提到了 TanStack Query、useSWR 以及 React Router 6.4+。这些工具已经能够处理去重、缓存问题以及上文提到的各种竞态条件。
  • React 19 中引入的 use API。 use(promise) 允许组件直接读取 Promise 的已解决值,在 Promise 处于待处理状态时暂停组件的渲染,并让 Suspense 渲染备用内容。需要提及的一个重要注意事项是:在渲染过程中新创建的 Promise 会在每次渲染时都被重新创建,因此 use 适用于使用来自 Server Components 的 Promise 或由缓存管理的 Promise,而不适合直接内联编写的原始 fetch() 调用。
  • 以上内容都无法替代核心解释。关于 effect contract 的问题实际上是在测试你是否理解 React 是如何处理返回值的。但如果以“因此,对于真正的数据获取,你应该使用 TanStack Query 或类似的框架加载器”这样的话作为结尾,那就说明你也明白当前的最佳实践是什么。

    30秒面试回答

    如果只能记住一个版本的回答,那就记这个:

    “useEffect 的 setup 函数遵循同步契约:它必须返回 nothing 或者一个清理函数,因为 React 需要在效果再次执行之前以及组件卸载时能够立即调用该清理函数。异步函数永远无法满足这一要求,因为无论其内部执行什么内容,总是会返回一个 Promise。React 会将这个 Promise 存放在本应存放清理函数的位置:在开发模式下它会输出‘It looks like you wrote useEffect(async () => …)’的警告,真正的清理逻辑根本无法运行;之后当 React 试图将存储的值当作清理函数来调用时,就会抛出 TypeError。在实际使用中,这会导致订阅泄露和竞态条件,因为没有有效的清理机制,就无法忽略或中止过时的请求。解决办法是保持……”

    回调本身是同步的,可在其中定义一个异步函数,调用该函数,然后返回一个清理函数,该函数要么将忽略标志设置为 true,要么在 AbortController 上触发 abort 方法。对于实际的数据获取场景,使用框架加载器或 TanStack Query 等库更为合适,因为这些工具能够自动处理这类竞态条件。

    这个回答涵盖了相关契约、底层的 JavaScript 规则、具体的故障模式、竞态条件、公认的解决方案,以及现代技术生态的发展方向。它向面试官表明,回答者并非仅仅死记硬背规则,而是理解了其背后的机制,而这正是这类问题想要考察的。