阅读 945

你不知道的React Hook

前言

自 React16.8 正式发布React Hook之后,已经过去了5个版本(本博客于React 16.13.1版本时发布)。自己使用Hook已经有了一段时间,不得不说在最初使用Hook的时候也进入了很多误区。在这篇文章中,我会抛出并解答我遇到的一些问题,同时整理对Hook的心得理解。

1. Hook的执行流

在解释后续内容之前,首先我们要明确Hook的执行流。

我们在写 Hook Component 的时候,本质是 Function Component + Hook ,Hook只是提供了状态管理等能力。对 Function Component 来说,每一次渲染都会从上到下将所有内容重新执行一次,如果有变量,就会创造新变量

来看一个简单的例子

// father.js

import Child from './child';

function Father() {
    const [num, setNum] = useState(0);

  	function handleSetNum() {
        setNum(num+1);
    }
  
    return (
        <div>
            <div onClick={handleSetNum}>num: {num}</div>
            <Child num={num}></Child>
        </div>
    );
}
复制代码
// child.js

function Child(props) {
    const [childNum, setChildNum] = useState(0);
  
    return (
        <>
            <div>props.num: {props.num}</div>
            <div onClick={() => {setChildNum(childNum + 1)}}>
                childNum: {childNum}
            </div>
        </>
    );
}
复制代码

然后我们来看看执行流:

father执行 useStateuseState 返回一个数组,然后解构赋值给 numsetNum ,然后创建函数 handleSetNum ,把 jsx代码 返回出去交给 react 处理。

child接受到来自father传递的数据,创建props变量并赋值,执行 useState ,解构赋值给 childNumsetChildNum ,把 jsx代码 返回出去交给 react 处理。


接下来,点击father中那个绑定了点击事件的变量,触发执行 handleSetNum 方法,修改Hook State,让num值加一。因为状态发生改变,会去触发react的重渲染。

然后我们再来看看第二次重渲染时的部分执行流:

father会 **再次执行 useState **; useState 返回一个 新数组 ,然后解构赋值给 **新创建的 numsetNum **;随后 创建一个新的函数 handleSetNum

child接受到来自father传递的数据,创建 新变量props并赋值;再次执行 useStateuseState返回一个新数组,但因为childNum没有发生变化,新数组里面的值全等于旧数组里面的值,解构赋值给 新变量 childNumsetChildNum


我们可以通过全局变量进行验证

// father.js

window.selfStates = []; // 创建一个全局的变量存储
window.selfStates2 = []; // 创建一个全局的变量存储

function Father() {
    const [num, setNum] = useState([0]); // 把数字改成一个数组
    const [num2, setNum2] = useState([0]); // 设置一个对照组,对照组就初始化,不进行修改
    window.selfStates.push(num);
    window.selfStates2.push(num);
  
  	function handleSetNum() {
        setNum([num[0] + 1]) // 不直接改值,避免影响旧数组
    }

  	...
}
复制代码

之后可以在浏览器的控制台里输出,看看结果

当然,我们创建的 handleSetNum 函数也可以用这样的方法进行验证。

所以,看到这里,我们已经了解了hook的执行流:每次渲染,每次更新,都会让整个内容全部更新一次,并且创建新的变量。

2. useState不要传递执行函数进行初始化

直接上代码

function initState() {
  	console.log('run'); // 执行一次就知道了
		return 1;
}

function Father() {
  	const [num, setNum] = useState(initState()); // ❎
  	const [num, setNum] = useState(initState); // ✅
}
复制代码

因为每次更新,都会执行一次useState,根据useState的机制,它会进行数据存储:如果没有数据,进行初始化,并创建新数据,存储起来;如果有数据,不进行初始化操作,返回数据的值。

但是如果我们使用 useState(func()) 这样形式进行初始化,那么每次都会先执行func,再把执行后得到的值作为参数传递给useState,但是如果已经初始化过了,那么会跳过对这个参数的初始化处理,每次更新时都会浪费一次跑func函数的时间。

3. Capture value

Hook Component

来看下面这个代码

function Father() {
    const [num, setNum] = useState(0);

    function handleSetNum() {
        setNum(num+1);
    }

  	// 延迟3秒后输出num值
    function handleGetNum() {
        setTimeout(() => {
            alert(num);
        }, 3000);
    }

    return (
        <div>
            <div onClick={handleSetNum}>num: {num}</div>
            <div onClick={handleGetNum}>点我输出内容</div>
        </div>
    );
}
复制代码

