技术0 阅读

闭包陷阱与 useCallback: 为什么你总是拿到旧值?

如果说前一篇解决的是「哪些逻辑不该放进 useEffect」,那这一篇要解决的,就是那些留下来的 effect 和回调,为什么还总是出现一些古怪行为。

你大概见过这些场景:

这些问题表面上像是 React 的坑,实际上,它们是 JavaScript 闭包和 React 按渲染快照工作这两件事,叠加在一起的结果。

一、先把问题说透:什么叫“旧值”?

先看一个最经典的例子:

tsx
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log('count:', count);
    }, 2000);

    return () => clearInterval(id);
  }, []);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

很多人第一次写到这种代码时,直觉会觉得:

但实际情况是:

这就是所谓的 stale closure,也就是“陈旧闭包”问题。

要理解它,有一个非常关键的心智模型:

React 函数组件的每一次渲染,都会产生一套新的 props、state 和函数;你在这次渲染里定义的 effect、回调、事件处理器,拿到的是这一版渲染的快照。

所以不是 React “没更新”,而是你创建出来的那个函数,本来就活在旧的那次渲染里。

二、为什么依赖数组会和闭包扯上关系?

依赖数组的本质,不是“优化项”,而是在告诉 React:

TkDodo 对这个问题讲得很透:Hooks 中的依赖关系,本质上就是在管理闭包什么时候该更新。

还是上面那个例子,如果你把 count 写进依赖:

tsx
useEffect(() => {
  const id = setInterval(() => {
    console.log('count:', count);
  }, 2000);

  return () => clearInterval(id);
}, [count]);

这时候行为就变了:

所以结论不是“依赖数组决定何时执行”,而更准确地说: > 依赖数组决定 React 什么时候应该丢弃旧闭包,换成一份新的闭包。

这也是为什么漏依赖会出 bug。不是因为 React 在惩罚你,而是因为你明明用了一个值,却没告诉 React 这份逻辑应该随着它一起更新。

三、闭包陷阱最常见的 4 种场景

1. 定时器 / 轮询

这个最典型,前面已经看过。 问题根源:定时器回调不会自动“连到最新 state”,它拿的是定义它时的闭包。

错误写法:

tsx
useEffect(() => {
  const id = setInterval(() => {
    fetchData(query);
  }, 5000);

  return () => clearInterval(id);
}, []);

如果 query 会变化,这里大概率就是 bug。

常见解法有两类:

解法 A:老实写依赖

tsx
useEffect(() => {
  const id = setInterval(() => {
    fetchData(query);
  }, 5000);

  return () => clearInterval(id);
}, [query]);

优点:

缺点:

解法 B:用 ref 保存最新值

tsx
const queryRef = useRef(query);

useEffect(() => {
  queryRef.current = query;
}, [query]);

useEffect(() => {
  const id = setInterval(() => {
    fetchData(queryRef.current);
  }, 5000);

  return () => clearInterval(id);
}, []);

这个模式的本质是:

什么时候用 A,什么时候用 B?

2. 异步请求里的旧参数

再看一个更接近业务的例子:

tsx
function SearchBox() {
  const [keyword, setKeyword] = useState('');

  useEffect(() => {
    async function search() {
      const res = await fetch(`/api/search?q=${keyword}`);
      const data = await res.json();
      console.log(data);
    }

    search();
  }, []);
}

如果这里依赖数组是空的,那这个请求永远只会用初始 keyword。 这不是“偶发 bug”,而是确定性错误。

正确写法通常是:

tsx
useEffect(() => {
  let ignore = false;

  async function search() {
    const res = await fetch(`/api/search?q=${keyword}`);
    const data = await res.json();
    if (!ignore) {
      console.log(data);
    }
  }

  search();

  return () => {
    ignore = true;
  };
}, [keyword]);

