陌上人如玉
公子世无双

2.14 依赖数组的常见错误

2.14 依赖数组的常见错误

一句话:依赖数组的规则是「把副作用里用到的每一个响应式值都列进去」;三类错误是「漏依赖(逻辑错误)、多依赖(性能问题)、不稳定依赖(完全失效)」,而 exhaustive-deps 的警告几乎总是对的。

一、依赖数组的作用

useEffect(fn, deps);        // deps 变化 → 先执行旧 fn 的清理,再执行新 fn
useMemo(fn, deps);          // deps 变化 → 重新计算
useCallback(fn, deps);      // deps 变化 → 返回新函数
useImperativeHandle(ref, fn, deps);
useLayoutEffect(fn, deps);  // 同 useEffect
React 的比较方式:
  ① 逐个元素用 Object.is 比较新旧依赖
  ② 只要有【一个】不同 → 认为依赖「变了」
  ③ 依赖数组的长度也必须一致(长度变化会警告)

「响应式值」的定义(官方):
  props、state、以及「在组件体内声明的变量与函数」
  → 它们每次渲染都可能变化,因此需要列进依赖

不需要列进依赖的:
  · 组件外部(模块作用域)的常量与函数
  · setState / dispatch(React 保证引用稳定)
  · ref 对象本身(ref.current 的读写不需要依赖,因为它不参与渲染)

二、错误一:漏依赖(逻辑错误)

// ❌ 漏了 id
function UserProfile({ id }: { id: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(id).then(setUser);      // ⚠️ id 变了但不会重新请求
  }, []);                             // ❌ 应该写 [id]

  return 
{user?.name}
; } // 现象:切换用户时,页面显示的仍是上一个用户
// ❌ 漏了外部函数
function Comp({ onSave }: { onSave: () => void }) {
  useEffect(() => {
    onSave();                          // ⚠️ onSave 变了但不会重新执行
  }, []);
}
漏掉的东西 表现
props 切换 props 后 Effect 不重跑(数据不更新)
state 闭包陷阱(读到旧值)
组件内定义的函数 函数逻辑变化后行为不更新
组件内定义的对象/数组 依赖它们的计算不更新
// ✅ 正确写法
useEffect(() => {
  fetchUser(id).then(setUser);
}, [id]);

三、错误二:多依赖(性能问题)

// ⚠️ 依赖了「用不到的值」→ 不必要的重新执行
useEffect(() => {
  console.log(a);
}, [a, b]);            // ⚠️ b 变化也会重新执行(虽然没用到 b)

// ⚠️ 依赖了整个对象,但只用到其中一项
useEffect(() => {
  console.log(user.name);
}, [user]);            // ⚠️ user 的任何字段变化都会触发(而且通常是新对象 → 每次触发)

// ✅ 依赖「具体用到的那一项」
useEffect(() => {
  console.log(user.name);
}, [user.name]);
多依赖的典型来源:
① 为了「避免警告」把一堆东西都塞进去
② 依赖了整个对象/数组(而实际只用一个字段)
③ 依赖了「每次渲染新建的对象/函数」(既是多依赖,也是不稳定依赖)

四、错误三:不稳定依赖(完全失效)

// ❌ 依赖了「每次渲染都新建的对象字面量」
useEffect(() => {
  doSomething(config);
}, [{ url: '/api', method: 'GET' }]);     // ⚠️ 每次渲染都是新对象 → Effect 每次都执行

// ❌ 依赖了「每次渲染都新建的内联函数」
useEffect(() => {
  doSomething(onChange);
}, [() => {}]);                            // ⚠️ 同样每次都执行

// ❌ 依赖了「上游传下来的内联函数」(上游每次都新建)
function Parent() {
  return  console.log('x')} />;   // ⚠️ 每次渲染都是新函数
}
function Child({ onChange }: { onChange: () => void }) {
  useEffect(() => {
    onChange();
  }, [onChange]);                          // ⚠️ 每次渲染都执行
}
// ✅ 解法一:把常量提到组件外(最省事,适用于「纯常量」)
const CONFIG = { url: '/api', method: 'GET' };

function Comp() {
  useEffect(() => {
    doSomething(CONFIG);
  }, []);                                  // ✅ 常量不需要依赖
}
// ✅ 解法二:依赖「原始值」而不是对象
useEffect(() => {
  doSomething({ url, method });
}, [url, method]);                         // ✅ 只在 url / method 变化时执行
// ✅ 解法三:用 useMemo / useCallback 稳定引用
const config = useMemo(() => ({ url, method }), [url, method]);
useEffect(() => {
  doSomething(config);
}, [config]);                              // ✅ 只有 url / method 变化时 config 才变

const handleChange = useCallback(() => {
  console.log(value);
}, [value]);
useEffect(() => {
  handleChange();
}, [handleChange]);                        // ✅ 只有 value 变化时执行
// ✅ 解法四:把函数定义移到 Effect 内部(最推荐,除非它需要在别处复用)
useEffect(() => {
  function handleChange() {
    console.log(value);
  }
  handleChange();
}, [value]);                               // ✅ 依赖变成函数用到的值
不稳定依赖的来源 解法
对象/数组字面量 提到组件外 / 依赖字段 / useMemo
内联箭头函数 useCallback / 移到 Effect 内 / 提出组件外
上游传入的内联函数 让上游稳定(useCallback)
上游传入的对象 让上游 useMemo,或在子组件依赖具体字段
useSelector 返回的新对象 用 shallowEqual 或选择器拆细

