React Hooks实战中那些你必须避开的常见陷阱

南宫晓萌 框架 阅读 1,223
赞 24 收藏
二维码
手机扫码查看
反馈

核心代码就这几行,但踩坑花了我两天

先上最常用、也最容易翻车的场景:在组件里封装一个「防抖搜索框」。不是那种教科书式 debounce + useEffect 的玩具 demo,是真实项目里要对接后端、带 loading、支持取消、还要能清空输入并重置状态的版本。

React Hooks实战中那些你必须避开的常见陷阱

亲测有效、上线跑了三个月没出问题的写法如下:

import { useState, useEffect, useRef } from 'react';

function SearchInput({ onSearch }) {
  const [value, setValue] = useState('');
  const [loading, setLoading] = useState(false);
  const timeoutRef = useRef(null);

  // 清除上一次定时器(关键!)
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);

  const handleSearch = () => {
    if (!value.trim()) return;

    setLoading(true);
    // 这里注意:我踩过好几次坑,ref 要用 current,不能直接赋值给 timeoutRef
    timeoutRef.current = setTimeout(async () => {
      try {
        const res = await fetch(https://jztheme.com/api/search?q=${encodeURIComponent(value)});
        const data = await res.json();
        onSearch(data);
      } catch (err) {
        console.error('搜索失败', err);
      } finally {
        setLoading(false);
      }
    }, 300);
  };

  // 输入变化时重置防抖
  useEffect(() => {
    if (value === '') {
      // 清空时立刻取消请求
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
        timeoutRef.current = null;
      }
      onSearch([]); // 传空数组表示清空结果
      return;
    }

    // 防抖逻辑:每次输入都重置定时器
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    timeoutRef.current = setTimeout(handleSearch, 300);
  }, [value, onSearch]);

  return (
    <div>
      <input
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="搜索..."
      />
      {loading && <span>加载中...</span>}
    </div>
  );
}

export default SearchInput;

这个场景最好用:表单联动 + 异步校验

比如「用户名输入后实时检查是否已被占用」——你不能等用户点提交才校验,也不能每敲一个字就发请求。得控制频率、避免竞态、还要及时更新 UI 状态。

我一开始用 useEffect + setState 嵌套搞,结果出现「校验结果和当前输入不匹配」的问题:用户快速输入 abcde,但返回的是 b 的校验结果,UI 显示“b 已被占用”,而 input 里已经是 e 了。折腾了半天发现是没处理竞态请求。

解决方案:用 useRef 记录当前最新输入值,在响应返回时比对是否仍是“最新”:

function UsernameField() {
  const [username, setUsername] = useState('');
  const [status, setStatus] = useState('idle'); // idle / checking / available / taken
  const latestUsernameRef = useRef(username);

  useEffect(() => {
    latestUsernameRef.current = username;
  }, [username]);

  useEffect(() => {
    if (!username.trim()) {
      setStatus('idle');
      return;
    }

    setStatus('checking');
    const controller = new AbortController();

    fetch(https://jztheme.com/api/check-username?name=${username}, {
      signal: controller.signal,
    })
      .then(res => res.json())
      .then(data => {
        // 关键判断:只更新“当前最新输入”的状态
        if (latestUsernameRef.current === username) {
          setStatus(data.available ? 'available' : 'taken');
        }
      })
      .catch(err => {
        if (err.name !== 'AbortError' && latestUsernameRef.current === username) {
          setStatus('idle');
        }
      });

    return () => controller.abort();
  }, [username]);

  return (
    <div>
      <input value={username} onChange={e => setUsername(e.target.value)} />
      <span>{status === 'checking' && '检查中...'}
            {status === 'available' && '✅ 可用'}
            {status === 'taken' && '❌ 已被占用'}</span>
    </div>
  );
}

踩坑提醒:这三点一定注意

  • useEffect 里不要直接依赖函数:比如把 onSearch 写进依赖数组却不加 useCallback,会导致无限触发。要么用 useCallback 包一层,要么把 onSearch 放到 ref 里缓存(更稳妥)。
  • 不要在 useEffect 里直接 setState 后立刻读 state:React 的 setState 是异步批处理的,你 set 完马上 console.log(state),大概率还是旧值。需要新值?用回调函数或 useRef 缓存。
  • useRef 不会触发重新渲染,但它的 current 是可变的:很多人以为 useRef 就是“不会变的常量”,其实 current 字段随时能被改。它只是不引起 rerender,这点特别容易混淆。

高级技巧:自定义 Hook 抽离通用逻辑

上面两个例子其实可以抽象成一个 useDebouncedFetch。但我不建议一上来就写太“通用”的 Hook —— 项目初期需求变来变去,写得太抽象反而难维护。我现在的做法是:先 copy-paste 复用代码,等重复出现 3 次以上,再抽。

抽出来之后长这样(已用于生产):

function useDebouncedFetch(urlTemplate, options = {}) {
  const { delay = 300, method = 'GET', signal } = options;
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const timeoutRef = useRef(null);
  const urlRef = useRef('');

  const trigger = useCallback((params) => {
    const url = urlTemplate.replace(/{(w+)}/g, (_, key) => params[key] || '');
    urlRef.current = url;

    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    setLoading(true);
    setError(null);

    timeoutRef.current = setTimeout(() => {
      fetch(url, { method, signal })
        .then(r => r.json())
        .then(d => {
          if (urlRef.current === url) setData(d);
        })
        .catch(e => {
          if (urlRef.current === url) setError(e);
        })
        .finally(() => {
          if (urlRef.current === url) setLoading(false);
        });
    }, delay);
  }, [urlTemplate, delay, method, signal]);

  useEffect(() => {
    return () => {
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
    };
  }, []);

  return { data, loading, error, trigger };
}

// 使用
function UserSearch() {
  const { data, loading, error, trigger } = useDebouncedFetch(
    'https://jztheme.com/api/users?q={query}',
    { delay: 400 }
  );

  return (
    <div>
      <input onChange={e => trigger({ query: e.target.value })} />
      {loading && '搜索中...'}
      {data?.length > 0 && <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>}
    </div>
  );
}

结尾说点实在的

以上是我过去半年在几个中大型 React 项目里反复打磨出来的 Hooks 实践。没有银弹,也没有“绝对最优解”——比如有些团队坚持所有 effect 都必须加 exhaustive-deps lint 规则,我们试了两周,最后妥协:允许手动 disable 一行,只要注释清楚原因。毕竟人不是机器,代码是给人看的,也是给机器跑的,但最终服务的是业务和时间表。

这个技术的拓展用法还有很多,比如结合 Suspense 做数据预取、用 useTransition 控制高优先级交互、甚至配合 Web Worker 做复杂计算卸载……后续会继续分享这类博客。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流 —— 尤其是那些我没踩过、但你已经趟过的坑,求分享!

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论