useEffectEvent 与副作用
解决 useEffect 依赖困境的新方案
1. useEffect 的依赖困境
1.1 经典问题
jsx
function ChatRoom({ roomId, serverUrl }) {
const [message, setMessage] = useState('');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
connection.on('message', (msg) => {
// 🔴 问题:需要使用最新的 message,但不想 message 变化时重连
showNotification(`New message in ${roomId}: ${msg}`);
});
return () => connection.disconnect();
}, [roomId, serverUrl, message]); // 🔴 message 作为依赖会导致频繁重连
}1.2 不完美的解决方案
jsx
// 方案一:useRef(可行但繁琐)
function ChatRoom({ roomId, serverUrl }) {
const [message, setMessage] = useState('');
const messageRef = useRef(message);
useEffect(() => {
messageRef.current = message;
}, [message]);
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('message', (msg) => {
// 使用 ref 读取最新值
showNotification(`${messageRef.current}: ${msg}`);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
// 方案二:忽略 lint 警告(危险!)
useEffect(() => {
// ...
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId, serverUrl]); // 🔴 忽略 message 可能导致 bug2. useEffectEvent
2.1 核心概念
useEffectEvent 创建一个总是读取最新 props/state 但不作为依赖的函数。
jsx
import { useEffectEvent } from 'react';
function ChatRoom({ roomId, serverUrl }) {
const [message, setMessage] = useState('');
// ✅ 不需要作为 useEffect 的依赖
const onMessage = useEffectEvent((msg) => {
// 总是能读取到最新的 message
showNotification(`${message}: ${msg}`);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('message', onMessage); // ✅ 无需加入依赖
return () => connection.disconnect();
}, [roomId, serverUrl]); // ✅ 只有真正需要的依赖
}2.2 工作原理
┌─────────────────────────────────────────────────────────────────┐
│ useEffectEvent 原理 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ useEffectEvent(fn) 返回一个稳定的函数引用 │
│ 但调用时总是执行最新的 fn │
│ │
│ render 1: message = 'hello' │
│ onMessage → fn1 (读取 message='hello') │
│ │
│ render 2: message = 'world' │
│ onMessage → 同一个引用,但执行 fn2 (message='world') │
│ │
└─────────────────────────────────────────────────────────────────┘2.3 简化实现
javascript
// 概念上的实现(非实际源码)
function useEffectEvent(fn) {
const ref = useRef(fn);
// 每次渲染更新 ref
useLayoutEffect(() => {
ref.current = fn;
});
// 返回稳定的函数引用
return useCallback((...args) => {
return ref.current(...args);
}, []);
}3. 使用场景
3.1 事件回调中读取最新状态
jsx
function Timer() {
const [count, setCount] = useState(0);
const [delay, setDelay] = useState(1000);
// ✅ onTick 可以读取最新的 count,但不影响 effect 重建
const onTick = useEffectEvent(() => {
console.log(`Current count: ${count}`);
});
useEffect(() => {
const id = setInterval(onTick, delay);
return () => clearInterval(id);
}, [delay]); // 只在 delay 变化时重建
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}3.2 日志和分析
jsx
function Page({ url }) {
const [userId, setUserId] = useState(null);
// 记录页面访问,userId 变化不应该重新发送
const logVisit = useEffectEvent(() => {
logAnalytics('visit', { url, userId });
});
useEffect(() => {
logVisit();
}, [url]); // 只在 url 变化时记录
}3.3 复杂的事件处理
jsx
function SearchResults({ query, filter, sortBy }) {
const [results, setResults] = useState([]);
// 处理搜索结果,需要读取最新的 filter 和 sortBy
const handleResults = useEffectEvent((data) => {
const filtered = data.filter(item => matches(item, filter));
const sorted = sort(filtered, sortBy);
setResults(sorted);
});
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(handleResults); // ✅ 使用最新的 filter/sortBy
return () => controller.abort();
}, [query]); // 只在 query 变化时重新请求
}4. useEffect 执行时机详解
4.1 执行顺序
┌─────────────────────────────────────────────────────────────────┐
│ React 渲染与 Effect 执行 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 渲染阶段 (Render Phase) │
│ └── 组件函数执行,生成 Virtual DOM │
│ │
│ 2. 提交阶段 (Commit Phase) │
│ ├── DOM 更新 │
│ ├── useLayoutEffect 清理函数执行 │
│ ├── useLayoutEffect 回调执行 │
│ └── 浏览器绘制 │
│ │
│ 3. Effect 阶段 │
│ ├── useEffect 清理函数执行(异步) │
│ └── useEffect 回调执行(异步) │
│ │
└─────────────────────────────────────────────────────────────────┘4.2 useEffect vs useLayoutEffect
jsx
function Example() {
const [count, setCount] = useState(0);
// 异步执行,不阻塞绘制
useEffect(() => {
console.log('useEffect', count);
}, [count]);
// 同步执行,阻塞绘制
useLayoutEffect(() => {
console.log('useLayoutEffect', count);
}, [count]);
console.log('render', count);
return <div>{count}</div>;
}
// 输出顺序:
// render 0
// useLayoutEffect 0
// useEffect 04.3 清理函数执行时机
jsx
useEffect(() => {
console.log('effect', count);
return () => {
console.log('cleanup', count); // 闭包捕获的是旧值
};
}, [count]);
// count: 0 → 1
// 输出:
// effect 0
// cleanup 0 (下次 effect 前执行)
// effect 15. 常见陷阱与解决方案
5.1 闭包陷阱
jsx
// ❌ 错误:闭包捕获旧值
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // 永远是 0 + 1
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}
// ✅ 方案一:使用函数式更新
setCount(c => c + 1);
// ✅ 方案二:添加依赖
useEffect(() => { ... }, [count]);
// ✅ 方案三:使用 useEffectEvent(如果不想重建 interval)5.2 对象/数组依赖
jsx
// ❌ 每次渲染都是新对象,effect 每次都执行
useEffect(() => {
fetchData(options);
}, [{ page: 1, limit: 10 }]);
// ✅ 方案一:拆解为原始值
useEffect(() => {
fetchData({ page, limit });
}, [page, limit]);
// ✅ 方案二:useMemo
const options = useMemo(() => ({ page, limit }), [page, limit]);
useEffect(() => {
fetchData(options);
}, [options]);5.3 函数依赖
jsx
// ❌ 每次渲染 fetchData 都是新函数
function Component({ userId }) {
const fetchData = () => {
return fetch(`/user/${userId}`);
};
useEffect(() => {
fetchData();
}, [fetchData]); // 每次都执行
}
// ✅ 方案一:useCallback
const fetchData = useCallback(() => {
return fetch(`/user/${userId}`);
}, [userId]);
// ✅ 方案二:函数移到 effect 内部
useEffect(() => {
const fetchData = () => fetch(`/user/${userId}`);
fetchData();
}, [userId]);
// ✅ 方案三:函数移到组件外部(如果不依赖 props/state)6. 面试高频问题
Q1: useEffectEvent 解决什么问题?
解决 useEffect 中需要读取最新 props/state,但不希望它们作为依赖 的问题。避免了在"依赖完整性"和"不必要的 effect 执行"之间的两难选择。
Q2: useEffect 和 useLayoutEffect 的区别?
| 区别 | useEffect | useLayoutEffect |
|---|---|---|
| 执行时机 | 浏览器绘制后(异步) | DOM 更新后、绘制前(同步) |
| 阻塞渲染 | 否 | 是 |
| 使用场景 | 数据获取、订阅 | DOM 测量、同步 DOM 操作 |
Q3: 为什么要返回清理函数?
- 取消订阅:避免内存泄漏
- 取消请求:避免竞态条件
- 清除定时器:避免重复执行
- 重置状态:在依赖变化前清理旧状态
Q4: useEffect 的依赖数组为空数组和不传的区别?
jsx
useEffect(() => {}, []); // 只在挂载时执行一次
useEffect(() => {}); // 每次渲染后都执行Q5: 如何避免 useEffect 的闭包陷阱?
- 函数式更新:
setState(prev => prev + 1) - 完整依赖:把所有使用的变量加入依赖
- useRef:存储最新值但不触发更新
- useEffectEvent:读取最新值但不作为依赖