用 React 钩子开发也有段时间了,从一开始对着文档小心翼翼地敲代码,到现在能根据场景灵活搭配使用,中间踩过不少坑,也攒了些自己的理解。今天不想搞成教科书式的讲解,就想以实战角度聊聊这些朝夕相处的钩子,说说那些 API 之外的细节和感悟。
useState
用useState时,我一直以为它就是普通的状态管理工具,直到有次遇到了这个问题:
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
// 这里的count永远是0
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
这之后才明白,useState的状态更新其实是 "捕获" 机制 —— 每次渲染都是独立的快照,也就是只替换不修改,effect 里拿到的是创建时的 count 值。解决办法有两个:要么把 count 加入依赖数组,要么用函数式更新:
// 函数式更新:总能拿到最新状态
setCount(prev => prev + 1);
为什么会有这种 "捕获" 特性?这和 React 内部的状态管理机制有关。React 会为每个组件维护一个钩子链表,每个useState调用都会按顺序对应链表中的一个节点,节点里存储着当前的状态值和更新函数。这种按调用顺序关联状态的设计,也决定了 Hooks 必须放在函数顶部、不能在条件语句里调用 —— 一旦顺序乱了,状态节点就会对应错误,整个组件的状态体系都会错乱。
有个容易忽略的点:useState的初始值只在首次渲染生效。如果初始值计算昂贵,直接写会导致每次渲染都执行计算,这时候应该用函数形式:
// 正确:初始值函数只执行一次
const [data, setData] = useState(() => heavyCalculation());
这个小细节让我明白,哪怕是最基础的钩子,也藏着 React 的设计哲学 —— 状态不可变性。现在写 useState 时,遇到对象或数组状态,我都会下意识地用扩展运算符或者 map、filter 这类返回新值的方法。
useReducer
当数据状态变多的时候,useStae 就显得有些力不从心了。比如:用户名、密码、邮箱、验证码... 每个字段一个状态,提交时还要逐个处理,代码乱得像一团麻。这个时候就得考虑一下useReducer了。
function Form() {
const [state, dispatch] = useReducer(formReducer, {
username: '',
password: '',
isSubmitting: false
});
const handleChange = (e) => {
dispatch({
type: 'UPDATE_FIELD',
name: e.target.name,
value: e.target.value
});
};
// 其他处理逻辑...
}
function formReducer(state, action) {
switch(action.type) {
case 'UPDATE_FIELD':
return { ...state, [action.name]: action.value };
case 'SUBMIT_START':
return { ...state, isSubmitting: true };
// 更多case...
default:
return state;
}
}
用 useReducer 后,所有状态更新逻辑都集中在 reducer 里,组件里只需要通过 dispatch 发送动作就行。这种方式特别适合状态之间有关联,或者更新逻辑比较复杂的场景。如果要处理的状态逻辑超过三个,就可以考虑使用useReducer了。
useReducer的本质是把状态更新逻辑抽离出来,形成一个纯函数。它接收两个参数:reducer 函数和初始状态,返回当前状态和 dispatch 方法。最妙的是,它的状态更新是可预测的 —— 相同的初始状态和 action,一定得到相同的新状态。
从底层看,useReducer 其实是 useState 的 "加强版"。React 源码里,useState 就是通过 useReducer 实现的,相当于一个简化版的 reducer。当你的状态更新依赖前一个状态,或者有多个子值时,useReducer比useState更合适。
还有一个冷知识:useReducer 返回的 dispatch 函数是 “稳定的”—— 在组件生命周期内引用不会变化。这是因为 dispatch 函数在 useReducer 内部只会创建一次,存储在 hook 节点里,不会随状态更新而重新创建。所以把 dispatch 放进 useEffect 依赖数组时,可以安全地省略。
useRef
处理表单焦点时,useRef 帮了我大忙。它创建的 ref 对象就像个容器,可以在组件生命周期内保存任意值,而且更新 ref 不会触发重新渲染。本质上,useRef创建的是一个可以在组件生命周期内保持不变的容器,它有两个重要特性:
- ref.current 的变化不会触发重渲染
- 可以保存跨渲染周期的值
function SearchInput() {
const inputRef = useRef(null);
const focusInput = () => {
inputRef.current.focus();
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>聚焦输入框</button>
</div>
);
}
从实现原理来看,useRef 创建的对象和普通 JS 对象没什么区别,只是 React 保证它在组件的整个生命周期中保持同一个引用。这也是为什么修改 ref.current 不会触发重渲染。 因为它不属于 React 的状态管理体系,不会引起组件更新机制的运行。
除了访问 DOM,我还发现 useRef 特别适合存储定时器 ID、上一次的状态值等需要跨渲染周期保存的数据。
比如实现一个 "只在第二次点击时执行" 的功能:
function DoubleClick() {
const clickCount = useRef(0);
const handleClick = () => {
clickCount.current += 1;
if (clickCount.current === 2) {
alert('双击触发!');
clickCount.current = 0;
}
};
return <button onClick={handleClick}>点击两次</button>;
}
这是利用 ref 的 "持久性" 来存储那些不需要触发 UI 更新的临时数据,相当于给组件开了个 "内存空间"。
forwarUrl
当需要在父子组件之间传递 ref 时,forwardRef 就成了必不可少的工具。它能让子组件接收父组件传来的 ref,并将其绑定到自身的某个 DOM 元素上,实现跨组件的 DOM 访问。
比如父组件想操作子组件内部的输入框,就可以这样配合使用:
// 子组件通过 forwardRef 接收 ref
const CustomInput = forwardRef((props, ref) => {
return <input ref={ref} type="text" {...props} />;
});
// 父组件使用子组件并传递 ref
function ParentComponent() {
const inputRef = useRef(null);
const focusChildInput = () => {
inputRef.current.focus();
};
return (
<div>
<CustomInput ref={inputRef} placeholder="子组件输入框" />
<button onClick={focusChildInput}>聚焦子组件输入框</button>
</div>
);
}
这里的关键是 forwardRef 会创建一个转发 ref 的组件,它接收两个参数:props 和 ref。子组件可以直接将这个 ref 绑定到内部的 DOM 元素,让父组件的 ref 能够穿透到子组件的具体节点。
需要注意的是,只有函数组件才能通过 forwardRef 接收转发的 ref,类组件有自己的 ref 处理方式。另外,forwardRef 并不会改变组件接收 props 的方式,只是在原有 props 基础上额外增加了 ref 参数。
在高阶组件中,forwardRef 也经常用来解决 ref 透传问题。比如创建一个日志高阶组件时,如果不处理 ref,父组件的 ref 会绑定到高阶组件本身,而不是被包裹的组件。这时用 forwardRef 转发一下就能解决:
function withLogging(WrappedComponent) {
return forwardRef((props, ref) => {
console.log('组件渲染了');
return <WrappedComponent {...props} ref={ref} />;
});
}
// 使用高阶组件
const LoggedInput = withLogging(CustomInput);
这种组合方式既保留了高阶组件的功能,又不影响 ref 的正常传递。
总结来说,useRef 负责创建可持久化的容器,forwardRef 负责打破组件边界传递 ref,两者配合能灵活处理各种跨组件 DOM 操作场景。它们共同扩展了 React 中 ref 的使用范围,让我们在需要直接操作 DOM 或传递持久化数据时更加得心应手。
useEffect
useEffect 大概是踩坑最多的钩子了。一不小心就会写出无限循环:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// 问题:依赖数组为空,但内部用到了userId
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, []); // 这里漏了userId依赖
return <div>{user?.name}</div>;
}
这个组件会在 userId 变化时保持显示旧数据,因为空依赖数组让 effect 只执行一次。后来学会了认真处理依赖数组,每次写 useEffect 都会检查内部用到的所有外部变量,确保它们都在依赖数组里。
清理函数也是个容易忽略的点。处理定时器、事件监听时,如果忘了清理,轻则内存泄漏,重则引发奇怪的 bug:
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
// 清理函数:组件卸载或依赖变化时执行
return () => clearInterval(timer);
}, []);
清理函数的执行时机有两个:一是组件卸载时,二是 effect 即将重新执行时。这种设计确保了副作用不会 "残留",始终与当前的组件状态保持同步。
要理解useEffect,首先得明白它的执行时机:组件渲染到屏幕后异步执行。它的工作流程是这样的:
- 渲染阶段:收集 effect 信息,存储依赖数组和回调函数
- 提交阶段:在 DOM 更新后,比较依赖数组,决定是否执行 effect
- 清理阶段:执行上一次的清理函数(如果有的话)
为什么在 effect 里修改 DOM,可能会看到闪烁 ? 因为已经渲染完了。这时候就需要useLayoutEffect来救场。
useLayoutEffect
useLayoutEffect和useEffect的作用其实是一样的。这两个钩子的区别在于执行时机:useEffect 在浏览器渲染完成后执行,是异步的;而 useLayoutEffect 在 DOM 更新后、浏览器渲染前执行,是同步的。
一个判断技巧:如果 effect 里有修改 DOM 的操作,并且希望这些修改不会引起视觉闪烁,就用 useLayoutEffect,否则用 useEffect。不过要注意,useLayoutEffect 会阻塞渲染,别滥用。
useMemo 与 useCallback
性能优化这事儿,我以前总觉得离自己很远,直到遇到列表渲染的性能问题。一个包含几十项的列表,每次父组件状态变化,子组件都会重新渲染,哪怕 props 根本没变。
// 子组件
function ListItem({ item, onDelete }) {
console.log('重新渲染:', item.id);
return (
<div>
{item.name}
<button onClick={() => onDelete(item.id)}>删除</button>
</div>
);
}
// 父组件
function List({ items }) {
// 每次渲染都会创建新的onDelete函数
const onDelete = (id) => {
// 删除逻辑
};
return (
<div>
{items.map(item => (
<ListItem key={item.id} item={item} onDelete={onDelete} />
))}
</div>
);
}
解决办法就是用 useCallback 缓存函数,useMemo 缓存计算结果:
// 缓存函数
const onDelete = useCallback((id) => {
// 删除逻辑
}, []); // 依赖不变时,函数不会重新创建
// 缓存计算结果
const filteredItems = useMemo(() => {
return items.filter(item => item.status === 'active');
}, [items]); // 只有items变化时才重新计算
不过也不要无脑给每个函数都包上useCallback,每个计算都用useMemo。
这两个钩子的本质是缓存:useMemo缓存计算结果,useCallback缓存函数引用。它们的工作原理是比较依赖数组,没变就返回缓存值,变了才重新计算。
从实现角度看,useCallback 其实是 useMemo 的特殊形式:useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。它们都会在组件渲染时检查依赖,不同的是 useMemo 缓存的是函数执行结果,useCallback 缓存的是函数本身。
但缓存也是有成本的:需要额外的内存存储,比较依赖数组也需要时间。所以优化的原则应该是:当计算成本很高,或者传递给子组件的回调 / 值会导致不必要的重渲染时,才考虑使用。
特别要注意,useCallback缓存的函数内部如果用到了组件内的变量,一定要把它们加入依赖数组,否则可能拿到旧值:
// 错误:依赖缺失
const handleClick = useCallback(() => {
console.log(count); // 可能拿到旧的count
}, []);
// 正确:包含所有依赖
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
其他实用钩子
useContext 是个很方便的状态共享工具,它的原理是通过 "上下文查找" 实现跨组件通信。但要注意它的性能问题 ,只要 Provider 的值变了,所有消费它的组件都会重渲染,不管用到的具体值有没有变化。解决办法是拆分 Context,或者用 useMemo 缓存 Provider 的值:
// 优化:避免不必要的Context更新
<MyContext.Provider value={useMemo(() => ({
user,
updateUser
}), [user, updateUser])}>
{children}
</MyContext.Provider>
useImperativeHandle 则是为了 "定制暴露给父组件的实例方法"。它的设计初衷是避免将完整的 DOM 元素暴露给父组件,增强组件封装性:
function CustomInput(props, ref) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
// 只暴露需要的方法,而不是整个DOM
}));
return <input ref={inputRef} />;
}
export default forwardRef(CustomInput);
从本质上来说,它是对 ref 传递机制的一种 "拦截"—— 父组件拿到的 ref 对象,是子组件精心设计的 "接口",而不是原始的 DOM 元素。
自定义 Hook
当多个组件需要相同逻辑时,自定义 Hook 简直是救星。我做过一个项目,好几个组件都需要处理 "加载 - 成功 - 失败" 的异步状态,于是封装了一个 useAsync 钩子:
function useAsync(fn) {
const [state, setState] = useState({
loading: false,
data: null,
error: null
});
const execute = useCallback(async (...args) => {
try {
setState({ loading: true, data: null, error: null });
const result = await fn(...args);
setState({ loading: false, data: result, error: null });
} catch (error) {
setState({ loading: false, data: null, error });
}
}, [fn]);
return { ...state, execute };
}
用的时候就特别清爽:
function UserProfile({ userId }) {
const { loading, data: user, error, execute } = useAsync(fetchUser);
useEffect(() => {
execute(userId);
}, [execute, userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{user?.name}</div>;
}
自定义 Hook 的本质是 "钩子的组合与封装"。它本身并不产生新的功能,而是将已有的 Hooks 按照特定逻辑组合起来,形成可复用的逻辑单元。
但要注意,自定义 Hook 必须以 "use" 开头,而且只能在函数组件或其他自定义 Hook 中调用。这不是语法限制,而是 React 官方的约定 —— 遵循这个约定,React 才能正确检测 Hooks 的调用顺序,确保状态管理不出问题。
写在最后
其实 Hooks 最妙的地方,在于它把复杂的状态逻辑拆解得更清晰了。现在写组件时,我很少刻意纠结 “该用哪个钩子”,更多是顺着逻辑自然选择 —— 简单状态用 useState,复杂逻辑上 useReducer,需要跨渲染存值就用 useRef。
如果说有什么经验可分享,那就是别害怕犯错。刚开始用 useEffect 漏写依赖、用 useCallback 搞错依赖数组都很正常,这些坑踩多了,自然就理解背后的逻辑了。毕竟编程这事儿,从来不是靠记规则学会的,而是在不断试错中摸清规律。
最后想说,这些钩子就像一套精密的工具,没有绝对的好坏,只有合不合适。希望这篇文章能给正在学 Hooks 的你一点参考,要是你有不同的使用心得,欢迎在评论区交流 —— 技术这东西,越聊越通透嘛。