12k
All articles

如何修复 React 中的 Invalid Hook Call 错误

通过检查堆栈、Rules of Hooks、重复的 React 副本和 react-dom 版本不匹配,修复 React invalid hook call 错误。

OpenReplay Team
OpenReplay Team
如何修复 React 中的 Invalid Hook Call 错误

Invalid hook call 错误通常有三个常见原因:你自己的代码违反了 Hook 规则(Rules of Hooks)、应用中存在多份 React 副本,或者 reactreact-dom 版本不匹配。堆栈跟踪会告诉你应该先排查哪一个。

它常常出现在你自己的组件代码毫无问题的情况下。你用 npm link 链接了一个本地组件库、添加了一个依赖,或者重构了 monorepo,错误就出现了,却不告诉你到底是三种原因中的哪一种。

关键要点

  • 如果出错的 hook 调用位于你自己的组件文件中,问题就在 hook 的调用位置;如果它位于 node_modules 内部,问题几乎总是存在第二份 React 副本。
  • 运行 npm ls react(或 pnpm why reactyarn why react);如果输出解析出多个 React 版本,那么重复副本就是原因,任何组件代码的修改都无法解决问题。
  • 组件库必须在 peerDependencies 中声明 React,并将其从构建产物中排除;如果它打包了自己的 React,每个使用它的应用都会得到两份副本。
  • rules-of-hooks lint 规则可以在代码运行前捕获位置错误的 hook 调用,但没有任何 linter 能检测出重复的 React 副本,因为这类故障存在于已安装的依赖树中,而不在源代码里。
  • 在生产环境中,该错误会以压缩后的 error #321 形式出现,所以在猜测原因之前,先在 React 的 error decoder 上解码它。

Invalid Hook Call 错误意味着什么?

只要 hook 在函数组件的渲染过程之外运行,React 就会抛出这个错误,而错误信息本身就列举了各种可能性:

Invalid hook call. Hooks can only be called inside of the body of a function component.
This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app

React 的 invalid hook call 警告页面 涵盖了这三种情况,另外还有一个用于更少见情形的兜底章节。本文余下部分将按照该错误通常呈现的方式,依次排查这些原因。

先读堆栈跟踪

在改动任何配置之前,先从堆栈跟踪中回答一个问题:调用 hook 的那一帧是位于你自己的源文件中,还是位于 node_modules 内部?如果它指向你的组件文件,那就是违反了 Hook 规则,修复点在你的代码里。如果它指向一个此前一直正常工作的依赖,那你几乎肯定有两份 React 副本,此时你在组件里做任何修改都不会改变结果。

原因如何确认修复方式
违反 Hook 规则堆栈跟踪指向你自己的文件将 hook 移到组件的顶层
两份 React 副本npm ls react 解析出两个版本对依赖树去重(见下文)
react/react-dom 版本不匹配npm ls react react-dom 显示不同版本一起安装两者

修复你自己代码中的 React Invalid Hook Call

只有两条规则会导致这个错误:hook 必须在函数组件的渲染过程中被调用(或者在被组件调用的自定义 hook 中调用),并且它们必须位于该组件的顶层,而不是在 if、循环或嵌套函数内部。位于模块级别、事件处理函数中或普通辅助函数中的 hook 违反了第一条规则;位于条件语句或 .map 回调中的 hook 违反了第二条规则。

辅助函数这种情况最让人意外,因为代码看起来完全合理:

// Wrong: buildLink is a plain function, not a component
export function buildLink() {
  const { pathname } = useLocation(); // invalid hook call
  return `https://example.com${pathname}`;
}

// Right: call the hook in a component, pass the value down
function Page() {
  const { pathname } = useLocation();
  return <a href={buildLink(pathname)}>Canonical</a>;
}

export function buildLink(pathname) {
  return `https://example.com${pathname}`;
}

对于循环的情况,修复方式是结构性的:提取一个子组件,让每一项拥有自己的 state。

// Wrong: one hook call per array item
function List({ items }) {
  return items.map((item) => {
    const [open, setOpen] = useState(false); // invalid hook call
    return <li key={item.id}>{item.name}</li>;
  });
}

// Right: each row is a component with its own state
function Row({ item }) {
  const [open, setOpen] = useState(false);
  return <li onClick={() => setOpen(!open)}>{item.name}</li>;
}

function List({ items }) {
  return items.map((item) => <Row key={item.id} item={item} />);
}

为什么两份 React 副本会破坏 Hooks?

只有当你的应用和 react-dom 加载的是同一个 react 模块时,hooks 才能正常工作。如果两者各自拿到自己的副本,即使你代码中的每个 hook 调用位置都完全正确,React 依然会抛出这个错误。在做其他任何事之前先确认这一点:

npm ls react     # npm
pnpm why react   # pnpm
yarn why react   # yarn

pnpm whyyarn why 会从某个包出发反向追溯是谁引入了它,因此你可以准确看出哪个依赖带进了第二份副本。大多数重复副本源于以下两种情形:

