10 — 副作用 useEffect 与 useRef

这一章讲 React 中处理「和外部世界打交道」的两个 Hook:useEffectuseRef

  • useEffect:发请求、操作 DOM、定时器、订阅事件
  • useRef:拿到 DOM 元素、存可变值(不触发渲染)

1. 什么是「副作用」?

1.1 渲染 = 纯计算

React 组件的渲染过程应该是纯的

1
2
3
4
5
// ✅ 纯渲染:只根据 state 和 props 计算 UI
function Counter({ count }) {
return <p>{count}</p>;
}
// 同样的输入,永远得到同样的输出

1.2 副作用 = 渲染之外的事

1
2
3
4
5
6
// ❌ 渲染里不该做的事:
// - 改 DOM(React 自己会改)
// - 发 API 请求
// - 设置定时器
// - 打印日志(其实可以)
// - 订阅/取消订阅事件

这些东西叫「副作用」(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('组件挂载了');
// 比如:初始化第三方库、订阅事件、读 localStorage
}, []); // ← 依赖数组为空 = 只执行一次

项目例子

1
2
3
4
5
6
7
8
9
10
// App.tsx
useEffect(() => {
(async () => {
await testConnection();
await loadChats();
createNewChat();
})();
}, [testConnection, loadChats, createNewChat]);
// ↑ 依赖数组里有函数引用,但 Zustand 的函数引用稳定
// 所以实际上等价于 []

场景 2:依赖变化时执行

1
2
3
4
5
6
7
8
9
10
function UserProfile({ userId }) {
const [user, setUser] = useState(null);

useEffect(() => {
// userId 变了就重新加载
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => setUser(data));
}, [userId]); // ← 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
// useEffect 在 useSseStream 内部,处理 SSE 连接
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}`) // 用了 userId
.then(r => r.json())
.then(setUser);
}, []); // ❌ 没加 userId,userId 变了也不会重新请求
}

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(() => {
// 把函数定义在 useEffect 内部
const load = () => {
fetch(...).then(setData);
};
load();
}, [userId]); // 不需要把 load 加到依赖

项目里

1
2
3
4
5
6
7
8
// App.tsx
useEffect(() => {
(async () => {
await testConnection();
await loadChats();
createNewChat();
})(); // ← 立即执行函数(IIFE)
}, [testConnection, loadChats, createNewChat]);

2.4 不要在 useEffect 里做的事

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 错的:没必要用 useEffect 的场景
useEffect(() => {
setCount(count + 1); // 只想根据 props 计算
}, [props.value]);
// 这种情况直接用:
const count = props.value + 1;

// ❌ 错的:在 useEffect 里调用同步转换函数
useEffect(() => {
const formatted = formatDate(date);
setFormatted(formatted);
}, [date]);
// 应该用 useMemo:
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(); // 命令式地调用 DOM 方法
};

return (
<>
<input ref={inputRef} />
<button onClick={focusInput}>聚焦输入框</button>
</>
);
}

项目里最典型的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// InputArea.tsx(项目代码)
function InputArea() {
const fileInputRef = useRef<HTMLInputElement>(null);
// ↑ 类型注解: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);
// ↑ 存一个定时器 ID,不触发渲染

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
// chatStore.ts
abortController = new AbortController();
// ↑ 这个不是 React state(因为不需要触发渲染)
// ↑ 只是闭包变量,存在 Store 内部

项目里 useRef 的所有用法

文件用途
InputArea.tsxfile input DOM 引用
hooks/useAutoScroll.ts滚动容器 DOM 引用
hooks/useTextareaAutosize.tstextarea DOM 引用

3.3 useRef vs useState

useStateuseRef
修改触发重新渲染✅ 是❌ 否
值的类型任意任意
用途状态引用 / 存可变值
读取直接读变量通过 .current
1
2
3
4
5
6
7
const [count, setCount] = useState(0);
count; // 直接读
setCount(1); // 通过 setter 改

const countRef = useRef(0);
countRef.current; // 通过 .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) {
// 1. State:消息列表
const [messages, setMessages] = useState([]);

// 2. Ref:滚动容器
const scrollRef = useRef<HTMLDivElement>(null);

// 3. useEffect:会话变化时加载消息
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]);

// 4. useEffect:消息变化时滚动到底部
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
# 搜索项目里的 useEffect
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
// hooks/useAutoScroll.ts
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
// hooks/useTextareaAutosize.ts
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);
}, []); // 警告:依赖 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); // 改 state → 重新渲染 → effect 再执行 → 再改 → ...
}, [count]);
// 会无限循环(虽然在某种条件下会停下来)

// ✅ 对的:限制触发条件
useEffect(() => {
if (count < 5) setCount(count + 1);
}, [someOtherDep]);

错误 3:用 useRef 存了应该触发渲染的值

1
2
3
4
5
6
7
// ❌ 错的
const countRef = useRef(0);
// 用户点按钮,countRef.current++,但 UI 不更新

// ✅ 对的
const [count, setCount] = useState(0);
setCount(c => c + 1); // UI 跟着更新

8. 一段话总结

useEffect 处理「渲染之外的事」:发请求、定时器、订阅事件、操作 DOM。
依赖数组控制执行时机:空 [] = 只一次、有值 = 变化时执行。
返回函数用于清理(取消请求、清除定时器)。
useRef 两种用法:① 拿 DOM 引用 ② 存可变值(不触发渲染)。
项目里 useEffect 主要在 hooks 目录中,组件用得不多(业务逻辑大多在 Store 里)。


接下来

你现在已经掌握了 React 三大基础:组件 + Props + State + 副作用
接下来讲 React 怎么响应交互:11-事件处理与条件列表渲染.md