小Cの已经记不起来的博客

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 被多处调用会怎样”,能省掉后面不少麻烦。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放