useEffect 完全指南:从原理到精通

0 阅读10分钟

useEffect 完全指南:从原理到精通

目录

  1. useEffect 是什么
  2. 内部实现原理
  3. 基础语法与依赖数组
  4. 模拟生命周期
  5. 清理函数 (Cleanup)
  6. 依赖项最佳实践
  7. 你可能不需要 useEffect
  8. useEffect vs useLayoutEffect
  9. 复杂场景与陷阱
  10. React 19.2 新特性:useEffectEvent
  11. 常见错误与解决方案
  12. 最佳实践总结

1. useEffect 是什么

useEffect 是 React 的副作用 Hook,用于处理组件中的"副作用"操作——即那些影响组件外部世界的操作。

副作用包括:

  • 数据获取 (API 调用)
  • 订阅 (WebSocket、事件监听)
  • 手动 DOM 操作
  • 定时器 (setTimeout、setInterval)
  • 日志记录
useEffect(() => {
  // 副作用代码
  return () => {
    // 清理代码 (可选)
  };
}, [dependencies]);

2. 内部实现原理

Fiber 架构中的 Effect

React 使用 Fiber 架构管理组件树,每个组件对应一个 Fiber 节点。useEffect 的工作原理:

组件渲染 → Fiber 节点更新 → 收集 Effect → 浏览器绑制 → 异步执行 Effect

核心数据结构

// Effect 对象结构
interface Effect {
  tag: number;           // 标记 Effect 类型和是否需要执行
  create: () => void;    // 我们传入的回调函数
  destroy: () => void;   // cleanup 函数
  deps: any[] | null;    // 依赖数组
  next: Effect | null;   // 链表指针,指向下一个 Effect
}

执行流程

初次挂载 (mountEffect):

  1. 创建新的 Hook 对象
  2. 调用 pushEffect() 创建 Effect 对象
  3. 设置 HookHasEffect 标记,表示需要执行
  4. Effect 存储在 Fiber 的 updateQueue

更新阶段 (updateEffect):

  1. 获取当前 Hook
  2. 比较新旧依赖数组 (areHookInputsEqual)
  3. 如果依赖变化,设置 HookHasEffect 标记
  4. 如果依赖未变,不设置标记,Effect 不会执行
// 简化的依赖比较逻辑
function areHookInputsEqual(nextDeps, prevDeps) {
  if (prevDeps === null) return false;
  
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (Object.is(nextDeps[i], prevDeps[i])) continue;
    return false;
  }
  return true;
}

Effect 执行时机

Render Phase (可中断)
    ↓
Commit Phase (不可中断)
    ├── Before Mutation (DOM 更新前)
    ├── Mutation (DOM 更新)
    └── Layout (useLayoutEffect 执行)
    ↓
浏览器绑制
    ↓
Passive Effects (useEffect 异步执行)

关键点: useEffect 在浏览器绘制后异步执行,不会阻塞渲染。


3. 基础语法与依赖数组

三种依赖模式

// 1. 无依赖数组:每次渲染后都执行
useEffect(() => {
  console.log('每次渲染后执行');
});

// 2. 空依赖数组:仅在挂载时执行一次
useEffect(() => {
  console.log('仅挂载时执行');
}, []);

// 3. 有依赖数组:依赖变化时执行
useEffect(() => {
  console.log(`count 变化了: ${count}`);
}, [count]);

依赖比较机制

React 使用 Object.is() 进行浅比较:

// 基本类型:值比较
Object.is(1, 1);           // true
Object.is('a', 'a');       // true

// 引用类型:引用比较
Object.is({}, {});         // false (不同引用)
Object.is([], []);         // false (不同引用)

const obj = { a: 1 };
Object.is(obj, obj);       // true (同一引用)

陷阱: 每次渲染创建的新对象/数组/函数都是新引用!

// ❌ 错误:每次渲染 options 都是新对象,导致无限循环
useEffect(() => {
  fetchData(options);
}, [options]); // options = { page: 1 } 每次都是新引用

// ✅ 正确:使用 useMemo 稳定引用
const options = useMemo(() => ({ page }), [page]);
useEffect(() => {
  fetchData(options);
}, [options]);

4. 模拟生命周期

