本地正常、线上正常,为什么一个 Hook 仍会报错?

当你看到 Cannot read properties of null (reading 'useState'),不要只检查业务数据。它通常意味着 Hook 的调用上下文或 React 单例出现了问题。

现象

某个页面、弹窗或抽屉在本地开发环境渲染时崩溃,调用栈指向第三方组件库的 Hook:

text 复制代码
Uncaught TypeError: Cannot read properties of null (reading 'useState')
  at useWatch (...)
  at useSomeFeatureState (...)
  at SomeModal (...)

常见特点:

  • 对应页面可能在线上正常;
  • 同一库的其他 Hook 在当前项目中也可能正常;
  • 报错发生在渲染阶段,尚未执行接口请求或业务事件;
  • 重新启动开发服务、切换分支或 HMR 更新后,复现概率可能变化。

本文以 Ant Design 的 Form.useWatch 为例,但诊断方法适用于所有内部调用 React Hook 的 UI 库、状态库和自定义组件。

结论先说

这类异常最常见的两类原因是:

  1. Hook 不在函数组件或自定义 Hook 的顶层执行,违反了 Hook 规则。
  2. 执行 Hook 的 React 实例,与渲染当前组件的 React 实例不是同一个。

第二类尤其容易被忽略。它往往由依赖版本重复、软链接/monorepo、包的 CJS/ESM 双入口、深路径导入,或 Vite 等开发服务器的预构建和 HMR 模块图造成。

一个典型触发方式

以下写法类型检查完全正常,却可能在某些本地开发环境出问题:

ts 复制代码
// sharedFormHooks.ts
import Form from 'antd/es/form';

export function useFormFeatureState(form: FormInstance) {
  return Form.useWatch('feature', form);
}

而调用页面使用的是:

ts 复制代码
import { Form } from 'antd';

const [form] = Form.useForm();
const feature = useFormFeatureState(form);

修复方式是统一入口:

ts 复制代码
import { Form, type FormInstance } from 'antd';

export function useFormFeatureState(form: FormInstance) {
  return Form.useWatch('feature', form);
}

重点不是"antd/es/form 一定不能用"。深路径本身可能是库支持的 API;真正的风险是同一条组件调用链混用了不同入口,导致开发构建的依赖解析结果不一致。

原理:为什么会读到 null

React Hook 并不是独立运行的普通函数。React 渲染函数组件时,会在 React 单例上设置当前 Hook dispatcher;useStateuseMemouseContext 等 Hook 都通过这个 dispatcher 工作。

text 复制代码
组件由 React A 开始渲染
→ React A 的 Hook dispatcher 已设置
→ 某依赖模块从 React B 调用 useState
→ React B 的 Hook dispatcher 仍为 null
→ 抛出"Cannot read properties of null (reading 'useState')"

因此,堆栈指向 Form.useWatchuseSelector 或某个库的 Hook,不代表表单实例、store 或业务参数为 null。需要检查的是:该 Hook 最终从哪个 React 实例执行。

为什么只在本地出现

生产构建和开发服务的模块处理方式不同:

场景 模块处理特征 可能结果
生产构建 打包器会重新分析并合并依赖 最终可能只有一个 React 实例
Vite 本地开发 依赖预构建、按入口缓存、HMR 按模块替换 深路径或双格式依赖可能形成另一条模块图

所以"线上没问题"只能说明生产产物的依赖图没有触发异常,不能证明源码中的导入方式安全。应始终在冷启动后的开发环境验证。

排查步骤

1. 确认 Hook 调用规则

先排除最基础的问题:

  • Hook 是否只在函数组件或自定义 Hook 内调用;
  • 是否位于顶层,而不是 if、循环、事件回调、异步函数或普通工具函数中;
  • 同一组件的不同渲染之间,Hook 调用顺序是否稳定。

2. 从报错位置向上检查 import

重点看报错 Hook 的定义文件与调用组件:

text 复制代码
[ ] 是否混用了库的桶导出和深路径导出?
[ ] 是否混用了 ESM 路径与 CJS 路径?
[ ] 是否从两个不同封装包间接引入同一个 UI 库?
[ ] 共享 Hook 是否自行引入了调用方未使用的组件入口?

