JSON.stringify 在 React Hooks 依赖项里的坑:一次复盘

JSON.stringify 在 React Hooks 依赖项里的坑:一次复盘

✨ 阅读时间约 4 分钟。

最近 review 代码,看到有人这样写依赖项:

javascript 复制代码
useEffect(() => {
  // do something with userProfile
}, [JSON.stringify(userProfile)]);

这种写法看起来能"深度比较"对象,实际上埋了个大坑

为什么有人想用 JSON.stringify

项目中有个编辑表单,userProfile 是一个包含七八个字段的对象。每次用户输入,状态更新,组件重新渲染。开发者发现 useEffect 总是重复执行,想用 JSON.stringify 把对象转成字符串来做"深度比较",以为这样就能避免无谓的触发。

这个思路在直觉上说得通------字符串相同则对象相同嘛。但实际跑起来,问题比想象中更隐蔽。

现象:依赖项确实变了,但不是因为你想的原因

改完这行代码后,useEffect 确实不频繁触发了。但调试发现,某个下拉框的选项数据在某些情况下被清空了。进一步排查,useEffect 里的逻辑在错误的时间点执行,导致了数据覆盖。

更糟糕的是,如果 userProfile 里包含某些特殊值,整个应用直接崩溃

根因:JSON.stringify 根本不是可靠的对象比较方式

MDN 上 JSON.stringify 的文档(MDN)明确说了几类值会被忽略或转换:

  • 函数(function)、undefinedSymbol 作为属性值会被忽略
  • Symbol 作为属性键会被完全忽略
  • NaNInfinity 会变成 null
  • Date 对象会转成字符串("2024-01-01T00:00:00.000Z"
  • RegExpMapSet 会变成空对象 {} 或空结构
  • 如果对象里有循环引用,直接报错:"Converting circular structure to JSON"

这就意味着,两个完全不同的对象可能输出相同的字符串,而两个看起来相同的对象可能因为属性顺序不同而输出不同结果。

解决:要么拆分依赖,要么用专业的比较函数

正确做法是直接把用到的字段放进依赖数组:

javascript 复制代码
useEffect(() => {
  // 只用到了 name 和 age
}, [userProfile.name, userProfile.age]);

如果对象确实很大,可以用 lodashisEqual 做深度比较,但要在 useEffect 里手动处理:

javascript 复制代码
const prevProfile = useRef(userProfile);

useEffect(() => {
  if (!isEqual(prevProfile.current, userProfile)) {
    // 业务逻辑
    prevProfile.current = userProfile;
  }
}, [userProfile]); // 依赖项还是得加,只是里面不做字符串转换

触类旁通:同类坑一起看清

除了依赖项,useMemouseCallback 的依赖数组同样有这个问题。很多人以为用 JSON.stringify 包裹对象就能稳定缓存,实际上每次渲染生成的新对象转成字符串后内容相同,但引用变了,缓存依然失效。

另一个常见场景是表单状态。很多同学把所有表单项塞进一个对象,然后试图用 JSON.stringify 判断是否修改过。建议用专门的表单库(如 react-hook-form)或者维护一个 dirtyFields 标记对象,只追踪实际修改过的字段。

排查思路:为什么没按预期执行

如果有人反馈"看起来像被缓存了、没按预期执行(或执行时机不对)",可以按这个顺序排查:

  • 看依赖数组里是不是包了 JSON.stringify,以及依赖项里是否包含函数或 Symbol
  • 打印依赖项和字符串结果,确认"预期的变化是否被序列化忽略"
  • Object.is 或简单比较确认引用是否变化,而不是内容变化
  • 临时改成显式依赖字段,验证行为是否回到预期

这套排查能快速判断问题是不是出在 JSON.stringify 的序列化边界上。

不同类型值如何影响比较

javascript 复制代码
const objA = {
  a: 1,
  u: undefined,
  fn: () => 1,
  s: Symbol('x'),
  d: new Date('2024-01-01T00:00:00Z'),
};

const objB = {
  a: 1,
  u: undefined,
  fn: () => 2,
  s: Symbol('y'),
  d: new Date('2024-01-01T00:00:00Z'),
};

JSON.stringify(objA) === JSON.stringify(objB); // true

这里 fns 的变化会被忽略,Date 会被序列化成相同的 ISO 字符串,所以两个对象会被误判为"相同"。如果你的依赖项靠它来判断变化,结果就是 "明明变了,但 effect 不触发"

结论:依赖项表达的是"何时重新运行"

依赖项的本质是"引用比较"而非"内容比较"。React 不知道也不关心对象内部长什么样,它只关心这个引用是不是上一次渲染时的那个引用

JSON.stringify 看似解决了比较问题,但它本身的行为规则太多太复杂,用来当依赖项无异于给自己埋炸弹

相关推荐
进击的尘埃2 小时前
把 LLM 吐出来的组件扔进 `iframe` 跑:沙箱隔离这件事没你想的那么简单
javascript
ujainu2 小时前
Electron 极简时钟应用开发全解析:托盘驻留、精准北京时间与 HarmonyOS PC 适配实战
javascript·electron·harmonyos
清空mega2 小时前
《Vue Router 与 Pinia 入门:页面跳转、动态路由、全局状态管理一篇打通》
前端·javascript·vue.js
进击的尘埃2 小时前
从一个 `console.log` 顺序翻车说起,聊聊微任务那些糟心事
javascript
脑子不好真君2 小时前
手势操控的粒子土星 (Three.js + MediaPipe)
开发语言·javascript·ecmascript
坚持学习前端日记2 小时前
AI 产品开发经验
前端·javascript·人工智能·visual studio
雾削木2 小时前
STM32输入捕获测量PWM频率占空比
前端·javascript·stm32
JamesYoung79712 小时前
第八部分 — UI 表面 动作(工具栏)、徽标、弹出窗口
前端·javascript
Joker Zxc2 小时前
【前端基础(Javascript部分)】5、JavaScript的循环语句
开发语言·前端·javascript