useCallback基础用法

3/16/2023 React

React Hook useCallback基础用法

第五个Hook(钩子函数)是useCallback,他的作用是"钩住"组件属性中某些处理函数,创建这些函数对应在react原型链上的变量引用。useCallback第2个参数是处理函数中的依赖变量,只有当依赖变量发生改变时才会重新修改并创建的一份处理函数。

react原型链?

我对react原型链也不懂,但是可以简单的把react原型链 理解成"react定义的一块内存"。使用某些 hook 定义的"变量或函数"都存放在这块内存里。这块内存里保存的变量或函数,并不会因为组件重新渲染而小时。

  1. 当需要使用可以"对象的引用名"从该内存里获取,例如useContext
  2. 当希望更改某些变量时,可以通过特定的函数来修改该内存中变量的值,例如useState中的setXxx()
  3. 当某些函数依赖变量发生改变时,react可以重新生成、并修改该内存中对应的函数,例如useReducer、useCallback

这里这个"修改"这个词,因为"修改"牵扯到react最为隐秘极其重要的一层概念。
"修改"有3种情况:

  1. 用完全不一样的新值去替换之前的旧值 ——> 这会触发react重新渲染 ——> 例如{age: 34}去替换{age: 18}
  2. 用和旧值看似一模一样的新值去替换之前的旧值 ——> 这依然会触发react重新渲染,因为react底层对新旧值做比对时使用的是 Object.is 判断,字面上看似一模一样没有用,react依然会认为这是2个对象,依然会出发react重新渲染 ——> 例如{age:18}去替换{age:18}
  3. 用旧值的引用去替换旧值 ——> 这次就不会触发重新渲染 ——> 例如:let obj = {age: 18}; let obj2 = obj,用obj2去替换obj

为了提高react性能,就需要用旧值的引用去替换旧值,从而阻止本次没用的渲染。

问题的关键来了,如何获取旧值的引用?
答:对于函数来说可以使用useCallback就可以。

ok,先学习以下2个知识点。useCallback一会儿说。

第1个知识点:React.memo() 的用法

首先要知道,默认情况下如果父组件重新渲染,那么该父组件下的所有子组件都会随着父级的重新渲染而重新渲染。

  1. 无论子组件是类组件还是函数组件。
  2. 无论子组件在本次渲染过程中,子组件是否有任何相关的数据变化。

假设某父组件中有3个子组件:子组件A、子组件B、子组件C。若因为子组件A发生了某些操作,引发父组件重新渲染,这时即使子组件B和子组件C没有任何需要更改的地方,但是默认他们两个也会重新被渲染一次。

为了减少这个不必要的重新渲染,如果是类组件的话,可以在组件shouldComponentUpdata(准别要开始更新前)生命周期中,通过比较props和state中前后两次的值,如果完全相等则跳过本次渲染,改为直接使用上一次渲染结果,以此提高性能提升。

伪代码:

shouldComponentUpdata(nextProps, nextStates) {
    // 判断xxx值是否相同,如果相同则不进行重新渲染
    return (nextProps.xxx !== this.props.xxx); // 注意是 !== 而不是 !=
}
1
2
3
4

为了简化这一步操作,可以将类组件由默认继承自React.Component改为React.PureComponent。React.PureComponent默认会完成上面的浅层对比,以跳过本次重新渲染。

注意下:React.PureComponent会对props上所有可枚举属性做一遍浅层对比。而不像 shouldComponentUdate中可以有针对性的指对某属性做对比。

上面说的都是类组件,与之对应的是React.memo(),这个是针对函数组件的,作用和React.PureComponent完全相同。

React.memo()的使用方法很简单,就是把要导出的函数组件包裹在React.memo中即可。

伪代码:

import React from 'react';
function Xxxx() {
    return <div>xx</div>;
}
export default React.memo(Xxxx); // 使用React.memo包裹住导出的函数组件
1
2
3
4
5

