React 函数组件中自定义 Hook 的常见状态管理误区
前阵子把项目里获取用户信息的逻辑抽成了一个 useUser Hook,想着一次封装,到处能用,美滋滋。结果上线前测试发现,页面里两个组件显示的用户数据对不上,Network 面板里同一个接口还被请求了两次。排查了半天,才想起来一个特别基础、但就是容易忘的事:自定义 Hook 根本不是单例,每个组件调用它,都会拿到一份完全独立的状态。
下面这几个误区,基本都是我亲自踩过、或者帮同事排查过的,写出来也算是给自己提个醒。
误区一:以为自定义 Hook 里的状态是共享的
这是最常见的一个,也是我栽得最狠的一个。看代码:
function useUser() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
return user;
}
看起来没毛病对吧?但只要有两个组件各自调用了 useUser(),那就是两份独立的 user,两遍请求,互不相干。你在 A 组件里更新了用户昵称,B 组件纹丝不动。
自定义 Hook 本质上就是把 useState、useEffect 这些东西换个地方调用而已,它没有魔法,不会帮你做全局共享。真要共享状态,老老实实上 Context,或者 zustand 这类状态库,别指望一个函数能替你解决所有问题。
误区二:闭包里拿到的永远是旧值
第二个坑,定时器里改计数,怎么改都是 1:
function useTimer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 永远打印 0
setCount(count + 1); // 永远是 0 + 1
}, 1000);
return () => clearInterval(timer);
}, []);
}
原因是 useEffect 的依赖数组是空的,这个回调捕获的是第一次渲染时的 count,后面不管 state 怎么变,闭包里的 count 还是那个 0。
修法很简单,函数式更新一把梭:
setCount(c => c + 1);
如果要在事件监听、异步回调里读取最新的 state,可以用 useRef 存一份最新值,或者干脆把依赖补全。总之别相信闭包里的 state,它大概率是过期的。
误区三:一个 Hook 里塞十几个 useState
接手过一个大表单的 Hook,里面密密麻麻十几个 useState,返回值是一个十几项的大对象。调用它的组件,随便改一个字段,整棵组件树跟着重渲染,性能直接拉胯。
字段一多,优先考虑 useReducer:
function useBigForm() {
const [state, dispatch] = useReducer(formReducer, {
name: '',
age: 0,
address: '',
// ...
});
return { state, dispatch };
}
相关的状态收拢在一起,更新逻辑集中到 reducer 里,测试也好写。另外顺手检查一下这个 Hook 是不是太贪心了,里面是不是混了几个不相关的功能,拆开也许更合理。
误区四:在条件判断、循环里调用 Hook
这个属于一看就懂、写的时候手滑的类型:
function Profile({ isLogin }) {
if (!isLogin) {
return <div>请先登录</div>;
}
const user = useUser(); // 直接报错
return <div>{user.name}</div>;
}
React 是靠调用顺序来记住每个 Hook 对应的 state 的,这次渲染调了、下次渲染没调,顺序一乱,state 全对不上号,React 直接给你甩一个报错。
正确姿势是把提前 return 放到所有 Hook 调用之后,或者把条件挪进 Hook 内部。总之 Hook 只能出现在函数组件或另一个 Hook 的最顶层。
误区五:不把 ESLint 的依赖警告当回事
react-hooks/exhaustive-deps 的警告弹出来,看一眼,哦,加上 [count] 就好了。但有些人图省事,直接 // eslint-disable-next-line 糊脸上,然后这个 effect 就开始悄悄用旧值了。
还有一种更隐蔽的情况,依赖了对象或函数:
useEffect(() => {
doSearch(options);
}, [options]);
父组件每次渲染都新建一个 options 字面量,引用变了,这个 effect 就无限触发,请求打到怀疑人生。
要么用 useMemo / useCallback 把引用稳住,要么别依赖整个对象,只依赖真正用到的那个基础类型字段。ESLint 比你想象的靠谱,警告还是老老实实处理吧。
误区六:能算出来的,就别再存一份 state
最后这个不算大坑,但很常见:基于已有的 state 再派生一个 state,还要小心翼翼同步:
const [list, setList] = useState([]);
const [keyword, setKeyword] = useState('');
const [filteredList, setFilteredList] = useState([]);
useEffect(() => {
setFilteredList(list.filter(item => item.name.includes(keyword)));
}, [list, keyword]);
多一份 state,就多一次忘记同步的机会,还会多渲染一轮。直接算就完了:
const filteredList = list.filter(item => item.name.includes(keyword));
数据量真大到需要缓存,再上 useMemo 也来得及。
写在最后
以上这些坑,说实话每一个都踩得挺疼的,尤其第一个,当时排查了快一个小时,最后发现是自己的基础认知问题,还挺尴尬的。自定义 Hook 本身是个好东西,逻辑复用确实方便,但它只是把 Hooks 换了个地方写,useState 的规则、闭包的规则,一个都跑不掉。封装的时候多想一步“这个 Hook 被多处调用会怎样”,能省掉后面不少麻烦。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。