React18 源码解析之 useCallback 和 useMemo

蚊子前端博客
发布于 2022-10-14 00:33
本篇文章我们主要了解下 useCallback 和 useMemo 是如何来优化React组件的。

我们解析的源码是 React18.1.0 版本,请注意版本号。React 源码学习的 GitHub 仓库地址:https://github.com/wenzi0github/react

React 中有两个 hooks 可以用来缓存函数和变量,提高性能,减少资源浪费。那什么时候会用到 useCallback 和 useMemo 呢?

1. 使用场景

useCallback 和 useMemo 的使用场景稍微有点不一样,我们分开来说明。

1.1 useCallback

我们知道 useCallback 可以缓存函数体,在依赖项没有变化时,前后两次渲染时,使用的函数体是一样的。

很多同学觉得用 useCallback 包括函数,可以减少函数创建的开销 和 gc。但实际上,现在 js 在执行一些闭包或其他内联函数时,运行非常快,性能非常快。若不恰当的使用 useCallback 包括一些函数,可能还会适得其反,因为 React 内部还需要创建额外的空间来缓存这些函数体,并且还要监听依赖项的变化。

如下面的这种方式其实就很没有必要:

COPYJAVASCRIPT

function App() { // 没必要 const handleClick = useCallback(() => { console.log(Date.now()); }, []); return <button onClick={handleClick}>click me</button>; }

那 useCallback 在什么场景下会用到呢?

  • 函数作为其他 hook 的依赖项时(如在 useEffect()中);

  • 函数作为 React.memo()(或 shouldComponentUpdate )中的组件的 props;

我们来一一看下。

1.1.1 作为其他 hook 的依赖项

我们经常会有请求数据的场景,然后在 useEffect() 中触发:

COPYJAVASCRIPT

function App() { const [state, setState] = useState(); const requestData = async () => { // fetch setState(); }; useEffect(() => { requestData(); }, []); }

我们在官方脚手架 create-react-app 写这段代码时,他就会给出提示,大致意思是 useEffect 使用了外部的变量,需要将其添加到依赖中,即:

COPYJAVASCRIPT

useEffect(() => { requestData(); }, [requestData]); // 将 requestData 添加到依赖项中

如若只是把它添加到依赖项中,再执行代码时,会发现代码陷入了无限循环。这是因为函数 requestData() 在每次 render()时,都是重新定义的,导致依赖项发生了变化,就会执行里面的 requestData(),进而触发 setState()进行下次的渲染,陷入无限循环。

为什么明明是同一个函数体,两个变量却不一样呢?比如下面的这个例子:

COPYJAVASCRIPT

const funcA = () => { console.log('www.xiabingbao.com'); }; const funcB = () => { console.log('www.xiabingbao.com'); }; console.log(funcA === funcB); // false

他们的函数体仅仅是看起来是一样的,但实际上是完全独立的两个个体。上面的 requestData()同理,每次都是重新声明一个新的,跟之前的函数肯定就不一样了。

这个时候,我们就需要把 requestData()用useCallback()包裹起来:

COPYJAVASCRIPT

const requestData = useCallback(async () => { // fetch setState(); }, []);

这就能保证函数 requestData()在多次渲染过程中是一致的(除非依赖项发生变化)。

1.1.2 作为 React.memo()等组件的 props;

有一些我们是需要向子组件传入回调函数的场景,比如 onClick, onSuccess, onClose 等。

COPYJAVASCRIPT

function Count({ onClick }) { const [count, setCount] = useState(count); const handleClick = () => { const nextCount = count + 1; setCount(nextCount); onClick(nextCount); }; console.log('Count render', Date.now()); return <button onClick={handleClick}>click me</button>; } function App() { const [now, setNow] = useState(0); const handleClick = count => { console.log('App count', count); }; return ( <div> <p> <button onClick={() => setNow(Date.now())}>set new time</button> </p> <Count onClick={handleClick} /> </div> ); }

函数组件 <App /> 中的 handleClick 传给了子组件 <Count />,当父级组件触发更新时,子组件也会执行,只不过 state 没有变化而已。那么如何避免子组件必须要的刷新呢?这里我们就需要用到 React.memo 了(注意,这里不是 useMemo())。

React.memo()可接受 2 个参数,第一个参数为纯函数的组件;第二个参数是 compare(prevProps, nextProps)函数(可选),用于自行实现功能,对比 props ,控制是否刷新。

我们用React.memo()包裹住函数组件后,只需要保证传入的 props 不发生变化,那么函数组件就不会二次执行。

COPYJAVASCRIPT

const MemoCount = React.memo(<Count />);

那传入的各种 callback 就得用useCallback()来封装了,如上面的 handleClick:

COPYJAVASCRIPT

const handleClick = useCallback(count => { console.log('App count', count); }, []);

1.2 useMemo

useMemo() 与 useCallback() 的功能很像,只不过 useMemo 用来缓存函数执行的结果,而 useCallback()用来缓存函数体。

1.2.1 useMemo 的使用

如每次渲染时都要执行一段很复杂的运算,或者一个变量需要依赖另一个变量的运算结果,就都可以使用useMemo()

比如有一个计算百分比的场景:用户可以在某个项目中,捐赠自己的虚拟金币,不过项目接收的虚拟金币有上限,然后实时显示该项目的受捐进度。同时,进度展示这里,还有几个其他的规则:

  1. 进度的百分比的数字显示整数,向下取整;

  2. 只要有捐助行为,则百分比至少为 1%;

  3. 进度不能超过 100%(最后一次的捐赠可能会超过上限);

在某个组件获取进度百分比的时候,我们这里可以封装到useMemo()中,因为进度的百分比只跟当前进度和总上限有关系。

COPYJAVASCRIPT

const curPercent = useMemo(() => { if (progress === 0 || topLimit === 0) { return 0; } const percent = (progress * 100) / topLimit; if (percent <= 1) { return 1; } if (percent >= 100) { return 100; } return Math.floor(percent); }, [progress, topLimit]);

若当前进度和总上限没有变化时,则不用重新计算百分比。

1.2.2 其他变体

其实我们可以看到,useMemo()类似于 useEffect() 和 useState() 的组合体:

COPYJAVASCRIPT

const [curPercent, setCurPercent] = useState(0); useEffect(() => { if (progress === 0 || topLimit === 0) { setCurPercent(0); return; } const percent = (progress * 100) / topLimit; if (percent <= 1) { setCurPercent(1); return; } if (percent >= 100) { setCurPercent(100); return; } setCurPercent(Math.floor(percent)); }, [progress, topLimit]);

相应地,若遇到上面需要用 useEffect 和 useState 实现的场景,就可以直接用useMemo()来实现。

而且,useCallback()也是可以用 useMemo()来实现的。因为 useMemo()返回的是函数执行的结果,那我们返回的结果就是一个函数不就行了。

COPYJAVASCRIPT

const handleClick = useMemo(() => { // 返回一个函数 return () => { console.log(Date.now()); }; }, []); hanleClick();

2. 源码

我们了解了 useCallback() 和 useMemo() 的基本用法之后,再来了解下他们源码的实现。

我们在之前 renderWithHooks 的章节中也了解到,所有的 hooks 在内部实现时,都区分了 mount 阶段和 update 阶段,useCallback()和 useMemo() 两个 hooks 也不例外。

2.1 useCallback 的源码

useCallback()在 React 内部实现时,分成了 mountCallback()和 updateCallback()。

  • mountCallback: 生成 hook 节点,并存储回调函数 callback 和依赖项 deps;

  • updateCallback: 新的依赖项与之前存储的依赖项进行对比,若没有变化,则直接返回,否则存储新的回调函数和依赖项;

2.1.1 mountCallback

初始化时很简单,就是把传入的 callback 和依赖项 deps 存储起来。

COPYJAVASCRIPT

/** * useCallback的创建 * @param callback * @param deps * @returns {T} */ function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T { const hook = mountWorkInProgressHook(); // 创建一个新的hook节点 const nextDeps = deps === undefined ? null : deps; hook.memoizedState = [callback, nextDeps]; // 直接将callback和依赖项进行存储 return callback; }

可以看到,这里用数组的方式,把 callback 和依赖项存储到了 hook 节点的 memoizedState 属性上,然后返回这个 callback。因此我们执行 useCallback()的返回值就是这个传入 callback。

2.1.2 updateCallback

updateCallback 的实现相对来说,也比较简单,关键点就在于依赖项的对比。

COPYJAVASCRIPT

/** * useCallback的更新 * @param callback * @param deps * @returns {T|*} */ function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T { const hook = updateWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; const prevState = hook.memoizedState; // 取出上次存储的数据: [callback, prevDeps] // 若之前的数据不为空 if (prevState !== null) { if (nextDeps !== null) { /** * 若依赖项不为空,且前后两个依赖项没有发生变化时, * 则直接返回之前的callback(prevState[0]); * 有个 areHookInputsEqual() 我们先不关心细节,只需要知道是用来对比依赖项的 */ const prevDeps: Array<mixed> | null = prevState[1]; if (areHookInputsEqual(nextDeps, prevDeps)) { // 若依赖项没有变化,则返回之前存储的callback return prevState[0]; } } } /** * 若依赖项为空,或者依赖项发生了变动,则重新存储callback和依赖项 * 然后返回最新的callback */ hook.memoizedState = [callback, nextDeps]; return callback; }

若前后两个依赖项都不为空,且依赖项没有发生变动,则直接返回之前存储的 callback,达到了缓存的目的。

若依赖项为空,或者依赖项发生了变化,则重新存储 callback 和依赖项,然后返回最新的 callback。因此,若不设置依赖项,或者依赖项一直在变,则无法达到缓存的目的。

这里有个工具函数 areHookInputsEqual(),该函数的作用,就是用来对比前后两个依赖项中所有的数据是否发生了变化,只要有一项的数据发生了变化(相同位置前后的两个数据不相等),则认为依赖项产生了变动。

2.2 useMemo 的源码

useMemo()的实现,与 useCallback 很相似,只不过在 useMemo()中,执行了 callback,然后缓存的是其返回的结果。

useMemo()在 React 内部实现时,分成了 mountMemo()和 updateMemo()。

  • mountMemo: 生成 hook 节点,并存储回调函数 callback 执行的结果和依赖项 deps;

  • updateMemo: 新的依赖项与之前存储的依赖项进行对比,若没有变化,则直接返回,否则存储新的回调函数的执行结果和依赖项;

2.2.1 mountMemo

初始节点源码的实现:

COPYJAVASCRIPT

/** * useMemo的创建 * @param nextCreate * @param deps 依赖项 * @returns {T} */ function mountMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T { const hook = mountWorkInProgressHook(); // 在链表的末尾创建一个hook节点 const nextDeps = deps === undefined ? null : deps; /** * 计算useMemo里callback的返回值 * 这是与 useCallback() 不同的地方,这里会执行回调函数callback */ const nextValue = nextCreate(); hook.memoizedState = [nextValue, nextDeps]; // 将返回值和依赖项进行存储 return nextValue; // 返回执行callback()的返回值 }

我们从源码中可以看到,在 mountMemo()里,会执行回调函数 callback(),然后存储该函数的返回结果。

2.2.2 updateMemo

在了解 updateCallback()的源码后,updaeMemo()的源码也很好理解。

COPYJAVASCRIPT

/** * useMemo的更新 * @param nextCreate * @param deps * @returns {T|*} */ function updateMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T { const hook = updateWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; const prevState = hook.memoizedState; if (prevState !== null) { // Assume these are defined. If they're not, areHookInputsEqual will warn. if (nextDeps !== null) { const prevDeps: Array<mixed> | null = prevState[1]; if (areHookInputsEqual(nextDeps, prevDeps)) { // 若依赖项没有变化,则返回之前存储的结果 return prevState[0]; } } } // 重新计算callback的返回结果,并进行存储 const nextValue = nextCreate(); hook.memoizedState = [nextValue, nextDeps]; return nextValue; }

当依赖项不为空,且没有变化时,直接返回之前存储的数据;否则执行最新的回调函数,然后存储该函数最新的返回结果,并返回。

3. 总结

这是 React 源码内部实现起来比较简单的 hooks,我们先做个开胃菜,后续比如 useState(), useEffect() 等 hooks,整体的逻辑会更加复杂一些。

参考链接:

标签:
阅读(1606)

公众号:

qrcode

微信公众号:前端小茶馆