useEffect 可以模拟 Class 组件的生命周期方法:

componentDidMount

useEffect(() => {
  // 组件挂载后执行
  console.log('Component mounted');
}, []); // 空依赖数组 = 仅执行一次

componentDidUpdate

// 方式一:监听特定状态变化
useEffect(() => {
  console.log('count updated:', count);
}, [count]);

// 方式二:跳过首次渲染,仅在更新时执行
const isFirstRender = useRef(true);

useEffect(() => {
  if (isFirstRender.current) {
    isFirstRender.current = false;
    return;
  }
  console.log('Component updated (not first render)');
});

componentWillUnmount

useEffect(() => {
  return () => {
    // 组件卸载前执行
    console.log('Component will unmount');
  };
}, []);

完整生命周期模拟

function LifecycleDemo({ userId }) {
  const [user, setUser] = useState(null);
  const isFirstRender = useRef(true);

  // componentDidMount
  useEffect(() => {
    console.log('Mounted');
    return () => {
      console.log('Unmounted'); // componentWillUnmount
    };
  }, []);

  // componentDidUpdate (仅 userId 变化时)
  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }
    console.log('userId changed:', userId);
  }, [userId]);

  // 数据获取
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

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

5. 清理函数 (Cleanup)

为什么需要清理

清理函数用于防止内存泄漏和避免对已卸载组件进行状态更新。

清理函数执行时机

Effect 执行 → 返回 cleanup 函数
    ↓
下次 Effect 执行前 → 先执行上次的 cleanup
    ↓
组件卸载时 → 执行最后一次的 cleanup

常见清理场景

1. 事件监听器
useEffect(() => {
  const handleResize = () => {
    setWidth(window.innerWidth);
  };
  
  window.addEventListener('resize', handleResize);
  
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);
2. 定时器
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  
  return () => clearInterval(timer);
}, []);
3. 订阅
useEffect(() => {
  const subscription = dataSource.subscribe(handleData);
  
  return () => subscription.unsubscribe();
}, [dataSource]);
4. 异步请求 (AbortController)
useEffect(() => {
  const controller = new AbortController();
  
  async function fetchData() {
    try {
      const response = await fetch(`/api/user/${userId}`, {
        signal: controller.signal,
      });
      const data = await response.json();
      setUser(data);
    } catch (error) {
      if (error.name !== 'AbortError') {
        setError(error);
      }
    }
  }
  
  fetchData();
  
  return () => controller.abort();
}, [userId]);
5. 使用标志位处理异步
useEffect(() => {
  let isCancelled = false;
  
  async function fetchData() {
    const data = await fetchUser(userId);
    if (!isCancelled) {
      setUser(data);
    }
  }
  
  fetchData();
  
  return () => {
    isCancelled = true;
  };
}, [userId]);

6. 依赖项最佳实践

规则一:诚实声明所有依赖

// ❌ 错误:遗漏依赖
useEffect(() => {
  fetchData(userId, page); // userId 和 page 都被使用
}, [userId]); // 遗漏了 page

// ✅ 正确:声明所有依赖
useEffect(() => {
  fetchData(userId, page);
}, [userId, page]);

规则二:使用函数式更新避免依赖

// ❌ 需要依赖 count
useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // 每次 count 变化都重新创建定时器

// ✅ 使用函数式更新,无需依赖 count
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(timer);
}, []); // 只创建一次定时器

规则三:使用 useCallback 稳定函数引用

// ❌ 每次渲染 handleClick 都是新函数
const handleClick = () => {
  console.log(count);
};

useEffect(() => {
  document.addEventListener('click', handleClick);
  return () => document.removeEventListener('click', handleClick);
}, [handleClick]); // 每次渲染都重新绑定

// ✅ 使用 useCallback 稳定引用
const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

useEffect(() => {
  document.addEventListener('click', handleClick);
  return () => document.removeEventListener('click', handleClick);
}, [handleClick]); // 仅 count 变化时重新绑定

规则四:使用 useMemo 稳定对象引用

// ❌ 每次渲染 config 都是新对象
const config = { theme, language };

useEffect(() => {
  initializeApp(config);
}, [config]); // 每次渲染都执行

// ✅ 使用 useMemo
const config = useMemo(() => ({ theme, language }), [theme, language]);