五、函数依赖的三种处理方式

// 情形:Effect 里用到了某个函数

// ① 函数只在 Effect 内部用 → 移进去(推荐)
useEffect(() => {
  function fetchData() {
    return api.get(`/users/${userId}`);
  }
  fetchData().then(setData);
}, [userId]);

// ② 函数需要在多处复用 → useCallback 稳定它
const fetchData = useCallback(() => api.get(`/users/${userId}`), [userId]);

useEffect(() => {
  fetchData().then(setData);
}, [fetchData]);              // ✅ fetchData 只在 userId 变化时变

// ③ 函数来自 props 且上游无法稳定 → 用 ref 保存
function Child({ onLoad }: { onLoad: () => void }) {
  const onLoadRef = useRef(onLoad);
  useEffect(() => { onLoadRef.current = onLoad; });

  useEffect(() => {
    onLoadRef.current();      // ✅ 引用稳定 + 读最新
  }, []);                     // 依赖为空
}

六、exhaustive-deps 的正确态度

// eslint.config.js
{
  "rules": {
    "react-hooks/exhaustive-deps": "warn"    // 至少是 warn
  }
}
✅ 正确态度:
① 警告几乎总是对的——它是「闭包陷阱」的自动检测
② 看到警告不要立刻 disable,先分析:
   · 漏了真正需要的依赖 → 补上
   · 依赖会导致无限循环 → 说明依赖里有不稳定对象 → 修它
   · 依赖会导致副作用频繁重建 → 用 useRef 保存最新值
   · 这段逻辑本就不该在 Effect 里 → 移出去
③ 确实需要 disable 时,写清楚原因:
   // eslint-disable-next-line react-hooks/exhaustive-deps -- 仅需在挂载时执行一次,xxx 用 ref 读取
