“c is not a function” - 一次由 useEffect 异步函数引发的 React 底层崩溃分析

0 阅读6分钟

不久前,一位外地项目组的同事遇到了一个紧急问题:一个基于 Taro 和 pnpm 的小程序项目在特定操作后会发生崩溃。他提供了一张控制台的报错截图:

b1751b490ef23c2e7597e22bd5e26a4.png

错误信息 TypeError: c is not a function 指向了 react-reconciler 的生产环境代码。这通常意味着问题发生在 React 的核心调度层,有点棘手。🤔

Part 1: 问题定位与初步排查

根据错误堆栈,我的初步怀疑方向是环境或依赖问题。

  1. 依赖问题:首先排查了 pnpm 相关的依赖缓存或 lock 文件一致性问题。在清理 node_modulespnpm-lock.yaml 并重新安装后,问题依旧存在。❌
  2. React 版本冲突:接着检查了项目的依赖树,确认不存在多个 React 版本互相冲突的情况。❌
  3. 框架兼容性:排查了当前 Taro 版本与 React 版本的兼容性,未发现已知的相关 issue。❌

初步排查未果后,同事提供了更详细的复现路径:“应用启动后,从首页进入商品详情页是正常的;但当从详情页返回首页时,就会必现此错误,导致应用崩溃。”

这个信息提供了一个关键线索 💡:问题发生在组件卸载(Unmount)阶段。这意味着错误很可能与组件销毁时的清理逻辑有关。

根据这个线索,我们检查了商品详情页组件的代码,很快就定位到了以下代码片段:

// 详情页组件中的问题代码
useEffect(async () => {
    getDetail();
    if (isLogin) {
        getCoupon();
        getIsCollection();
        getCardNum();
    } else {
        if (Taro.getStorageSync('token')) {
            getUserInfo();
        }
    }
}, []);

问题就在于直接将一个 async 函数作为 useEffect 的第一个参数。将其修改为标准写法后,问题得到解决:

// 修正后的代码
useEffect(() => {
    const fetchData = async () => {
        getDetail();
        if (isLogin) {
            await getCoupon();
            await getIsCollection();
            await getCardNum();
        } else {
            if (Taro.getStorageSync('token')) {
                await getUserInfo();
            }
        }
    };

    fetchData();
}, []);

问题虽然解决了,但作为技术人员,我们需要深入探究其根本原因:为什么向 useEffect 传递一个 async 函数,会在组件卸载时导致 React Reconciler 崩溃?

接下来,我们将深入 React 源码进行分析。

Part 2: 深入源码,探究根本原因

番外篇:async 函数与 Promise 的关系

在分析 React 源码之前,有必要先明确一个重要的 JavaScript 基础知识。

很多开发者可能不清楚,async () => {} 即使函数体为空,其返回值也不是 undefined

原因是:async/await 是基于 Generator 函数和 Promise 实现的语法糖 🍬。我们编写的 async 函数,通常需要通过 Babel 等工具转换为能在更广泛环境中运行的 ES5 代码。

例如,这样一段 async 代码:

// ES7+ async function
async function getUser() {
  const user = await fetch('/api/user');
  return user.name;
}

Babel 会将其转换为一个返回 Promise 的函数,并通过 Promise 链来模拟 await 的行为(以下为简化后的伪代码):

// Babel 转换后的伪代码
function getUser() {
  return new Promise(function(resolve, reject) {
    fetch('/api/user')
      .then(function(user) {
        resolve(user.name);
      })
      .catch(function(err) {
        reject(err);
      });
  });
}

从转换结果可以看出,async 函数的返回值永远是一个 Promise。理解了这一点,我们就能更好地理解它在 React Hooks 中引发的问题。

阶段一:渲染阶段 (Render Phase) 📜

当 React 渲染组件并遇到 useEffect 时,它并不会立即执行 effect 函数。

  • 内部行为:React 会调用 ReactFiberHooks.new.js 中的 pushEffect 函数。此函数创建一个 effect 对象,将 async 函数作为 create 属性存储,然后将此 effect 对象添加到一个挂载在组件 Fiber 节点上的更新队列(updateQueue)中。

    // 文件: src/ReactFiberHooks.new.js
    function pushEffect(tag, create, destroy, deps) {
      const effect: Effect = {
        tag,
        create, // 你的 async 函数被存储在这里
        destroy,
        deps,
        next: (null: any),
      };
      // ... 将 effect 添加到当前 Fiber 节点的 updateQueue 逻辑 ...
      return effect;
    }
    

    在此阶段,异步函数只是被注册,并未执行。

阶段二:挂载阶段 (Commit Phase - Mount) 🎭

