React 的源码与原理解读(十二):Hooks解读之一 useCallbackuseMemo

news/2024/10/23 9:40:16/

写在专栏开头(叠甲)

  1. 作者并不是前端技术专家,也只是一名喜欢学习新东西的前端技术小白,想要学习源码只是为了应付急转直下的前端行情和找工作的需要,这篇专栏是作者学习的过程中自己的思考和体会,也有很多参考其他教程的部分,如果存在错误或者问题,欢迎向作者指出,作者保证内容 100% 正确,请不要将本专栏作为参考答案。

  2. 本专栏的阅读需要你具有一定的 React 基础、 JavaScript 基础和前端工程化的基础,作者并不会讲解很多基础的知识点,例如:babel 是什么,jsx 的语法是什么,需要时请自行查阅相关资料。

  3. 本专栏很多部分参考了大量其他教程,若有雷同,那是作者抄袭他们的,所以本教程完全开源,你可以当成作者对各类教程进行了整合、总结并加入了自己的理解。

本一节的内容

从这个章节开始,我们将逐步讲解部分常用的 hooks,我会按照我们理解这些 hooks 的难度来讲解,本节主要讲解我们用于优化的useCallback 和 useMemo 两个 api,我们将从使用和源码两个角度来讲它:

useCallback 的定义

我们首先来看看 useCallback 的用法:它用于优化代码, 可以让对应的函数只有在依赖发生变化时才重新定义

useCallback(fn, dependencies)

它传入两个参数

  • fn : 你想要缓存的函数。 React将会在初次渲染中将这个函数返回。下一次渲染时, 如果 dependencies 自从上一次从未改变,React将会返回相同的函数;否则, React 将返回重新渲染你缓存的函数缓存并且返回给你。

  • dependencies :有关 fn 内部代码所有响应式值的一个列表。 依赖列表必须具有确切数量的项,并且像 [dep1, dep2, dep3] 的形式编写,React使用 Object.is 比较算法比较每一个依赖和它的先前值来决定是不是重新渲染你缓存的函数。

在初次渲染时, useCallback返回你已经传入的 fn 函数。在随后的渲染中, useCallback 返回在上一次渲染中已经缓存的 fn 函数(如果依赖都没有改变的话),或者返回你在这一次渲染中传入的 fn 函数

简单来说就是,useCallback 返回给你一个函数 fn ,这个函数会在 dependencies 发生改变的时候重新生成

const memoizedCallback = useCallback(() => {doSomething(a, b);},[a, b],
);

useCallback 的使用场景

以下是几种 useCallback 的使用场景:

和 React.memo 配合使用

React.memo 的作用是,用它包裹的组件,如果传入相同的 props,它不会重新渲染,而是直接复用最近一次渲染的结果。作用是保持提高组件的性能表现,和我们的 useCallback 类似,不过它维护的是一个组件。

显然如果要阻止一个重复组件渲染,我们需要维持 props 的不变,但是一个相同的函数如果创建两次,它指向的地址肯定是不一样的,但是 React 经常有把一个回调函数传入一个组件的情况:

