Skip to content

useEffect.jsx

文件信息

  • 📄 原文件:02_useEffect.jsx
  • 🔤 语言:jsx

React useEffect Hook useEffect 用于处理副作用(Side Effects),如数据获取、订阅、DOM 操作等。 它是函数组件中处理生命周期逻辑的主要方式。

完整代码

jsx
/**
 * ============================================================
 *                    React useEffect Hook
 * ============================================================
 * useEffect 用于处理副作用(Side Effects),如数据获取、订阅、DOM 操作等。
 * 它是函数组件中处理生命周期逻辑的主要方式。
 * ============================================================
 */

import React, { useState, useEffect } from 'react';

// ============================================================
//                    1. useEffect 基础
// ============================================================

/**
 * 【什么是副作用】
 *
 * 副作用是指组件渲染之外的操作:
 * - 数据获取(API 请求)
 * - 订阅(WebSocket、事件监听)
 * - 手动 DOM 操作
 * - 定时器
 * - 日志记录
 *
 * 【useEffect 语法】
 * useEffect(effectFunction, dependencyArray)
 *
 * - effectFunction: 副作用函数,在组件渲染后执行
 * - dependencyArray: 依赖数组,控制副作用何时重新执行
 */

// --- 基本用法 ---
function BasicEffect() {
    const [count, setCount] = useState(0);

    // 每次渲染后都执行
    useEffect(() => {
        console.log('组件渲染了,count =', count);
        // 更新文档标题
        document.title = `点击了 ${count} 次`;
    });

    return (
        <div>
            <p>点击次数: {count}</p>
            <button onClick={() => setCount(count + 1)}>点击</button>
        </div>
    );
}


// ============================================================
//                    2. 依赖数组
// ============================================================

/**
 * 【依赖数组的作用】
 *
 * 依赖数组决定 useEffect 何时重新执行:
 *
 * 1. 不传依赖数组:每次渲染后都执行
 *    useEffect(() => { ... })
 *
 * 2. 空数组 []:只在挂载时执行一次
 *    useEffect(() => { ... }, [])
 *
 * 3. 有依赖 [a, b]:依赖变化时执行
 *    useEffect(() => { ... }, [a, b])
 */

// --- 不传依赖数组:每次渲染都执行 ---
function EveryRender() {
    const [count, setCount] = useState(0);
    const [text, setText] = useState('');

    // 任何状态变化导致的重新渲染都会执行
    useEffect(() => {
        console.log('EveryRender: 组件渲染了');
        console.log('count:', count, 'text:', text);
    });

    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(c => c + 1)}>增加</button>
            <input
                value={text}
                onChange={(e) => setText(e.target.value)}
                placeholder="输入文字"
            />
        </div>
    );
}

// --- 空依赖数组:只在挂载时执行一次 ---
function MountOnly() {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);

    // 只在组件挂载时执行一次
    // 适合:初始数据获取、一次性设置
    useEffect(() => {
        console.log('MountOnly: 组件挂载');

        // 模拟 API 请求
        const fetchData = async () => {
            // 模拟延迟
            await new Promise(resolve => setTimeout(resolve, 1000));
            setData({ message: '数据加载成功!' });
            setLoading(false);
        };

        fetchData();
    }, []);  // 空数组 = 只执行一次

    if (loading) return <p>加载中...</p>;
    return <p>{data?.message}</p>;
}

// --- 有依赖数组:依赖变化时执行 ---
function WithDependencies() {
    const [userId, setUserId] = useState(1);
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(false);

    // 当 userId 变化时重新获取用户数据
    useEffect(() => {
        console.log('WithDependencies: userId 变化,重新获取数据');

        const fetchUser = async () => {
            setLoading(true);
            // 模拟 API 请求
            await new Promise(resolve => setTimeout(resolve, 500));
            setUser({
                id: userId,
                name: `用户 ${userId}`,
                email: `user${userId}@example.com`,
            });
            setLoading(false);
        };

        fetchUser();
    }, [userId]);  // 依赖 userId

    return (
        <div>
            <div>
                <button onClick={() => setUserId(1)}>用户 1</button>
                <button onClick={() => setUserId(2)}>用户 2</button>
                <button onClick={() => setUserId(3)}>用户 3</button>
            </div>
            {loading ? (
                <p>加载中...</p>
            ) : (
                user && (
                    <div>
                        <p>ID: {user.id}</p>
                        <p>姓名: {user.name}</p>
                        <p>邮箱: {user.email}</p>
                    </div>
                )
            )}
        </div>
    );
}


