使用React Hooks你可能会忽视的作用域问题

4,536 阅读7分钟

前言

其实React Hooks已经推出来一段时间了,直到前一阵子才去尝试了下,看到的一些博客都是以API的使用居多,还有一些是对于原理的解析。而我这篇文章想写的是关于React Hooks使用中的作用域问题,希望可以帮助到曾经有过困惑的你。

useEffect基础使用

在讲作用域之前,首先帮助你熟悉或者复习一下useEffect的使用,useEffect的基本使用如下:

useEffect(() => {
    // do something
    return () => {
        // release something
    };
}, [value1, value2...])

useEffect接受两个参数:一个函数和一个值数组,第二个参数是指在下次render的时候,如果这个数组中的任意一个值发生变化,那么这个effect的函数(第一个参数)会重新执行。

这么讲可能比较抽象,我们以下面的一个例子来说明:

如图,页面中有1个按钮,当点击 "+" 按钮时count要加1,computed始终要为count + 1(实际业务中,这个计算往往不会是这么简单的),现在我们就用useEffect来计算computed:

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

export default () => {
    const [count, setCount] = useState(0);
    const [computed, setComputed] = useState(0);

    useEffect(() => {
        setComputed(count + 1);
        // return () => {};
    }, [count]);

    return View代码略;
};

代码很简单,useEffect的第二个参数为[count],表示当count变化时,函数需要执行,在这个函数里面我们去设置computed为count+1,这样就完成了我们的需求。

下面我们深入讲解下useEffect的执行流程。

useEffect执行流程

我们利用console.log来帮助大家理解执行流程,上面代码改为:

export default () => {
    const [count, setCount] = useState(0);
    const [computed, setComputed] = useState(0);
    
    console.log('render before useEffect', count, computed);
    
    useEffect(() => {
        console.log('in useEffect', count, computed);
        setComputed(count + 1);
        return () => {
            console.log('just log release')
        };
    }, [count]);
    
    console.log('render after useEffect', count, computed);

    return View代码略;
};

首次刷新时,打印日志为:

我们来看发生了什么事情:

1、第一次render执行的时候,useEffect的函数是异步执行的,是在render后执行的,准确的说,在第一个render的时候是在DOM生成后执行的,相当于类组件的componentDidMount和componentDidUpdate。

2、render后开始执行useEffect的函数,这时候我们执行了setComputed函数,触发state的修改,触发重新render。

3、第二次render的时候,useEffect的函数本来应该是要异步执行的,但是这时候注意了,useEffect是有第二个参数的,第二次render的时候,count不变,所以useEffect的函数不执行。

我们点击下 "+" 按钮,再看下打印日志:

1、setCount触发render,首先执行render

2、检测useEffect第二个参数,发现count已经变化,所以这个effect要重新执行,执行effect之前,会去看前一次effect执行时是否返回了函数,如果返回了函数,那么会首先执行这个函数(主要让我们释放副作用)。

3、执行完release函数后,开始执行effect函数,这时候执行setComputed

4、setComputed再次触发render,这次的render,useEffect检测到count没有发生变化,所以不会重新再执行effect。

如果你没看懂这其中render、effect函数、release函数的执行顺序,那么对于后续的一些作用域问题你可能无法理解,麻烦多看几遍这个日志打印的例子。

作用域问题

首先我们看段代码:

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

export default () => {
    const [state, setState] = useState({
        count: 0,
        computed: 1,
    });
    useEffect(() => {
        const buttonNode = document.getElementById('button');
        
        function handler() {
            console.log('in handler', state.count, state.computed);
            setState({
                count: state.count + 1,
                computed: state.count + 2,
            });
        }
        
        buttonNode.addEventListener('click', handler);
        
        return () => buttonNode.removeEventListener('click', handler);
    }, []);

    console.log('render', state.count, state.computed);

    return (
        <div className="app">
            <p>count: {state.count}, computed: {state.computed}</p>
            <button id="button"> + </button>
        </div>
    );
};

我们把之前的例子改造了下,把button的点击事件改成了在useEffect里面绑定,useEffect的第二个参数传入空数组[],表示这个effect函数只在componentDidMount的时候执行。我们不断点击 "+" 按钮,期待的结果应该是和上面的例子一样,count不断增加,computed始终为count + 1,我们看下打印日志:

你猜对结果了吗?我们期待的count并没有不断增加,而handler里获取到的state.count居然始终为0。

按照我们的习惯,handler里面用到了state,在handler这个函数作用域里面没有这个变量,那么应该去render这个函数里面找,在第二次点击按钮的时候,state.count应该已经是1了,但是为什么拿到的还是0呢?

如果你看到这个结果没有一刻的困惑,那么你应该是个基础异常扎实的人,很不容易。

这个问题的答案要用作用域来解释。

静态作用域

关于作用域的详细解释大家自己去google,好文章很多,这里不展开讲太多,简单看段代码:

function foo() {
    console.log(a); 
}

function bar() {
    var a = 3;
    foo();
}

var a = 2;

bar();

这段代码执行打印结果为:2

为什么呢?因为JS的函数会创建一个作用域,这个作用域是在函数被定义的时候就定好的,在上面的代码中,foo函数定义的时候,它的外层作用域是global,global里面a变量是2,所以打印出来的结果是2,如果是动态作用域,那么打印出来的就是3。

记住了吗?

模拟useEffect的作用域问题

由于React Hooks的内部原理需要去看源码才能知道,这里我们用原生JS来模拟,这样你就可以更纯粹地理解。

let init = true;

const value = {count: 0};

function render() {
    let count = value.count;
    if (init) {
        function handler() {
            console.log(count);
            value.count = count + 1;
            render();
        }
        document.addEventListener('click', handler);
        init = false;
    }
}

render();

这段代码定义了一个函数render,render里面绑定了document点击事件,回调函数里面执行了value.count为count + 1,然后触发render,模拟修改state后触发render行为。

这里handler的count也是始终为0,为什么呢?

我们把上面说过的作用域概念引入就很好解释了,当第一次执行render的时候,render函数创建了一个作用域,这个作用域中count = value.count,也就是0,这时init为true,所以handler被定义,词法作用域被创建,它的上层作用域就是刚才执行render的创建的作用域。

根据静态作用域的特性,handler里面的count在它被定义的时候就决定是0了,所以它始终是0.

理解吗?

如果理解了,那么我们返回来看useEffect的作用域。

useEffect作用域问题

仍然是这段代码:

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

export default () => {
    const [state, setState] = useState({
        count: 0,
        computed: 1,
    });
    
    useEffect(() => {
        const buttonNode = document.getElementById('button');
        
        function handler() {
            setState({
                count: state.count + 1,
                computed: state.count + 2,
            });
        }
        
        buttonNode.addEventListener('click', handler);
        
        return () => buttonNode.removeEventListener('click', handler);
    }, []);

  
    return View省略;
};

1、在第一次render的时候,执行到useEffect函数的时候,可以想象成React内部是类似下面的代码:

const fnArray = [];
const consArray = [];

function useEffect(callback, conditions) {
    const index = <该useEffect对应的index>;
    if (<首次render>) {
        fnArray.push(callback);
        consArray.push(conditions);
    } else if (<根据conditions判定需要重新执行effect>) {
        fnArray[index] = callback;
        consArray[index] = conditions;
    }
}

源码肯定不是这样的,但是可以这么理解,是用数组在维护hooks,所以useEffect的函数的作用域在执行useEffect的时候就定好了,当你传入的conditions(第二个参数)判定不需要重新执行时,effect函数的作用域的外层为前面某个render创建的作用域,这次render中,conditions发生了变化,判定需要重新执行effect,

普通的useEffect,也就是第二个参数不传,每次都update的effect,这样的effect在每次render执行后,都会更新最新的effect函数,因此可以拿到最新的state

useEffect(() => {
    // do something
})

一个技巧

利用effect执行时机来记录前一个render的值

export function usePrevious(value) {
    const ref = useRef();
    useEffect(() => {
        ref.current = value;
    });
    return ref.current;
}

然后你在你的组件中就可以这么用:

const Component = () => {
    const [count, setCount] = useState(0);
    const prevCount = usePrevious(count); // 获取上一次render的count

    return (View代码);
}