react 的 useState 到底该怎么获取最新值???

7,120 阅读6分钟

这个话题其实很早就有结论了,使用 useRef 保存 state,在获取 state 时读 useRef 的值。但是一上手开发业务可能还是会懵逼,所以今天我们详细聊聊下面这几个问题:

  1. 之前写类组件的时候我们会使用 setTimeout 解决这个问题,为啥到了 hooks 这为啥就不好使了?
  2. useRef 是什么?为什么使用 useRef 就可以获取到最新的值呢?它和 useState 有哪些区别?
  3. 在 hooks 中,如何实现多次 setState 得到累计值?
  4. 在 Taro 中提供了一个 nextTick 方法,它是否能解决这个问题?

问题1 - 之前写类组件的时候我们会使用 setTimeout 解决这个问题,为啥到了 hooks 这为啥就不好使了?

先看举个例子阐述问题:

console 输出 count ---> 0

我们想在 setCount 后同步的获取到 count 的最新值,要怎么做呢? 先来看看我们在之前写类组件的时候,是怎么解决这个问题的?

通过加个 setTimeout 我们就可以获取到最新的 count,那在 hooks 中能行吗?

哪怕延迟了 1s 还是无法获取到最新的 count,这是为什么呢? 这就要聊聊 react 类组件和函数组件的更新机制了。

  • 在类组件中,setState 会调用类中的 render 函数,state 对象只是其中的属性发生改变,其对象地址并未改变,因为 state 数据更新是一个异步操作(微任务),所以无法同步的获取到最新的 count。解决方法是利用 setTimeout 的宏任务特性,则可以在其回调函数中获取到最新的 count,这是可行的。

  • 在函数组件中,setCount 后,React 重新调用整个 Index 函数,构建新的 Fiber 树。setTimeout虽然是在 setCount 后调用的,但是还是在第一次的 Index 函数中,此时的 count 还是 1,这样说可能比较绕,我们来举个例子:

这里我们模拟 React rerender 的过程,test 第一次执行会递归调用自己同时 a 属性变成 2,但是 setTimeout 中的 a 还会是第一次执行的 a,所以打印的 a 是 1。 而 testRef 变量因为定义在函数外部,因此 setTimeout 获取到的一定是最新值,这跟 useRef 的作用类似,那 useRef 是什么呢?

useRef is a React Hook that lets you reference a value that’s not needed for rendering. 查看官网

翻译下,useRef 是一个 react hooks,它可以让你在不需要重新渲染时获得一个值的引用。

useRef 一个非常经典的应用就是保存定时器的 Timer,偷个懒~ 直接上官方示例:

codesandbox.io/s/9trm60?fi…

其他应用场景直接看文档吧,不在这里赘述了。

问题2 - useRef 是什么?为什么使用 useRef 就可以获取到最新的值呢?它和 useState 有哪些区别?

知道了 useRef 的作用,我们可以想下,如果可以在每次 count 改变时将新的值通过 useRef 保存下来,在下次 render 时,我们直接获取 useRef 的值,这不就可以获取到最新值了嘛~ 来,上代码:

提醒:更新 countRef,我们放在了 useEffect 里面,没有直接放到函数组件中,在官方文档有提醒这样做不符合规范,详细请参考 react.dev/reference/r… 中的 Pitfall.

那我们在定义变量时,是用 useState 还是 useRef 呢?这里就算不需要特殊获取最新值,我也不建议大家无脑用 useState,因为 useRef 本质上就是一个普通的 JS 对象,而 useState 会引发 react rerender,所以性能开销是比 useRef 大很多的

核心抉择标准就是「这个变量更新后是否需要更新视图?」,比如定时器的 timer,我们只需要保存这个变量在 js 逻辑需要的时候引用,timer 的改变不需要更新视图,那这时候就没必要 useState 了。

拓展

使用 useRef 可以在 setTimeout 中获取到最新值,那不要 setTimeout 能直接同步的获取最新值吗?来,上代码:

答案是:不行!,因为 setCount也是一个异步任务(微任务),所以就算是用 useRef 获取,也需要等 useEffect 先监听到 count 变化后才能获取到最新的值。