这里顺便处理了另一个问题:竞态。 React 官方文档明确提醒过,effect 里的异步请求除了依赖要正确,还要防止旧请求在新请求之后返回,导致结果倒灌。

3. 事件回调里用旧 state

一个非常隐蔽但常见的坑:

tsx
const handleAdd = useCallback(() => {
  setCount(count + 1);
}, []);

看起来像在“优化”函数引用,实际上逻辑已经坏了:

这也是为什么很多文章强调:

useCallback 不是用来“让逻辑正确”的,而只是让函数引用更稳定;如果逻辑本身依赖没写对,它只会把错误稳定下来。

这里正确的改法有两种。

改法 A:把依赖写全

tsx
const handleAdd = useCallback(() => {
  setCount(count + 1);
}, [count]);

改法 B:如果是基于旧 state 计算新 state,用函数式更新

tsx
const handleAdd = useCallback(() => {
  setCount(prev => prev + 1);
}, []);

第二种通常更好,因为它不再直接依赖 count,也就不容易产生旧值问题。

这条经验特别重要:

只要你的下一个 state 依赖上一个 state,优先用函数式更新,而不是闭包里直接读当前 state。

4. 给子组件传回调时“优化过头”

很多人学了 React.memouseCallback 之后,容易进入一个阶段: 看到函数就包一层 useCallback,好像不包就不专业。

比如:

tsx
const handleSelect = useCallback((id: string) => {
  onChange(id, filters);
}, []);

这段代码的问题不是 useCallback 本身,而是它闭包住了旧的 filtersonChange。 如果这两个值会变,那这个 callback 就是错的。

正确写法应该是:

tsx
const handleSelect = useCallback((id: string) => {
  onChange(id, filters);
}, [onChange, filters]);

这时候又有人会说: “那不是每次还是会变吗?包 useCallback 有什么意义?”

答案是:如果依赖本来就总变,那 useCallback 可能本来就没意义。

这是 useCallback 最容易被误解的一点:

所以别把它当护身符。

四、useCallback 到底是干什么的?

先说最准确的一句话:

useCallback 用来缓存函数引用,不是用来修复闭包,也不是默认的性能优化按钮。

它主要有两个典型用途。

用途 1:配合 memoized 子组件,避免无意义重渲染

tsx
const Item = React.memo(function Item({
  onSelect,
  item,
}: {
  onSelect: (id: string) => void;
  item: Item;
}) {
  return <button onClick={() => onSelect(item.id)}>{item.name}</button>;
});

function List({ items }: { items: Item[] }) {
  const handleSelect = useCallback((id: string) => {
    console.log(id);
  }, []);

  return items.map(item => (
    <Item key={item.id} item={item} onSelect={handleSelect} />
  ));
}

这里 useCallback 的意义是:

用途 2:作为别的 Hook 的依赖,避免无意义重跑

比如:

tsx
const fetchUser = useCallback(async () => {
  const res = await api.getUser(userId);
  setUser(res);
}, [userId]);

useEffect(() => {
  fetchUser();
}, [fetchUser]);

这时 fetchUser 作为 effect 的依赖,useCallback 让它只在 userId 变时才变化。

但要注意一个更值得推荐的写法:

tsx
useEffect(() => {
  async function fetchUser() {
    const res = await api.getUser(userId);
    setUser(res);
  }

  fetchUser();
}, [userId]);

如果函数只在 effect 里用,把它放进 effect 通常更简单,也更不容易纠结依赖。

五、什么时候不该用 useCallback?

这是最实用的一节。

很多团队的问题不是“不会用 useCallback”,而是“用太多了”。

下面这些场景,通常都不值得包:

1. 函数没有传给 memo 子组件,也没有作为依赖

tsx
const handleClick = () => {
  setOpen(true);
};

这种函数完全没必要包 useCallback

因为:

2. 包了之后依赖一大串,还是每次都变

