聊聊那些你肯定遇到过的 react hook 开发疑问

424 阅读12分钟

在上篇文章 学会使用 react hook,从转变心智模型开始 里,我们介绍了正确的 react hook 心智模型应该是什么样的,但是在实际开发时依旧会遇到很多问题让你怀疑自己的设计思路是否正确。或者在经历一些面试的时候,面试官也会通过这些问题作为切入点,来了解你对 react 掌握的情况如何。

本篇文章就来讲一下如何正确应对这些问题,以及导致这个问题背后的原因。希望可以为大家提供一点思路。

OK,废话少说,先讲问题:

我的 useEffect 被频繁触发了

这个可以说是使用 react hook 是最常遇到的问题。比如一个 useEffect 里调用了某个接口,但是 network 里却发现这个接口以相同的参数调用了好几次。怎么样找到那些重复的无效触发呢,又该怎么写来减少或者规避这种问题的出现呢?

可以按照以下步骤检查:

1、是不是包含了不必要的依赖项?

这个乍一看是废话,但实际上确实是导致重复渲染的主要原因。

有些人可能会觉得,我有那么蠢?useEffect 里用了两个变量,但是我却把依赖项数组里加了三个?

useEffect(() => {
  execRequest(userId, formId)
}, [userId, formId, parkId]) // <=== 多写了个 parkId

确实,应该没人这么弱智,但是有可能会出现下面这种问题:

useEffect(() => {
  execRequest(userId, formInfo.id)
}, [userId, formInfo])

只用到了 formInfo 里的 id,却监听了整个 formInfo 对象。这就属于包含了不必要的依赖项,如果 formInfo 里的其他值,比如 updateTime,或者 content 变化了,那就会造成重复触发。

正确的写法应该是这样:

useEffect(() => {
  execRequest(userId, formInfo.id)
}, [userId, formInfo.id]) // <=== 只依赖 formInfo.id 而不是整个对象

这种“超量依赖”的情况十分常见,特别是开发的人懒得关心或者图省事。我曾经见到过这种写法:

const Comp = (props) => {

  useEffect(() => {
    // 执行一大堆逻辑...
  }, [props])

  //...
}

直接依赖整个 props,这样就不用再一个个的挑要依赖于那些值了。不得不说这十分高效的躲开了 eslint 的检查,令人大开眼界。

2、是不是依赖项变化的太频繁了

这个在依赖对象、数组等引用类型的变量时尤为常见,因为 react 只会进行浅比较来判断依赖是否变化。比如上面举的例子,就是因为别处更新了 formInfo 导致这个对象重建了,从而引发了不必要的更新。

所以说,在实际开发时应该尽可能少的去依赖一个对象或数组。多去依赖某个具体的属性。

那如果说真的要依赖于整个对象,这时候该怎么办呢?react 开发时有个概念叫做“值稳定化”,或者叫做“稳定值”。了解这个之前我们要知道一个 react 的基本知识:

const [myData, setMyData] = useState({ })

上面这种引用类型的状态,只要你不调用 setMyData,那么无论渲染多少次,myData 的引用都是不变的。而稳定值的意思就是:一个引用类型的数据除非其内部数据变化了,否则这个数据的引用是保证不变的。

不稳定的值在 react 里很常见,比如在调用一个组件的时候传递的配置:

<Col
    style={{ backgroundColor: 'black' }}
    span={{ xs: 24, sm: 12, md: 8, lg: 6 }}
></Col>

由于每次渲染的时候都会重新创建,所以哪怕内容没变也会导致对象的引用产生变化。所以你可以看到 antd 的示例里对于一些静态的配置对象,都会将其提升到组件外边来保证值的稳定。

所以说,可以在开发时尽量保证值的稳定性,从而让下游组件可以直接依赖这个对象而不会触发额外的副作用调用。

也可以从另一个角度理解:react 的函数式思想追求值不可变性(Immutability)。即当值发生变化时必须产生一个完全独立的副本。那么也就是说 react 确实只需要检查对象的引用是否变化这种轻量级监听手段就可以了。因为没有变化时引用必然不变,而存在修改时(由于值不可变性导致)引用必然变化。

3、是不是更新批次错开了?

如果你了解过 react 的更新机制的话,应该知道 react 会把同步调用的 setState 合并成一个更新批次,并在最后统一结算。但是在某些超出了 react 监控范围的调用里,比如 setTimeout 或者 promise.then 时,react 将会为其中调用的 每个 setState 单独执行一次渲染。

比如下面这个例子:

const [data, setData] = useState(1);

useEffect(() => {
  setData(2)
  setData(3)
}, [])