// ============================================================
//                    3. 清理函数
// ============================================================

/**
 * 【清理函数】
 *
 * useEffect 可以返回一个清理函数:
 * - 在组件卸载时执行
 * - 在下一次 effect 执行前执行
 *
 * 【使用场景】
 * - 取消订阅
 * - 清除定时器
 * - 取消网络请求
 * - 移除事件监听
 */

// --- 定时器清理 ---
function TimerExample() {
    const [seconds, setSeconds] = useState(0);
    const [isRunning, setIsRunning] = useState(false);

    useEffect(() => {
        // 如果没有运行,不设置定时器
        if (!isRunning) return;

        console.log('设置定时器');

        // 设置定时器
        const intervalId = setInterval(() => {
            setSeconds(s => s + 1);
        }, 1000);

        // 清理函数:清除定时器
        return () => {
            console.log('清除定时器');
            clearInterval(intervalId);
        };
    }, [isRunning]);  // 依赖 isRunning

    return (
        <div>
            <p>秒数: {seconds}</p>
            <button onClick={() => setIsRunning(!isRunning)}>
                {isRunning ? '暂停' : '开始'}
            </button>
            <button onClick={() => setSeconds(0)}>重置</button>
        </div>
    );
}

// --- 事件监听清理 ---
function WindowSizeTracker() {
    const [windowSize, setWindowSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight,
    });

    useEffect(() => {
        // 事件处理函数
        const handleResize = () => {
            setWindowSize({
                width: window.innerWidth,
                height: window.innerHeight,
            });
        };

        console.log('添加 resize 监听器');

        // 添加事件监听
        window.addEventListener('resize', handleResize);

        // 清理函数:移除事件监听
        return () => {
            console.log('移除 resize 监听器');
            window.removeEventListener('resize', handleResize);
        };
    }, []);  // 空数组,只在挂载/卸载时执行

    return (
        <div>
            <p>窗口宽度: {windowSize.width}px</p>
            <p>窗口高度: {windowSize.height}px</p>
        </div>
    );
}

// --- 订阅清理 ---
function ChatRoom({ roomId }) {
    const [messages, setMessages] = useState([]);

    useEffect(() => {
        console.log(`连接到聊天室: ${roomId}`);

        // 模拟 WebSocket 连接
        const connection = createConnection(roomId);

        // 订阅消息
        connection.on('message', (message) => {
            setMessages(prev => [...prev, message]);
        });

        // 连接
        connection.connect();

        // 清理函数:断开连接
        return () => {
            console.log(`断开聊天室: ${roomId}`);
            connection.disconnect();
        };
    }, [roomId]);  // roomId 变化时重新连接

    return (
        <div>
            <h3>聊天室: {roomId}</h3>
            <ul>
                {messages.map((msg, i) => (
                    <li key={i}>{msg}</li>
                ))}
            </ul>
        </div>
    );
}

// 模拟 WebSocket 连接
function createConnection(roomId) {
    return {
        on: (event, callback) => {
            console.log(`监听事件: ${event}`);
        },
        connect: () => {
            console.log(`已连接到 ${roomId}`);
        },
        disconnect: () => {
            console.log(`已断开 ${roomId}`);
        },
    };
}


// ============================================================
//                    4. 数据获取模式
// ============================================================

/**
 * 【数据获取最佳实践】
 *
 * 1. 处理加载状态
 * 2. 处理错误状态
 * 3. 处理竞态条件(Race Condition)
 * 4. 支持取消请求
 */

// --- 完整的数据获取示例 ---
function DataFetchingExample() {
    const [query, setQuery] = useState('react');
    const [results, setResults] = useState([]);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);

    useEffect(() => {
        // 标记是否已取消(用于处理竞态条件)
        let cancelled = false;

        const fetchData = async () => {
            // 空查询不请求
            if (!query.trim()) {
                setResults([]);
                return;
            }

            setLoading(true);
            setError(null);

            try {
                // 模拟 API 请求
                await new Promise(resolve => setTimeout(resolve, 500));

                // 模拟返回数据
                const data = [
                    { id: 1, title: `${query} 结果 1` },
                    { id: 2, title: `${query} 结果 2` },
                    { id: 3, title: `${query} 结果 3` },
                ];

                // 检查是否已取消(防止竞态条件)
                if (!cancelled) {
                    setResults(data);
                }
            } catch (err) {
                if (!cancelled) {
                    setError('获取数据失败');
                }
            } finally {
                if (!cancelled) {
                    setLoading(false);
                }
            }
        };

        fetchData();

        // 清理函数:标记为已取消
        return () => {
            cancelled = true;
        };
    }, [query]);

    return (
        <div>
            <input
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="搜索..."
            />

            {loading && <p>搜索中...</p>}
            {error && <p className="error">{error}</p>}

            <ul>
                {results.map(item => (
                    <li key={item.id}>{item.title}</li>
                ))}
            </ul>
        </div>
    );
}

