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

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

错误信息 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)阶段。这意味着错误很可能与组件销毁时的清理逻辑有关。

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

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

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

javascript 复制代码
// 修正后的代码
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 代码:

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

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

javascript 复制代码
// 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)中。

    javascript 复制代码
    // 文件: 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 函数处理。

  • 源码分析

    javascript 复制代码
    // 文件: 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 方法。

  • 源码分析

    javascript 复制代码
    // 文件: 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
创建 effect 对象并加入 updateQueue
effect.create = 你的 async 函数"]; D --> E["挂载阶段 (Commit Phase)"]; E --> F["调用 commitHookEffectListMount"]; F --> G["执行 effect.create()"]; G --> H["async 函数返回一个 Promise"]; H --> I["【错误发生点】
effect.destroy = Promise 对象"]; I --> J["销毁阶段 (Unmount Phase)"]; J --> K["调用 commitHookEffectListUnmount"]; K --> L["取出 effect.destroy (Promise 对象)"]; L --> M["调用 safelyCallDestroy(Promise)"]; M --> N["执行 Promise()"]; N --> O["💥 崩溃!
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 的设计约定。
javascript 复制代码
useEffect(() => {
  // 在 effect 内部定义并调用异步函数
  const doSomethingAsync = async () => {
    // await ...
  };
  doSomethingAsync();

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

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

相关推荐
电商API大数据接口开发Cris15 分钟前
Node.js + TypeScript 开发健壮的淘宝商品 API SDK
前端·数据挖掘·api
还要啥名字17 分钟前
基于elpis下 DSL有感
前端
一只毛驴23 分钟前
谈谈浏览器的DOM事件-从0级到2级
前端·面试
用户81686947472525 分钟前
封装ajax
前端
pengzhuofan25 分钟前
Web开发系列-第13章 Vue3 + ElementPlus
前端·elementui·vue·web
yvvvy26 分钟前
白嫖 React 性能优化?是的,用 React.memo!
前端·javascript
NicolasCage33 分钟前
react-typescript学习笔记
javascript·react.js
火车叼位34 分钟前
GSAP 动画开发者的终极利器:像素化风格 API 速查表
前端
JohnYan35 分钟前
Bun技术评估 - 16 Package Manager
javascript·后端·bun
袁煦丞1 小时前
全球热点一键抓取!NewsNow:cpolar内网穿透实验室第630个成功挑战
前端·程序员·远程工作