useEffect(() => {
  console.log('data 的值变了!', data)
}, [data])

image.png

只会打印两次,因为中间的两个 setData 被合并成一个批次。但是如果包裹一个 setTimeout:

const [data, setData] = useState(1);

useEffect(() => {
  setTimeout(() => {
    setData(2)
    setData(3)
  })
}, [])

useEffect(() => {
  console.log('data 的值变了!', data)
}, [data])

image.png

可以发现中间状态 2 也触发了一次更新。所以当需要用到类似方法的时候,就需要注意下是否会导致这种重复调用的情况。

4、使用提前退出对不必要的执行进行剪枝

这种情况常见于依赖了一个值,但是这个值在某些状态下不需要进行触发的情况。比如:在弹窗打开时加载内容详情:

const [visible, setVisible] = useState(false)

useEffect(() => {
  getModalDetail();
}, [visible])

这种写法虽然可以完成任务,但是在弹窗关闭时(visible 由 true 变为 false)也会白白发送一次请求。所以可以对其进行剪枝:

const [visible, setVisible] = useState(false)

useEffect(() => {
  if (!visible) return;
  getModalDetail();
}, [visible])

其他情况还有比如存在异步请求链的时候:请求B的参数需要请求A响应中的某个值。比如:需要先请求完用户的信息,然后再拿用户信息中的公司 id 请求公司信息。

const [userInfo, setUserInfo] = useState({})

useEffect(() => {
  queryUserInfo(userId).then(resp => {
    setUserInfo(resp.data)
  })
}, [userId])

useEffect(() => {
  queryCompanyInfo(userInfo?.companyId)
}, [userInfo?.companyId])

上面这种写法就会导致 queryCompanyInfo 请求了两次,第一次中的携带的 companyId 为空也会导致接口报错。这时候就需要对第二个 useEffect 进行剪枝来排除 companyId 还没抵达时的情况:

useEffect(() => {
  if (!userInfo?.companyId) return;
  queryCompanyInfo(userInfo?.companyId)
}, [userInfo?.companyId])

这种做法很常见,特别是在一些请求库中,比如 react-query 中可以通过指定 enable 参数来决定一个请求要不要执行,其背后的原理和这个是类似的。

5、添加请求缓存层

或者另一种思路,可以为你的核心请求封装添加一个缓存层,当请求路径、请求类型、请求参数相同时,就先检查有没有已经存在的请求 promise,有就直接返回。

这个做法在早期比较常见,现在大多是直接使用 useSWR、react-query 之类的请求库了。如果你的项目比较老的话,可以尝试用这种方法解决。

我的 useEffect 没有触发

说完了重复触发,咱们再来聊聊 useEffect 没有被正确触发的情况。这种情况的原因就简单很多:没有正确的更新依赖项

上文也提到过,react 会通过引用是否变化来决定是否需要重新触发副作用。所以任何仅修改原始对象的操作都会被忽略掉,比如:

const [pageInfo, setPageInfo] = useState({ size: 10, page: 1 })

useEffect(() => {
  pageInfo.page = 2;
  setPageInfo(pageInfo)
}, [])

useEffect(() => {
  console.log('pageInfo 变化了')
}, [pageInfo])

这个例子里 pageInfo 里只会触发一次,并且 setPageInfo 甚至不会导致组件重新渲染,因为新值和旧值的引用相同,所以这次 setState 行为就被直接丢弃了。

不过话说回来,这种问题一般仅存在于刚开始学 react 的新手。所以现在除了面试题,一般也不会遇到这种情况了。

我不知道哪些状态应该使用 useState

新手常犯的另一个错误就是,不知道怎么声明 useState,只要 jsx 里用到的状态,就都会创建一个 useState 来保存。并且不知道怎么判断 state 的粒度,导致本来一个对象 state 就能搞定的却拆成了很多个基本类型的 state,或者本来应该拆分出来的却塞到了一个巨大的 state 里。

我们一个一个的讲:

1、某个状态是否应该使用 useState 来保存?

这个可以通过检查下面两个问题来判断:

  • 这个值是否参与渲染,改变后是否应该更新页面?
  • 这个状态能不能通过其他状态得到?

不参与渲染的值就不要用 useState,可以用 useRef,或者在组件外部管理。

比如需要在接口返回后保存一下接口响应的最初拷贝,然后在最后提交时检查找到变更。这个“最初拷贝”参与渲染了么?很明显是没有的,它的作用就是在最后提供一个对比范本。它的变化不会也不应该导致组件重新渲染,那么它就应该使用 useRef。

在网上也能看到这种言论:前端的业务逻辑应该是独立于 react 之外的,react 只有薄薄的一层,里边仅仅包含需要显示在页面上的状态。