例如 antdantd/es/*antd/lib/* 不应在同一个组件调用链中随意混用。

3. 检查 React 是否被重复安装或解析

执行以下命令确认依赖树:

bash 复制代码
pnpm why react
pnpm why react-dom
pnpm list react react-dom -r

尤其关注:

  • monorepo 中每个子包是否都安装了 React;
  • link:file:workspace:pnpm link 的组件库是否把 React 放到了 dependencies,而不是 peerDependencies
  • reactreact-dom 的版本是否匹配;
  • lockfile 是否同时存在无法去重的 React 版本。

4. 冷启动,排除陈旧的预构建缓存

关闭开发服务器后,删除 Vite 缓存并重新启动:

bash 复制代码
rm -rf node_modules/.vite
pnpm dev

Windows PowerShell:

powershell 复制代码
Remove-Item -Recurse -Force node_modules\.vite
pnpm dev

如果冷启动后不再复现,说明缓存/HMR 参与了触发;但仍应统一有风险的导入路径,避免下次更新再次出现。

5. 在浏览器中确认实际加载模块

若仍需进一步确认:

  1. 打开浏览器 DevTools 的 Network 或 Sources;
  2. 搜索 react、目标库和目标深路径的模块 URL;
  3. 检查它们是否从不同的预构建产物或不同的 node_modules 目录加载;
  4. 对照 source map 确认报错 Hook 依赖的 React 解析位置。

这一步能把"怀疑存在两份 React"变成可验证的结论。

修复策略

优先级从高到低:

  1. 在同一调用链统一 React 和 UI 库的导入入口。
  2. 将 React、React DOM 声明为共享组件库的 peer dependency,避免组件库自行打包 React。
  3. 解决 monorepo、软链接或嵌套 node_modules 引入的重复 React。
  4. 清理开发服务器依赖缓存并冷启动。
  5. 必要时在构建工具中配置 React 去重,例如 Vite 的 resolve.dedupe

示例:

ts 复制代码
// vite.config.ts
export default defineConfig({
  resolve: {
    dedupe: ['react', 'react-dom'],
  },
});

dedupe 是解决"实际解析到多份 React"的工具配置,不应用来掩盖业务代码中的错误 Hook 调用或不一致的依赖声明。

预防清单

text 复制代码
[ ] React 与 react-dom 版本匹配,且依赖树中只有预期的实例。
[ ] 共享组件库将 React 声明为 peerDependencies。
[ ] 同一组件调用链使用统一的 UI 库导入入口。
[ ] 不在组件/Hook 以外调用 Hook,也不在条件或回调中调用。
[ ] 依赖升级、软链接变更或 HMR 异常后,进行一次冷启动验证。
[ ] 线上正常时,仍在本地开发环境验证模块解析和热更新行为。

如何真正防止下一个项目再踩坑

单靠 IDE 规则、Code Review 提醒或某个开发者的经验都不够:它们只覆盖部分人和部分场景。更可靠的做法是把防线分成"规范、静态检查、依赖控制、CI 验证"四层,并将它们放进项目模板或组织级脚手架。

1. 写入团队工程规范

将以下规则放进团队通用的 React 开发规范、项目 CONTRIBUTING.md 或模板仓库:

md 复制代码
# React Hook 与依赖单例规则

1. Hook 只能在函数组件或自定义 Hook 顶层调用;不得在条件分支、循环、回调、异步函数或普通工具函数中调用。
2. 同一组件调用链必须统一 React 与 UI 库的导入入口。优先使用包的公开入口;禁止把公开入口与同一库的深路径默认组件导入混用。
3. 共享组件库、monorepo 包和软链接包必须将 react、react-dom 声明为 peerDependencies,不得把它们作为会被重复安装的运行时依赖。
4. 遇到 Invalid hook call、Hook dispatcher 为 null 等错误时,依次检查 Hook 调用位置、import 路径和重复 React 实例;不要先归因于业务数据或最近的无关改动。
5. 仅当确认实际解析到多份 React 时,才使用构建工具去重配置;该配置不能替代修复错误的 Hook 调用或依赖声明。

这里的"统一入口"同样适用于 React Router、状态管理库和组件库:例如不要在同一调用链混用包公开入口、CJS 入口与 ESM 深路径入口,除非项目明确约定且已经验证其模块解析结果。

2. 用 ESLint 阻止可识别的问题

eslint-plugin-react-hooks 能静态阻止第一类问题(违反 Hook 调用规则)。项目应在基础 ESLint 配置中启用:

js 复制代码
export default [
  {
    rules: {
      'react-hooks/rules-of-hooks': 'error',
      'react-hooks/exhaustive-deps': 'warn',
    },
  },
];

对于已确定不应使用的深路径入口,可用 no-restricted-imports 将经验变成硬约束。下面以 Ant Design 为例;规则应只限制团队确认有风险的路径,不能笼统禁止所有深路径导入:

js 复制代码
export default [
  {
    rules: {
      'no-restricted-imports': [
        'error',
        {
          patterns: [
            {
              group: ['antd/es/form', 'antd/lib/form'],
              message: '请从 antd 统一导入 Form,避免混用桶导出与 Form 深路径入口。',
            },
          ],
        },
      ],
    },
  },
];

3. 在依赖层保证单例

共享组件库的 package.json 应采用 peer dependency,而不是自行携带 React:

json 复制代码
{
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  },
  "devDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}

devDependencies 仅用于组件库自身的开发和测试;消费应用负责提供实际运行时的 React。对于 monorepo,需要在根目录统一版本策略,并定期检查是否出现嵌套 node_modules/react

4. 在 CI 中验证依赖树

将依赖检查加入依赖升级、发布共享组件库或变更软链接配置的 CI 任务。根据包管理器选择等价命令:

bash 复制代码
# pnpm
pnpm why react
pnpm why react-dom

# npm
npm ls react react-dom

# Yarn
yarn why react
yarn why react-dom

CI 不应简单要求输出中"只能有一行 React":在 monorepo、测试工具或多版本迁移期可能存在合理的多版本。应由脚本或人工审查确认,最终浏览器运行时的应用模块图只解析到预期的 React 实例。

5. 配置是最后一道防线

确认存在多份 React 解析实例后,可按构建工具增加去重配置。例如 Vite:

ts 复制代码
export default defineConfig({
  resolve: {
    dedupe: ['react', 'react-dom'],
  },
});

该配置用于统一解析结果,不能掩盖源码中的违规 Hook 调用、深路径入口混用或共享库依赖声明错误。依赖入口、软链接或 HMR 行为发生变化后,仍要清理预构建缓存并冷启动验证。

小结

useState 读取 null 往往不是数据问题,而是 React Hook 的运行上下文出了问题。先检查 Hook 规则,再检查 import 入口和 React 的实际解析路径;不要只因某次无关业务改动后复现,就把它直接归因到那次改动。

相关推荐
Android小行家22 分钟前
Android APK 加固原理(五):SO `.text` 段加密、ELF 加载与运行时动态解密
前端
lerhxx24 分钟前
R3F 第一人称漫游与碰撞检测:Pointer Lock + 不穿墙的滑墙秘诀(中)
前端·javascript·three.js
LSCLikeApple28 分钟前
入门promise
前端
爬楼的猪31 分钟前
DeepSeek Harness 本地安装和对话过程的网络问题
前端
labixiong32 分钟前
button按钮原生开关弹窗,零 JS 搞定80%交互场景
前端·javascript·html
悟空瞎说37 分钟前
Vue3 应用实例 API
前端
隔岸观火烧连营38 分钟前
如何用 WebCodecs 在浏览器里实现高清录屏 —— 无插件、无水印、直接导出 MP4
前端·javascript
杉氧39 分钟前
页面栈与路由:React Navigation 与 Expo Router 深度实践
android·前端·react native
用户9314563556641 分钟前
别再满屏 try-catch 了:聊聊异常处理的正确姿势
前端