通俗易懂的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 被分发到 updateState、updateEffect 这类 update 分支,然后内部会走到 updateWorkInProgressHook。
重新渲染时的 Hooks
updateWorkInProgressHook 里有这么一行:
1 | |
当执行到“子组件内部的 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 | |
只要读到 prevDeps.length,就已经直接报错了。
问题结论
同事截图里的报错,基本就是发生在这种“把带 Hooks 的组件当普通函数执行”的场景里。
简单把链路捋一遍:
组件被 component() 调用,没有独立 Fiber
Hooks 挂在 currentlyRenderingFiber上,其实就是挂到了父组件的 Fiber 上
某次渲染中组件出现/消失或路径变化,导致父组件 hooks 顺序发生变化
hooks 错位,本次 useEffect 对上次 useState,读到不该读的字段,运行时直接炸
渲染流程中断,页面白屏
思考
同事其实已经抓住了直接原因,别把组件当普通函数调用就行,她用React.createElement渲染来替换直接调用函数。但如果要把“为什么会这么报错”讲清楚,就要深入源码,了解hooks的具体实现了。
React有很多约定,来保证它的功能不会出问题。但如果只知其然,不知其所以然,我觉得还是差了点意思。知道为什么不能这样做之后,才能更好地遵守约定。