组件挂载到 DOM 后,React 会在提交阶段异步执行所有注册的 useEffect

  • 内部行为:此过程由 ReactFiberCommitWork.new.js 中的 commitHookEffectListMount 函数处理。

  • 源码分析

    // 文件: src/ReactFiberCommitWork.new.js
    function commitHookEffectListMount(flags, finishedWork) {
      // ...
      do {
        // ...
        const create = effect.create; // 1. 获取在渲染阶段存入的 async 函数
        effect.destroy = create();    // 2. 执行该函数,并将其返回值存入 effect.destroy
    
        if (__DEV__) { // 3. 在开发模式下,React 会检查返回值类型
          const destroy = effect.destroy;
          if (destroy !== undefined && typeof destroy !== 'function') {
            // 4. 如果返回值是一个 Promise (有 .then 方法),则打印警告
            if (typeof destroy.then === 'function') {
              console.error(
                'It looks like you wrote useEffect(async () => ...) or returned a Promise...'
              );
            }
          }
        }
        // ...
      } while (/* ... */);
    }
    

    关键在于 effect.destroy = create();async 函数被调用,并立即返回一个 Promise 对象。React 接收此 Promise,并错误地将其存储在 effect.destroy 属性中,期望它是一个清理函数。

阶段三:销毁阶段 (Commit Phase - Unmount) 💣

当组件卸载时,React 必须执行所有 useEffect 返回的清理函数。

  • 内部行为:卸载过程会调用 commitHookEffectListUnmount,该函数会遍历所有 effect 并执行它们的 destroy 方法。

  • 源码分析

    // 文件: src/ReactFiberCommitWork.new.js
    function commitHookEffectListUnmount(flags, finishedWork, nearestMountedAncestor) {
      // ...
      do {
        const destroy = effect.destroy; // 1. 取出 effect.destroy,此刻它是一个 Promise 对象
        if (destroy !== undefined) {
          // 2. 将 Promise 对象传递给 safelyCallDestroy
          safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
        }
      } while (/* ... */);
    }
    
    function safelyCallDestroy(current, nearestMountedAncestor, destroy) {
      try {
        destroy(); // 3. 尝试以函数形式调用 Promise 对象,导致崩溃
      } catch (error) {
        // 4. 捕获错误并上报
        captureCommitPhaseError(current, nearestMountedAncestor, error);
      }
    }
    

    在销毁阶段,React 从 effect.destroy 中取出了 Promise 对象,并尝试以函数形式调用它,这必然导致 TypeError: destroy is not a function。在生产环境的压缩代码中,变量 destroy 就是被压缩成了 c,这与我们最初看到的错误信息完全对应。

流程图

以下是整个过程的流程图:

graph TD
    A["渲染阶段 (Render Phase)"] --> B{"组件函数执行"};
    B --> C["遇到 useEffect(async () => {})"];
    C --> D["调用 pushEffect<br>创建 effect 对象并加入 updateQueue<br>effect.create = 你的 async 函数"];
    D --> E["挂载阶段 (Commit Phase)"];
    E --> F["调用 commitHookEffectListMount"];
    F --> G["执行 effect.create()"];
    G --> H["async 函数返回一个 Promise"];
    H --> I["【错误发生点】<br>effect.destroy = Promise 对象"];
    I --> J["销毁阶段 (Unmount Phase)"];
    J --> K["调用 commitHookEffectListUnmount"];
    K --> L["取出 effect.destroy (Promise 对象)"];
    L --> M["调用 safelyCallDestroy(Promise)"];
    M --> N["执行 Promise()"];
    N --> O["💥 崩溃!<br>TypeError: c is not a function"];

    style I fill:#f9f,stroke:#333,stroke-width:2px
    style O fill:#ff6347,stroke:#333,stroke-width:4px

Part 3: 总结

通过对 React 源码的深入分析,我们得出了清晰的结论:

  1. 核心原则useEffect 的回调函数,其返回值必须是一个用于清理副作用的函数,或者 undefined
  2. async 函数的特性async 函数的返回值永远是一个 Promise 对象。
  3. 崩溃原因:将 async 函数直接传递给 useEffect,导致 React 在组件挂载时将返回的 Promise 存储为清理函数。在组件卸载时,React 尝试执行这个 Promise 对象,从而引发了类型错误。
  4. 最佳实践:始终在 useEffect 内部定义一个独立的 async 函数,然后再调用它。这样可以确保 useEffect 本身的返回值是 undefined,符合 React 的设计约定。
useEffect(() => {
  // 在 effect 内部定义并调用异步函数
  const doSomethingAsync = async () => {
    // await ...
  };
  doSomethingAsync();

  // 依然可以返回一个标准的清理函数
  return () => {
    // 清理逻辑
  };
}, []);

这次问题排查再次印证了深入理解技术底层原理的重要性。一个简单的语法糖背后,关联着框架核心的精密设计。希望本次分享能对大家有所帮助。🚀