Skip to content
第 11 章 前端 ⏱ 14 分钟阅读

第 11 章:useEffect ​

学习目标 ​

  • 深入掌握 useEffect 的工作时机
  • 熟练写 cleanup 函数防止内存泄漏
  • 理解依赖数组的判断规则
  • 掌握数据获取 / 订阅 / 防抖 3 大实战
  • 避开 useEffect 的 6 个常见坑

一、useEffect 时机 ​

tsx
useEffect(() => {
  // 这里在浏览器绘制完成后执行(异步)
  return () => {
    // 这里在下一次 effect 跑之前/组件卸载时执行
  };
}, [deps]);

执行顺序(单次 mount):

  1. React 渲染组件(return JSX)
  2. React 更新 DOM(屏幕仍显示旧内容)
  3. React 触发 useEffect(此时屏幕可能仍显示旧内容一帧)
  4. 浏览器绘制新内容

useEffect 不影响渲染性能,渲染慢不归它。

二、依赖数组的判断规则 ​

tsx
const [count, setCount] = useState(0);

useEffect(() => {
  console.log('mount 或 count 变化时');
}, [count]);

useEffect(() => {
  console.log('只在 mount 时');
}, []);

React 用 Object.is 比较每个依赖项,变化才重跑。

⚠️ 坑 1:依赖是对象/数组字面量时,每次渲染引用都变,effect 会无限重跑:

tsx
// ❌ 每次渲染 deps 都是新对象
useEffect(() => { ... }, [{ a: 1 }]);

// ✅ 提取到外面或用 useMemo 缓存
const opts = useMemo(() => ({ a: 1 }), []);
useEffect(() => { ... }, [opts]);

三、cleanup 函数详解 ​

tsx
useEffect(() => {
  const id = setInterval(() => console.log('tick'), 1000);
  return () => clearInterval(id);     // ⚠️ cleanup
}, []);

什么时候 cleanup?

  • 组件 unmount(从 DOM 移除)
  • 依赖变了,下次 effect 跑之前
tsx
const [roomId, setRoomId] = useState('1');

useEffect(() => {
  const ws = new WebSocket(`wss://chat/${roomId}`);
  ws.onmessage = (e) => console.log(e.data);

  return () => ws.close();    // 切房间/退出时关旧连接
}, [roomId]);

四、实战:数据获取 ​

tsx
function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const ctrl = new AbortController();

    async function load() {
      setLoading(true);
      try {
        const r = await fetch('/api/users', { signal: ctrl.signal });
        const data = await r.json();
        setUsers(data);
      } catch (e) {
        if (!ctrl.signal.aborted) setError(e as Error);
      } finally {
        if (!ctrl.signal.aborted) setLoading(false);
      }
    }

    load();
    return () => ctrl.abort();    // ⚠️ 取消未完成的请求
  }, []);

  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

⚠️ 坑 2:组件卸载后 setState 会报 "Can't perform state update on unmounted component",用 AbortController 或 ignore 标志防 setState。

场景:fetch 没回来,组件就卸载了 → 请求回来时调 setState,组件已经不在了。

tsx
useEffect(() => {
  const ctrl = new AbortController()

  async function load() {
    try {
      const r = await fetch('/api/users', { signal: ctrl.signal })
      const data = await r.json()
      setUsers(data)  // 卸载后这行不会跑(请求被取消)
    } catch (e) {
      if (!ctrl.signal.aborted) setError(e as Error)
    } finally {
      if (!ctrl.signal.aborted) setLoading(false)
    }
  }

  load()
  return () => ctrl.abort()  // 卸载时中断 fetch
}, [])

时间线:

0s: 组件挂载,fetch 发出
0s: 组件卸载,cleanup 跑 → ctrl.abort() → fetch 直接中断
3s: 原来的 then 不再执行 → 没有 setState,React 安静

简单方案:ignore 标志(不能省请求,只忽略结果):

tsx
useEffect(() => {
  let ignore = false

  fetch('/api/users')
    .then(r => r.json())
    .then(data => { if (!ignore) setUsers(data) })

  return () => { ignore = true }
}, [])
AbortControllerignore 标志
能不能省请求✅ 能(直接中断)❌ 不能(请求跑完)
代码复杂度略复杂简单
适用推荐(大请求)简单 demo / 小请求

💡 fetch 没回来组件就卸载 → setState 会警告——用 AbortController 在 cleanup 里 ctrl.abort() 取消请求。React 18 起警告没了,但主动取消仍然是好习惯。

五、实战:搜索防抖 ​

tsx
function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    if (!query) return setResults([]);

    const id = setTimeout(async () => {
      const r = await fetch(`/api/search?q=${query}`);
      const data = await r.json();
      setResults(data);
    }, 300);

    return () => clearTimeout(id);     // ⚠️ 输入时取消旧请求
  }, [query]);

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ul>{results.map(r => <li key={r}>{r}</li>)}</ul>
    </>
  );
}

六、useEffect 事件(React 18.3 实验) ​

解决 effect 里读最新 state 的问题:

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log('count:', count);    // ⚠️ 闭包陷阱:永远是 0
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);  // 没依赖 count

  // React 18.3 实验 API:
  useEffect(() => {
    const id = setInterval(() => {
      console.log('count:', count);    // ✅ 始终是最新值
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);   // eslint-disable-line
  // 需要 @enable EffectEvent 实验编译选项
}

实际项目用 setCount(c => c + 1) 函数式更新解决闭包陷阱。

七、什么时候不需要 useEffect? ​

场景错误正确
从 props 计算数据useEffect 改 state直接 const x = props.a * 2
重置组件 stateuseEffectkey={id}(见下)
触发事件useEffect事件 handler 里调
tsx
// ❌ 用 effect 同步 props 到 state(会双渲染)
const [name, setName] = useState('');
useEffect(() => setName(props.name), [props.name]);

// ✅ 直接读 props
return <div>{props.name}</div>;

// ✅ 重置 state 用 key
<UserForm key={userId} userId={userId} />

八、本章小结 ​

要点关键
异步执行浏览器绘制后跑,不影响渲染
依赖数组Object.is 比较
cleanup定时器/订阅/请求必须清理
数据获取AbortController 取消未完成请求
防抖搜索cleanup 用 clearTimeout
闭包陷阱用函数式更新或 ref 存最新值
不滥用能直接算就不要 useEffect

动手练习 ​

  1. 计时器组件:开始/停止/重置,cleanup 处理定时器
  2. 搜索框:300ms 防抖拉数据,cleanup 取消旧请求
  3. 窗口尺寸:封装 useWindowSize hook
  4. 找坑:以下代码为什么无限循环?
    tsx
    useEffect(() => {
      setCount(count + 1);
    }, [count]);

下一章:第 12 章:useRef →

本站基于 VitePress 构建 · 由 StackHub 团队维护