本文是在:https://juejin.im/post/5ceb36dd51882530be7b1585 的基础上进行的探究,非常建议阅读原文

一、useCallback 与 componentDidUpdate 的区别

使用 useReducer 和 useCallback 解决 useEffect 依赖诚实与方法内置&外置问题 这篇文章中,我们使用 useCallback 解决了 useEffect 中的依赖诚实问题,并且做到了 useEffect 的间接依赖,通过 useCallback 将依赖变量移到了 useEffect 外部。

这个过程中我们的关注点是 依赖state/props 的变化对组件本身的影响 ,这会影响到我们的很多代码逻辑。

1、componentDidUpdate 带来的问题

在 class component 时代,判断 component 更新我们会使用 componentDidUpdate ,并且会拿到更新前的 props 和 state,然后在这个逻辑中判断我们是否需要做一些事情。

首先 componentDidUpdate 无法做到单个 prop 或者 state 依赖,是全局的,因此即使依赖的内容没发生变化,我们也得判断一次,否则就会重新取数据。

最尴尬的时候,如果传入的 prop 中包含了一个 function,并且 function 在父组件中依赖了某些父组件中的变量,这个变化会让人捉摸不透。

举个例子:

  • 一个父组件中使用了两个 state ,分别是 count 和 step,同时一个 countNumber 方法,返回 count + step
  • 父组件会修改 step 和 count
  • state 和 count 以及 countNumber 都会传给子组件

父组件

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

export default (props = {}) => {
    const [count, setCount] = useState(0);
    const [step, setStep] = useState(0);

    const countNumber = () => {
        return count + step;
    }

    const handleSetStep = () => {
        setStep(step + 1);
        setCount(count + step);
    }

    return (
        <div>
            <button onClick={handleSetStep}>set step is : {step} count is : {count}</button>
            <Child countNumber={countNumber} count={count} step={step} />
        </div>
    );
}

子组件

import React, { Component } from 'react';

export default class Demo7Classs extends Component {

    constructor(props) {
        super(props);
    }

    componentDidMount() {
        this.props.countNumber();
    }

    componentDidUpdate(prevProps) {
        console.log(prevProps, this.props);
        console.log('---updated---');
        if (prevProps.count !== this.props.count && prevProps.step !== this.props.step) {
            console.log(`---- count number is ${this.props.countNumber()} ----`);
        }
    }
    render() {
        return (
            <div>
                <p>{this.props.count} -- {this.props.step} -- {this.props.countNumber()}</p>
            </div>
        );
    }
}

子组件 componentDIdUpdate 中对依赖的使用

我们在子组件中判断了只有 count 和 step 都发生变化的时候才会重新取值,假设这是我们需要的逻辑

    componentDidUpdate(prevProps) {
        console.log(prevProps, this.props);
        console.log('---updated---');
        if (prevProps.count !== this.props.count && prevProps.step !== this.props.step) {
            console.log(`---- count number is ${this.props.countNumber()} ----`);
        }
    }

如果少写了 countstep 中的任何一个,这次都不会触发重新计数。

这样带来的问题是什么?

很明显,这样带来的问题就是我们在子组件中显示的需要去理解 countNumber 中依赖的两个 state:count 和 step

一旦父组件这个相关的代码逻辑发生变化,所有的子组件都需要改动这块代码。

2.使用 useCallback 解决 componentDidUpdate 问题

class component 中使用 componentDidUpdate 有时候需要我们理解父组件的内部机制,否则很难明白依赖

使用 useCallback 可以方便的自己管理自己的依赖:

1、父组件

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

export default (props = {}) => {
    const [count, setCount] = useState(0);
    const [step, setStep] = useState(0);

    const countNumber = useCallback(() => {
        return count + step;
    }, [count, step]);


    const handleSetStep = () => {
        setStep(step + 1);
        setCount(count + step);
    }

    return (
        <div>
            <button onClick={handleSetStep}>set step is : {step} count is : {count}</button>
            <Child countNumber={countNumber}/>
        </div>
    );
}

可以发现上面的代码中,父组件的 countNumber 通过 useCallback 包装了一下,并且内部依赖了 count 和 step 两个 state。

在传给子组件的时候,直接传入了 countNumber 这个包装后的方法

2、子组件

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

export default (props = {}) => {

    useEffect(() => {
        console.log('.....')
        console.log(props.countNumber());
    }, [props.countNumber]);

    return (
        <div>
            {props.countNumber()}
        </div>
    );
}