但是要记住以下2点:

  1. React.memo()只会帮我们做浅层对比,例如props.name = 'liam' 或 props.list = [1, 2, 3],如果是props中包含复杂的数据结构,例如props.obj.list = [{age: 18}],那么就达不到预期,因为不会做到深层次的比对。
  2. 使用React.memo仅仅是让该函数组件具备了可以跳过本次渲染的基础,若组件在使用的时候属性值中有某些处理函数,那么还需要配合useCallback才可以做到跳过本次重新渲染。

...,不是在说memo么,咋又说到了useCallback。~~~

第2个知识点:=== 等比运算

在原生JS中

  1. {} === {} 为true还是false?
  2. {a: 2} === {a: 2} 为true还是false?

这是一道很容易却很容易迷惑人的题目,如果对原生ja中 === 等比运算不够深入了解,很容易会认为结果为true。

答案两者均是false。

以{}==={}为例,虽然字面上 === 左右两侧完全相同的,但是实际上在js中 左右两侧分别为独立的{}对象,各自占有各自的内存空间,因此他们的对比结果都是false。

相反,看下面的代码:

let obj = {};
let obj1 = obj;
obj2.name = 'react';
console.log(obj === obj2); // true
1
2
3
4

上面输出结果为true,为何obj === obj2为true?因为obj和obj2都是对同一个对象的引用,所以对比结果为true,因为他们最终指向同一个对象。

还记得开头对于useCallback概念解释中的那字?useCallback的作用是"钩住"组件属性中某些处理函数,创建这些函数对应在react原型链上的变量引用。

重点!圈起来要考的:记住"useCallback"和"原型链上处理函数的引用"这两个关键词。

来回到useCallback基础学习中。

# useCallback是来解决什么问题的?

useCallback是通过获取函数在react原型链上的引用,当即将重新渲染时,用旧值的引用去替换旧值,配个React.memo,达到"阻止组件不必要的重新渲染"。

# 详细解释

useCallback可以将组件的某些处理函数挂载到react底层原型链上,并返回该处理函数的引用,当组件每次即将要重新渲染时,确保props中该处理函数为同一函数(因为是同一个对象引用,所以===运算结果一定为true),跳过本次无意义的重新渲染,达到提高组件性能的目的。当前前提是该组件在导出时使用了React.memo()。

# 补充说明

假设某组件使用到了mufun这个处理函数,结合一下前面提到的js中 === 运算规则。

默认不使用useCallback,其实组件执行了以下伪代码:

let obj = {}; // 上一次渲染时创建的props
obj.myfun = {xxx}; // props中的myfun属性值,实为独立创建的{xxx}

let obj2 = {}; // 本次渲染时创建的props
obj2.myfun = {xxx}; // props中的myfun属性值,实为独立创建的{xxx}

if(obj.myfun === obj2.myfun) {
    // 跳过本次渲染,改为使用上一次渲染结果即可
}
1
2
3
4
5
6
7
8
9

由于obj.myfun 和 obj2.myfun 为分别独立创建的函数{xxx},所以对比结果为false,也就意味着无法跳过本次重新渲染,尽管函数{xxx}字面相同。

相反,如果使用了useCallback,其实组件执行了以下伪代码:

let myfun = {xxx}; // 独立定义处理函数myfun

let obj = {}; // 上一次渲染时创建的props
obj.myfun = myfun; // props中的myfun属性值,实为myfun的引用

let obj2 = {}; // 本次渲染时创建的props
obj2.myfun = myfun; // props中的myfun属性之,实为myfun的引用

if(obj.myfun === obj2.myfun) {
    // 跳过本次渲染,改为使用上一次渲染结果即可
}
1
2
3
4
5
6
7
8
9
10
11

此时 obj.myfun 和 obj2.myfun 均为myfun的引用,因此该比对结果为true,也就意味着可以顺利跳过本次渲染,达到提高组件性能的目的。

以上是代码仅仅是为了示意默认子组件为什么会被迫重新渲染,以及useCallback作用机理。