但是在一些实际业务场景中,我们在 setState 后需要马上在后面获取到这个值,包一层 setTimeout 看着又不够优雅,要怎么做呢?我的建议是将后面的逻辑做成纯函数,将新的值直接以参数的形式传入,而不是在函数中获取 state 的值,举个例子:

const App: React.FC = () => {
  const [pageNo, setPageNo] = useState<number>(0);
  
  const pageNoRef = useRef(pageNo);

  useEffect(() => {
    pageNoRef.current = pageNo;
  });
  
  // 下拉加载下一页
  const onPullDown = () => {
    setPageNo(pageNo + 1);
    setTimeout(() => {
      getList();
    }, 0);
  };

  const getList = () => {
    fetchList({ pageNo: pageNoRef.current });
  };
  
  return (
    <div>list</div>
  );
}

比如有一个带下拉加载更多功能的列表,在下拉时需要加载下一页数据,我们需要在下拉的回调中将 pageNo 加1,然后调用 getList,按照上面我们说的方案,可以用 useRef + setTimeout 的方案。但是代码显得过于臃肿。我更推荐下面这种写法:

const App: React.FC = () => {
  const [pageNo, setPageNo] = useState<number>(0);
  
  // 下拉加载下一页
  const onPullDown = () => {
    const newPageNo = pageNo + 1;
    setPageNo(newPageNo);
    getList(newPageNo);
  };

  const getList = (pageNo: number) => {
    fetchList({ pageNo });
  };
  
  return (
    <div>list</div>
  );
}

pageNo 以参数的形式传入 getList,来实现同步获取最新值。

当然这里用 useEffectpageNo 作为依赖,执行 getList 函数也没问题,只是一些场景下我们不得不这么做。

问题3 - 在 hooks 中,如何实现多次 setState 得到累计值?

先看示例:

这里我们连续调用三次 setCount(count + 1);,最后打印的 count 是多少呢?

答案是 1。因为 setCount 是异步操作,所以三次 count 的值都是 0,这个前面已经提过,那如何解决实现最后打印结果为 3 呢?

我们知道,在类组件中 setState 的第二个参数是 callback,我们可以在 callback 获取到最新的 count 值,而在函数组件中,setCount 不仅可以接收一个值,还可以接收一个函数,其函数的参数就是最新的 state 值,我们利用它可以实现多次 setState 获取累计值

问题4 - 在 Taro 中提供了一个 nextTick 方法,它是否能解决这个问题?

react 是没有 nextTick 这个概念的,在微信小程序和vue是有的,Taro 在 h5 的 react 中也可以使用 nextTick 那它是怎么实现这个 API 的呢,我们看下 Taro 源码:

import { Current } from './current'
import { getPath } from './dsl/common'
import { TaroRootElement } from './dom/root'
import { document } from './bom/document'

import type { Func } from './interface'

function removeLeadingSlash (path?: string) {
  if (path == null) {
    return ''
  }
  return path.charAt(0) === '/' ? path.slice(1) : path
}

export const nextTick = (cb: Func, ctx?: Record<string, any>) => {
  const router = Current.router
  const timerFunc = () => {
    // ---> 核心代码
    setTimeout(function () {
      ctx ? cb.call(ctx) : cb()
    }, 1)
  }

  if (router !== null) {
    let pageElement: TaroRootElement | null = null
    const path = getPath(removeLeadingSlash(router.path), router.params)
    pageElement = document.getElementById<TaroRootElement>(path)
    if (pageElement?.pendingUpdate) {
      if (process.env.TARO_ENV === 'h5') {
        // eslint-disable-next-line dot-notation
        pageElement.firstChild?.['componentOnReady']?.().then(() => {
          timerFunc()
        }) ?? timerFunc()
      } else {
        pageElement.enqueueUpdateCallback(cb, ctx)
      }
    } else {
      timerFunc()
    }
  } else {
    timerFunc()
  }
}

可以看到,Taro 使用了 setTimeout 来模拟 nextTick,因此 h5-react 中只使用 nextTick 也是不行,还需要配合 useRef.

结尾

今天我们核心学习了如何使用 useRef + setTimeout 处理在异步获取 useState 的最新值,以及为什么可以这样做.

最后提供两个自定义 hooks, useGetStateuseGetMemo,内置了 useRef 的相关逻辑,直接暴露 getState 方法直接获取最新的 state 值。