React Hooks踩坑分享
摘要:return res。< div >當前點擊了{num}次 div >。
點擊關注“ 有贊coder ”
獲取更多技術乾貨哦~
作者:蘇木
團隊:增長中心
前言:React Hooks被越來越多的人認可,整個社區都以積極的態度去擁抱它。在最近的一段時間筆者也開始在一些項目中嘗試去使用React Hooks。原本以爲React Hooks很簡單,和類組件差不多,看看API就能用起來了。結果在使用中遇到了各種各樣的坑,通過閱讀React Hooks相關的文章發現React Hooks和類組件有很多不同。由此,想和大家做一些分享。
如果要在項目中使用React Hooks,強烈推薦先安裝 eslint-plugin-react-hooks
(由React官方發佈)。在很多時候,這個eslint插件在我們使用React Hooks的過程中,會幫我們避免很多問題。
本文主要講以下內容:
-
函數式組件和類組件的不同
-
React Hooks依賴數組的工作方式
-
如何在React Hooks中獲取數據
一、函數式組件和類組件的不同
React Hooks由於是函數式組件,在異步操作或者使用useCallBack、useEffect、useMemo等API時會形成閉包。
先看一下以下例子。在點擊了 展示現在的值
按鈕三秒後,會 alert 點擊次數:
function Demo() {
const [num, setNum] = useState( 0 );
const handleClick = () => {
setTimeout( () => {
alert(num);
}, 3000 );
};
return (
< div >
< div >當前點擊了{num}次</ div >
< button onClick={() => { setNum(num + 1) }}>點我</ button >
< button onClick={handleClick}>展示現在的值</ button >
</
div
);
};
我們按照下面的步驟去操作:
-
點擊
num
到3 -
點擊
展示現在的值
按鈕 -
在定時器回調觸發之前,點擊增加
num
到5。
可以猜一下 alert 會彈出什麼?
分割線
其最後彈出的數據是3。
爲什麼會出現這樣的情況,最後的 num
不是應該是5嗎?
上面例子中, num
僅是一個數字而已。 它不是神奇的“data binding”, “watcher”, “proxy”,或者其他任何東西。它就是一個普通的數字像下面這個一樣:
const num = 0 ;
// ...
setTimeout( () => {
alert(num);
}, 3000 );
// ...
我們組件第一次渲染的時候,從 useState()
拿到 num
的初始值爲0,當我們調用 setNum(1)
,React會再次渲染組件,這一次 num
是1。如此等等:
// 第一次渲染
function Demo() {
const num = 0 ; // 從useState()獲取
// ...
setTimeout( () => {
alert(num);
}, 3000 );
// ...
}
// 在點擊了一次按鈕之後
function Demo() {
const num = 1 ; // 從useState()獲取
// ...
setTimeout( () => {
alert(num);
}, 3000 );
// ...
}
// 又一次點擊按鈕之後
function Demo() {
const num = 2 ; // 從useState()獲取
// ...
setTimeout( () => {
alert(num);
}, 3000 );
// ...
}
在我們更新狀態之後,React會重新渲染組件。每一次渲染都能拿到獨立的 num
狀態,這個狀態值是函數中的一個常量。
所以在 num
爲3時,我們點擊了 展示現在的值
按鈕,就相當於:
function Demo() {
// ...
setTimeout( () => {
alert( 3 );
}, 3000 )
// ...
}
即便num的值被點擊到了5。但是觸發點擊事件時,捕獲到的 num
值爲3。
上面的功能,我們嘗試用類組件實現一遍:
class Demo extends Component {
state = {
num : 0 ,
}
handleClick = () => {
setTimeout( () => {
alert( this .state.num);
}, 3000 );
}
render() {
const { num } = this .state;
return (
< div >
< p >當前點擊了{num}次</ p >
< button onClick={() => { this.setState({ num: num + 1 }) }}>點擊</ button >
< button onClick={this.handleClick}>展示現在的值</ button >
</
div
);
}
};
我們按照之前同樣的步驟去操作:
-
點擊
num
到3 -
點擊
展示現在的值
按鈕 -
在定時器回調觸發之前,點擊增加
num
到5
這一次彈出的數據是5。
爲什麼同樣的例子在類組件會有這樣的表現呢?
我們可以仔細看一下handleClick方法:
handleClick = () => {
setTimeout( () => {
alert( this .state.num);
}, 3000 )
}
這個類方法從this.state.num中讀取數據,在React中state是不可變的。然而,this是可變的。
通過類組件的 this
,我們可以獲取到最新的state和props。
所以如果在用戶再點擊了 展示現在的值
按鈕的情況下我們對 點擊
按鈕又點擊了幾次, this.state
將會改變。 handleClick
方法從一個“過於新”的 state
中得到了 num
。
這樣就引起了一個問題, 如果說我們UI在概念上是當前應用狀態的一個函數,那麼事件處理程序和視覺輸出都應該是渲染結果的一部分。我們的事件處理程序應該有一個特定的props和state 。
然而在類組件中,我們通過 this.state
讀取的數據並不能保證其是一個特定的state。 handleClick
事件處理程序並沒有與任何一個特定的渲染綁定在一起。
從上面的例子,我們可以看出React Hooks在某一個特定渲染中state和props是與其相綁定的,然而類組件並不是。
二、React Hooks依賴數組的工作方式
在React Hooks提供的很多API都有遵循依賴數組的工作方式,比如useCallBack、useEffect、useMemo等等。
使用了這類API,其傳入的函數、數據等等都會被緩存。被緩存的內容其依賴的props、state等值就像上面的例子一樣都是“不變”的。只有當依賴數組中的依賴發生變化,它纔會被重新創建,得到最新的props、state。所以在用這類API時我們要特別注意,在依賴數組內一定要填入依賴的props、state等值。
這裏給大家舉一個反例:
function Demo() {
const [num, setNum] = useState( 0 );
const handleClick = useCallback( () => {
setNum(num + 1 );
}, []);
return (
< div >
< p >當前點擊了{num}次</ p >
< button onClick={handleClick}>點擊</ button >
</
div
);
}
useCallback
本質上是添加了一層依賴檢查。當我們函數本身只在需要的時候才改變。
在上面的例子中,我們無論點擊多少次 點擊
按鈕, num
的值始終爲1。這是因爲 useCallback
中的函數被緩存了,其依賴數組爲空數組,傳入其中的函數會被一直緩存。
handleClick
其實一直都是:
const handleClick = () => {
setNum( 0 + 1 );
};
即便函數再次更新, num
的值變爲1,但是React並不知道你的函數中依賴了 num
,需要去更新函數。
唯有在依賴數組中傳入了 num
,React纔會知道你依賴了 num
,在 num
的值改變時,需要更新函數。
function Demo() {
const [num, setNum] = useState( 0 );
const handleClick = useCallback( () => {
setNum(num + 1 );
}, [num]); // 添加依賴num
return (
< div >
< p >當前點擊了{num}次</ p >
< button onClick={handleClick}>點擊</ button >
</
div
);
};
點擊 點擊
按鈕,num的值不斷增加。
(其實這些歸根究底,就是React Hooks會形成閉包)
三、如何在React Hooks中獲取數據
在我們用習慣了類組件模式,我們在用React Hooks中獲取數據時,一般剛開始大家都會這麼寫吧:
function Demo( props ) {
const { query } = props;
const [list, setList] = useState([]);
const fetchData = async () => {
const res = await axios( `/getList?query=${query}` );
setList(res);
};
useEffect( () => {
fetchData(); // 這樣不安全(調用的fetchData函數使用了query)
}, []);
return (
< ul >
{list.map(({ text }) => {
return (
< li key={text}>{ text }</ li >
);
})}
</
ul
);
};
其實這樣是不推薦的一種模式,要記住effect外部的函數使用了哪些props和state很難。這也是爲什麼 通常你會想要在effect內部去聲明它所需要的函數。 這樣就能容易的看出那個effect依賴了組件作用域中的哪些值:
function Demo( props ) {
const { query } = props;
const [list, setList] = useState([]);
useEffect( () => {
const fetchData = async () => {
const res = await axios( `/getList?query=${query}` );
setList(res);
};
fetchData();
}, [query]);
return (
< ul >
{list.map(({ text }) => {
return (
< li key={text}>{ text }</ li >
);
})}
</
ul
);
};
但是如果你在不止一個地方用到了這個函數或者別的原因,你無法把一個函數移動到effect內部,還有一些其他辦法:
-
如果這函數不依賴state、props內部的變量。可以把這個函數移動到你的組件之外。這樣就不用其出現在依賴列表中了。
-
如果其不依賴state、props。但是依賴內部變量,可以將其在effect之外調用它,並讓effect依賴於它的返回值。
-
萬不得已的情況下,你可以把函數加入effect的依賴項,但把它的定義包裹進
useCallBack
。這就確保了它不隨渲染而改變,除非它自身的依賴發生了改變。
另外一方面,業務一旦變的複雜,在React Hooks中用類組件那種方式獲取數據也會有別的問題。
我們做這樣一個假設,一個請求入參依賴於兩個狀態分別是query和id。然而id的值需要異步獲取(只要獲取一次,就可以在這個組件卸載之前一直用),query的值從props傳入:
function Demo( props ) {
const { query } = props;
const [id, setId] = useState();
const [list, setList] = useState([]);
const fetchData = async (newId) => {
const myId = newId || id;
if (!myId) {
return ;
}
const res = await axios( `/getList?id=${myId}&query=${query}` );
setList(res);
};
const fetchId = async () => {
const res = await axios( '/getId' );
return res;
};
useEffect( () => {
fetchId().then( id => {
setId(id);
fetchData(id);
});
}, []);
useEffect( () => {
fetchData();
}, [query]);
return (
< ul >
{list.map(({ text }) => {
return (
< li key={text}>{ text }</ li >
);
})}
</
ul
);
};
在這裏,當我們的依賴的 query
在異步獲取 id
期間變了,最後請求的入參,其 query
將會用之前的值。(引起這個問題的原因還是閉包,這裏就不再複述了)
對於從後端獲取數據,我們應該用React Hooks的方式去獲取。這是一種關注數據流和同步思維的方式。
對於剛纔這個例子,我們可以這樣解決:
function Demo( props ) {
const { query } = props;
const [id, setId] = useState();
const [list, setList] = useState([]);
useEffect( () => {
const fetchId = async () => {
const res = await axios( '/getId' );
setId(res);
};
fetchId();
}, []);
useEffect( () => {
const fetchData = async () => {
const res = await axios( `/getList?id=${id}&query=${query}` );
setList(res);
};
if (id) {
fetchData();
}
}, [id, query]);
return (
< ul >
{list.map(({ text }) => {
return (
< li key={text}>{ text }</ li >
);
})}
</
ul
);
}
一方面這種方式可以讓我們的代碼更加清晰,一眼就能看明白獲取這個接口的數據依賴了哪些state、props,讓我們更多的去關注 數據流的改變 。另外一方面也避免了 閉包可能會引起的問題 。
但是同步思維的方式也會有一些坑,比如這樣的場景,有一個列表,這個列表可以通過子元素的按鈕增加數據:
function Children( props ) {
const { fetchData } = props;
return (
< div >
< button onClick={() => { fetchData(); }}>點擊</ button >
</
div
);
};
function Demo() {
const [list, setList] = useState([]);
const fetchData = useCallback( async () => {
const res = await axios( `/getList` );
setList([...list, ...res]);
}, [list]);
useEffect( () => {
fetchData();
}, [fetchData]);
return (
< div >
< ul >
{list.map(({ text }) => {
return (
< li key={text}>{ text }</ li >
);
})}
</ ul >
< Children fetchData={fetchData} />
</ div >
);
};
這種場景下,會一直加載數據,造成死循環。
每次調用 fetchData
函數會更新 list
, list
更新後 fetchData
函數就會被更新。 fetchData
更新後 useEffect
會被調用, useEffect
中又調用了 fetchData
函數。 fetchData
被調用導致 list
更新...
當出現這種 根據前一個狀態更新狀態 的時候,我們可以用useReducer去替換useState:
function Children( props ) {
const { fetchData } = props;
return (
< div >
< button onClick={() => { fetchData(); }}>點擊</ button >
</
div
);
};
const initialList = [];
function reducer( state, action ) {
switch (action.type) {
case 'increment' :
return [...state, ...action.payload];
default :
throw new Error ();
}
}
export default function Demo() {
const [list, dispatch] = useReducer(reducer, initialList);
const fetchData = useCallback( async () => {
const res = await axios( `/getList` );
dispatch({
type : 'increment' ,
payload : res
});
}, []);
useEffect( () => {
fetchData();
}, [fetchData]);
return (
< div >
< ul >
{list.map(({ text }) => {
return (
< li key={text}>{ text }</ li >
);
})}
</ ul >
< Children fetchData={fetchData} />
</ div >
);
};
React會保證 dispatch
在組件的聲明週期內保持不變。所以上面的例子中不需要依賴 dispatch
。
用了 useReducer
我們就可以移除 list
依賴。不會再出現死循環的情況。
通過dispatch了一個action來描述發生了什麼。這使得我們的 fetchData
函數和 list
狀態解耦。我們的 fetchData
函數不再關心怎麼更新狀態,它只負責告訴我們發生了什麼。更新的邏輯全都交由reducer去統一處理。
(我們使用函數式更新也能解決這個問題,但是更推薦使用useReducer)
在某些場景下 useReducer
會比useState更適用。例如 state 邏輯較複雜且包含多個子值,或者下一個 state 依賴於之前的state等。並且,使用 useReducer
還能給那些會觸發深更新的組件做性能優化,因爲你可以向子組件傳遞 dispatch
而不是回調函數。
如果大家遇到其它的一些複雜場景,用上面介紹的方法無法解決。那就試試用useRef吧。
文章如有疏漏、錯誤歡迎批評指正。
擴展閱讀
Vol.303