这种其实才是规范的做法,因为 react 只是一个负责 UI 展示的库,对于复杂的超大型应用来说,你可以自己设计一个纯 js 应用,业务功能的流转和数据的更新都是在应用内部进行的,然后通过这个应用提供的一些 onChange 方法,把需要展示的值绑定到 react 组件中。

不过话虽然这么说,但这也不是强制要求,对于常规后台应用的增删改查需求来说,只在 react 组件里开发就可以了。

扯远了,判断要不要用 useState 的另一个关键点在于 能不能通过其他状态得到。这个很重要,比如现在有两个状态,一个是标签的 id、name 的对应关系,另一个是当前用户的标签 id 列表。

const [tagInfo, setTagInfo] = useState({ 1: '新标签', 2: '旧标签', 3: '大标签' })
const [userTags, setUserTags] = useState([1, 3])

现在页面上需要显示这个用户所有的标签名字,这时候需要使用 useState 来保存这个新状态么?

不需要,因为这个状态是通过其他状态派生出来的,或者说它是“受控”的状态。所以说,你就应该直接在组件里把这个状态构造出来,或者用 useMemo 缓存一下:

const Comp = () => {
  const [tagInfo, setTagInfo] = useState({ 1: '新标签', 2: '旧标签', 3: '大标签' })
  const [userTags, setUserTags] = useState([1, 3])

  // 直接生成出来就行
  const userTagName = userTags.map(tagId => tagInfo[tagId]).join('、')

  // 或者这么写
  const userTagName2 = useMemo(() => {
    return userTags.map(tagId => tagInfo[tagId]).join('、')
  }, [tagInfo, userTags])

  return <div>{userTagName}</div>
}

使用 useState 的话,还得在用个 useEffect 来手动同步值,并且还会触发额外的渲染,得不偿失。

2、一个 useState 的粒度应该怎么判断?

这个问题也很好解决,即 会触发相同更新的状态应该放在一个 useState 里。比如一个包含搜索条件和分页的常规表格。

搜索条件会有很多个,搜索条件 A 和搜素条件 B 导致的更新是一样的么?是的,A 会导致表格数据更新,B 也是这样,所以这两个应该放在一个状态里。

但是分页更新也能导致表格数据更新,它应该和搜素条件放在一个状态里么?不能,因为分页更新会导致分页选择器更新,而搜索条件更新不会(直接)导致分页选择器更新。所以这两个应该拆开。

就通过这种思考来确定一个状态的粒度。思考多个状态之间有没有异同点,如果你能更新一个东西,但是我不能,那就要考虑是不是应该将其拆成两个 useState。

我的 useEffect 第二个参数列表太长了

这个问题其实一般开发的时候不会遇到,除非是那种超级巨大的复杂组件,才有可能会遇到需要同时监听超多个状态的时候。

如果你在平时开发时就经常遇到这个问题,那就要检查下你的代码是不是有问题了。这个问题的根本原因其实就是上一个问题:不知道如何设计状态,如果你的状态粒度太细了,就会出现需要同时多个小状态的情况。

所以说,你可以通过检查有没有拆分过细的状态,通过合并状态再搭配值稳定化,几乎可以把 99% 场景里的 useEffect 依赖项限制在四五个以内(大多数也就一两个)。

如果真的发现没法合并了,就得考虑你这个 useEffect 里是不是干的事太多了,尝试用单一职责拆成多个 useEffect 吧。

不知道怎么正确的使用 useCallback 和 useMemo

答案很简单,都是尽量不用,尤其是觉得页面卡之前。你为什么要提前干活,干掉本来属于你的工作量呢?再说那些增删改查能造成多大的负担。

如果你现在就要优化性能的话,用 useMemo 把消耗量大的同步操作包裹一下就行,让 useMemo 内部的函数是纯的,这样可以减少 bug 产生的风险。尽量不要用 useCallback,稍有不慎就可能导致闭包陷阱,这排查起来可就麻烦多了。

下篇文章会讲一些如何正确性能优化的手段,有兴趣的可以关注下。

总结

本来打算概况性的讲一下的,但没想到还是啰里啰嗦了这么多,实际上很多问题在你了解了 react 中使用的函数式思想并加以运用后就可以自然的发现答案。但是我在工作中见到过的很多人都是在总结出自己的第一套增删改查的“代码片段”后就停止思考了。遇到什么需求就直接复制自己之前的表单页或者列表页,这相当于变相压低了自己的提升空间。

上面提到的这些也只是我在经历了很多开发工作后留下的一点感想,如果你有自己的看法的话也欢迎评论区交流。