本文聚焦两件事:函数组件怎么写,以及不同 Hook 解决什么问题。 全文分为五部分:
- 一、函数组件基础:组件怎么写、有哪些坑。
- 二、Hook 基础:Hook 是什么、几条必须记住的规则。
- 三、常用 Hook:日常开发天天用。
- 四、非常用 Hook:特定场景 / 进阶。
- 五、工程实践:自定义 Hook、性能、陷阱清单。
示例基于 React 19。多数基础 API 从 React 16.8 就有,React 19 新增的 API 会标注版本。
一、函数组件基础
1.1 函数组件是什么
一个函数组件,就是一个返回 JSX 的函数:
jsx
function Welcome({ name }) {
return <h1>你好,{name}</h1>;
}
- 组件就是函数:和写普通函数一样,用组合代替继承。
props是只读的 :子组件不能修改父组件传来的props,数据只能自上而下流动。- 渲染要纯净 :渲染期间不要改外部变量、发请求、操作 DOM。同样的
props+state,应该渲染出同样的结果。 - 其他逻辑使用 hook 和事件 :渲染期间只负责算 UI。需要和外部系统同步(订阅、定时器、请求)交给
useEffect等 Hook;响应用户交互则写进事件处理函数。
StrictMode下,开发环境会故意把渲染函数调用两次、把 Effect 按「挂载 → 卸载 → 再挂载」跑一遍(生产环境不会)。它靠「让不纯的代码把结果放大一倍」来暴露问题:比如渲染里改了外部变量,数字就会翻倍。
jsx
// 🔴 渲染期间改了外部变量
let renderCount = 0;
function Bad() {
renderCount++; // 每次渲染都在改它
return <p>渲染了 {renderCount} 次</p>;
}
// StrictMode 下渲染被调用两遍 → 页面每次显示 +2
1.2 渲染模型:渲染即快照
每次渲染,都是把组件函数重新调用一次 。这一次渲染里的 props、state、函数,都被「冻结」在那一刻------也就是闭包快照。
jsx
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setTimeout(() => {
alert(count); // 🔴 弹出的是「点击那一刻」的 count
}, 3000);
}
return (
<>
<p>{count}</p>
<button onClick={handleClick}>3 秒后提示</button>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</>
);
}
setTimeout 闭包捕获了旧值,即使 count 更新了,还是会使用上次渲染时的 count 值。要读「最新值」又不触发渲染,用 ref 把当前值存下来,回调里读 ref.current:
jsx
const countRef = useRef(count);
// 不要直接在渲染里写 ref,放到 Effect 中同步
useEffect(() => {
countRef.current = count; // 每次 count 变化后同步最新值
}, [count]);
function handleClick() {
setTimeout(() => {
alert(countRef.current); // ✅ 读到最新值
}, 3000);
}
1.3 组件的类型和 key
React 靠元素类型 + key 判断一个组件是「复用」还是「重建」:
- 同位置同类型 → 复用,保留内部 state;
- 类型变化 → 卸载重建,state 重置;
key变化 → 视为另一个组件,同样重建。
用 key 主动重置状态
当同一个组件承载不同数据、你希望它们互不共享状态时:
jsx
// 切换用户时 key 变化 → Chat 重新挂载,草稿自动清空
<Chat key={userId} userId={userId} />
不要在组件内部定义组件
jsx
// 🔴 每次渲染都创建「新类型」,导致整棵子树卸载重建:state 丢失、性能变差
function Parent() {
function Child() { return <p>hi</p>; }
return <Child />;
}
1.4 函数组件 vs 类组件
结论:新代码一律用「函数组件 + Hook」。
类组件没有废弃,React 19 仍然支持,但新代码通常选择函数组件。许多新 API(例如 useActionState)只提供给函数组件;存量老代码不必为了使用 Hook 而一次性重写,两种组件可以在同一棵组件树中共存。
类组件目前唯一难以替代 的场景是错误边界 (componentDidCatch / getDerivedStateFromError 没有 Hook 等价物,通常借助 react-error-boundary 库)。
生命周期 → Hook 对照
| 类组件生命周期 | 函数组件替代方案 |
|---|---|
constructor |
useState(初始化函数) / useReducer |
componentDidMount |
useEffect(fn, []) |
componentDidUpdate |
useEffect(fn, [deps]) |
componentWillUnmount |
useEffect 的清理函数 return () => {} |
shouldComponentUpdate / PureComponent |
React.memo + useMemo / useCallback |
getDerivedStateFromProps |
渲染期直接推导(尽量别用) |
getSnapshotBeforeUpdate |
useLayoutEffect(时机接近,非精确等价) |
componentDidCatch / getDerivedStateFromError |
无 Hook 替代 ,仍需类组件或 react-error-boundary |
forceUpdate() |
useReducer((x) => x + 1, 0)(反模式,不推荐) |
二、Hook 基础
2.1 Hook 是什么、为什么需要它
Hook 是让函数组件拥有状态和副作用能力的函数 ,都以 use 开头。
它主要解决逻辑复用:类组件时代要有状态地复用逻辑,得靠 HOC(高阶组件:用函数「包住」组件、返回增强后的新组件)或 render props(把函数作为 prop 传入,用参数接收内部状态),层层嵌套、难以阅读;用自定义 Hook,逻辑可以直接抽成一个函数,在多处调用。
jsx
function useOnline() { /* 一段可复用的逻辑 */ }
function A() { const online = useOnline(); /* ... */ }
function B() { const online = useOnline(); /* ... */ } // 直接复用
2.2 Hook 调用规则
Hook 只能在函数组件(或自定义 Hook)的顶层调用,不能写在条件、循环、嵌套函数里。
原因很简单:React 靠调用顺序 来对应每个 Hook 的状态------第 1 次调用对应第 1 个 Hook,第 2 次对应第 2 个。所以两次渲染之间,Hook 的数量和顺序必须一致。
错误写法:
jsx
// 🔴 条件里调用:count 变化后 Hook 数量变了,状态错配 / 直接报错
if (count > 0) {
const [name, setName] = useState('a');
}
// 🔴 提前 return:后面的 Hook 不执行了
function Profile({ user }) {
if (!user) return null;
const [name, setName] = useState(user.name);
}
// 🔴 写在事件处理里:会报 Invalid hook call
function Component() {
function handleClick() {
const [x] = useState();
}
}
正确写法:所有 Hook 先无条件调用完,再做条件判断 / 返回。
jsx
function Profile({ user }) {
const [name, setName] = useState(user?.name ?? ''); // ✅ Hook 在前
if (!user) return null; // ✅ 条件在后
// ...
}
装上
eslint-plugin-react-hooks,写错的当下就会报警。
2.3 依赖数组机制
带依赖数组的 Hook(useEffect / useLayoutEffect / useMemo / useCallback)都会在每次渲染时比较依赖。依赖没变时,Effect 不会重新同步,Memo/Callback 会复用上一次的结果或函数。
useEffect 的三种写法:
jsx
// ① []:通常只在挂载后执行一次;不要在里面直接读取会变化的值
useEffect(() => { sync(count); }, []);
// ② [count]:count 变化时才执行,读到最新值
useEffect(() => { sync(count); }, [count]);
// ③ 不写:每次渲染后都执行,永远读最新
useEffect(() => { sync(count); });
三、常用 Hook
3.1 useState ------ 组件自己的记忆
声明一个会触发重新渲染的状态变量,返回「当前值 + 更新函数」。它是函数组件记住数据、变化后刷新界面的主要方式。
适用场景
- 表单输入、开关、选中项等简单可变状态;
- 需要触发重新渲染的本地数据。
示例:受控输入框
jsx
import { useState } from 'react';
function NameForm() {
const [name, setName] = useState('');
return (
<label>
姓名:
<input value={name} onChange={(e) => setName(e.target.value)} />
</label>
);
}
3.2 useEffect ------ 与外部系统同步
在渲染提交后执行副作用,把组件和 React 之外的世界同步。依赖变化或首次挂载时运行,组件卸载或依赖再次变化前会先执行清理函数。
适用场景
- 请求数据、订阅事件、启动定时器,并在卸载时清理;
- 同步
document.title、localStorage等副作用。
示例:订阅与清理
jsx
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect(); // 清理函数
}, [roomId]);
示例:请求数据 + 取消
jsx
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((err) => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort();
}, [userId]);
3.3 useRef ------ 不触发渲染的「盒子」
返回一个带 current 属性的可变盒子,读写它都不会引起重新渲染。可以把它理解成「跨渲染存活的普通变量」,和 state 不同,改它不会刷新界面。
适用场景
- 获取 DOM 节点并调用其方法(聚焦、滚动、播放);
- 保存定时器 id、上一次的值、请求标记等「渲染之外」的数据。
示例:操作 DOM
jsx
function TextInput() {
const inputRef = useRef(null);
return (
<>
<input ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>聚焦</button>
</>
);
}
示例:保存定时器 id
jsx
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
function start() {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => setSeconds((s) => s + 1), 1000);
}
function stop() {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// ...
}
3.4 useContext ------ 跨层级读取共享数据
读取最近的 Context Provider 提供的值,让深层组件不必一层层通过 props 传递。适合跨层级共享主题、语言、登录用户等数据。
适用场景
- 主题、语言、当前登录用户、权限等全局或大范围共享的数据;
- 常配合自定义 Hook 封装,对外暴露更友好的接口。
示例:主题
jsx
import { createContext, useContext } from 'react';
// 默认值只在没有 Provider 时使用;设为 undefined 便于发现漏写 Provider
const ThemeContext = createContext(undefined);
function Toolbar() {
const theme = useContext(ThemeContext);
return <button className={theme}>按钮</button>;
}
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
示例:封装成自定义 Hook
jsx
function useTheme() {
const theme = useContext(ThemeContext);
if (theme === undefined) throw new Error('useTheme 必须在 Provider 内使用');
return theme;
}
3.5 useMemo ------ 缓存计算结果
在依赖不变时复用上一次的计算结果,避免每次渲染都重跑昂贵运算。返回缓存下来的值(计算结果、对象或数组)。
适用场景
- 大列表过滤 / 排序 / 统计等耗时计算;
- 需要给子组件或 Effect 稳定对象 / 数组引用。
示例
jsx
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
// 稳定对象引用,避免子组件因「每次都是新对象」而重渲染
const options = useMemo(() => ({ page, size }), [page, size]);
3.6 useCallback ------ 缓存函数引用
在依赖不变时返回同一个函数引用,避免函数在每次渲染时被重新创建。本质上是「缓存函数的 useMemo」。
适用场景
- 把回调传给用
memo包裹的子组件,避免其无谓重渲染; - 函数作为其他 Hook(如
useEffect)的依赖时,保持引用稳定。
示例
jsx
const handleDelete = useCallback((id) => {
setTodos((prev) => prev.filter((t) => t.id !== id));
}, []);
// <TodoItem onDelete={handleDelete} />
3.7 useReducer ------ 复杂状态逻辑
把多个状态的更新逻辑集中到一个纯函数 reducer 里,通过 dispatch(action) 触发,类似简化版 Redux,返回 [state, dispatch]。
适用场景
- 多个互相关联的状态字段,更新逻辑复杂;
- 状态变化能清晰表示成一系列「动作」(action);
- 想把更新逻辑抽出来单独测试。
示例:计数器状态机
jsx
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
default:
throw Error('未知 action:' + action.type);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</>
);
}
3.8 useTransition ------ 把更新标记为非阻塞
把某次状态更新标记为「过渡更新」,让它可以被紧急更新(如输入)打断,从而保持界面响应。返回 [isPending, startTransition]。
适用场景
- 切换 Tab、筛选、路由跳转等可能渲染较重的内容;
- 用
isPending展示加载反馈。
示例:切换标签页不卡输入
jsx
function TabContainer() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
function select(nextTab) {
startTransition(() => {
setTab(nextTab); // 这次更新可被打断
});
}
return (
<>
<button onClick={() => select('home')}>首页</button>
<button onClick={() => select('posts')}>文章</button>
{isPending && <Spinner />}
{tab === 'home' ? <Home /> : <Posts />}
</>
);
}
3.9 useDeferredValue ------ 让慢部分「滞后」更新
拿到某个值的「延迟版本」:先用旧值渲染保证响应,再在后台用最新值追上。它的更新可以被更高优先级的更新打断。
适用场景
- 搜索联想、实时过滤大列表:输入框要即时,结果列表可以稍慢;
- 当值来自 props、无法在产生更新的地方调用
startTransition时,可以用它把慢组件的更新推迟。
示例:输入即时、列表延迟
jsx
function App() {
const [text, setText] = useState('');
const deferredText = useDeferredValue(text);
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<SlowList text={deferredText} />
</>
);
}
3.10 useId ------ 生成稳定的无障碍 id
生成一个在服务端和客户端都一致、且在本组件内唯一的 id,专门用于 id / htmlFor、aria-* 等无障碍属性。
适用场景
label与表单控件关联(htmlFor/id);aria-describedby、aria-labelledby等无障碍属性。
示例
jsx
function PasswordField() {
const id = useId();
return (
<>
<label htmlFor={id}>密码</label>
<input id={id} type="password" />
</>
);
}
四、非常用 Hook
4.1 useLayoutEffect ------ 在浏览器绘制前同步执行
useEffect 的同步版本,在 DOM 更新后、浏览器重绘前立即执行。因为它会阻塞绘制,只有确实需要避免闪烁时才用。
适用场景
- 需要读布局信息(尺寸、位置)并同步调整 UI,避免画面闪烁;
- 工具提示定位、滚动位置恢复、测量元素。
示例:测量并定位工具提示
jsx
function Tooltip({ targetRef }) {
const [rect, setRect] = useState(null);
useLayoutEffect(() => {
const node = targetRef.current;
if (node) setRect(node.getBoundingClientRect());
}, [targetRef]);
return rect && <div style={{ top: rect.bottom }}>提示</div>;
}
4.2 useImperativeHandle ------ 定制对外暴露的 ref
配合 ref 使用,把子组件对外暴露的内容「收窄」成一组指定方法,而不是整个 DOM 节点。
适用场景
- 封装第三方输入组件时,只暴露
focus()、clear()、scrollTo()等; - 库 / 设计系统组件。
示例
jsx
import { useRef, useImperativeHandle } from 'react';
// React 19:ref 可以像普通 prop 一样接收
function FancyInput({ ref }) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus() { inputRef.current.focus(); },
clear() { inputRef.current.value = ''; },
}), []);
return <input ref={inputRef} />;
}
// 父组件:<FancyInput ref={inputRef} /> 后调用 inputRef.current.focus()
4.3 useSyncExternalStore ------ 订阅外部 store
安全地订阅 React 之外的数据源,并在数据变化时触发重新渲染,保证并发渲染下数据一致。是状态库与 React 对接的标准方式。
适用场景
- 集成第三方状态库(Redux、Zustand 等内部就用它);
- 订阅浏览器暴露的可变值(网络状态、媒体查询、窗口尺寸)。
示例:订阅网络状态
jsx
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
const getSnapshot = () => navigator.onLine;
const useOnlineStatus = () =>
useSyncExternalStore(subscribe, getSnapshot, () => false);
// 用法:在组件里像普通 Hook 一样调用
function StatusBar() {
const isOnline = useOnlineStatus();
return <p>{isOnline ? '在线' : '离线'}</p>;
}
4.4 useActionState ------ 管理 Action 的状态(React 19)
把表单提交 / 异步 Action 的「结果状态 + pending 状态 + 触发函数」打包管理,返回 [state, formAction, isPending]。
适用场景
- 表单提交,需要展示校验错误、提交中状态;
- 配合 Server Actions /
<form action={...}>使用。
示例:登录表单
jsx
import { useActionState } from 'react';
async function submitAction(prevState, formData) {
const name = formData.get('name');
if (!name) return { error: '姓名不能为空' };
await saveToServer(name);
return { success: true };
}
function NameForm() {
const [state, formAction, isPending] = useActionState(submitAction, null);
return (
<form action={formAction}>
<input name="name" />
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
<button disabled={isPending}>
{isPending ? '提交中...' : '提交'}
</button>
</form>
);
}
4.5 useOptimistic ------ 先展示乐观结果(React 19)
在异步请求真正返回前,先把「预期结果」显示给用户,等真实结果到达后自动替换;请求失败则会回滚。
适用场景
- 点赞、发消息、加购物车等「先给反馈、再等服务端」的交互;
- 配合
useActionState/<form action>/startTransition使用。
示例:点赞乐观更新
jsx
function LikeButton({ likes, saveLike }) {
const [optimisticLikes, addOptimistic] = useOptimistic(
likes,
(current, change) => current + change
);
async function action() {
addOptimistic(1);
await saveLike();
}
return <form action={action}><button>赞 {optimisticLikes}</button></form>;
}
4.6 useFormStatus ------ 在子组件读取表单提交状态(react-dom)
在表单的子组件 里读取最近一次提交的状态(主要是 pending),无需父级通过 props 透传。从 react-dom 引入,不是 react。
适用场景
- 提交按钮、加载指示器放在子组件里,却想知道父级
<form>是否在提交; - 配合
<form action={...}>(Server Actions /useActionState)使用。
示例:子组件读取提交状态
jsx
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? '提交中...' : '提交'}</button>;
}
function Form() {
return <form action={submitAction}><SubmitButton /></form>;
}
4.7 useDebugValue ------ 给自定义 Hook 加调试标签
为自定义 Hook 在 React DevTools 中显示一个可读的标签,属于纯开发体验的优化。
适用场景
- 自定义 Hook 希望在 DevTools 中展示更有意义的标签;
- 排查库或团队内部 Hook 问题时辅助定位。
示例:给自定义 Hook 加标签
jsx
function useOnlineStatus() {
const online = useSyncExternalStore(subscribe, getSnapshot);
useDebugValue(online ? '在线' : '离线');
return online;
}
4.8 useInsertionEffect ------ 在 DOM 变更前插入样式
在 React 修改 DOM 之前触发,专供 CSS-in-JS 库在布局读取前插入动态样式。
适用场景
- 样式库需要在布局读取前把
<style>插入 DOM; - 普通业务代码几乎不会用到。
示例:注入动态样式
jsx
useInsertionEffect(() => {
const style = document.createElement('style');
style.textContent = `.box { color: ${color}; }`;
document.head.appendChild(style);
return () => style.remove();
}, [color]);
4.9 useEffectEvent ------ 在 Effect 中读取最新值(React 19.2)
让 Effect 中的回调读到最新的 props / state,同时 Effect 不会因为这个值变化而重建。
适用场景
- Effect 的响应式依赖(如
roomId)决定它何时重新同步------这些值要留在依赖数组里; - 但 Effect 里注册的回调(连接、订阅、定时器等)又需要读取最新 的
theme等值;Effect 不重建时,普通闭包只能读到旧值; - 把这类「只读最新值、不决定是否重建」的逻辑放进 Effect Event,即可同时做到「不重建」和「读到最新值」。
示例:连接回调读取最新主题
jsx
const onConnected = useEffectEvent(() => {
showNotification('连接成功', theme); // 读取最新 theme
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // theme 变化不会重建连接
五、工程实践
5.1 自定义 Hook ------ 真正的「复用逻辑」
把多个组件共享的状态逻辑,抽成一个以 use 开头的函数,就是自定义 Hook。它本质是普通 JavaScript 函数,内部可以调用其它 Hook。
jsx
// useWindowSize.js
import { useState, useEffect } from 'react';
export function useWindowSize() {
const [size, setSize] = useState(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
useEffect(() => {
const onResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return size;
}
这个例子适用于浏览器环境;如果项目使用 SSR,需要避免在服务端初始化时直接访问 window,并为首屏准备一个服务端可用的初始值。
jsx
// 使用
const { width } = useWindowSize();
5.2 渲染性能
在写 useMemo / useCallback 之前,先考虑两个更简单、收益更大的手段。
手段一:状态下沉(colocation)
把 state 放到真正用到它的最近组件,而不是一股脑堆在顶层。state 在谁那里,谁变化时就只重渲染那棵小树。
jsx
// 🔴 hover 状态放在大列表里,每次 hover 都重渲染整个列表
function List() {
const [hovered, setHovered] = useState(null);
return items.map((item) => (
<Row key={item.id} onHover={() => setHovered(item.id)} />
));
}
// ✅ 把 hover 状态下沉到 Row 内部,List 不再受影响
手段二:用 children 做插槽,隔离重渲染
children 是由父组件 渲染后作为「元素」传进来的。拥有自己 state 的组件重渲染时,并不会重新创建这个元素,因此 children 可以跳过重渲染------不写 memo 也能优化:
jsx
function ColorBox({ children }) {
const [color, setColor] = useState('red');
// color 变化只会重渲染 ColorBox 自身;
// children 是同一个元素引用,ExpensiveList 不会重渲染。
return (
<div style={{ color }}>
<input value={color} onChange={(e) => setColor(e.target.value)} />
{children}
</div>
);
}
// 把昂贵的子树作为 children 传进去
<ColorBox>
<ExpensiveList />
</ColorBox>
前提是 <ExpensiveList /> 由 ColorBox 的上层 创建并传入,而不是写在 ColorBox 内部。
5.3 常见陷阱 checklist
提交前对照一遍,能挡掉大部分低级问题:
- Hook 都在顶层,没写在条件 / 循环 / 提前
return前后。 -
useEffect的依赖数组写全了里面用到的响应式值。 - 没在渲染期间做副作用(改外部变量、发请求、操作 DOM)。
-
state是返回新对象更新的,没有直接改原对象。 - 列表没有用数组下标当
key;key来自数据本身。 - 没有在组件内部定义组件。
- 需要读最新值的地方用了函数式更新或 ref,而不是直接读闭包旧值。
- 表单优先受控(
value+onChange)。 - 没有「见计算就包
useMemo」。 - 能靠 props / state 推导的值,没有用
useEffect存一份。