假设现在num值为0,我先触发 handleGetNum ,然后再触发1次 handleSetNum 修改num的值,3秒倒计时结束后,输出的num值是多少?

留点空间来思考一下













下面公布答案













答案是0

如果我们使用 Class Component 来做,得到的结果却相反,输出的值为1。

在Hook Component中,这种现象被称作 Capture value


为什么会有这样的情况?

解答这个问题,这还是得回到最初的起点:Hook执行流

我们知道每次渲染都会创建该次渲染的新变量,因此在最初的状态下,我用handleGetNum_0来表示最初的 handleGetNum 函数,num_0表示最初的num。

我们先触发 handleGetNum,随后触发 handleSetNum ,之后数据更新,创建新函数 handleGetNum_1 和 新变量 num_1。

在这个过程中我们实际点击的是handleGetNum_0,handleGetNum_0里操作的是num_0,所以 alert 的内容还是 num_0 的值。

Class Component

那么问题来了,为什么 Class Component 不会有这样的情况发生?

我们来看一下如果用 Class Component 会怎么写

class App extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            num: 0
        }
    }

    handleSetNum = () => {
        this.setState({num: this.state.num+1});
    }

    handleGetNum = () => {
        setTimeout(() => {
            alert(this.state.num);
        }, 3000);
    }

    render() {
        return (
            <div>
                <div onClick={this.handleSetNum}>num: {this.state.num}</div>
                <div onClick={this.handleGetNum}>点我输出内容</div>
            </div>
        )
    }
}
复制代码

我们在 Class Component 中获取参数时,是通过 this.state.num 进行获取,this会指向最新的state值,因此不会出现 Capture Value的情况。

如果我们想在 Class Component 中实现 Capture Value,一个简单的办法就是做一个闭包,这个比较简单,因此不在文中详细陈述。


那么如果我们不想在 Hook Component 中触发 Capture Value 应该怎么做?

答案就是用 useRef

function App() {
    const [num, setNum] = useState(0);
    const nowNum = useRef(0); // 额外创建一个Ref对象,因为Ref对象更新不会触发react的重渲染

    function handleSetNum() {
        setNum(num + 1);
        nowNum.current = num + 1; // 给num赋值的同时也给nowNum赋值
    }

    function handleGetNum() {
        setTimeout(() => {
            alert(nowNum.current);
        }, 3000);
    }
  
  	...
}
复制代码

我们使用 nowNum.current 就类似 this.state ,因为Ref会创建一个对象,这个对象会指向最新的值。

4. 需要用useCallback和useMemo吗

useCallback 和 useMemo 比较类似,我们放在一起来说。

他们都是用来做数据缓存的,区别就在于useCallback返回的是函数,useMemo返回的是函数执行后的返回值。

先上结论

什么时候需要用他们:

  1. 成本很高的计算
  2. 避免子组件无意义的重渲染
  3. 数据需要传递给其他组件,且数据为对象、函数

什么时候不需要用他们:

  1. 仅仅在组件内部使用,不存在向下传递数据。
  2. 如果要向下传递数据,但数据值是非对象、非函数的值

  1. 如果我有一个jsx代码,里面有一个值需要通过计算得出。这个计算非常复杂,计算成本很高,但是计算时用到的参数基本没有变化,那么此时用useMemo进行包裹,就能节省很多计算成本。

  1. 如果有一个父组件,有一个子组件,父组件的每次更新一定会触发子组件的更新。使用useMemo包裹子组件,能避免子组件的无意义更新。

ex.

function Father() {		
		const [num, setNum] = useState([0]);
    const [num2, setNum2] = useState([0]);

    function handleSetNum() {
        setNum([num[0] + 1]);
    }

    function handleSetNum2() {
        setNum2([num2[0] + 1]);
    }

    return (
        <div>
            <div onClick={handleSetNum}>num: {num[0]}</div>
            <div onClick={handleSetNum2}>num2: {num2[0]}</div>
            <Child num={num}></Child>
        </div>
    );
}
复制代码

上面的代码就是没有使用useMemo,Child接受num参数,每当num参数更新的时候,会触发Child的更新,同时还有一个num2,num2的更新触发Father的更新,同时造成Child的更新。

处理后的代码:

function Father() {
    const [num, setNum] = useState([0]);
    const [num2, setNum2] = useState([0]);

    function handleSetNum() {
        setNum([num[0] + 1]);
    }

    function handleSetNum2() {
        setNum2([num2[0] + 1]);
    }

    const ChildHtml = useMemo(() => {
      	return <Child num={num}></Child>
    }, [num])

    return (
        <div>
            <div onClick={handleSetNum}>num: {num[0]}</div>
            <div onClick={handleSetNum2}>num2: {num2[0]}</div>
            {ChildHtml}
        </div>
    );
}
复制代码

