Skip to content

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 可能导致 bug

2. 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 0

4.3 清理函数执行时机

jsx
useEffect(() => {
    console.log('effect', count);
    
    return () => {
        console.log('cleanup', count);  // 闭包捕获的是旧值
    };
}, [count]);

// count: 0 → 1
// 输出:
// effect 0
// cleanup 0  (下次 effect 前执行)
// effect 1

5. 常见陷阱与解决方案

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 的区别?

区别useEffectuseLayoutEffect
执行时机浏览器绘制后(异步)DOM 更新后、绘制前(同步)
阻塞渲染
使用场景数据获取、订阅DOM 测量、同步 DOM 操作

Q3: 为什么要返回清理函数?

  1. 取消订阅:避免内存泄漏
  2. 取消请求:避免竞态条件
  3. 清除定时器:避免重复执行
  4. 重置状态:在依赖变化前清理旧状态

Q4: useEffect 的依赖数组为空数组和不传的区别?

jsx
useEffect(() => {}, []);     // 只在挂载时执行一次
useEffect(() => {});         // 每次渲染后都执行

Q5: 如何避免 useEffect 的闭包陷阱?

  1. 函数式更新setState(prev => prev + 1)
  2. 完整依赖:把所有使用的变量加入依赖
  3. useRef:存储最新值但不触发更新
  4. useEffectEvent:读取最新值但不作为依赖

前端面试知识库