// ❌ 典型的「用 disable 掩盖问题」
useEffect(() => {
  setCount(count + 1);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);                        // 逻辑依然是错的

// ✅ 正确:用函数式更新
useEffect(() => {
  setCount((c) => c + 1);
}, []);

七、依赖数组的常见「修复」验证清单

加完依赖后,用这三个问题自测:

① 「依赖变了,这个副作用真的应该重跑吗?」
   ❌ 不应该 → 说明依赖写多了,或者逻辑放错地方了

② 「依赖没变,副作用会不会漏掉必要的更新?」
   ❌ 会 → 说明漏依赖了

③ 「加完依赖后会不会死循环 / 频繁重跑?」
   ✅ 会 → 依赖里有「每次渲染都新建的值」→ 需要稳定它
// 一个完整的排查示例
function Search({ query, onResults }: Props) {
  const [results, setResults] = useState([]);

  // ❌ 问题版本
  useEffect(() => {
    const filtered = filterData(query);
    setResults(filtered);
    onResults(filtered);              // ⚠️ onResults 是用到的外部函数
  }, [query]);                        // ⚠️ 漏了 onResults

  // ✅ 修复版本(假设 onResults 上游用 useCallback 稳定了)
  useEffect(() => {
    const filtered = filterData(query);
    setResults(filtered);
    onResults(filtered);
  }, [query, onResults]);

  // ✅ 如果 onResults 上游无法稳定 → 用 ref
  const onResultsRef = useRef(onResults);
  useEffect(() => { onResultsRef.current = onResults; });

  useEffect(() => {
    const filtered = filterData(query);
    setResults(filtered);
    onResultsRef.current(filtered);
  }, [query]);
}

八、常见坑

// ① 依赖数组写成「值而不是数组」
useEffect(() => {}, dep);          // ❌ 应该是 [dep](传非数组会每次都执行)

// ② 依赖数组长度变化
useEffect(() => {}, cond ? [a] : [a, b]);   // ⚠️ 长度不稳定(React 会警告)

// ③ 依赖了「未定义的变量」(会报错)
useEffect(() => {}, [undefinedVar]);        // ❌

// ④ 依赖了「ref.current」(不该依赖它)
useEffect(() => {}, [ref.current]);         // ⚠️ ref 变化不触发渲染,依赖它没意义
// ✅ 依赖 ref 对象本身(或者不要依赖)

// ⑤ 在依赖里写「函数调用」
useEffect(() => {}, [getValue()]);          // ❌ 每次渲染都执行 getValue()

// ⑥ 依赖了「setState / dispatch」
useEffect(() => {}, [setCount]);            // ⚠️ 它们稳定,写了无害但多余

// ⑦ 依赖了「每次渲染新建的数组」
useEffect(() => {}, [items.map((i) => i.id)]);   // ❌ 每次都是新数组

// ⑧ 认为「依赖数组是性能优化手段」
// → 它的作用是「语义」(什么时候重新同步),不是优化

// ⑨ 为了让 Effect「只跑一次」而删依赖
// → 制造闭包陷阱

// ⑩ 在 useMemo 的依赖里漏了「在计算中读到的 props」
const v = useMemo(() => a + b, [a]);        // ❌ 漏了 b

// ⑪ 加了 useCallback 但依赖里放了「新对象」→ 引用还是不稳定
const fn = useCallback(() => f({ a }), [{ a }]);   // ❌

// ⑫ 认为「加 useMemo 就能让它稳定」(但依赖不稳定时无效)

九、面试延伸

  1. 「useEffect 的依赖数组有什么用?」
    它的作用是语义而非性能:告诉 React「这个副作用依赖哪些值」,从而在依赖变化时重新同步(先执行上一次的清理,再执行新的)。三种写法分别是「不写(每次渲染都执行)」「[](只在挂载时执行)」「[deps](依赖变化时重新执行)」。它不是「优化开关」——写 [] 不会让代码更快,只会改变「什么时候执行」。

  2. 「漏写依赖会怎样?」
    会产生闭包陷阱(stale closure):副作用捕获的是「某次渲染的变量快照」,后续变化不会反映进去。典型表现是「setInterval 里读到的永远是初始值」「切换 id 后不重新请求」「事件回调里用的是旧 state」。eslint-plugin-react-hooks 的 exhaustive-deps 规则就是用来检测这类问题的,它的警告几乎总是对的。

  3. 「为什么加了依赖会导致无限循环?」
    因为依赖里出现了「每次渲染都新建的值」——对象/数组字面量、内联箭头函数、useSelector 返回的新对象。Effect 执行 → 修改 state → 重新渲染 → 那个值又是新引用 → React 认为依赖变了 → 再执行 → 死循环。解法:① 把常量提到组件外;② 依赖具体字段(user.id 而不是 user);③ 用 useMemo/useCallback 稳定引用;④ 把函数移到 Effect 内部。

  4. 「依赖了整个对象,但只用到一个字段,该怎么处理?」
    有三种做法:① 依赖那个字段([user.name])——最推荐,最精确;② 如果上游能保证对象引用稳定,依赖整个对象也没问题;③ 如果对象经常新建又必须传整个对象,可以让上游用 useMemo 稳定它。核心原则是「依赖的粒度越细越好」——依赖越细,副作用被不必要地重建的概率越低。

  5. 「函数依赖怎么处理?」
    三种方式:① 函数只在 Effect 里用 → 把定义移进 Effect 内部(推荐,依赖就变成函数用到的值);② 函数需要在多处复用 → 用 useCallback 稳定它,然后把它列进依赖;③ 函数来自 props 且上游无法稳定 → 用 useRef 保存最新值(useEffect(() => { ref.current = fn; })),Effect 依赖写 [],调用时读 ref.current。

  6. 「exhaustive-deps 的警告要不要理?」
    要理。它检测的正是「漏依赖 → 闭包陷阱」这类 bug,警告几乎总是对的。看到警告应该按这个顺序处理:① 漏了真正需要的依赖 → 补上;② 加上会导致无限循环 → 说明依赖里有不稳定对象 → 先修它;③ 加上会导致副作用频繁重建(性能问题) → 改用 useRef 保存最新值;④ 这段逻辑本就不该在 Effect 里(派生数据、事件响应)→ 移出去。只有在确实需要「只执行一次」时才 disable,并且要写清原因。

  7. 「为什么 ref 和 setState 不需要写进依赖?」
    因为它们引用稳定:useRef 返回的对象在整个组件生命周期内是同一个引用,setState 与 dispatch 也由 React 保证引用不变。所以把它们写进依赖数组是无害的(但多余)。另外 ref.current 也不应该写进依赖——因为修改 ref.current 不会触发渲染,把它作为依赖不会产生任何有意义的行为(exhaustive-deps 也会提示你「ref.current 通常不应该在依赖里」)。

  8. 「依赖数组的设计哲学是什么?」
    核心是「让 Effect 与它所依赖的数据保持一致的同步语义」:Effect 应该能正确反映「任何一次渲染中它看到的状态」——也就是说,如果某个值变化了而 Effect 没有重新执行,那么 Effect 里的逻辑就是过期的。从这个角度看,依赖数组不是「性能调优」而是「正确性保证」:写全依赖保证正确,再通过「稳定引用 / 用 ref 读最新值 / 用函数式更新」来优化执行频率,而不是反过来「先删依赖、再去修 bug」。

一句话速记

依赖数组是语义不是优化:列全副作用里用到的每一个响应式值(props、state、组件内声明的变量与函数),setState/dispatch/ref 对象本身不需要写、ref.current 不该写;三类错误是「漏依赖(闭包陷阱)」「多依赖(不必要的重跑)」「不稳定依赖(完全失效)」;修不稳依赖的四招是「提到组件外 → 依赖具体字段 → useMemo/useCallback 稳定 → 函数移进 Effect」;exhaustive-deps 的警告几乎总是对的,不要靠 disable 掩盖,实在需要就写清原因。

赞(0) 打赏
未经允许不得转载:陌上寒 » 2.14 依赖数组的常见错误

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