这样我们用useMemo包裹了子组件,useMemo会存储return的值,当num变化时,会重新执行useMemo第一个参数里的函数,返回新值,并保存新值。 当num2变化时,会把之前保存的值取出来,这样就能避免子组件的重渲染。

小心对象!

有时候会有这样的处理

// father.js
function Father() {
    ...
    function func() {};

    return {
      	<Child func={func} obj={{name: abc, num:2}}></Child>
    }
}

// child.js
function Child(props) {
    useEffect(() => {
        ...
    }, [props])

    return (
        ...
    );
}
复制代码

在子组件里,需要模拟 componentDidUpdate 处理各种参数,用当props改变的时候,进行一些操作。

如果我们已经用了useMemo包裹了子组件,那么解决了一个隐藏问题。如果我们没有用useMemo包裹子组件,那么就要小心了

Father传递给Child的props里有2个属性,一个函数,一个自定义对象

props: {
    func: func,
    obj: {
      	name: abc,
        num: 2
    }
}
复制代码

当子组件接受的props发生了变化,会执行useEffect函数里的内容。

如果我们props.obj里的值发生了改变,引起了useEffect的执行,这是正常的。

但是我们要明确一点,Hook每次执行都会创建新的对象。也就是说,如果Father每次更新,都会创建新的func函数和新的obj对象,即使我们认为func函数和obj对象没有发生变化,但是props里的变量指针都会指向新对象,然后触发了本不该触发的useEffect


2种解决方法:

  1. 用useMemo包裹子组件。
  2. 用useCallback和useMemo包裹func函数和obj对象

也许有人会采用 React.memoReact.PureComponent ,但是他们的缓存策略也只是用全等符比较props里的值,因此即使使用了这2个方法,如果不用useCallback和useMemo包裹props中传递的值,依然也会触发上文代码里写的 useEffect

tip: 通过useState得到的变量不需要使用useMemo,因为useState已经进行了处理,保证未更新的值引用不变

const num = useState(0); // 因为返回的是一个数组,每次更新num会变,但是如果具体的state值没有变化,num[0]和num[1]的引用不会变。

const [num, setNum] = useState(0); // 这种情况下,如果num的值没变,每次更新num和setNum的引用就不会变
复制代码

tip2: 如果传递的是非对象、非函数的内容,比如number、string,就没必要包裹。

全等符比较他们是比较值是否相等,而不是去比较地址是否相等


这里只对props传递进行了距离,一些非props传递的地方,只要用上了对象,都有可能埋下这种隐藏问题。 所以一定要小心对象!


性能优化

还要明确一点,很多时候,重新执行一段代码(不用useMemo/useCallback)远比存储、比较、取值(使用useMemo/useCallback)来得更快。而且在很多情况,即使用 useMemo/useCallback 进行优化,优化效果也根本看不出来(现代浏览器和计算机速度并不慢)

所以如果不是为了优化多组件嵌套或者高成本计算,很多时候其实也不需要刻意去使用useMemo/useCallback

感兴趣的可以浏览 When to useMemo and useCallback 以获取更多信息

总结

  1. Hook Component 每一次渲染都会从上到下将所有内容重新执行一次,如果有变量,就会创造新变量

  2. useState(func()) 不要这样写代码!

  3. Hook Component 具备 Capture value 的特性

    • 可以用 useref 避开 Capture value
  4. useCallback 和 useMemo 的注意事项

    什么时候需要用他们:

    1. 成本很高的计算
    2. 避免子组件无意义的重渲染
    3. 数据需要传递给其他组件,且数据为对象、函数

    什么时候不需要用他们:

    1. 仅仅在组件内部使用,不存在向下传递数据。
    2. 如果要向下传递数据,但数据值是非对象、非函数的值
  5. 一定要小心对象!!

  6. 有时候 重新执行一段代码远比从缓存中获取一段结果来得更快


尾声

如果文中有错误/不足/需要改进/可以优化的地方,希望能在评论里友善提出,作者看到后会在第一时间里处理

如果你喜欢这篇文章,👍点个赞再走吧,github的星星⭐是对作者持续创作的支持❤️️


相关资料

kentcdodds.com/blog/usemem…

overreacted.io/zh-hans/how…

zhuanlan.zhihu.com/p/85969406?…