function ParentComponent() {const onHandleClick = () => {//....};return (<MemoizedSubComponenthandleClick={onHandleClick}/>);
}

这种情况下,我们的子组件每次都会重新渲染,即使我们没有对它进行任何的修改,为了阻止它进行重复渲染,我们需要将我们的方法进行缓存,此时 useCallback 就发挥了作用,如果它的依赖没有改变,它将返回一个上次缓存的函数,也就是指向相同地址的函数,那么此时我们的判定我们的 props 没有进行更改,此时我们的子组件就不会重复渲染了:

function ParentComponent() {// 缓存const onHandleClick = useCallback(() => {//....});return (<MemoizedSubComponenthandleClick={onHandleClick}/>);
}
const Child = React.memo(() => {// .... 子组件
})

解决其他 hooks 的死循环问题

我们来看下面一个场景:

  • 父组件将一个 getData 方法传入了子组件,子组件在 useEffect 中调用它获取 val
  • 因为在 getData 中调用了 setVal 方法,触发了重新渲染
  • 因为重新渲染产生了新的 getData 函数,导致子组件重新渲染,又触发了 useEffect,产生无限循环
function FatherTest() {const [val, setVal] = useState("");function getData() {setTimeout(() => {setVal("new data " + count);count++;}, 500);}return <Child val={val} getData={getData} />;
}function Child({ val, getData }) {useEffect(() => {getData();}, [getData]);return <div>{val}</div>;
}

我们利用 useCallback 可以固定住我们的 getData 函数,使得它不会重新渲染生成新的 getData,那么此时即使 setVal 使得页面重新渲染,我们也会生成和上次一样的 getData 函数,子组件不会重新绘制,从而解决了死循环的问题

function FatherTest() {const [val, setVal] = useState("");const getData = useCallback(() => {setTimeout(() => {setVal("new data " + count);count++;}, 500);}, []);return <Child val={val} getData={getData} />;
}

useCallback 的源码

useCallback 的源码非简洁,我们直接来看:

  • mountCallback 很简单,因为是第一次运行,我们获取传入的 callback 和 deps,直接把数据缓存到 memoizedState 即可,之后我们返回我们的 callback

  • updateCallback 则是将我们缓存的 deps 和传入的进行比较,如果相同则返回我们缓存的 callback,否则把新的 callback 和 deps 进行缓存

function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {const hook = mountWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps;hook.memoizedState = [callback, nextDeps]; return callback;
}function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {const hook = updateWorkInProgressHook();const nextDeps = deps === undefined ? null : deps;const prevState = hook.memoizedState; if (prevState !== null) {if (nextDeps !== null) {const prevDeps: Array<mixed> | null = prevState[1];if (areHookInputsEqual(nextDeps, prevDeps)) {return prevState[0];}}}hook.memoizedState = [callback, nextDeps];return callback;
}

其中 areHookInputsEqual 这个函数是用了 Object.is 这个 API 来比较两次传入内容每一项的一致性

function areHookInputsEqual(nextDeps: Array<mixed>,prevDeps: Array<mixed> | null,
) {if (prevDeps === null) {return false;}for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {if (is(nextDeps[i], prevDeps[i])) {continue;}return false;}return true;
}

useMemo 的定义

useMemouseCallback 的功能很像,只不过 useMemo 用来缓存函数执行的结果

const cachedValue = useMemo(calculateValue, dependencies)

我们可以看到,它的定义和 useCallback 类似,我们就不再赘述,不同的是它的返回值是 fn 的计算结果:

const visibleTodos = useMemo(() => filterTodos(todos, tab),[todos, tab]
);

useMemo 的使用场景

以下是几种 useCallback 的使用场景:

避免大量重复计算

这里我们需要先明确一件事情,在每次渲染组件的时候,我们的写在组件里的运算逻辑会重复运行,也就是说,如果你有一个非常复杂的计算放在我们的组件中,你可以使用 useMemo 对它进行缓存,但是,我们的缓存是有开销的,也就是说,不能滥用我们的缓存,可以看看这篇文章,里面提到了在运行在不同运算级别下,使用和不使用 useMemo 的性能消耗:

https://medium.com/swlh/should-you-use-usememo-in-react-a-benchmarked-analysis-159faf6609b7

比如我们要生成一个 1-n 的数组,我们就可以通过缓存的方式来实现,如果我们的 level 保持稳定,那么它就不需要再次渲染,这笔开销可以节约下来:

import React, {useMemo} from 'react';
const BenchmarkMemo = ({level}) => {const complexObject = useMemo(() => {const result = {values: []};for (let i = 0; i <= level; i++) {result.values.push({'mytest'});};return result;}, [level]);return (<div>Benchmark with memo level: {level}</div>);
};

和 React.memo 配合使用

同样,有时候我们也需要使用 useMemo 配合 React.memo 使用,应该会有很多人好奇,如果我们只是传入一个数值,这个数值不变化不就不会产生重新绘制吗?

我们看下面的例子:我们使用一个函数计算出一个对象,把这个数值传入我们的子组件中,请问如果现在我们点击按钮,子组件会重绘吗?答案是会重新绘制!

因为 memo 是通过校验 Props 中的数据的 内存地址 是否改变来决定组件是否重新渲染组件的一种技术。父组件重新构建的时候,如果不缓存计算属性,则会返回了一个在新的存储地址的返回值,它传入到子组件中会被检测为栈地址更新,从而发生重新渲染

因此对于这种情况,我们需要使用 useMemo 来实现重绘时返回的数据地址一致,从而阻止重新渲染:

// 这个会导致子组件重绘
const Parent = () => {const [parentState,setParentState] = useState(0);const toChildComputed = () => {return {a:100};}return (<div><Button onClick={() => setParentState(val => val+1)}></Button><Child computedParams={toChildComputed()}></Child><div>)
}
// 这个不会导致子组件重绘
const Parent = () => {const [parentState,setParentState] = useState(0);const toChildComputed = useMemo(() => {return {a:100};},[])return (<div><Button onClick={() => setParentState(val => val+1)}></Button><Child computedParams={toChildComputed}></Child><div>)
}
const Child = memo(() => {//....
})

拓展:关于 React.memo 的原理

既然讲到了 React.memo ,这里我们顺便讲解一下关于 React.memo 这个的源码部分,这个部分其实在我们的 beginWork 函数中出现过,不过当时我们直接没有讲解相关的内容,它在 switch 语句中出现了:

它获取了两次更新的 props 之后调用了 updateMemoComponent 函数:

case MemoComponent: {const type = workInProgress.type;const unresolvedProps = workInProgress.pendingProps;let resolvedProps = resolveDefaultProps(type, unresolvedProps);resolvedProps = resolveDefaultProps(type.type, resolvedProps);return updateMemoComponent(current,workInProgress,type,resolvedProps,renderLanes,);
}

我们来看看这个建议版本的 updateMemoComponent 函数,它的核心逻辑是,使用 compare 函数对我们的两次 props 进行对比,如果两次对比相同则使用 bailoutOnAlreadyFinishedWork 复用之前的组件,阻止重绘的发生。

而这个 compare 函数是你在调用 React.memo 的时候传入的 ( 可以自定义比较规则 ),具体可以查看相关官方文档,如果我们不指定比较方式,就会使用默认的 shallowEqual 函数进行比较

function updateMemoComponent(current: Fiber | null,workInProgress: Fiber,Component: any,nextProps: any,renderLanes: Lanes,
): null | Fiber {if (current === null) {//....const prevProps = currentChild.memoizedProps;let compare = Component.compare;compare = compare !== null ? compare : shallowEqual;if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);}}workInProgress.flags |= PerformedWork;const newChild = createWorkInProgress(currentChild, nextProps);newChild.ref = workInProgress.ref;newChild.return = workInProgress;workInProgress.child = newChild;return newChild;
}

我们最后来看看这个 shallowEqual ,它定义在 /packages/shared/shallowEqual.js 中:可以看到,这是一个浅比较,因为我们传入的是 object 类型的 props ,也就是默认会走第一个判定之后的逻辑,会比较 props 每一项的值,使用 Object.is 进行判定,显然 Object.is 只有在两个对象指向同一个地址的时候才会判定为 true ,所以上文的例子里我们判定为需要重新绘制。

值得一提的是,如果我们传入的是基础数据类型,比如 number 或者 string,则只需要值相同就会判定为 true,所以作者在查阅相关资料的时候,发现有部分博文将上文的 return {a:100}; 写成了 return 1000; ,这样的情况例子中是不会重复重绘的!可见网上的资料有时候也会误导人,还需要自己进行实践

function shallowEqual(objA: mixed, objB: mixed): boolean {if (is(objA, objB)) {return true;}if (typeof objA !== 'object' ||objA === null ||typeof objB !== 'object' ||objB === null) {return false;}const keysA = Object.keys(objA);const keysB = Object.keys(objB);if (keysA.length !== keysB.length) {return false;}for (let i = 0; i < keysA.length; i++) {if (!hasOwnProperty.call(objB, keysA[i]) ||!is(objA[keysA[i]], objB[keysA[i]])) {return false;}}return true;
}

useMemo 的源码

useMemo 的实现与 useCallback 类似,我想应该不需要我过多赘述了,我们直接放出代码,和 useCallback 唯一的区别是,我们需要通过传入的 nextCreate 函数算出结果,然后将结果进行缓存:

function mountMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T {const hook = mountWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps;const nextValue = nextCreate();hook.memoizedState = [nextValue, nextDeps]; return nextValue; 
}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) {if (nextDeps !== null) {const prevDeps: Array<mixed> | null = prevState[1];if (areHookInputsEqual(nextDeps, prevDeps)) {return prevState[0];}}}const nextValue = nextCreate();hook.memoizedState = [nextValue, nextDeps];return nextValue;
}

