HeyBinyang

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

·技术·visibility0 阅读 ·开发·React

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

你大概见过这些场景:

  • 定时器里打印出来的 state 永远是旧的。

  • 请求回调里拿到的参数不是当前输入框的值。

  • 给子组件传了个函数,结果为了“优化”加上 useCallback 之后,逻辑反而坏了。

  • ESLint 提示依赖没写全,你一补上就疯狂重跑;你一删掉,又开始出现诡异 bug。

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

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

先看一个最经典的例子:

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>
  );
}

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

  • count 变了,定时器里应该也能看到最新的 count。

但实际情况是:

  • effect 只在初次渲染后执行一次。

  • setInterval 里的回调闭包住的是第一次渲染时的 count

  • 所以后面不管你点多少次按钮,控制台里看到的都还是 0。

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

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

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

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

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

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

  • 这个 effect / memo / callback 用到了哪些响应式值;

  • 当这些值变化时,你需要重新创建这段逻辑。

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

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

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

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

这时候行为就变了:

  • 每次 count 变化,effect 都会先清理旧定时器,再创建一个新定时器。

  • 新定时器闭包住的是新的 count。

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

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

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

1. 定时器 / 轮询

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

错误写法:

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

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

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

常见解法有两类:

解法 A:老实写依赖

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

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

优点:

  • 最直观。

  • 没有隐藏状态。

缺点:

  • query 每变一次,定时器都会被重建一次。

  • 如果你不希望外部订阅频繁重建,这个做法就不合适。

解法 B:用 ref 保存最新值

const queryRef = useRef(query);

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

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

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

这个模式的本质是:

  • 定时器只注册一次;

  • 但它读取的数据来自一个始终更新的 ref,而不是旧闭包。

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

  • 如果允许重建,优先用依赖数组,简单直接。

  • 如果不希望重建外部订阅,再考虑 ref 保存最新值。

2. 异步请求里的旧参数

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

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”,而是确定性错误。

正确写法通常是:

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

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

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

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

  • 依赖数组是空的,所以这个 callback 永远是第一次渲染时那一版。

  • 它里的 count 永远是初始值。

  • 每次点按钮都在执行 setCount(0 + 1)

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

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

这里正确的改法有两种。

改法 A:把依赖写全

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

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

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

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

这条经验特别重要:

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

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

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

比如:

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

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

正确写法应该是:

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

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

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

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

  • 它不是“让函数不变”的魔法。

  • 它只是“当依赖不变时,复用旧函数;依赖变了,就给你新函数”。

所以别把它当护身符。

四、useCallback 到底是干什么的?

先说最准确的一句话:

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

它主要有两个典型用途。

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

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 的意义是:

  • 如果父组件每次 render 都创建新函数,React.memo 子组件可能仍然会因为 props 引用变了而重新渲染。

  • 把函数引用稳定下来后,memo 才更容易发挥作用。

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

比如:

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

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

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

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

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

  fetchUser();
}, [userId]);

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

五、什么时候不该用 useCallback?

这是最实用的一节。

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

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

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

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

这种函数完全没必要包 useCallback

因为:

  • 它不参与性能瓶颈。

  • 它不作为依赖。

  • 包了只会增加认知负担。

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

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 里用到了响应式值,就先检查依赖是不是完整。

不要第一反应就是:

  • “这是不是 React 的 bug?”

  • “是不是 useCallback 不稳定?”

  • “是不是异步有问题?”

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

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

比如:

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

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

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

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

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

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

判断标准可以很简单:

  • 有没有配合 React.memo

  • 有没有作为别的 Hook 依赖?

  • 有没有实测性能问题?

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

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

先看坏代码:

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:

  • handleSearch 永远拿的是初始 query。

  • 定时器 effect 永远拿的是第一次的 handleSearch。

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

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 模式:

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;否则它只是给代码增加噪音。

分享