useEffect(() => {
  initializeApp(config);
}, [config]); // 仅 theme 或 language 变化时执行

规则五:将函数移入 Effect 内部

// ❌ 函数在外部,需要作为依赖
const fetchData = () => {
  return fetch(`/api/user/${userId}`);
};

useEffect(() => {
  fetchData().then(setUser);
}, [fetchData, userId]); // fetchData 每次都是新引用

// ✅ 将函数移入 Effect 内部
useEffect(() => {
  const fetchData = () => {
    return fetch(`/api/user/${userId}`);
  };
  
  fetchData().then(setUser);
}, [userId]); // 只依赖 userId

7. 你可能不需要 useEffect

React 官方文档强调:Effect 是 React 范式的"逃生舱",用于与外部系统同步。很多场景不需要 useEffect。

场景一:基于 props/state 计算派生数据

// ❌ 错误:使用 Effect 计算派生状态
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

// ✅ 正确:直接在渲染时计算
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`; // 直接计算

场景二:过滤/转换数据

// ❌ 错误:使用 Effect 过滤列表
const [items, setItems] = useState([]);
const [filter, setFilter] = useState('');
const [filteredItems, setFilteredItems] = useState([]);

useEffect(() => {
  setFilteredItems(items.filter(item => item.includes(filter)));
}, [items, filter]);

// ✅ 正确:直接计算,必要时用 useMemo
const filteredItems = useMemo(
  () => items.filter(item => item.includes(filter)),
  [items, filter]
);

场景三:响应用户事件

// ❌ 错误:使用 Effect 响应表单提交
const [submitted, setSubmitted] = useState(false);

useEffect(() => {
  if (submitted) {
    sendAnalytics('form_submitted');
    setSubmitted(false);
  }
}, [submitted]);

const handleSubmit = () => {
  setSubmitted(true);
};

// ✅ 正确:直接在事件处理器中执行
const handleSubmit = () => {
  submitForm();
  sendAnalytics('form_submitted');
};

场景四:初始化应用

// ❌ 错误:在 Effect 中初始化
useEffect(() => {
  initializeApp();
}, []);

// ✅ 正确:在模块顶层或应用入口初始化
// app.ts
if (typeof window !== 'undefined') {
  initializeApp();
}

场景五:重置状态

// ❌ 错误:使用 Effect 重置状态
function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');
  
  useEffect(() => {
    setComment('');
  }, [userId]);
}

// ✅ 正确:使用 key 强制重新挂载
function ProfilePage({ userId }) {
  return <Profile userId={userId} key={userId} />;
}

function Profile({ userId }) {
  const [comment, setComment] = useState(''); // userId 变化时自动重置
}

何时需要 useEffect

  • 与外部系统同步 (DOM、第三方库、WebSocket)
  • 数据获取 (但推荐使用 React Query、SWR 等库)
  • 设置订阅
  • 发送分析日志 (页面访问,非用户操作)

8. useEffect vs useLayoutEffect

执行时机对比

Render → DOM 更新 → useLayoutEffect (同步) → 浏览器绘制 → useEffect (异步)
特性useEffectuseLayoutEffect
执行时机浏览器绘制后浏览器绘制前
执行方式异步同步
阻塞渲染
性能影响可能较大

何时使用 useLayoutEffect

1. 测量 DOM 元素
function Tooltip({ children, targetRef }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  
  // 使用 useLayoutEffect 避免闪烁
  useLayoutEffect(() => {
    const rect = targetRef.current.getBoundingClientRect();
    setPosition({
      top: rect.bottom + 10,
      left: rect.left,
    });
  }, [targetRef]);
  
  return (
    <div style={{ position: 'absolute', ...position }}>
      {children}
    </div>
  );
}
2. 同步 DOM 变更
function AutoFocus({ children }) {
  const ref = useRef(null);
  
  useLayoutEffect(() => {
    ref.current?.focus();
  }, []);
  
  return <div ref={ref} tabIndex={-1}>{children}</div>;
}
3. 防止视觉闪烁
function AnimatedComponent() {
  const ref = useRef(null);
  
  // 在绘制前设置初始位置,避免闪烁
  useLayoutEffect(() => {
    ref.current.style.transform = 'translateX(-100%)';
    // 触发重排后设置动画
    requestAnimationFrame(() => {
      ref.current.style.transition = 'transform 0.3s';
      ref.current.style.transform = 'translateX(0)';
    });
  }, []);
  
  return <div ref={ref}>Animated</div>;
}

默认使用 useEffect

// ✅ 大多数情况使用 useEffect
useEffect(() => {
  fetchData();
  subscribeToEvents();
  logAnalytics();
}, []);

// ⚠️ 仅在需要同步 DOM 操作时使用 useLayoutEffect
useLayoutEffect(() => {
  measureElement();
  updatePosition();
}, []);

9. 复杂场景与陷阱

陷阱一:无限循环

// ❌ 无限循环:Effect 中更新依赖
const [count, setCount] = useState(0);

useEffect(() => {
  setCount(count + 1); // 更新 count → 触发 Effect → 更新 count...
}, [count]);

// ✅ 解决方案 1:使用函数式更新
useEffect(() => {
  setCount(c => c + 1);
}, []); // 不依赖 count

// ✅ 解决方案 2:添加条件判断
useEffect(() => {
  if (count < 10) {
    setCount(count + 1);
  }
}, [count]);

陷阱二:闭包陷阱 (Stale Closure)

// ❌ 闭包陷阱:count 永远是 0
const [count, setCount] = useState(0);

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // 永远打印 0
  }, 1000);
  return () => clearInterval(timer);
}, []); // 空依赖,闭包捕获初始值

// ✅ 解决方案 1:添加依赖
useEffect(() => {
  const timer = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // 每次 count 变化重新创建定时器

// ✅ 解决方案 2:使用 ref 存储最新值
const countRef = useRef(count);
countRef.current = count;

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

陷阱三:竞态条件 (Race Condition)

// ❌ 竞态条件:快速切换 userId 可能导致显示错误数据
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]);

// ✅ 解决方案:使用标志位或 AbortController
useEffect(() => {
  let cancelled = false;
  
  fetchUser(userId).then(user => {
    if (!cancelled) {
      setUser(user);
    }
  });
  
  return () => {
    cancelled = true;
  };
}, [userId]);

陷阱四:对象/数组依赖

// ❌ 每次渲染都是新对象,导致 Effect 每次都执行
function Component({ items }) {
  const config = { sortBy: 'name', items };
  
  useEffect(() => {
    processData(config);
  }, [config]); // 每次都是新引用
}

// ✅ 解决方案 1:展开为基本类型
useEffect(() => {
  processData({ sortBy: 'name', items });
}, [items]); // 只依赖 items

// ✅ 解决方案 2:使用 useMemo
const config = useMemo(() => ({ sortBy: 'name', items }), [items]);

useEffect(() => {
  processData(config);
}, [config]);

// ✅ 解决方案 3:使用 JSON.stringify (谨慎使用)
useEffect(() => {
  processData(config);
}, [JSON.stringify(config)]);

陷阱五:StrictMode 双重执行

React 18+ 的 StrictMode 会在开发环境中双重执行 Effect,用于检测副作用是否正确清理。

// ❌ 不幂等的 Effect
useEffect(() => {
  items.push(newItem); // 双重执行会添加两次
}, []);

// ✅ 幂等的 Effect
useEffect(() => {
  if (!items.includes(newItem)) {
    items.push(newItem);
  }
}, []);

// ✅ 或者使用 cleanup 确保正确清理
useEffect(() => {
  const subscription = subscribe();
  return () => subscription.unsubscribe();
}, []);

10. React 19.2 新特性:useEffectEvent

React 19.2 引入了 useEffectEvent,解决了 Effect 中读取最新 props/state 而不触发重新执行的问题。

问题场景

// ❌ 问题:onTick 变化会导致定时器重建
function Timer({ onTick, interval }) {
  useEffect(() => {
    const id = setInterval(() => {
      onTick(); // 需要最新的 onTick
    }, interval);
    return () => clearInterval(id);
  }, [onTick, interval]); // onTick 变化会重建定时器
}

useEffectEvent 解决方案

import { useEffectEvent } from 'react';

function Timer({ onTick, interval }) {
  // useEffectEvent 创建一个稳定的函数,但总是读取最新的 props
  const tick = useEffectEvent(() => {
    onTick(); // 总是调用最新的 onTick
  });
  
  useEffect(() => {
    const id = setInterval(tick, interval);
    return () => clearInterval(id);
  }, [interval]); // 不需要依赖 onTick
}

useEffectEvent 特点

  1. 返回稳定的函数引用
  2. 函数内部总是读取最新的 props/state
  3. 不需要作为 Effect 的依赖
  4. 只能在 Effect 内部调用

使用场景

// 场景:日志记录需要最新的 props,但不应触发 Effect 重新执行
function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    logConnection(roomId, theme); // 读取最新的 theme
  });
  
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', onConnected);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // 只在 roomId 变化时重连,theme 变化不会重连
}

11. 常见错误与解决方案

错误 1:async 函数作为 Effect 回调

// ❌ 错误:async 函数返回 Promise,不是 cleanup 函数
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

// ✅ 正确:在内部定义 async 函数
useEffect(() => {
  async function fetchData() {
    const data = await fetch('/api/data');
    setData(data);
  }
  fetchData();
}, []);

// ✅ 或使用 IIFE
useEffect(() => {
  (async () => {
    const data = await fetchData();
    setData(data);
  })();
}, []);

错误 2:遗漏依赖

// ❌ ESLint 警告:React Hook useEffect has a missing dependency
useEffect(() => {
  fetchUser(userId);
}, []); // 遗漏 userId

// ✅ 添加所有依赖
useEffect(() => {
  fetchUser(userId);
}, [userId]);

错误 3:在卸载后更新状态

// ❌ 警告:Can't perform a React state update on an unmounted component
useEffect(() => {
  fetchData().then(data => {
    setData(data); // 组件可能已卸载
  });
}, []);

// ✅ 使用标志位检查
useEffect(() => {
  let mounted = true;
  
  fetchData().then(data => {
    if (mounted) {
      setData(data);
    }
  });
  
  return () => {
    mounted = false;
  };
}, []);

错误 4:Effect 执行顺序假设

// ❌ 错误假设:Effect 按声明顺序执行
useEffect(() => {
  console.log('Effect 1');
}, []);

useEffect(() => {
  console.log('Effect 2');
}, []);

// 实际上:子组件的 Effect 先于父组件执行
// 同一组件内的 Effect 按声明顺序执行

错误 5:过度使用 Effect

// ❌ 过度使用:用 Effect 同步状态
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

// ✅ 直接计算
const fullName = `${firstName} ${lastName}`;

12. 最佳实践总结

核心原则

  1. Effect 是逃生舱 - 仅用于与外部系统同步
  2. 诚实声明依赖 - 不要欺骗 React 关于依赖
  3. 保持 Effect 幂等 - 多次执行应产生相同结果
  4. 始终清理副作用 - 防止内存泄漏

依赖项检查清单

依赖类型处理方式
基本类型 (string, number, boolean)直接添加到依赖数组
函数使用 useCallback 或移入 Effect 内部
对象/数组使用 useMemo 或展开为基本类型
Ref不需要添加 (ref.current 是可变的)
setState 函数不需要添加 (React 保证稳定)

代码模板

数据获取
function useData(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    
    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url, { signal: controller.signal });
        const json = await response.json();
        setData(json);
        setError(null);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    }
    
    fetchData();
    
    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}
事件监听
function useEventListener(eventName, handler, element = window) {
  const savedHandler = useRef(handler);
  
  useLayoutEffect(() => {
    savedHandler.current = handler;
  }, [handler]);

  useEffect(() => {
    const eventListener = (event) => savedHandler.current(event);
    
    element.addEventListener(eventName, eventListener);
    
    return () => element.removeEventListener(eventName, eventListener);
  }, [eventName, element]);
}
定时器
function useInterval(callback, delay) {
  const savedCallback = useRef(callback);

  useLayoutEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay === null) return;
    
    const id = setInterval(() => savedCallback.current(), delay);
    
    return () => clearInterval(id);
  }, [delay]);
}

参考资源


文档基于 React 19.2 及 2025-2026 年最新实践整理,部分内容参考自 React 官方文档、jser.dev 等来源并进行了改写。