总结

这期我们讲了两个用于优化的 api useCallbackuseMemo,他们的原理都是用 Hook 对象的 memoizedState 空间作为缓存来存储我们需要的内容,通过依赖项是不是发生变化来决定是直接返回我们缓存的内容还是重新计算得到新的值,这是相对最为简单的两个 hooks,之后我们会讲解更多的 hooks 的内容,敬请期待


http://www.ppmy.cn/news/66430.html

相关文章

Educational Codeforces Round #148 (Rated for Div.2) A~C

A. New Palindrome 题意&#xff1a; 给定一个回文字符串&#xff0c;问是否可以调换其中两个字符&#xff0c;得到另一个不同的回文字符串。 思路&#xff1a; 题目的条件给的宽松&#xff0c;只是询问是否可以调换&#xff0c;并没有要求调换的位置。 方法一&#xff1a;…

LeetCode5. 最长回文子串

写在前面&#xff1a; 题目链接&#xff1a;LeetCode5. 最长回文子串 编程语言&#xff1a;C 题目难度&#xff1a;中等 一、题目描述 给你一个字符串 s&#xff0c;找到 s 中最长的回文子串。 如果字符串的反序与原始字符串相同&#xff0c;则该字符串称为回文字符串。 示例…

数据库系统工程师——第三章 数据结构与算法

