鲁斯前端布鲁斯前端

文章中英模式

布鲁斯前端React面试题目 - useEffect的执行时机

深入了解React useEffect执行时机、执行顺序与常见问题。掌握useEffect在组件生命周期中的运作原理,清理函数的执行时机,以及依赖数组的设计,避免无限循环与跳过执行等常见错误。

影片縮圖

懒得看文章?那就来看视频吧

useEffect 的基本执行时机

useEffect 是React的一个核心Hook,用于处理有副作用的逻辑。其执行时机是React渲染流程中的关键点,理解它何时运行对于正确使用它非常重要。

基本执行时机如下:

  • 1. 首次渲染后:组件挂载完成并出现在画面上后执行
  • 2. 依赖项变更的渲染后:当指定的依赖项变更导致重新渲染后执行
  • 3. 组件卸载前:清理函数(return的函数)会在组件卸载前执行
function ExampleComponent() {
  const [count, setCount] = useState(0);
  
  // 基本用法示范
  useEffect(() => {
    console.log('Effect 执行:组件挂载或count变更');
    document.title = `计数: ${count}`;
    
    // 清理函数
    return () => {
      console.log('清理执行:下一次effect执行前或卸载时');
    };
  }, [count]); // 依赖count变更
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      增加 ({count})
    </button>
  );
}

React 渲染周期与 useEffect 的关系

useEffect 总是在画面更新后才执行,这是它的关键特性:

React渲染流程

1. 执行组件函数,计算虚拟DOM

2. 更新实际DOM,画面重绘

3. 执行useEffect函数

这种设计有重要好处:

  • 1. 不阻塞UI渲染,用户能更快看到画面
  • 2. 可以安全访问已更新的DOM元素
// 简单示例
function App() {
  const [name, setName] = useState('');
  
  // 在DOM更新后执行
  useEffect(() => {
    // 此时画面已更新,用户已看到新内容
    document.title = name || 'React App';
  }, [name]);
  
  return <input value={name} onChange={e => setName(e.target.value)} />;
}

⚠️ 重要: useEffect 总是异步执行的,它会在浏览器完成绘制之后才触发,不会阻塞UI更新。

多个 useEffect 的执行顺序

当组件中有多个useEffect时,它们的执行顺序遵循以下规则:

  • 1. 按照组件中声明的顺序,从上到下依次执行
  • 2. 每个useEffect的清理函数在下一次该effect执行前运行
  • 3. 卸载时,清理函数按照useEffect的相反顺序执行
function MultipleEffectsExample() {
  // 以下效果将按顺序执行 (挂载后)
  useEffect(() => {
    console.log('第一个效果');
    return () => console.log('清理第一个效果');
  }, []);
  
  useEffect(() => {
    console.log('第二个效果');
    return () => console.log('清理第二个效果');
  }, []);
  
  useEffect(() => {
    console.log('第三个效果');
    return () => console.log('清理第三个效果');
  }, []);
  
  // 组件卸载时的日志顺序:
  // "清理第三个效果"
  // "清理第二个效果"
  // "清理第一个效果"
}

清理函数的执行时机

清理函数(effect return的函数)的执行时机有两种情况:

  1. 1. 下一次effect执行前:当依赖项变更,重新执行effect前,会先执行上一次effect的清理函数
  2. 2. 组件卸载时:组件从DOM中移除前,会执行最后一次effect的清理函数

清理函数的主要目的是防止内存泄漏和取消不再需要的订阅或操作:

function WindowWidthObserver() {
  const [width, setWidth] = useState(window.innerWidth);
  
  useEffect(() => {
    // 设置事件监听
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    
    // 清理函数 - 防止内存泄漏
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // 空依赖数组,只在挂载和卸载时执行
  
  return <div>窗口宽度: {width}px</div>;
}

清理函数在多次渲染中的执行顺序

首次渲染

组件渲染

执行 Effect A

依赖项变更

组件重新渲染

执行 Effect A 的清理函数

执行新的 Effect A

组件卸载

组件即将移除

执行 Effect A 的清理函数

组件从DOM移除

依赖数组与执行时机

依赖数组(第二个参数)决定了useEffect何时重新执行:

依赖数组执行时机使用场景
未提供每次渲染后都执行需要在每次渲染后同步某些内容
[]只在挂载后和卸载前执行设置/清理只需执行一次的操作
[a, b]挂载后和a或b变更后执行响应特定数据变化的副作用
function DependencyExample({ userId, filter }) {
  // 1. 每次渲染后都执行 (没有依赖数组)
  useEffect(() => {
    console.log('组件已渲染');
  });
  
  // 2. 只在挂载和卸载时执行 (空依赖数组)
  useEffect(() => {
    console.log('组件已挂载');
    return () => console.log('组件即将卸载');
  }, []);
  
  // 3. 当userId变更时执行 (指定依赖)
  useEffect(() => {
    console.log(`正在获取用户 ${userId} 的数据`);
    fetchUserData(userId);
  }, [userId]);
  
  // 4. 当userId或filter变更时执行 (多个依赖)
  useEffect(() => {
    console.log(`正在为用户 ${userId} 应用过滤器 ${filter}`);
    fetchFilteredData(userId, filter);
  }, [userId, filter]);
}

useEffect 常见错误及解决方案

1. 无限循环

最常见的错误之一是在useEffect中更新state,但未正确设置依赖数组:

// ❌ 错误示例 - 无限循环
function InfiniteLoopExample() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    // effect更新state,导致重新渲染,再次触发effect
    setCount(count + 1);
  }, [count]); // count变更时重新执行
  
  return <div>{count}</div>;
}

// ✅ 修正方法1 - 使用函数式更新,移除依赖
function FixedExample1() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setCount(c => c + 1); // 函数式更新不需要依赖count
  }, []); // 只执行一次
  
  return <div>{count}</div>;
}

// ✅ 修正方法2 - 条件控制
function FixedExample2() {
  const [count, setCount] = useState(0);
  const [hasIncremented, setHasIncremented] = useState(false);
  
  useEffect(() => {
    if (!hasIncremented) {
      setCount(count + 1);
      setHasIncremented(true);
    }
  }, [count, hasIncremented]);
  
  return <div>{count}</div>;
}

2. 遗漏依赖

另一个常见错误是没有包含effect中使用的所有变量作为依赖:

// ❌ 错误示例 - 遗漏依赖
function MissingDependencyExample({ productId, userId }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    // effect使用了productId,但依赖数组中缺少它
    fetchProductData(productId, userId).then(setData);
  }, [userId]); // 仅依赖userId
  
  return <div>{/* 显示数据 */}</div>;
}

// ✅ 修正方法 - 包含所有依赖
function FixedDependencyExample({ productId, userId }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetchProductData(productId, userId).then(setData);
  }, [productId, userId]); // 包含所有依赖
  
  return <div>{/* 显示数据 */}</div>;
}

3. 副作用中的异步问题

在处理异步操作时,可能出现竞态条件(Race Condition)问题:

竞态条件问题示意图

用户快速输入搜索字符串:

输入 "a"
发出请求A(较慢)
输入 "ab"
发出请求B(较快)
请求B完成
显示"ab"的结果 ✓
请求A完成
覆盖为"a"的结果 ❌

问题:旧的请求结果覆盖了新的请求结果,导致UI显示与用户最新输入不符。

// ❌ 有潜在问题的示例 - 竞态条件 
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    fetchResults(query).then(data => {
      // ⚠️ 这里没有检查 query 是否还是最新的
      setResults(data);
    });
  }, [query]);
  
  return <ResultsList results={results} />;
}

使用 AbortController 解决竞态条件

AbortController 工作原理:

输入 "a" 时

建立 controller A

发送请求 A

输入 "ab" 时

执行清理函数

controller A.abort()

→ 请求 A 被取消 ✓

建立 controller B

发送请求 B

结果

只有请求 B 完成

显示 "ab" 的结果

→ 符合最新输入 ✓

优点:自动取消过时的请求,避免内存泄漏,并确保UI始终显示最新请求的结果。

// ✅ 方法:使用 AbortController(更干净)
function FixedSearchResults2({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    const controller = new AbortController();
    
    fetchResults(query, { signal: controller.signal })
      .then(data => {
        setResults(data);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('获取失败:', err);
        }
      });
    
    return () => {
      controller.abort(); // 中止前一个请求
    };
  }, [query]);
  
  return <ResultsList results={results} />;
}

避免内存泄漏的 useEffect 处理

不正确的 useEffect 清理可能导致内存泄漏,特别是在处理订阅、计时器、事件监听器等资源时。以下是常见的内存泄漏情境与解决方案:

