用 React 钩子开发也有段时间了,从一开始对着文档小心翼翼地敲代码,到现在能根据场景灵活搭配使用,中间踩过不少坑,也攒了些自己的理解。今天不想搞成教科书式的讲解,就想以实战角度聊聊这些朝夕相处的钩子,说说那些 API 之外的细节和感悟。
useState
用useState
时,我一直以为它就是普通的状态管理工具,直到有次遇到了这个问题:
jsx
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 加入依赖数组,要么用函数式更新:
jsx
// 函数式更新:总能拿到最新状态
setCount(prev => prev + 1);
为什么会有这种 "捕获" 特性?这和 React 内部的状态管理机制有关。React 会为每个组件维护一个钩子链表,每个useState
调用都会按顺序对应链表中的一个节点,节点里存储着当前的状态值和更新函数。这种按调用顺序关联状态的设计,也决定了 Hooks 必须放在函数顶部、不能在条件语句里调用 ------ 一旦顺序乱了,状态节点就会对应错误,整个组件的状态体系都会错乱。
有个容易忽略的点:useState
的初始值只在首次渲染生效。如果初始值计算昂贵,直接写会导致每次渲染都执行计算,这时候应该用函数形式:
jsx
// 正确:初始值函数只执行一次
const [data, setData] = useState(() => heavyCalculation());
这个小细节让我明白,哪怕是最基础的钩子,也藏着 React 的设计哲学 ------ 状态不可变性。现在写 useState
时,遇到对象或数组状态,我都会下意识地用扩展运算符或者 map
、filter
这类返回新值的方法。
useReducer
当数据状态变多的时候,useStae 就显得有些力不从心了。比如:用户名、密码、邮箱、验证码... 每个字段一个状态,提交时还要逐个处理,代码乱得像一团麻。这个时候就得考虑一下useReducer
了。
jsx
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 的变化不会触发重渲染
- 可以保存跨渲染周期的值
jsx
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、上一次的状态值等需要跨渲染周期保存的数据。 比如实现一个 "只在第二次点击时执行" 的功能:
jsx
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 更新的临时数据,相当于给组件开了个 "内存空间"。
useEffect
useEffect
大概是踩坑最多的钩子了。一不小心就会写出无限循环:
jsx
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:
jsx
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 根本没变。
jsx
// 子组件
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
缓存计算结果:
jsx
// 缓存函数
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
缓存的函数内部如果用到了组件内的变量,一定要把它们加入依赖数组,否则可能拿到旧值:
jsx
// 错误:依赖缺失
const handleClick = useCallback(() => {
console.log(count); // 可能拿到旧的count
}, []);
// 正确:包含所有依赖
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
其他实用钩子
useContext
是个很方便的状态共享工具,它的原理是通过 "上下文查找" 实现跨组件通信。但要注意它的性能问题 ,只要 Provider 的值变了,所有消费它的组件都会重渲染,不管用到的具体值有没有变化。解决办法是拆分 Context,或者用 useMemo
缓存 Provider 的值:
jsx
// 优化:避免不必要的Context更新
<MyContext.Provider value={useMemo(() => ({
user,
updateUser
}), [user, updateUser])}>
{children}
</MyContext.Provider>
useImperativeHandle
则是为了 "定制暴露给父组件的实例方法"。它的设计初衷是避免将完整的 DOM 元素暴露给父组件,增强组件封装性:
jsx
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
钩子:
jsx
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 };
}
用的时候就特别清爽:
jsx
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 的你一点参考,要是你有不同的使用心得,欢迎在评论区交流 ------ 技术这东西,越聊越通透嘛。