在上面的 class component 中,我们通过了在 componentDidUpdate 判断 count 和 state 是否发生变化来判断是否需要重新执行方法。

而 function component 的子组件中,我们在 useEffect 只依赖了 props.countNumber

    useEffect(() => {
        console.log('.....')
        console.log(props.countNumber());
    }, [props.countNumber]);

至于 countNumbe 内部依赖了什么我们并不关心

最终结果也是我们想要的,每次 count 或者 step 发生变化后,通过 useCallback 包装的方法在 props.countNumber 其实发生了变化,而子组件的 useEffect 也会重新执行

92299-2js1qnpoeqj.png

二、利用 useCallback 将函数抽离到组件外部

1、自定义 hook 包装 useCallback

之前我们实现的是通过 useCallback 将函数抽离到了 useEffect 外面,使得 useEffect 不会直接依赖某些变量

而某些时候,我们需要将函数抽离到整个组件的外面,实现代码的复用性和可读性

useCallback 的变量依赖上我们可以发现,一个 useCallback 依赖变化的变量我们可以通过传入的方式:

function a(count, step) {
    return useCallback(() => {
        return count + step;
    }, [count, step])
}

既然已经有了这样的思路,那就可以通过一个自定义 hook 来实现:

const useCountNumber = (count, step) => {
    console.log('----useCountNumber----');
    return useCallback(() => {
        return count + step;
    }, [count, step]);
}

上面的 hook 传入了 count 和 step,在 useCallback 中依赖了这两个变量的变化,并且返回了 useCallback 包装后的结果。

在使用的过程中,我们只需要通过 useCountNumber 拿到包装后的方法,即可在 function component 中使用:

    const [count, setCount] = useState(0);
    const [step, setStep] = useState(0);
    const countNumber = useCountNumber(count, step);

    useEffect(() => {
        console.log(countNumber());
    }, [countNumber]);

    const handleSetStep = () => {
        setStep(step + 1);
        setCount(count + step);
    }

上面代码中,const countNumber = useCountNumber(count, step); 拿到包装后的结果,在 useEffect 中只需依赖 countNumber 即可。

50241-70jga66qlj4.png

2、自定义 hook 包装 useCallback 带来的性能问题

从上面的截图中我们可以发现,每次变更 count 和 step 之后,countNumber() 都会因为依赖变化,而重新实例化并且执行一次。

但是对我们来说,我们只是希望在需要的时候 useCallback 里面的内容执行一次,而不是整个 useCallback 每次都要因为 count 和 step 的变化重新实例化

我们期望每次都拿到 count 和 step 的最新值,而不是每次都要通过依赖变化自动实例化依赖最新值的方法出来。

通过 useRef 我们可以将变量挂载到 useRef.current 上,之前在解决渲染闭包环境问题的时候有使用,具体可以参照:使用 useEffect 配合 useRef 在渲染闭包中总是获取最新值

在我们的自定义 hook 中,我们为了解决 useCallback 带来的性能问题(每次都会重新实例化一次 useCallback)

const useCountNumber2 = (count, step) => {
    console.log('----useCountNumber[2]----');
    const countRef = useRef(null);
    const stepRef = useRef(null);

    useEffect(() => {
        console.log(`---number2--- count: ${count} - step: ${step}--`);
        countRef.current = count;
        stepRef.current = step;
    });

    return useCallback(() => {
        return `number2 : ${countRef.current + countRef.current}`;
    }, [countRef, stepRef]);

}

上面的代码证,我们可以发现,在自定义 hook 内部,我们通过 useEffect 每次都会将传入的 count 和 step 赋值给两个 ref.current,这样做的目的在于,在一次渲染闭包环境中,只要 count 和 step 改动了 ,那么 ref.current 中的值必然发生了改动

而在 useCallback 的依赖中,依赖的是两个 ref,首先这两个 ref 肯定是不会发生依赖变化的(浅比较),因此我们的 useCallback 不会每次在 count 和 step 发生变化的时候,实例化一个新的方法出来。

而在方法内部,通过 ref.current 即使不重新实例化,每次也能拿到最新的 count 和 step

两者的区别通过下面的代码可以作为比较:

onst useCountNumber = (count, step) => {
    return useCallback(() => {
        return `number1 : ${count + step}`;
    }, [count, step]);
}