只有理解了这个机理,才会明白何时使用useCallback。切记切记,不要滥用useCallback。

多说一句,我写的时候我都觉的 React Hook 很绕,我又重新去官网看useCallback,哈哈,但是当充分理解React的编程哲学思想后,用起来就会如鱼得水。加油!!!

# useCallback函数源码

回到useCallback的学习中,首先看一下React源码中的ReactHooks.js (opens new window)

export function useCallback<T>(
  callback: T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useCallback(callback, deps);
}
1
2
3
4
5
6
7

要注意第2个参数,deps为该函数依赖的数据变量,值为Array 或 void 或 null。意味着如果该函数没有依赖的情况下,可以传入空数组[]或void或null。个人建议是传入空数组[]。

补充一点Typescript知识点。
像<T>(callback: T):T 这种类型定义成为"泛型",里面T的含义为"一模一样的同类型"。
举例:

  1. 若T为function,即参数callback类型为function,那么函数返回值也为function。
  2. 若T为object,即参数callback类型为object,那么函数返回值也为object。

# useCallback基本用法

useCallback(callback, deps)函数通常传入2个参数,第1个参数为我们定义的一个"处理函数",通常为一个箭头函数。第2个参数为该处理函数中存在的依赖变量,凡是处理函数中有的数据变量都需要放入deps中。如果处理函数没有任何依赖变量,可以传入一个空数组[]。

useCallback中的第2个依赖变量数组和useEffect中第2个依赖变量数组,作用完全不同!!!
useEffect中第2个依赖变量数组是真正起作用的,是具有关键性质的。而useCallback中第2个依赖变量数组目前作用来说仅仅是起到一个辅助作用。

仅仅是辅助?辅助了什么?既然处理函数中所有的依赖变量都需要作为第2个参数的内容,为啥React不智能一些,不用传第2个参数,省略掉这一步呢?

在React文章中,针对第2个参数有以下这段话

注意:依赖项数组不会作为参数传给回调函数。虽然从概念上来说它表现为:所有回调函数中引用的值都应该出现在依赖项数组中。未来编译器会更加智能,届时自动创建数组将成为可能

# 代码形式

import Button from './button'; // 引入一个自定义的组件<Button>

// 组件内声明一个age变量
const [age, setAge] = useStae(18);

// 通过callback,将鼠标点击处理函数保存到React底层原型链中,并获取该函数的引用,将引用赋值给handleClick
const handleClick = useCallback(() => {
    setAge(age + 1)
}, [age]);

// 由于该处理函数中使用到了age这个变量,因此useCallback的第2个参数中,需要将age添加进去

// 使用该处理函数,实为使用该处理函数的在React底层原型链上的引用
return <Button handleClick={handleClick}></Button>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 拆解说明

  1. 具体已经在示例代码中做了多项注释,不再重复。(不想浪费时间)

'age'补充说明

  1. 上述代码示例中,age为该组件通过useState创建的内部变量,事实上也可以是父组件通过属性传值的props.xx中的变量。
  2. 只要依赖变量不发生变化,那么重新渲染时就可以一直使用之前创建的那个函数,达到阻止本次渲染,提升性能的目的。但是如果依赖变量发生变化,那么下次重新渲染时根据变量重新创建一份处理函数并替换React底层原型链上原有的处理函数。

'handleClick'补充说明
再次强调,handleClick实际上是真正的处理函数在React底层原型链上的引用。

'<Button>'补充说明
<Button>为自定义的一个组件,在上述代码中相当于"子组件"。

上面示例伪代码仅仅是为了演示useCallback的使用方法,实际组件运用中,如果父组件只有1个子组件,那其实完全没有必要使用useCallback。只有父组件同时有多个子组件时,才有必要去做性能优化,防止某一个子组件引发的重新渲染也导致其他子组件跟着重新渲染。

# useCallback使用示例

若我们现在又一个自定义组件<Button>,代码如下

import React from 'react';
function Button({label, handleClick}) {
    // 为了方便查看该子组件是否被重新渲染,这里增加一行console.log
    console.log(`rendering ... ${label}`);
    return <button onClick={handleClick}>{label}</button>
}
export default React.memo(Button); // 使用React.memo()包裹住要导出的组件
1
2
3
4
5
6
7

现在,要实现一个组件,功能如下:

  1. 组件内部有2个变量age,salary
  2. 有2个自定义组件Button,点击之后分别可以修改age,salary的值

若不使用useCallback,代码示例如下:

import React,{ useState, useCallback, useEffect } from 'react'
import Button from './Button';

function Mybutton() {
    const [age, setAge] = useState(18);
    const [salary, setSalary] = useState(7000);
    
    useEffect(() => {
        document.title = `Hooks - ${Math.floor(Math.random() * 100)}`
    });
    
    const handleClick01 = () => {
        setAge(age + 1);
    };
    
    const handleClick02 = () => {
        setSalary(salary + 1);
    };
    
    return (
        <div>
            {age} - {salary}
            <Button label="Bth01" handleClick={handleClick01}></Button>
            <Button label="Bth02" handleClick={handleClick02}></Button>
        </div>
    )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

运行中无论点击哪个按钮,都会收到:
rendering ... Bt01
rendering ... Bt02

只是点击操作了其中一个按钮,另外一个按钮也要跟着重新渲染一次,试想一下如果该组件中有100个子组件甚至更多的话都要跟着重新渲染,那性能...

再来看看使用useCallback,示例代码如下:

import React, { useState, useEffect, useCallback } from 'react';
import Button from './Button';

function Mybutton() {
    const [age, setAge] = useState(18);
    const [salary, setSalary] = useState(7000);
    
    useEffect(() => {
        document.title = `Hooks -${Math.floor(Math.random()* 100)}`;
    });
    //使用useCallback()包裹住原来的处理函数
    const handleClick01 = useCallback(() => {
        setAge(age + 1);
    }, [age]);
    //使用useCallback()包裹住原来的处理函数
    const handleClick02 = useCallback(() => {
        setSalary(salary + 1);
    }, [salary]);

    return (
        <div>
            {age} - {salary}
            <Button label='Bt01' handleClick={handleClick01}></Button>
            <Button label='Bt02' handleClick={handleClick02}></Button>
        </div>
    )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

修改后的代码,运行就会发现,当点击某个按钮时,仅仅是当前按钮重新做了一份渲染,另外一个按钮没有重新渲染,而是直接使用上一次的渲染结果。

使用useCallback减少子组件没有必要的渲染目的达成。

useCallback用法很简单,就是包裹住原来的处理函数,关键点得理解useCallback背后的机理,才能知道在什么情况下可以使用useCallback。否则很容易滥用 useCallback,反而造成性能的浪费。

# 思考题

假设上面示例代码中,做以下修改:每个按钮上新增一个属性:random={Math.floor(Math.random() * 100)}

<Button label='Bt01' handleClick={handleClick01}></Button>
<Button label='Bt02' handleClick={handleClick02}></Button>
// 修改为
<Button label='Bt01' handleClick={handleClick01} random={Math.floor(Math.random()*100)}></Button>
<Button label='Bt02' handleClick={handleClick02} random={Math.floor(Math.random()*100)}></Button>
1
2
3
4
5

此时这种情况针对性能优化而使用的useCallback没有任何意义,虽然使用useCallback保证了每次handleClick是相同的,可是 random 的值每次都是却是随机不一样的,尽管子组件<Button>并没有使用 random 这个值,但是它的加入造成了 props 每次都不一样(其实props.random 不一样),结果就是子组件每一次都会被重新渲染。所以此时使用useCallback已经失去了存在的意义。

至此,就已经写完了,高级用法??? 我也没遇到过useCallback高级用法... 要有了我遇到了学习完我再补。