hooks中注意事项

1. 硬性规定

如果你的工程,当前没开启hooks lint rule,请不要编写任何hooks代码。如果你CR代码时,发现对方前端工程,没有开启相应规则,并且提交了hooks代码,请不要合并。该要求适应于任何一个React前端工程。

如若有发现hooks相关lint导致的warning,不要全局autofix,除非保证每处逻辑都做到了充分回归。

然后建议开启vscode的「autofix on save」。未来无论是什么问题,能把error与warning 尽量遏制在最开始的开发阶段,保证自测跟测试时就是符合规则的代码。

2. 常见注意点

依赖问题

function ErrorDemo() {
  const [count, setCount] = useState(0);
  const dom = useRef(null);
  useEffect(() => {
    dom.current.addEventListener('click', () => setCount(count + 1));
  }, []);
  return <div ref={dom}>{count}</div>;
}

这段代码的初始想法是:每当用户点击dom,count就加1。理想中的效果是一直点,一直加。但实际效果是 {count} 到「1」以后就加不上了。

我们来梳理一下, useEffect(fn, []) 代表只会在mount时触发。也即是首次render时,fn执行一次,绑定了点击事件,点击触发 setCount(count + 1) 。乍一想,count还是那个count,肯定会一直加上去呀,当然现实在啪啪打脸。

状态变更 触发 页面渲染的本质是什么?本质就是 ui = fn(props, state, context) 。props、内部状态、上下文的变更,都会导致渲染函数(此处就是ErrorDemo)的重新执行,然后返回新的view。

那现在问题来了, ErrorDemo 这个函数执行了多次,第一次函数内部的 count 跟后面几次的 count 会有关系吗?这么一想,感觉又应该没有关系了。那为什么 第二次又知道 count 是1,而不是0了呢?第一次的 setCount 跟后面的是同一个函数吗?这背后涉及到hooks的一些底层原理,也关系到了为什么hooks的声明需要声明在函数顶部,不允许在条件语句中声明。在这里就不多讲了。

结论是:每次 count 都是重新声明的变量,指向一个全新的数据;每次的 setCount 虽然是重新声明的,但指向的是同一个引用。

回到正题,我们知道了每次render,内部的count其实都是全新的一个变量。那我们绑定的点击事件方法,也即:setCount(count + 1) ,这里的count,其实指的一直是首次render时的那个count,所以一直是0 ,因此 setCount,一直是设置count为1。

那这个问题怎么解?

首先,应该遵守前面的硬性要求,必须要加lint规则,并开启autofix on save。然后就会发现,其实这个 effect 是依赖 count 的。autofix 会帮你自动补上依赖,代码变成这样:

useEffect(() => {
  dom.current.addEventListener('click', () => setCount(count + 1));
}, [count]);

那这样肯定就不对了,相当于每次count变化,都会重新绑定一次事件。所以对于事件的绑定,或者类似的场景,有几种思路,我按我的常规处理优先级排列:

思路1:消除依赖
在这个场景里,很简单,我们主要利用 setCount 的另一个用法 functional updates。这样写就好了:
() => setCount(prevCount => ++prevCount) ,不用关心什么新的旧的、什么闭包,省心省事。

思路2:重新绑定事件
那如果我们这个事件就是要消费这个count怎么办?比如这样:

dom.current.addEventListener('click', () => {
  console.log(count);
  setCount(prevCount => ++prevCount);
});

我们不必执着于一定只在mount时执行一次。也可以每次重新render前移除事件,render后绑定事件即可。这里利用useEffect的特性,具体可以自己看文档:

useEffect(() => {
  const $dom = dom.current;
  const event = () => {
    console.log(count);
    setCount(prev => ++prev);
  };
  $dom.addEventListener('click', event);
  return () => $dom.removeEventListener('click', event);
}, [count]);

思路3:如果嫌这样开销大,或者编写麻烦,也可以用 useRef
其实用 useRef 也挺麻烦的,我个人不太喜欢这样操作,但也能解决问题,代码如下:

const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
  dom.current.addEventListener('click', () => {
    console.log(countRef.current);
    setCount(prevCount => {
      const newCount = ++prevCount;
      countRef.current = newCount;
      return newCount;
    });
  });
}, []);

useCallback与useMemo

这两个api,其实概念上还是很好理解的,一个是「缓存函数」, 一个是缓存「函数的返回值」。但我们经常会懒得用,甚至有的时候会用错。

从上面依赖问题我们其实可以知道,hooks对「有没有变化」这个点其实很敏感。如果一个effect内部使用了某数据或者方法。若我们依赖项不加上它,那很容易由于闭包问题,导致数据或方法,都不是我们理想中的那个它。如果我们加上它,很可能又会由于他们的变动,导致effect疯狂的执行。真实开发的话,大家应该会经常遇到这种问题。

所以,在此建议:

在组件内部,那些会成为其他useEffect依赖项的方法,建议用 useCallback 包裹,或者直接编写在引用它的useEffect中。
己所不欲勿施于人,如果你的function会作为props传递给子组件,请一定要使用 useCallback 包裹,对于子组件来说,如果每次render都会导致你传递的函数发生变化,可能会对它造成非常大的困扰。同时也不利于react做渲染优化。
不过还有一种场景,大家很容易忽视,而且还很容易将useCallback与useMemo混淆,典型场景就是:节流防抖。

举个例子:

function BadDemo() {
  const [count, setCount] = useState(1);
  const handleClick = debounce(() => {
    setCount(c => ++c);
  }, 1000);
  return <div onClick={handleClick}>{count}</div>;
}

我们希望防止用户连续点击触发多次变更,加了防抖,停止点击1秒后才触发 count + 1 ,这个组件在理想逻辑下是OK的。但现实是骨感的,我们的页面组件非常多,这个 BadDemo 可能由于父级什么操作就重新render了。现在假使我们页面每500毫秒会重新render一次,那么就是这样:

function BadDemo() {
  const [count, setCount] = useState(1);
  const [, setRerender] = useState(false);
  const handleClick = debounce(() => {
    setCount(c => ++c);
  }, 1000);
  useEffect(() => {
    // 每500ms,组件重新render
    window.setInterval(() => {
      setRerender(r => !r);
    }, 500);
  }, []);
  return <div onClick={handleClick}>{count}</div>;
}

每次render导致handleClick其实是不同的函数,那么这个防抖自然而然就失效了。这样的情况对于一些防重点要求特别高的场景,是有着较大的线上风险的。

那怎么办呢?自然是想加上 useCallback :

const handleClick = useCallback(debounce(() => {
  setCount(c => ++c);
}, 1000), []);

现在我们发现效果满足我们期望了,但这背后还藏着一个惊天大坑。
假如说,这个防抖的函数有一些依赖呢?比如 setCount(c => ++c); 变成了 setCount(count + 1) 。那这个函数就依赖了 count 。代码就变成了这样:

const handleClick = useCallback(
  debounce(() => {
    setCount(count + 1);
  }, 1000),
  []
);

大家会发现,你的lint规则,竟然不会要求你把 count 作为依赖项,填充到deps数组中去。这进而导致了最初的那个问题,只有第一次点击会count++。这是为什么呢?

因为传入useCallback的是一段执行语句,而不是一个函数声明。只是说它执行以后返回的新函数,我们将其作为了 useCallback 函数的入参,而这个新函数具体是个啥,其实lint规则也不知道。

更合理的姿势应该是使用 useMemo :

const handleClick = useMemo(
  () => debounce(() => {
    setCount(count + 1);
  }, 1000),
  [count]
);

这样保证每当 count 发生变化时,会返回一个新的加了防抖功能的新函数。

总而言之,对于使用高阶函数的场景,建议一律使用 useMemo

有些网友提供了宝贵的反馈,我继续补充:刚使用useMemo,依旧存在一些问题。

问题1:useMemo「将来」并不「稳定」

react的官方文档中提到:

你可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证。 将来,React 可能会选择“遗忘”以前的一些 memoized 值,并在下次渲染时重新计算它们,比如为离屏组件释放内存。先编写在没有 useMemo 的情况下也可以执行的代码 —— 之后再在你的代码中添加 useMemo,以达到优化性能的目的。

也就是说,在将来的某种特殊情况下,这个防抖函数依旧会失效。当然,这种情况是发生在「将来」,且相对比较极端,出现概率较小,即使出现,也不会“短时间内连续”出现。所以对于不是 「前端防不住抖就要完蛋」的场景,风险相对较小。

问题2:useMemo并不能一劳永逸解决所有高阶函数场景

在示例的场景中,防抖的逻辑是:「连续点击后1秒,真正执行逻辑,在这过程中的重复点击失效」。而如果业务逻辑改成了「点击后立即发生状态变更,再之后的1秒内重复点击无效」,那么我们的代码可能就变成了。

const handleClick = useMemo( () => throttle(() => { setCount(count + 1); }, 1000), [count] );

然后发现又失效了。原因是点击以后,count立即发生了变化,然后handleClick又重复生成了新函数,这个节流就失效了。

所以这种场景,思路又变回了前面提到的,「消除依赖」 或 「使用ref」。当然啦,也可以选择自己手动实现一个 debounce 或 throttle 。我建议可以直接使用社区的库,比如react-use,或者参考他们的实现自己写两个实现。

完结撒花,有疑问留言探讨哈~~~

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值