技术0 阅读

为什么 React Hooks 写着写着就乱了?

很多人刚上手 React Hooks 时,都会有一个类似的心路历程:一开始觉得真香,函数组件加 useState/useEffect,写着比 class 清爽多了。

过几个月回头看以前的代码,发现一堆奇怪的问题——

这篇我们先不讲任何高深原理,就围绕三个问题来拆解:

  1. 为什么「什么都往 useEffect 里塞」会让组件越来越难维护?

  2. 为什么「能算出来的值」一旦进了 state,逻辑就会开始扭曲?

  3. 为什么闭包在 Hooks 里格外容易坑到你?

先把这些坏味道认清楚,后面几篇再分别从 useEffect 边界、闭包实战、计算属性等角度展开。

一、一个典型的「能跑但很难维护」组件长什么样?

先看一段「很多项目里都长得差不多」的代码:

tsx
function ProductList({ initialKeyword }: { initialKeyword: string }) {
  const [keyword, setKeyword] = useState(initialKeyword);
  const [items, setItems] = useState<Product[]>([]);
  const [filtered, setFiltered] = useState<Product[]>([]);
  const [total, setTotal] = useState(0);
  const [loading, setLoading] = useState(false);

  // 初始化拉数据
  useEffect(() => {
    setLoading(true);
    fetch(`/api/products?keyword=${keyword}`)
      .then(res => res.json())
      .then(data => {
        setItems(data.items);
        setTotal(data.total);
      })
      .finally(() => setLoading(false));
  }, [keyword]);

  // 根据 keyword 过滤本地列表(有点多余,但真实项目里经常这样写)
  useEffect(() => {
    const next = items.filter(item =>
      item.name.toLowerCase().includes(keyword.toLowerCase())
    );
    setFiltered(next);
  }, [keyword, items]);

  // 当 filtered 变化时更新总数
  useEffect(() => {
    setTotal(filtered.length);
  }, [filtered]);

  // 监听 initialKeyword 变化(比如父组件路由切换)
  useEffect(() => {
    setKeyword(initialKeyword);
  }, [initialKeyword]);

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      <input
        value={keyword}
        onChange={e => setKeyword(e.target.value)}
      />
      <div>共 {total} 个商品</div>
      <ul>
        {filtered.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

这段代码有几个典型特征:

你可能会说:能跑啊,看起来也还行,有什么问题?

问题在于:这段代码里,很多状态根本不应该存在,很多 effect 也不应该存在。

二、状态一多,逻辑就一定复杂吗?

我们从数据的角度,重新梳理一下这个组件「真正需要知道」的东西:

  1. 用户当前的关键词 keyword。

  2. 后端返回的原始商品列表 items。

那么:

也就是说:

filtered 和 total 都是标准的「派生状态」(derived state),本质是计算属性,而不是原始状态。

一旦你把这些可推导的值也塞进 state:

React 官方文档里反复强调: 当某个值可以由 props 或 state 计算出来时,不要再为它单独维护一份 state。

现在回头看这段代码,你会发现:

相当于:本来一条公式可以解决的事,被硬生生拆成了三份状态 + 两个副作用。

三、useEffect 被当成「逻辑垃圾桶」之后会怎样?

上面那个组件还有另一个问题: 几乎所有逻辑都被「拆碎」塞进了 useEffect:

问题在于,useEffect 本来的定位是:

用来把 React 的状态,和某个「外部系统」同步,比如请求、订阅、DOM 操作。

而过滤和统计,都是纯计算,完全可以在 render 期间完成:

把纯计算塞进 effect 之后,会带来这些额外成本:

  1. 性能上:

    • 你每次更新 filtered/total,都经历「渲染 → 跑 effect → setState → 再渲染」两轮流程。

  2. 心智上:

    • 逻辑被拆散在多个 effect 里,改某个行为需要到处翻。

    • 很难一眼看出「这个 UI 状态到底是怎么算出来的」。

  3. bug 风险:

    • 某次重构时,没注意依赖数组里漏了一个变量,effect 不再按预期更新;

    • 或者为了解决「无限循环」随意删依赖,靠感觉调到「不报错就行」。

久而久之,这个组件就从「函数 + Hooks」退化成了「函数写的 class 生命周期」:

四、闭包为什么在 Hooks 里特别容易坑你?

再看一个简化版的例子:

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log('search with', keyword);
      // fetch(`/api/search?keyword=${keyword}`)
    }, 5000);

    return () => clearInterval(id);
  }, []); // 注意:依赖数组是 []

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

问题非常典型:

这就是经典的「闭包 + 不老实的依赖数组」问题:

之所以在 Hooks 时代这个问题格外普遍,是因为:

更糟的是:

结果就是:

代码表面上「很安静」,没有 warning,没有报错,但行为是错的。

这类问题调试起来特别浪费时间——console.log 也能看到「值是对的」, 可是 effect 里的回调拿到的就是旧值。

五、从这个例子里我们真正应该学到什么?

回到我们一开始的 ProductList 示例,如果我们顺着 React 官方推荐的几个原则重写一版,会是这样:

  1. 只为「最小不可推导」的东西建 state:keyword、items、loading 等。

  2. 所有可推导值(filtered、total)都通过计算属性实现,而不是再加一层 state + effect。

  3. useEffect 只做「拉数据」这种和外部系统打交道的事。

一个更干净的版本是:

tsx
function ProductList({ initialKeyword }: { initialKeyword: string }) {
  const [keyword, setKeyword] = useState(initialKeyword);
  const [items, setItems] = useState<Product[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);

    fetch(`/api/products?keyword=${keyword}`)
      .then(res => res.json())
      .then(data => {
        if (cancelled) return;
        setItems(data.items);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

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

  const filtered = useMemo(
    () =>
      items.filter(item =>
        item.name.toLowerCase().includes(keyword.toLowerCase())
      ),
    [items, keyword]
  );

  const total = filtered.length;

  useEffect(() => {
    setKeyword(initialKeyword);
  }, [initialKeyword]);

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      <input
        value={keyword}
        onChange={e => setKeyword(e.target.value)}
      />
      <div>共 {total} 个商品</div>
      <ul>
        {filtered.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

可以看到:

你甚至可以继续优化:如果 initialKeyword 只在挂载时用一次,而不会后续变化,可以通过路由 key 或父组件控制,彻底删掉第二个 effect。

六、这一篇,你可以带走的「代码审查清单」

第 1 篇我们不急着讲具体技巧,只先种下几个判断标准。 你可以直接拿去 review 自己项目里的 Hooks 代码:

  1. 这个组件里有多少个 state?每一个 state 是否「最小不可推导」?

    • 如果某个字段完全可以通过别的 state/props 算出来,那它就是冗余的。

  2. 这个组件里有多少个 useEffect?

    • 它们是不是在做和「外部世界」的同步?

    • 有没有纯计算逻辑被塞进 effect 里?

  3. 有没有 useEffect 的依赖数组明显「少写」了东西,只是为了躲开 ESLint 提示?

  4. 有没有那种「状态之间相互驱动」的链条:A 改变触发 effect 改 B,B 改变触发 effect 改 C?

    • 这种链式更新往往意味着:B、C 其实是可以算出来的。

  5. 有没有「打印出来是旧值」的疑难杂症?

    • 这类问题基本都和闭包 + 不完整依赖数组有关。

SHARE

分享

分享这篇文章。