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

错误信息 TypeError: c is not a function
指向了 react-reconciler
的生产环境代码。这通常意味着问题发生在 React 的核心调度层,有点棘手。🤔
Part 1: 问题定位与初步排查
根据错误堆栈,我的初步怀疑方向是环境或依赖问题。
- 依赖问题 :首先排查了
pnpm
相关的依赖缓存或lock
文件一致性问题。在清理node_modules
和pnpm-lock.yaml
并重新安装后,问题依旧存在。❌ - React 版本冲突:接着检查了项目的依赖树,确认不存在多个 React 版本互相冲突的情况。❌
- 框架兼容性:排查了当前 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
,这与我们最初看到的错误信息完全对应。
流程图
以下是整个过程的流程图:
创建 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 源码的深入分析,我们得出了清晰的结论:
- 核心原则 :
useEffect
的回调函数,其返回值必须是一个用于清理副作用的函数,或者undefined
。 async
函数的特性 :async
函数的返回值永远是一个Promise
对象。- 崩溃原因 :将
async
函数直接传递给useEffect
,导致 React 在组件挂载时将返回的Promise
存储为清理函数。在组件卸载时,React 尝试执行这个Promise
对象,从而引发了类型错误。 - 最佳实践 :始终在
useEffect
内部定义一个独立的async
函数,然后再调用它。这样可以确保useEffect
本身的返回值是undefined
,符合 React 的设计约定。
javascript
useEffect(() => {
// 在 effect 内部定义并调用异步函数
const doSomethingAsync = async () => {
// await ...
};
doSomethingAsync();
// 依然可以返回一个标准的清理函数
return () => {
// 清理逻辑
};
}, []);
这次问题排查再次印证了深入理解技术底层原理的重要性。一个简单的语法糖背后,关联着框架核心的精密设计。希望本次分享能对大家有所帮助。🚀