技術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

分享

分享這篇文章。