常见内存泄漏来源

  • 未清理的事件监听器
  • 未取消的计时器 (setTimeout/setInterval)
  • 未关闭的网络连接或订阅
  • 未释放的DOM引用

正确的清理模式如下:

// 计时器清理示例
useEffect(() => {
  const timer = setTimeout(() => {
    console.log('这是一个计时器');
  }, 1000);
  
  // 清理函数
  return () => {
    clearTimeout(timer); // 清理计时器
  };
}, []);

// 事件监听器清理示例
useEffect(() => {
  const handleResize = () => {
    console.log('窗口大小改变');
  };
  
  window.addEventListener('resize', handleResize);
  
  // 清理函数
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

内存泄漏的影响

未清理的组件: 即使组件已卸载,仍保持活动连接

组件已卸载

监听器/计时器仍在运行

内存无法释放

可能导致应用崩溃

正确清理的组件: 组件卸载时释放所有资源

组件即将卸载

执行清理函数

释放所有资源

内存正确回收

一个实用的内存泄漏检测技巧是在开发时添加警告日志:

function DataFetcher() {
  useEffect(() => {
    let isMounted = true;
    
    fetchData().then(data => {
      // 防止在已卸载组件上设置状态
      if (isMounted) {
        setData(data);
      }
    });
    
    return () => {
      isMounted = false;
      console.log('DataFetcher 已清理资源'); // 开发时的检查日志
    };
  }, []);
  
  // ...
}

💡 提示: 使用 React 18 的严格模式可以帮助发现潜在的内存泄漏问题,因为它会故意重复挂载和卸载组件,使清理问题更容易被发现。

🔥 常见面试题目

(一) useEffect的执行时机,useEffect的清理函数何时执行?它有什么用途?

解答: useEffect在每次渲染完成后(DOM已更新)执行。根据依赖数组设置:

  • 没有依赖数组:每次渲染后执行
  • 空依赖数组 []:只在首次渲染后执行
  • 有依赖的数组:首次渲染后和依赖变更时执行

useEffect执行流程

1. 元件渲染

2. 畫面更新

3. 執行useEffect

清理函数执行时机:

  1. 在下一次effect执行<strong>之前</strong>(依赖变更时)
  2. 在组件<strong>卸载时</strong>(从DOM移除前)

清理函数用途:

  • 防止内存泄漏(取消订阅、清除计时器)
  • 取消进行中的API请求
  • 移除事件监听器
  • 避免在已卸载组件上更新状态
useEffect(() => {
  const timer = setTimeout(() => {...}, 1000);
  
  return () => {
    clearTimeout(timer); // 清理计时器
  };
}, [dependency]);

(二) 如何处理useEffect中的异步操作?

解答: 处理异步操作的关键点:

  1. 使用标记变量避免在已卸载组件上更新状态
  2. 妥善处理错误
  3. 处理竞态条件(多个请求时只使用最新结果)
function FixedSearchResults2({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    const controller = new AbortController();
    
    fetchResults(query, { signal: controller.signal })
      .then(data => {
        setResults(data);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('获取失败:', err);
        }
      });
    
    return () => {
      controller.abort(); // 中止前一个请求
    };
  }, [query]);
  
  return <ResultsList results={results} />;
}

处理竞态条件

1. 用户搜索'A'

→ 发送请求A

2. 用户快速搜索'B'

→ 取消请求A

→ 发送请求B

3. 只显示B的结果

→ 避免旧数据覆盖

(三) 如何处理useEffect中的内存泄漏问题

解答: 防止内存泄漏的主要方法:

  1. 使用清理函数释放资源
  2. 取消未完成的异步操作
  3. 移除事件监听器和订阅
  4. 使用AbortController取消fetch请求
useEffect(() => {
  // 1. Event listener
  window.addEventListener('resize', handleResize);
  
  // 2. Timer
  const interval = setInterval(tick, 1000);
  
  // 3. Use AbortController to cancel fetch
  const controller = new AbortController();
  fetch(url, { signal: controller.signal });
  
  // Cleanup function
  return () => {
    window.removeEventListener('resize', handleResize);
    clearInterval(interval);
    controller.abort();
  };
}, []);

常见内存泄漏来源

未清理的计时器

setTimeout, setInterval

未移除的事件监听器

addEventListener

未取消的网络请求

fetch, axios

未取消的订阅

WebSocket, Observable

使用React 18严格模式可以帮助发现内存泄漏,因为它会重复挂载和卸载组件,使清理问题更容易被发现。