前言
其實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程式碼);
}
複製程式碼