const useCountNumber2 = (count, step) => {
    const countRef = useRef(null);
    const stepRef = useRef(null);

    useEffect(() => {
        countRef.current = count;
        stepRef.current = step;
    });

    return useCallback(() => {
        return `number2 : ${countRef.current + stepRef.current}`;
    }, [countRef, stepRef]);

}

export default (props = {}) => {
    const [count, setCount] = useState(0);
    const [step, setStep] = useState(0);
    const countNumber = useCountNumber(count, step);
    const countNumber2 = useCountNumber2(count, step);

    useEffect(() => {
       console.log('---re-useEffect--- useCountNumber')
    }, [countNumber]);

    useEffect(() => {
        console.log('---re-useEffect--- useCountNumber2')
    }, [countNumber2]);

    const handleSetStep = () => {
        setStep(step + 1);
        setCount(count + step);
        console.log(`handleSetStep  number1: ${countNumber()}`)
        console.log(`handleSetStep number2: ${countNumber2()}`)
        
    }

    return (
        <div>
            <button onClick={handleSetStep}>set step is : {step} count is : {count}</button>
        </div>
    );
}

结果:
56118-t920t7gakpc.png

通过上面的结果我们可以发现,每次点击触发更改 count 和 step 的时候,第一个 useCallback 封装的自定义 hook,每次都会执行一次重新实例化,而第二个则不会。

同样的输出结果,第二种方式除了第一次实例化之外,每次都能少实例化一次。

3、自定义 hook 解决 useCallback 重新实例化问题

上面虽然使用 useRef 解决了依赖问题,但本质上对函数代码改动比较大,后面维护可能也会出现问题。

如果按照 useRef 的方式,每次都会搞很多次 useRef 的使用,因此我们需要将其进行封装。

封装的基本思想是:

  • 真实的计算逻辑发生的方法,通过参数的形式传入给 hook
  • 依赖变化的 step 和 count 也通过参数的形式传入
  • 将逻辑计算的方法赋值给 ref.current
  • 无论是计算逻辑方法本身发生变化还是依赖的 state 发生变化,都会将 逻辑计算方法赋值给 ref.current
  • 在 useCallback 中真正执行的方法是通过 ref.current 拿到的,保证每次拿到的都是最新的

核心代码如下:

const useOnceCallback = (fn, dependencies) => {
    const ref = useRef(null);

    useEffect(() => {
        ref.current = fn;
    }, [fn, ...dependencies]);

    return useCallback(() => {
        const fn = ref.current;
        return fn();
    }, [ref]);
}

具体代码:

import React, { useEffect, useState, useCallback, useRef } from 'react';

const useOnceCallback = (fn, dependencies) => {
    const ref = useRef(null);

    useEffect(() => {
        ref.current = fn;
    }, [fn, ...dependencies]);

    return useCallback(() => {
        const fn = ref.current;
        return fn();
    }, [ref]);
}

export default (props = {}) => {
    const [count, setCount] = useState(0);
    const [step, setStep] = useState(0);

    const calNum = () => {
        return count + step;
    }

    const countNumber = useOnceCallback(calNum, [count, step]);

    useEffect(() => {
        console.log('-----re-useEffect-----');
    }, [countNumber])

    const handleSetStep = () => {
        setStep(step + 1);
        setCount(count + step);
        console.log(countNumber());
    }


    return (
        <div>
            <button onClick={handleSetStep}>set step is : {step} count is : {count}</button>
        </div>
    );
}

结果:

12948-mqnqix56oxm.png

4、hack useCallback() 是不推荐的

上面我们用来优化 useCallback 多次实例化方法的这种方式非常的 hack ,React 官方不推荐使用这种方式。

官方推荐的方法是通过传递 dispatch 来解决类似的问题。

因为上面的这种方式,在同步渲染(同步渲染参考:https://zh-hans.reactjs.org/blog/2018/03/27/update-on-async-rendering.html)下可能存在问题。

向下传递回调的这种场景中,使用 dispatch 是最好的解决方案。(dispatch 不会在重新渲染之间变化)

四。代码地址:

useCallback 与 componentDidUpdate 的区别与实践:

useCallback 与 componentDidUpdate 的区别与实践:

自定义 hook 封装 useCallback 将函数抽离出组件:

自定义hook解决 useCallback 多次实例化方法带来的性能问题: