10 — 副作用 useEffect 与 useRef
这一章讲 React 中处理「和外部世界打交道」的两个 Hook:useEffect 和 useRef。
- useEffect:发请求、操作 DOM、定时器、订阅事件
- useRef:拿到 DOM 元素、存可变值(不触发渲染)
1. 什么是「副作用」?
1.1 渲染 = 纯计算
React 组件的渲染过程应该是纯的:
1 2 3 4 5
| function Counter({ count }) { return <p>{count}</p>; }
|
1.2 副作用 = 渲染之外的事
这些东西叫「副作用」(side effects),因为它们:
- 会影响组件之外的「世界」
- 时机不对会产生 bug(比如在渲染时改 DOM 会让 React 困惑)
1.3 解决方案:useEffect
1 2 3 4 5 6 7 8 9 10 11 12 13
| import { useEffect } from 'react';
function MyComponent() { useEffect(() => { console.log('组件挂载了');
return () => { console.log('组件要卸载了,清理一下'); }; }, []); }
|
2. useEffect 详解
2.1 三个位置
1 2 3 4 5 6 7 8 9
| useEffect(() => { console.log('执行副作用');
return () => { console.log('清理'); }; }, []);
|
| 依赖数组 | 何时执行 | 清理时机 |
|---|
| 不写(第 2 个参数) | 每次渲染后 | 每次下次渲染前 |
[] | 只在挂载时(第一次渲染后) | 卸载时 |
[a, b] | 挂载时 + a 或 b 变化时 | 下次执行前 + 卸载时 |
2.2 三种常见场景
场景 1:只在挂载时执行一次(类似 componentDidMount)
1 2 3 4
| useEffect(() => { console.log('组件挂载了'); }, []);
|
项目例子:
1 2 3 4 5 6 7 8 9 10
| useEffect(() => { (async () => { await testConnection(); await loadChats(); createNewChat(); })(); }, [testConnection, loadChats, createNewChat]);
|
场景 2:依赖变化时执行
1 2 3 4 5 6 7 8 9 10
| function UserProfile({ userId }) { const [user, setUser] = useState(null);
useEffect(() => { fetch(`/api/users/${userId}`) .then(r => r.json()) .then(data => setUser(data)); }, [userId]); }
|
场景 3:需要清理的副作用
1 2 3 4 5 6 7 8 9 10
| function Timer() { useEffect(() => { const id = setInterval(() => { console.log('tick'); }, 1000);
return () => clearInterval(id); }, []); }
|
项目里 useSseStream.ts(简化):
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| export function useSseStream({ url, onEvent, onDone }) { useEffect(() => { const controller = new AbortController(); fetch(url, { signal: controller.signal }) .then(r => r.body?.getReader()) .then(reader => { });
return () => controller.abort(); }, [url]); }
|
2.3 依赖数组的陷阱
错误:忘记加依赖
1 2 3 4 5 6 7 8 9
| function MyComponent({ userId }) { const [user, setUser] = useState(null);
useEffect(() => { fetch(`/api/users/${userId}`) .then(r => r.json()) .then(setUser); }, []); }
|
React 在 dev 模式下会警告这种问题(控制台红色字)。
正确:完整列出依赖
1 2 3 4 5
| useEffect(() => { fetch(`/api/users/${userId}`) .then(r => r.json()) .then(setUser); }, [userId]);
|
技巧:函数依赖可以提到 useEffect 里
1 2 3 4 5 6 7
| useEffect(() => { const load = () => { fetch(...).then(setData); }; load(); }, [userId]);
|
项目里:
1 2 3 4 5 6 7 8
| useEffect(() => { (async () => { await testConnection(); await loadChats(); createNewChat(); })(); }, [testConnection, loadChats, createNewChat]);
|
2.4 不要在 useEffect 里做的事
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| useEffect(() => { setCount(count + 1); }, [props.value]);
const count = props.value + 1;
useEffect(() => { const formatted = formatDate(date); setFormatted(formatted); }, [date]);
const formatted = useMemo(() => formatDate(date), [date]);
|
useEffect 的真正用途:与「组件外的东西」交互(API、DOM、定时器、事件订阅等)。
3. useRef 详解
3.1 两种用法
用法 1:拿到 DOM 元素的引用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { useRef } from 'react';
function TextInput() { const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => { inputRef.current?.focus(); };
return ( <> <input ref={inputRef} /> <button onClick={focusInput}>聚焦输入框</button> </> ); }
|
项目里最典型的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function InputArea() { const fileInputRef = useRef<HTMLInputElement>(null);
return ( <> <button onClick={() => fileInputRef.current?.click()}> 上传 </button> {/* 隐藏的文件 input,通过 ref 控制 */} <input ref={fileInputRef} type="file" style={{ display: 'none' }} /> </> ); }
|
为什么需要 useRef?
<input type="file"> 长得很丑,不显示在页面上- 用 ref 拿到它,命令式地触发 click(打开文件选择框)
- 用户体验:点自定义按钮就触发文件选择
用法 2:保存可变值(不触发重新渲染)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function Stopwatch() { const intervalRef = useRef<number | null>(null);
const start = () => { intervalRef.current = window.setInterval(() => { console.log('tick'); }, 1000); };
const stop = () => { if (intervalRef.current !== null) { clearInterval(intervalRef.current); } }; }
|
为什么用 useRef 而不是 useState?
useState 的值变 → 触发重新渲染useRef 的值变 → 不触发重新渲染- 适合存「和 UI 无关的可变数据」(定时器 ID、socket、AbortController 等)
项目里:
1 2 3 4
| abortController = new AbortController();
|
项目里 useRef 的所有用法:
| 文件 | 用途 |
|---|
InputArea.tsx | file input DOM 引用 |
hooks/useAutoScroll.ts | 滚动容器 DOM 引用 |
hooks/useTextareaAutosize.ts | textarea DOM 引用 |
3.3 useRef vs useState
| useState | useRef |
|---|
| 修改触发重新渲染 | ✅ 是 | ❌ 否 |
| 值的类型 | 任意 | 任意 |
| 用途 | 状态 | 引用 / 存可变值 |
| 读取 | 直接读变量 | 通过 .current |
1 2 3 4 5 6 7
| const [count, setCount] = useState(0); count; setCount(1);
const countRef = useRef(0); countRef.current; countRef.current = 1;
|
3.4 ref 转发(项目里没用但要知道)
1 2 3 4 5 6 7 8 9
| const MyInput = forwardRef<HTMLInputElement, Props>((props, ref) => { return <input ref={ref} {...props} />; });
function Parent() { const ref = useRef<HTMLInputElement>(null); return <MyInput ref={ref} />; }
|
项目里没有用 ref forwarder,组件间的 ref 不穿透。
4. 实战:useEffect + useRef 完整例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| function ChatWindow({ conversationId }: Props) { const [messages, setMessages] = useState([]);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { const controller = new AbortController();
fetch(`/api/session/${conversationId}`, { signal: controller.signal }) .then(r => r.json()) .then(data => setMessages(data.messages));
return () => controller.abort(); }, [conversationId]);
useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, }); }, [messages]);
return ( <div ref={scrollRef}> {messages.map(m => <Message key={m.id} message={m} />)} </div> ); }
|
两个 useEffect 各司其职:
- 第一个:处理数据加载(外部 API)
- 第二个:处理 UI 行为(DOM 滚动)
5. 项目里所有 useEffect 的位置
1 2
| grep -r "useEffect" src/ --include="*.ts*"
|
| 文件 | 作用 |
|---|
App.tsx | 应用挂载时初始化(测连接、加载会话) |
hooks/useAutoScroll.ts | 消息变化时滚动到底部 |
hooks/useTextareaAutosize.ts | 输入变化时调整高度 |
hooks/useSseStream.ts | 监听 SSE 流(虽然不一定用 useEffect) |
components/Sidebar/Sidebar.tsx | 媒体查询监听 |
components/Sidebar/Sidebar.tsx (useMediaQuery) | 内部 useState + useEffect |
6. 项目 hooks 目录的所有 Hook
1 2 3 4 5 6 7 8 9 10 11 12
| export function useAutoScroll<T extends HTMLElement>(deps: unknown[]) { const ref = useRef<T>(null); useEffect(() => { const el = ref.current; if (el) el.scrollTop = el.scrollHeight; }, deps); return ref; }
const ref = useAutoScroll<HTMLDivElement>([messages.length, isSending]);
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| export function useTextareaAutosize<T extends HTMLTextAreaElement>(value: string) { const ref = useRef<T>(null); useEffect(() => { const el = ref.current; if (el) { el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px`; } }, [value]); return ref; }
const ref = useTextareaAutosize(inputMessage);
|
模式:自定义 Hook = 「复用的状态逻辑」。返回 ref,让调用方挂到 DOM 上。
7. 常见错误
错误 1:忘记依赖
1 2 3 4 5 6 7 8 9
| useEffect(() => { console.log(props.userId); }, []);
useEffect(() => { console.log(props.userId); }, [props.userId]);
|
错误 2:useEffect 里改 state 引起死循环
1 2 3 4 5 6 7 8 9 10 11
| const [count, setCount] = useState(0); useEffect(() => { setCount(count + 1); }, [count]);
useEffect(() => { if (count < 5) setCount(count + 1); }, [someOtherDep]);
|
错误 3:用 useRef 存了应该触发渲染的值
1 2 3 4 5 6 7
| const countRef = useRef(0);
const [count, setCount] = useState(0); setCount(c => c + 1);
|
8. 一段话总结
useEffect 处理「渲染之外的事」:发请求、定时器、订阅事件、操作 DOM。
依赖数组控制执行时机:空 [] = 只一次、有值 = 变化时执行。
返回函数用于清理(取消请求、清除定时器)。
useRef 两种用法:① 拿 DOM 引用 ② 存可变值(不触发渲染)。
项目里 useEffect 主要在 hooks 目录中,组件用得不多(业务逻辑大多在 Store 里)。
接下来
你现在已经掌握了 React 三大基础:组件 + Props + State + 副作用。
接下来讲 React 怎么响应交互:11-事件处理与条件列表渲染.md。