Skip to content
React Hooks API:useEffect 与自定义 Hook
概述
React 在渲染 UI 时要求组件保持纯粹:相同 props 与 state 对应相同的 DOM 输出,渲染过程中不应修改外部变量或执行对外部环境产生可观察影响的代码。实际应用中,总有一些操作无法放进渲染流程——订阅数据源、手动操作 DOM、启动计时器、发送网络请求——这些统一称为副作用。
useEffect 是 React 为这类“渲染之外的工作”提供的出口。它把副作用逻辑放到一个专门函数里,由 React 在合适的时机执行,而不打断渲染路径。
基本概念
React 将内部执行阶段分为 渲染阶段 与 提交阶段。渲染阶段计算 UI 的下一版本,提交阶段则把变更应用到 DOM。useEffect 接收的 setup 函数在提交阶段之后运行,且执行是异步的——React 允许浏览器在 setup 运行前重新绘制屏幕,从而不会阻塞用户交互。如果确实需要在 DOM 更新后同步执行某些操作,应当使用 useLayoutEffect。
另一个特性:Effect 只会在客户端执行,服务端渲染时不会运行 setup,这直接避免了在 Node.js 环境中访问浏览器 API 的错误。
基本用法
useEffect 的调用形式为 useEffect(setup, dependencies?)。
setup:包含 Effect 逻辑的函数,可选择性返回一个清理函数(cleanup),用于拆除上一次执行所建立的资源。dependencies:可选的依赖值数组,控制 setup 何时重新执行。
以下示例在组件挂载后打印信息:
tsx
import { useEffect } from 'react';
function App() {
useEffect(() => {
console.log('组件已挂载到 DOM');
});
return <div>Hello</div>;
}这里没有提供依赖数组,因此 setup 会在每次组件提交更改后重新执行——包括首次挂载和之后的每一次更新。如果只希望在挂载时执行一次,需要将依赖数组设为空。
依赖项数组:控制重新执行
依赖项数组决定 setup 的重新触发时机。React 使用 Object.is 逐项比较新旧值,只有检测到变化时才重新执行 setup。三种典型配置如下:
tsx
// 每次渲染后都执行
useEffect(() => {
// 运行于每轮提交
});
// 只在挂载时执行(空数组)
useEffect(() => {
// 挂载后执行一次
}, []);
// 依赖值变化时重新执行
useEffect(() => {
// mount 或 count/name 变化时执行
}, [count, name]);省略依赖数组表示 Effect 不依赖任何响应式值,因此每次渲染后都会重新运行;空数组意味着依赖列表为空,React 判定依赖从未变化,只在挂载时执行一次。
执行次数对比
以下示例同时声明三种 Effect,方便观察实际运行次数:
tsx
import { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('无依赖项:每次渲染后执行');
});
useEffect(() => {
console.log('空依赖项:只执行一次');
}, []);
useEffect(() => {
console.log('依赖 count:count 改变时执行');
}, [count]);
return <button onClick={() => setCount(c => c + 1)}>点击 {count}</button>;
}首次渲染时三个 Effect 都会运行。点击按钮增加 count 会触发重渲染:第一个 Effect 没有依赖数组,再次执行;第二个 Effect 依赖为空数组,不重新执行;第三个 Effect 检测到 count 发生变化,重新运行。
需要清理的副作用
很多副作用在组件卸载或依赖变化时需要撤销,否则可能造成内存泄漏、过期回调等问题。setup 函数可以返回一个 cleanup 函数,React 会在组件卸载前或下一次执行 setup 之前先调用它。
清理订阅
通过 Effect 连接外部系统时,需要在 cleanup 中断开连接。以下示例连接聊天室,并在 serverUrl 或 roomId 变化时切换连接:
tsx
import { useState, useEffect } from 'react';
function createConnection(serverUrl: string, roomId: string) {
return {
connect() {
console.log(`✅ 连接到 ${roomId} 房间,服务器 ${serverUrl}`);
},
disconnect() {
console.log(`❌ 断开 ${roomId} 房间连接`);
}
};
}
function ChatRoom({ roomId }: { roomId: string }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
return (
<div>
<label>
服务器地址:
<input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
</label>
<h1>欢迎来到 {roomId} 房间</h1>
</div>
);
}依赖项变更时,React 会用旧值执行 cleanup 断开连接,再以新值运行 setup 建立新连接;组件从 DOM 移除时也会最后一次执行 cleanup。
清除定时器
setInterval 或 setTimeout 创建的定时任务必须在 cleanup 中取消,否则组件卸载后仍会继续执行。
tsx
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{time.toLocaleTimeString()}</div>;
}终止过期请求
在 Effect 中发起数据请求时,如果组件在请求完成前卸载或依赖再次变化,应当忽略过期响应。可以通过布尔标志位在 cleanup 中标记请求已作废:
tsx
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [userId]);
// 渲染逻辑...
}拆分多个 useEffect
与类组件生命周期方法强制捆绑不同,函数组件可以声明多个 useEffect,每个专注于一类副作用。这样既能提高可读性,也能使依赖关系更清晰。
tsx
function Dashboard() {
useEffect(() => {
const analytics = connectAnalytics();
return () => analytics.disconnect();
}, []);
useEffect(() => {
const notif = subscribeNotifications();
return () => notif.unsubscribe();
}, []);
useEffect(() => {
document.title = 'Dashboard';
}, []);
}执行顺序与声明顺序一致:每次提交后 React 按顺序运行各 Effect 的 setup;当某个 Effect 需要重新执行时,先以旧依赖值运行其 cleanup,再按顺序执行所有 Effect 的新 setup;组件卸载时则按声明顺序执行全部 cleanup。
自定义 Hook
当多个组件共享相同的副作用模式时,可以把逻辑提取为自定义 Hook。自定义 Hook 本质上是内部可以调用 React Hooks 的普通函数。
命名与规则
- 函数名必须以
use开头,这是 React 识别自定义 Hook 的约定,也便于 lint 工具检查 Hooks 规则。 - 自定义 Hook 内部只能调用其他 Hook,不能放在条件或循环中;调用者也必须在顶层使用。
- 避免封装
useMount(fn)、useUpdateEffect(fn, deps)这类“生命周期” Hook,因为它们不能通过依赖数组正确响应变化,且 lint 工具无法检测到内部缺失的依赖。
useWindowSize
监听窗口尺寸是典型的复用场景。以下 Hook 封装了窗口大小跟踪逻辑:
tsx
import { useState, useEffect } from 'react';
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;
}
// 使用
function App() {
const { width, height } = useWindowSize();
return <div>窗口尺寸: {width} x {height}</div>;
}Hook 内部用 useState 存储尺寸,useEffect 订阅 resize 事件并在 cleanup 中移除监听器,空依赖数组保证只绑定一次事件。
useFetch
数据获取逻辑同样可以抽象为 Hook,使调用方声明式地获取数据、加载状态与错误信息。
tsx
import { useState, useEffect } from 'react';
function useFetch(url: string) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then(res => {
if (!res.ok) throw new Error('请求失败');
return res.json();
})
.then(data => {
if (!cancelled) {
setData(data);
setError(null);
}
})
.catch(err => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
// 使用
function UserList() {
const { data, loading, error } = useFetch('/api/users');
if (loading) return <div>加载中...</div>;
if (error) return <div>出错了:{error.message}</div>;
return <ul>{data.map((u: any) => <li key={u.id}>{u.name}</li>)}</ul>;
}当 url 变化时,Effect 重新运行:先通过取消标志使上一次请求的回调忽略结果,再发起新请求,从而避免竞态导致旧数据覆盖新数据。
依赖项规则与常见问题
Effect 中用到的所有响应式值(props、state、组件内部定义的变量和函数)都必须声明在依赖数组中。eslint-plugin-react-hooks 的 exhaustive-deps 规则可以自动检查。依赖数组长度必须固定,不能使用 [...someArray] 等动态生成的形式。
遗漏函数依赖是常见错误。下面的例子中 fetchResults 是组件内部函数,每次渲染都会重新创建,因此需要加入依赖数组:
tsx
function SearchResults({ query }: { query: string }) {
async function fetchResults(q: string) {
// 请求数据
}
useEffect(() => {
fetchResults(query);
}, [query]); // 缺少 fetchResults 依赖
}修正方式有两种:将 fetchResults 移入 Effect 内部,或使用 useCallback 包裹后再加入依赖。
另一种情况是 Effect 内部修改了自身依赖的 state,从而形成更新循环。此时应当重新审视是否需要 Effect——有些派生状态完全可以在渲染过程中直接计算,不必依赖 Effect。
副作用处理与组件通信
useEffect 让函数组件得以安全地执行订阅、请求、DOM 操作等副作用;依赖数组提供精确控制,清理函数防止资源泄漏。自定义 Hook 则把这些逻辑变得可复用、可声明,同时保持与 React 规则的一致性。
在组件中管理副作用之后,另一个常见任务是跨层级传递数据。下一章将介绍 Context,它允许将数据从祖先组件直接递送到任意深度的后代,而不必逐层转发 props。