// --- 使用 AbortController 取消请求 ---
function FetchWithAbort() {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(false);

    useEffect(() => {
        // 创建 AbortController
        const controller = new AbortController();
        const signal = controller.signal;

        const fetchData = async () => {
            setLoading(true);

            try {
                const response = await fetch('/api/data', { signal });
                const json = await response.json();
                setData(json);
            } catch (err) {
                // 忽略取消错误
                if (err.name !== 'AbortError') {
                    console.error('Fetch error:', err);
                }
            } finally {
                setLoading(false);
            }
        };

        fetchData();

        // 清理:取消请求
        return () => {
            controller.abort();
        };
    }, []);

    return (
        <div>
            {loading ? <p>加载中...</p> : <pre>{JSON.stringify(data)}</pre>}
        </div>
    );
}


// ============================================================
//                    5. 多个 useEffect
// ============================================================

/**
 * 【多个 useEffect】
 *
 * 可以使用多个 useEffect 将不相关的逻辑分开:
 * - 更好的代码组织
 * - 更清晰的依赖关系
 * - 更容易理解和维护
 */

function MultipleEffects({ userId }) {
    const [user, setUser] = useState(null);
    const [posts, setPosts] = useState([]);

    // Effect 1: 获取用户信息
    useEffect(() => {
        console.log('获取用户信息');

        const fetchUser = async () => {
            // 模拟请求
            await new Promise(r => setTimeout(r, 300));
            setUser({ id: userId, name: `用户 ${userId}` });
        };

        fetchUser();
    }, [userId]);

    // Effect 2: 获取用户文章(独立的逻辑)
    useEffect(() => {
        console.log('获取用户文章');

        const fetchPosts = async () => {
            await new Promise(r => setTimeout(r, 500));
            setPosts([
                { id: 1, title: `文章 1 by ${userId}` },
                { id: 2, title: `文章 2 by ${userId}` },
            ]);
        };

        fetchPosts();
    }, [userId]);

    // Effect 3: 更新文档标题
    useEffect(() => {
        if (user) {
            document.title = `${user.name} 的主页`;
        }

        return () => {
            document.title = 'React App';
        };
    }, [user]);

    return (
        <div>
            {user && <h2>{user.name}</h2>}
            <ul>
                {posts.map(post => (
                    <li key={post.id}>{post.title}</li>
                ))}
            </ul>
        </div>
    );
}


// ============================================================
//                    6. 常见陷阱和解决方案
// ============================================================

/**
 * 【常见陷阱】
 *
 * 1. 无限循环
 * 2. 过时的闭包
 * 3. 缺少依赖
 * 4. 对象/数组依赖
 */

// --- 陷阱1: 无限循环 ---
function InfiniteLoopProblem() {
    const [count, setCount] = useState(0);

    // ❌ 错误:每次渲染都会触发,导致无限循环
    // useEffect(() => {
    //     setCount(count + 1);  // 更新状态 -> 触发渲染 -> 再次执行 effect
    // });

    // ✅ 正确:添加条件或正确的依赖
    useEffect(() => {
        if (count < 10) {
            setCount(count + 1);
        }
    }, [count]);

    return <p>Count: {count}</p>;
}

// --- 陷阱2: 过时的闭包 ---
function StaleClosureProblem() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        const timer = setInterval(() => {
            // ❌ 问题:count 总是初始值 0(闭包陷阱)
            // console.log('count:', count);

            // ✅ 解决方案1:使用函数式更新
            setCount(c => c + 1);
        }, 1000);

        return () => clearInterval(timer);
    }, []);  // 空依赖,effect 只执行一次

    // ✅ 解决方案2:将 count 加入依赖(但会重新创建定时器)
    // useEffect(() => {
    //     const timer = setInterval(() => {
    //         console.log('count:', count);
    //         setCount(count + 1);
    //     }, 1000);
    //     return () => clearInterval(timer);
    // }, [count]);

    return <p>Count: {count}</p>;
}

// --- 陷阱3: 对象/数组依赖 ---
function ObjectDependencyProblem() {
    const [user, setUser] = useState({ name: 'Alice' });

    // ❌ 问题:每次渲染都创建新对象,导致 effect 每次都执行
    // const options = { userId: user.name };
    // useEffect(() => {
    //     console.log('options changed');
    // }, [options]);

    // ✅ 解决方案1:直接依赖原始值
    useEffect(() => {
        console.log('user.name changed');
    }, [user.name]);

    // ✅ 解决方案2:使用 useMemo 缓存对象(见后续章节)

    return (
        <div>
            <p>User: {user.name}</p>
            <button onClick={() => setUser({ name: 'Bob' })}>
                改名
            </button>
        </div>
    );
}


// ============================================================
//                    7. useEffect vs 事件处理
// ============================================================

/**
 * 【何时用 useEffect,何时用事件处理】
 *
 * useEffect 用于:
 * - 同步外部系统(数据获取、订阅)
 * - 响应状态/props 变化
 * - 组件生命周期相关操作
 *
 * 事件处理用于:
 * - 响应用户交互
 * - 不需要在渲染时执行的操作
 */

// --- 正确使用事件处理 ---
function EventVsEffect() {
    const [items, setItems] = useState([]);

    // ❌ 不要用 effect 来响应事件
    // const [shouldAdd, setShouldAdd] = useState(false);
    // useEffect(() => {
    //     if (shouldAdd) {
    //         setItems([...items, 'new item']);
    //         setShouldAdd(false);
    //     }
    // }, [shouldAdd]);

    // ✅ 直接在事件处理中更新状态
    const handleAdd = () => {
        setItems([...items, `Item ${items.length + 1}`]);
    };

    // ✅ useEffect 用于同步外部系统
    useEffect(() => {
        // 将 items 同步到服务器
        console.log('同步 items 到服务器:', items);
    }, [items]);

    return (
        <div>
            <button onClick={handleAdd}>添加</button>
            <ul>
                {items.map((item, i) => (
                    <li key={i}>{item}</li>
                ))}
            </ul>
        </div>
    );
}


// ============================================================
//                    8. 自定义 Hook 抽取逻辑
// ============================================================

/**
 * 【自定义 Hook】
 *
 * 当 useEffect 逻辑可复用时,可以抽取为自定义 Hook
 */

// --- 自定义 Hook: useDocumentTitle ---
function useDocumentTitle(title) {
    useEffect(() => {
        document.title = title;
        return () => {
            document.title = 'React App';
        };
    }, [title]);
}

// --- 自定义 Hook: useWindowSize ---
function useWindowSize() {
    const [size, setSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight,
    });

    useEffect(() => {
        const handleResize = () => {
            setSize({
                width: window.innerWidth,
                height: window.innerHeight,
            });
        };

        window.addEventListener('resize', handleResize);
        return () => window.removeEventListener('resize', handleResize);
    }, []);

    return size;
}

// --- 自定义 Hook: useFetch ---
function useFetch(url) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        let cancelled = false;

        const fetchData = async () => {
            setLoading(true);
            setError(null);

            try {
                const response = await fetch(url);
                const json = await response.json();

                if (!cancelled) {
                    setData(json);
                }
            } catch (err) {
                if (!cancelled) {
                    setError(err.message);
                }
            } finally {
                if (!cancelled) {
                    setLoading(false);
                }
            }
        };

        fetchData();

        return () => {
            cancelled = true;
        };
    }, [url]);

    return { data, loading, error };
}

// --- 使用自定义 Hook ---
function CustomHookDemo() {
    // 使用自定义 Hook
    useDocumentTitle('自定义 Hook 示例');
    const { width, height } = useWindowSize();
    // const { data, loading, error } = useFetch('/api/users');

    return (
        <div>
            <p>窗口尺寸: {width} x {height}</p>
        </div>
    );
}


// ============================================================
//                    导出
// ============================================================

export {
    BasicEffect,
    EveryRender,
    MountOnly,
    WithDependencies,
    TimerExample,
    WindowSizeTracker,
    DataFetchingExample,
    MultipleEffects,
    CustomHookDemo,
    useDocumentTitle,
    useWindowSize,
    useFetch,
};

export default function UseEffectTutorial() {
    return (
        <div className="tutorial">
            <h1>useEffect Hook 教程</h1>
            <BasicEffect />
            <TimerExample />
            <WindowSizeTracker />
            <DataFetchingExample />
        </div>
    );
}

💬 讨论

使用 GitHub 账号登录后即可参与讨论

基于 MIT 许可发布