前端开发··2 阅读·预计 17 分钟

useEffect 的陷阱与救赎:一个资深 React 开发者的踩坑实录

用了三年 React,我才真正理解 useEffect。

如果你写过 React,一定用过 useEffect。这个看似简单的 Hook,却是新手最容易踩坑、老手也时常翻车的重灾区。今天,我想结合自己三年来的实战经验,聊聊 useEffect 的那些"坑",以及如何优雅地避开它们。

一、依赖数组:你以为的依赖,真的是依赖吗?

经典陷阱:闭包问题

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

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // 永远是 0
      setCount(count + 1); // 永远变成 1
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 空依赖数组是罪魁祸首

  return <div>{count}</div>;
}

这个问题困扰了无数 React 开发者。为什么 count 永远是 0?因为 useEffect 的回调在组件第一次渲染时创建,它"记住"了那个时刻的 count 值。即使 count 后续变化,定时器里的 count 依然是旧的。

解决方案

方案 1:使用函数式更新

useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1); // ✅ 使用函数式更新
  }, 1000);
  return () => clearInterval(timer);
}, []);

方案 2:使用 ref(需要读取最新值但不触发重渲染时)

function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  
  useEffect(() => {
    countRef.current = count;
  });

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(countRef.current); // ✅ 永远是最新值
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  return <div>{count}</div>;
}

二、依赖数组的"过度诚实"问题

有时候,ESLint 会提示你添加依赖,但添加后却导致无限循环。

场景:对象作为依赖

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const config = { apiKey: 'xxx', timeout: 5000 }; // 每次渲染都创建新对象

  useEffect(() => {
    fetchUser(userId, config).then(setUser);
  }, [userId, config]); // ❌ config 每次都变,导致无限请求

  return <div>{user?.name}</div>;
}

解决方案

方案 1:将对象移到组件外

const CONFIG = { apiKey: 'xxx', timeout: 5000 }; // 组件外定义

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId, CONFIG).then(setUser);
  }, [userId]); // ✅ 只有 userId 变化时才请求

  return <div>{user?.name}</div>;
}

方案 2:使用 useMemo

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const config = useMemo(() => ({ apiKey: 'xxx', timeout: 5000 }), []);

  useEffect(() => {
    fetchUser(userId, config).then(setUser);
  }, [userId, config]); // ✅ config 引用稳定

  return <div>{user?.name}</div>;
}

方案 3:使用 useRef(如果确实不需要响应式)

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const configRef = useRef({ apiKey: 'xxx', timeout: 5000 });

  useEffect(() => {
    fetchUser(userId, configRef.current).then(setUser);
  }, [userId]); // ✅ 不需要在依赖数组中

  return <div>{user?.name}</div>;
}

三、竞态条件:当请求顺序变得重要

问题场景

function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(data => setResults(data));
  }, [query]);

  return <ResultsList results={results} />;
}

如果用户快速输入 "react" -> "react hooks",第二个请求可能比第一个先返回,导致显示错误的结果。

解决方案:使用 AbortController

function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    const controller = new AbortController();
    
    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => setResults(data))
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('Search failed:', err);
        }
      });

    return () => controller.abort(); // ✅ 清理时取消请求
  }, [query]);

  return <ResultsList results={results} />;
}

四、清理函数:被遗忘的守护者

事件监听器泄漏

function MouseTracker() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    window.addEventListener('mousemove', (e) => {
      setPosition({ x: e.clientX, y: e.clientY });
    });
    // ❌ 忘记移除监听器!
  }, []);

  return <div>Mouse: {position.x}, {position.y}</div>;
}

每次组件卸载后,事件监听器还在,导致内存泄漏和状态更新错误。

正确做法

function MouseTracker() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handleMouseMove = (e) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };
    
    window.addEventListener('mousemove', handleMouseMove);
    
    return () => {
      window.removeEventListener('mousemove', handleMouseMove); // ✅ 清理
    };
  }, []);

  return <div>Mouse: {position.x}, {position.y}</div>;
}

五、useLayoutEffect:什么时候用它?

useLayoutEffect 和 useEffect 几乎一样,但它会在浏览器绘制之前同步执行。

使用场景:防止视觉闪烁

function Tooltip({ targetRef }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const tooltipRef = useRef();

  useLayoutEffect(() => {
    const targetRect = targetRef.current.getBoundingClientRect();
    const tooltipRect = tooltipRef.current.getBoundingClientRect();
    
    // 计算 tooltip 位置,避免超出视口
    setPosition({
      top: targetRect.bottom + 8,
      left: targetRect.left + (targetRect.width - tooltipRect.width) / 2
    });
  }, []);

  return (
    <div ref={tooltipRef} style={{ position: 'absolute', ...position }}>
      Tooltip content
    </div>
  );
}

如果用 useEffect,用户可能会看到 tooltip 从错误位置"跳"到正确位置。useLayoutEffect 确保在绘制前就调整好位置。

但注意:useLayoutEffect 会阻塞浏览器绘制,只在必要时使用。大多数情况下,useEffect 是更好的选择。

六、自定义 Hook:封装最佳实践

把常用的 useEffect 逻辑封装成自定义 Hook:

// hooks/useAsync.js
function useAsync(asyncFunction, immediate = true) {
  const [status, setStatus] = useState('idle');
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  const execute = useCallback(() => {
    setStatus('pending');
    setData(null);
    setError(null);

    return asyncFunction()
      .then(response => {
        setData(response);
        setStatus('success');
      })
      .catch(error => {
        setError(error);
        setStatus('error');
      });
  }, [asyncFunction]);

  useEffect(() => {
    if (immediate) {
      execute();
    }
  }, [execute, immediate]);

  return { execute, status, data, error };
}

// 使用
function UserProfile({ userId }) {
  const { data: user, status, error } = useAsync(
    useCallback(() => fetchUser(userId), [userId])
  );

  if (status === 'pending') return <Loading />;
  if (status === 'error') return <Error message={error.message} />;
  return <div>{user?.name}</div>;
}

七、React 18 的并发特性与 useEffect

React 18 引入了并发渲染,useEffect 的行为有了一些变化:

  1. useEffect 可能在渲染被中断后再次执行:确保你的 effect 可以处理多次执行的情况。
  2. 严格模式下组件会故意双重挂载:帮助发现副作用问题。
// 确保 effect 可以安全地多次执行
useEffect(() => {
  let cancelled = false;
  
  fetchData().then(data => {
    if (!cancelled) {
      setData(data);
    }
  });

  return () => {
    cancelled = true; // ✅ 防止设置已卸载组件的状态
  };
}, []);

总结

useEffect 看似简单,但要真正用好它,需要理解:

  1. 闭包陷阱:使用函数式更新或 ref 解决
  2. 依赖数组:谨慎处理对象和函数依赖
  3. 竞态条件:使用 AbortController 或标志位
  4. 清理函数:别忘了移除事件监听器、定时器等
  5. useLayoutEffect:只在需要防止闪烁时使用
  6. 自定义 Hook:封装重复逻辑,提高复用性

希望这篇文章能帮你避开 useEffect 的那些坑。记住:好的代码不是一次写成的,而是在踩坑中不断完善的。


你有什么 useEffect 的踩坑经历?欢迎在评论区分享。

0 评论

评论区

登录 后参与评论