通俗易懂的React原理(番外篇之四):函数组件与函数?

代码以React v19.1.0为例,https://github.com/facebook/react/tree/v19.1.0 。此版本React的RSC功能近期被发现有致命漏洞,我们研究源码不涉及相关功能,但此版本应该严禁在生产使用。

这一期番外篇还是来自工作中的生产问题,不过这次不是我踩坑,是同事遇到并排查的,她修完之后顺手把坑点分享了出来。

现象很直观,把一个函数组件当成普通函数直接调用了,触发了 JS error,页面直接白屏。

我听她讲的时候能感觉出来,她知道“怎么改才对”,但对“为什么会这么报错、为什么会白屏”的根因还没完全串起来。领导当时也挺感兴趣,追问了几句,可惜后面她就解释不清了,所以这篇我把这个问题讲透一点。

二者区别?

命名要求

函数组件首先当然是函数,React 这边还有个很常见的约定,函数组件通常以大写字母开头。小写开头的 <foo /> 会更像原生 DOM 标签,React 不会把它当成组件来处理,这种一般属于基础错误,我们就不细说了。

更关键的是,你写了一个大写开头、返回 JSX 的函数,它到底是“普通函数”还是“函数组件”,不是看长相,是看你怎么调用它。

没有 Hooks 的场景

如果这个函数内部完全没用 Hooks,那你把它当普通函数调用,很多时候不会立刻出事。

用 JSX 的方式调用(比如 <Comp />),它会先变成 ReactElement,然后在 diff/reconcile 过程中,React 会为它创建对应的 Fiber。

但如果你直接写 Comp(),它就只是一次普通函数执行,返回什么你就拿什么用,本质上更像“把这段 JSX 结果直接复制过来”。这一点我在前面的番外篇二里也简单提过。

用了 Hooks 的场景

但只要函数里用了 Hooks,你就不能再把它当普通函数直接调用了。

同事那次线上问题就是这样来的,项目里有一套“按配置文件渲染不同组件”的机制,组件通过 props 的 component 字段传进来,然后用 return component() 的方式渲染。

这就相当于把组件当普通函数执行了。

以前可能传进来的组件都比较简单,没 Hooks,所以一直没暴露问题。这次新增了一个更复杂的组件,里面用了 Hooks,当用户的某个交互触发它渲染时,运行时直接报错,页面就白了。

细说原因

到这里为止,其实和同事讲的结论差不多,但如果只说“违反 React 约定”“Hooks 规则不允许”,听起来还是像背教科书。真正想让人都能明白为什么,还是要把底层机制讲清楚。

我们在讲 Hooks 原理时说过,Hooks 会以链表的形式挂在函数组件对应的 Fiber 上,也就是 fiber.memoizedState 上。每次更新渲染时,React 会一边走上一轮的 hooks 链表,一边构建这一轮的 hooks 链表,用来复用 useState 的 state、对比 useEffect 的依赖等。

那如果你把一个“带 Hooks 的函数组件”当普通函数调用,会发生什么呢,核心变化就一个,它没有自己的 Fiber。

而React执行hooks的时候,会把hooks挂到currentlyRenderingFiber上面。如果子组件没有自己的Fiber,那么currentlyRenderingFiber就还是父组件。也就是说,这些 hooks 最终会挂到父组件的 Fiber 上。

在源码路径里,你会看到 Hooks 被分发到 updateStateupdateEffect 这类 update 分支,然后内部会走到 updateWorkInProgressHook

重新渲染时的 Hooks

updateWorkInProgressHook 里有这么一行:

1
nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;

当执行到“子组件内部的 Hooks”时,因为子组件并没有对应的 Fiber,所以 currentlyRenderingFiber 仍然是父组件的 Fiber,这一轮 hooks 链表也就从父组件的 memoizedState 开始继续往后接。

而 updateWorkInProgressHook 要做的事,是把上一轮的 hook 节点按顺序复制到这一轮,让每个 hook 能拿到自己上一轮的状态,比如 useState 的当前值、useEffect 的依赖项等等。

Hooks 前后不一致

问题就出在“按顺序”这件事上。

既然这些 hooks 都挂在父组件的 Fiber 上,那只要子组件在某次渲染里出现/消失,或者调用路径变化,就会让父组件两次渲染的 hooks 数量或顺序发生变化,链表自然就对不上了。

如果新一次比前一次多,React 会直接抛内部错误:Rendered more hooks than during the previous render.

渲染流程被中断,本次更新直接失败,你看到的就是白屏(或者说这次渲染没跑完,页面状态断在半路)。

如果新一次比前一次少,updateWorkInProgressHook 未必立刻抛错,但两次链表可能已经发生了错位。比如新的链表中间缺了几个hooks,导致后续所有的hooks都没办法和原来对应上了。

比如父组件链表的第 3 个 hook,上一次是 useState,这一次却变成了 useEffect。数量没炸,但顺序变了,类型也跟着变了。

这会导致什么后果呢,别忘了,复制 hooks 链表只是第一步,后面还要真正执行 hook 的逻辑,错误往往是在这里才体现出来的。

打个比方,函数组件渲染时执行一个个 Hooks,很像厨房做一串固定顺序的菜。更新渲染时先把上一轮每道菜的“备料”复制过来,然后再继续做。但如果顺序乱了,你拿着锅包肉的备料去做西红柿炒鸡蛋,怎么可能不出事。

拿 useEffect 来说,它需要比较本次依赖数组和上次依赖数组。如果本次是 useEffect,上次却是 useState,那“上次的依赖数组”从哪来,prevDeps 很可能就是 undefined。

然后你再看这段比较依赖的逻辑:

1
2
3
4
5
6
7
8
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
// $FlowFixMe[incompatible-use] found when upgrading Flow
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;

只要读到 prevDeps.length,就已经直接报错了。

问题结论

同事截图里的报错,基本就是发生在这种“把带 Hooks 的组件当普通函数执行”的场景里。

简单把链路捋一遍:

组件被 component() 调用,没有独立 Fiber

Hooks 挂在 currentlyRenderingFiber上,其实就是挂到了父组件的 Fiber 上

某次渲染中组件出现/消失或路径变化,导致父组件 hooks 顺序发生变化

hooks 错位,本次 useEffect 对上次 useState,读到不该读的字段,运行时直接炸

渲染流程中断,页面白屏

思考

同事其实已经抓住了直接原因,别把组件当普通函数调用就行,她用React.createElement渲染来替换直接调用函数。但如果要把“为什么会这么报错”讲清楚,就要深入源码,了解hooks的具体实现了。

React有很多约定,来保证它的功能不会出问题。但如果只知其然,不知其所以然,我觉得还是差了点意思。知道为什么不能这样做之后,才能更好地遵守约定。


通俗易懂的React原理(番外篇之四):函数组件与函数?
https://miku03090831.github.io/2025/12/28/通俗易懂的React原理(番外篇之四):函数组件与函数?/
作者
qh_meng
发布于
2025年12月28日
许可协议