这个话题其实很早就有结论了,使用 useRef 保存 state,在获取 state 时读 useRef 的值。但是一上手开发业务可能还是会懵逼,所以今天我们详细聊聊下面这几个问题:
- 之前写类组件的时候我们会使用
setTimeout解决这个问题,为啥到了 hooks 这为啥就不好使了? useRef是什么?为什么使用useRef就可以获取到最新的值呢?它和 useState 有哪些区别?- 在 hooks 中,如何实现多次
setState得到累计值? - 在 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 是什么呢?
useRefis a React Hook that lets you reference a value that’s not needed for rendering. 查看官网
翻译下,useRef 是一个 react hooks,它可以让你在不需要重新渲染时获得一个值的引用。
useRef 一个非常经典的应用就是保存定时器的 Timer,偷个懒~ 直接上官方示例:
其他应用场景直接看文档吧,不在这里赘述了。
问题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,来实现同步获取最新值。
当然这里用 useEffect 将 pageNo 作为依赖,执行 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, useGetState 和 useGetMemo,内置了 useRef 的相关逻辑,直接暴露 getState 方法直接获取最新的 state 值。