文章目录 &#x1f4c2; 第三章、数据结构与算法 &#x1f4c1; 3.1 线性结构 &#x1f4d6; 3.1.1 线性表 &#x1f4d6; 3.1.2 栈和队列 &#x1f4d6; 3.1.3 串 &#x1f4c1; 3.2 数组和矩阵 &#x1f4c1; 3.3 树和图 &#x1f4d6; 3.3.1 树 &#x1f4d6; 3.3.2 图 &…

windows 编译 opencv

编译需要的基础工具 #cmake是配置构建工具&#xff0c;mingw是编译工具 cmake CMake是一款跨平台的编译管理工具&#xff0c;可以自动生成各种不同编译环境&#xff08;如Makefile、Visual Studio Solution等&#xff09;&#xff0c;从而实现在不同平台上进行代码编译的目的…

亲测好用|甲方、专家和领导,用三维模型汇报方案如何投其所好?

身为设计方的你&#xff0c;有没有这样的经历&#xff1a; ➤ 一个非常优秀的方案未能被甲方采纳&#xff0c;反而甲方选择了一个不如自己的方案&#xff0c;造成了很大的遗憾&#xff1b; ➤ 在讲述自己的设计方案的时候&#xff0c;经常越说越散&#xff0c;甚至到了最后自…

MD-MTSP:遗传算法GA求解多仓库多旅行商问题(提供MATLAB代码,可以修改旅行商个数及起点)

一、多仓库多旅行商问题 多旅行商问题&#xff08;Multiple Traveling Salesman Problem, MTSP&#xff09;是著名的旅行商问题&#xff08;Traveling Salesman Problem, TSP&#xff09;的延伸&#xff0c;多旅行商问题定义为&#xff1a;给定一个&#x1d45b;座城市的城市集…

MySQL 字段为 NULL 的坑,你踩过吗?

前言 很多小知识点&#xff0c;我以为自己懂了&#xff0c;实际没搞透。 数据库字段允许空值(null)的问题&#xff0c;你遇到过吗&#xff1f; 在验证问题之前&#xff0c;我们先建一张测试表及测试数据。 构建的测试数据&#xff0c;如下图所示&#xff1a; 有了上面的表及…

Feign踩坑源码分析--@FeignClient注入容器

一. EnableFeignClients 1.1.类介绍 从上面注释可以看出是扫描声明了FeignClient接口的类&#xff0c;还引入了 FeignClientsRegistrar类&#xff0c;从字面意思可以看出是进行了 FeignClient 客户端类的注册。 1.2.FeignClientsRegistrar 详解 最主要的一个方法&#xff1a;re…