使用React Hooks你可能會忽視的作用域問題

frontdog發表於2019-03-31

前言

其實React Hooks已經推出來一段時間了,直到前一陣子才去嘗試了下,看到的一些部落格都是以API的使用居多,還有一些是對於原理的解析。而我這篇文章想寫的是關於React Hooks使用中的作用域問題,希望可以幫助到曾經有過困惑的你。

useEffect基礎使用

在講作用域之前,首先幫助你熟悉或者複習一下useEffect的使用,useEffect的基本使用如下:

useEffect(() => {
    // do something
    return () => {
        // release something
    };
}, [value1, value2...])
複製程式碼

useEffect接受兩個引數:一個函式和一個值陣列,第二個引數是指在下次render的時候,如果這個陣列中的任意一個值發生變化,那麼這個effect的函式(第一個引數)會重新執行。

這麼講可能比較抽象,我們以下面的一個例子來說明:

使用React Hooks你可能會忽視的作用域問題

如圖,頁面中有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程式碼略;
};

複製程式碼

首次重新整理時,列印日誌為:

使用React Hooks你可能會忽視的作用域問題

我們來看發生了什麼事情:

1、第一次render執行的時候,useEffect的函式是非同步執行的,是在render後執行的,準確的說,在第一個render的時候是在DOM生成後執行的,相當於類元件的componentDidMount和componentDidUpdate。

2、render後開始執行useEffect的函式,這時候我們執行了setComputed函式,觸發state的修改,觸發重新render。

3、第二次render的時候,useEffect的函式本來應該是要非同步執行的,但是這時候注意了,useEffect是有第二個引數的,第二次render的時候,count不變,所以useEffect的函式不執行。

我們點選下 "+" 按鈕,再看下列印日誌:

使用React Hooks你可能會忽視的作用域問題

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,我們看下列印日誌:

使用React Hooks你可能會忽視的作用域問題

你猜對結果了嗎?我們期待的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程式碼);
}
複製程式碼

相關文章