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)、undefined、Symbol作为属性值会被忽略 Symbol作为属性键会被完全忽略NaN和Infinity会变成nullDate对象会转成字符串("2024-01-01T00:00:00.000Z")RegExp、Map、Set会变成空对象{}或空结构- 如果对象里有循环引用,直接报错:
"Converting circular structure to JSON"
这就意味着,两个完全不同的对象可能输出相同的字符串,而两个看起来相同的对象可能因为属性顺序不同而输出不同结果。
解决:要么拆分依赖,要么用专业的比较函数
正确做法是直接把用到的字段放进依赖数组:
javascript
useEffect(() => {
// 只用到了 name 和 age
}, [userProfile.name, userProfile.age]);
如果对象确实很大,可以用 lodash 的 isEqual 做深度比较,但要在 useEffect 里手动处理:
javascript
const prevProfile = useRef(userProfile);
useEffect(() => {
if (!isEqual(prevProfile.current, userProfile)) {
// 业务逻辑
prevProfile.current = userProfile;
}
}, [userProfile]); // 依赖项还是得加,只是里面不做字符串转换
触类旁通:同类坑一起看清
除了依赖项,useMemo 和 useCallback 的依赖数组同样有这个问题。很多人以为用 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
这里 fn 和 s 的变化会被忽略,Date 会被序列化成相同的 ISO 字符串,所以两个对象会被误判为"相同"。如果你的依赖项靠它来判断变化,结果就是 "明明变了,但 effect 不触发"。
结论:依赖项表达的是"何时重新运行"
依赖项的本质是"引用比较"而非"内容比较"。React 不知道也不关心对象内部长什么样,它只关心这个引用是不是上一次渲染时的那个引用。
JSON.stringify 看似解决了比较问题,但它本身的行为规则太多太复杂,用来当依赖项无异于给自己埋炸弹。