tsx
const handleSave = useCallback(() => {
  submit(form, user, settings, permissions, locale);
}, [form, user, settings, permissions, locale]);

如果依赖本来每次都在变,这个 callback 基本等于没缓存。 这时候真正该做的,往往不是继续包,而是重新拆状态、瘦身依赖或重构组件边界。

3. 把 useCallback 当成“解决闭包”的默认手段

这是最大误区。

useCallback 不会自动拿到最新值; 它只是帮你缓存一份函数,而这份函数照样有闭包。 如果依赖写错,useCallback 只会把错误缓存得更久。

所以一个非常重要的判断是:

代码如果“不用 useCallback 就会错”,那通常不是说明你该加 useCallback,而是说明你本来的数据流就有问题。

六、遇到闭包问题时,优先按这个顺序排查

这里给你一套非常适合 code review 和日常排错的顺序。

第一步:先怀疑依赖数组是不是少写了

只要 effect、memo、callback 里用到了响应式值,就先检查依赖是不是完整。

不要第一反应就是:

大多数时候,先把依赖写对,问题就消失了。

第二步:如果只是基于旧 state 更新新 state,改成函数式更新

比如:

tsx
setTodos(prev => [...prev, newTodo]);
setCount(prev => prev + 1);

这种写法可以显著减少你对闭包中当前 state 的依赖。

第三步:如果外部订阅不想重建,用 ref 读最新值

这是处理“定时器 / 监听器 / 第三方订阅”类闭包问题的通用招式。

但注意,ref 是工具,不是逃生通道。 如果只是普通 effect,本来就应该随着依赖更新,那还是优先写依赖。

第四步:只在真的有收益时再上 useCallback

判断标准可以很简单:

如果都没有,那大概率不用。

七、一个完整重构例子:从“旧值地狱”到可维护写法

先看坏代码:

tsx
function SearchPanel({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('');

  const handleSearch = useCallback(() => {
    onSearch(query);
  }, []);

  useEffect(() => {
    const id = setInterval(() => {
      handleSearch();
    }, 5000);

    return () => clearInterval(id);
  }, []);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  );
}

这里有两个 stale closure:

一种直接但会频繁重建的修法:

tsx
function SearchPanel({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('');

  const handleSearch = useCallback(() => {
    onSearch(query);
  }, [onSearch, query]);

  useEffect(() => {
    const id = setInterval(() => {
      handleSearch();
    }, 5000);

    return () => clearInterval(id);
  }, [handleSearch]);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  );
}

这是正确的,但每次 query 变化都要重建 interval。

如果业务上希望“定时器只注册一次,但每次都用最新 query”,那就换成 ref 模式:

tsx
function SearchPanel({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('');
  const queryRef = useRef(query);
  const onSearchRef = useRef(onSearch);

  useEffect(() => {
    queryRef.current = query;
  }, [query]);

  useEffect(() => {
    onSearchRef.current = onSearch;
  }, [onSearch]);

  useEffect(() => {
    const id = setInterval(() => {
      onSearchRef.current(queryRef.current);
    }, 5000);

    return () => clearInterval(id);
  }, []);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  );
}

这类代码的关键,不是“高级”,而是你要先搞清楚目标:

这两个目标不一样,代码就该不一样。

八、这一篇可以带走的 6 条结论

  1. React 里的“旧值”问题,本质上几乎都是闭包问题,不是 React 神秘失灵。

  2. 依赖数组的作用,是告诉 React 什么时候该放弃旧闭包,创建新闭包。

  3. 只要下一次 state 依赖上一次 state,优先用函数式更新。

  4. 外部订阅、定时器这类“不想频繁重建”的场景,可以用 ref 存最新值。

  5. useCallback 只缓存函数引用,不会自动帮你拿最新值,也不会修复错误逻辑。

  6. 只有当函数稳定引用真的有价值时,再上 useCallback;否则它只是给代码增加噪音。

SHARE

分享

分享这篇文章。