链接的本地包。 通过 npm linkpnpm link 链接的库会从它自己的 node_modules 中解析 React,而不是从你的项目中解析,这就是为什么在你链接一个正常安装时完全正常的组件库时,错误往往立刻出现。React 文档介绍了 npm link 的情况,其修复方式是让该库指向应用中已安装的那份 React。在 Vite 项目中,把相关包列入 resolve.dedupe,Vite 就会将它们各自固定到取自项目根目录的单一副本上:

// vite.config.js
export default {
  resolve: { dedupe: ['react', 'react-dom'] },
}

自带 React 的库。 如果一个包把 react 声明为普通依赖,或者把它打包进构建产物,那么每个使用者都会得到两份副本。库侧的修复方式是在 peerDependencies 中声明 React 及其支持的版本范围,并在构建中将其标记为 external。应用侧的变通做法是强制统一解析,而字段名取决于你使用的包管理器。npm 读取 overrides,其中 $react 表示“与我自己为 react 声明的版本相同”:

{ "overrides": { "react": "$react", "react-dom": "$react-dom" } }

yarn 则读取 resolutions,其值是一个普通的版本号。不要在同一个文件中同时写入这两个字段;每个包管理器都会忽略另一个的键。

react 与 react-dom 版本不匹配

reactreact-dom 是成对发布的,所以要同时检查两者,并用一条命令一起安装。运行 npm ls react react-dom;如果两个版本不同,就把它们一起重新安装(npm install react react-dom),使它们解析到同一个发布版本。这是最容易排除的原因,早点排除它可以避免你去追查根本不存在的代码 bug。

如何借助 Linter 更早发现它?

eslint-plugin-react-hooks 包会在编辑阶段标记出这个错误的所有代码层面成因。使用 ESLint 的 flat config:

// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';

export default defineConfig([reactHooks.configs.flat.recommended]);

在低于 9.0.0 的 ESLint 版本中,旧式写法是 "extends": ["plugin:react-hooks/recommended"]。Next.js 项目已经通过 eslint-config-next 获得了这些规则。rules-of-hooks 规则能在代码运行之前捕获条件式调用和位置错误的 hook 调用,但没有任何 linter 能检测出重复的 React 副本或版本不匹配;这些故障只存在于已安装的依赖树中,因此只会在运行时暴露出来。

生产环境形态:压缩后的 Error #321

在生产构建中,这个错误会以压缩后的错误码而非完整信息的形式出现,因此在断定问题类型之前,先解码这个错误码。React error #321 展开后正是 invalid hook call 的文本;先确认这一点可以避免你去调试错误的不变式(invariant)。压缩后的堆栈很少会指明抛出错误的组件,这使得重复副本的情况在生产环境中格外难以追踪。像 OpenReplay 这样的会话重放工具,会把控制台错误与对应的路由以及此前的用户交互一并捕获,从而显示出抛错那一刻正在挂载的组件树,而这通常会指向那个带入第二份 React 副本的懒加载 chunk 或第三方组件。

从堆栈跟踪开始

把这个错误当作一个分流问题,而不是一个谜团:堆栈跟踪要么把你引向你自己的组件(修正 hook 的位置),要么引向依赖树(运行 npm ls react 并去重)。就从这一条命令开始;它能在几秒内解决三种原因中最令人困惑的那一种,而之后的每一步都是已知的修复方案。

常见问题

自定义 hook 必须以 'use' 开头才能避免 invalid hook call 错误吗?

不需要。'use' 前缀既不会导致也不会阻止这个运行时错误,因为 React 在运行时并不检查 hook 的名称。这个前缀对工具链才有意义:eslint-plugin-react-hooks 依赖它来识别 hooks 并强制执行 Hook 规则,所以命名不规范的自定义 hook 会悄悄绕过 lint 检查。给它加上前缀重命名,这样违规就能在编辑阶段被标记出来,而不是在浏览器中才暴露。

我可以在类组件内部调用 hooks 吗?

不可以。Hooks 只能在函数组件以及被函数组件调用的自定义 hook 中工作,因此在类方法内部调用 useState 或 useContext 会抛出 invalid hook call 错误。如果你无法重写某个类组件但又想配合使用 hook,可以创建一个小的函数组件来调用该 hook,并将结果作为 props 传给类组件,或者把这个类转换为函数组件。

同一个页面上运行两份 React 副本有可能不报错吗?

可以。同一页面上的两个应用各自加载自己的 React 完全没问题,例如不同团队分别发布各自的应用。只有当某个组件与渲染它的 react-dom 实例对使用哪个 react 模块产生分歧时,错误才会出现。相互独立的副本本身是安全的;一旦它们共享同一棵渲染树,问题就会出现。

删除 node_modules 并重新安装能修复重复的 React 副本吗?

只有当重复副本源于陈旧或冲突的安装状态时才有效,因为全新安装能让包管理器对依赖树去重。如果某个依赖把 react 声明为普通依赖、把 React 打包进构建产物,或者你用 npm link 在本地链接了它,那么第二份副本每次安装后都会回来。这些情况需要 overrides 或 resolutions 配置项、库侧的 peerDependencies 修复,或者打包器层面的去重。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.