這篇文檔將通過一個 demo,介紹 React 幾個 Hook 的使用方法和場景。包括:
- useState
- useEffect
- useMemo
- useCallback
- 自定義 hook
閱讀此文檔之前,建議先仔細閱讀 React 官網文檔中的 Hooks 部分。感興趣的話,當然也強烈建議看看 Dan Abramov 在 React Conf 2018 上介紹 Hook 的演講視頻。
1. 什么是 Hook
Hook 是 React 16.8 加入的新特性,它帶來什么好處呢?解決什么問題呢?
- 代碼復用更簡單:Hook 可以讓我們復用狀態邏輯, 而不需要改變組件層次結構,避免造成“Wrapper Hell”——組件層級結構復雜一層包一層。
-
簡化代碼結構: 當組件有越來越多的狀態和副作用(Side Effect,指數據獲取,事件監聽,手動修改DOM 等操作)時,代碼也會變得更難理解。相互關聯的代碼不得不分散在不同的生命周期方法中,比如我們經常需要在
componentDidMount
中設置事件監聽,然后在componentWillUnmount
中得清除它們。另外componentDidMount
還會包含一些無關聯的代碼,比如數據請求,這導致互不關聯的代碼被放在同一個方法中。而Hook 可以讓我們根據代碼之間的聯系,將一個組件拆分成小的 function,而不再是根據生命周期方法來拆分。 -
開發更方便:對于剛開始學習使用 React 的開發者來說, class 組件容易讓人迷惑,比如
this
的用法,bind
事件監聽,什么時候用class 組件,什么時候用 function 組件等等。Hook 的出現讓開發者更容易快速地使用 React 的特性,而不一定需要使用 class。
其實 function 組件一直都有,但跟 class 組件相比,它最大的限制就是,它只有 props 而沒有 state 管理,也沒有生命周期方法。所以在 Hook 特性出來之前,創建 function 組件時,不得不考慮到,如果之后這個組件需要添加 state 怎么辦呢?那不是還得改寫成 class 組件嗎?多麻煩啊,不如一開始就直接創建 class 組件。
Hook 出來之后就不用擔心這個問題了:需要添加 state 的話,我們可以使用useState
,需要添加生命周期相關的副作用時,我們可以使用 useEffect
。這樣就避免改寫成 class 了。
2. 使用 Hook 的規則
- 只能在 function 組件或者自定義 Hook 中調用 Hook
- 只能在代碼頂層調用,不能在循環,條件,嵌套語句中調用Hook
React 提供了 linter 插件 來自動檢查 Hook 相關的代碼語法并修復錯誤,在初學 Hook 時有必要使用。
那么 Hook 能夠完全替代 Class 嗎?我們需要把 Class 用 Hook 重寫嗎?需要清楚以下幾點:
- hook 不能在 Class 中調用。但是在組件樹中,我們可以同時使用 class 組件或者 function 組件。
- 有一些不常用的生命周期函數目前沒有對應的 hook,比如
getSnapshotBeforeUpdate
,getDerivedStateFromError
或者componentDidCatch
。 - 某些第三方庫可能跟 Hooks 不兼容。
- 重寫組件代價高,可以在新代碼中嘗試 Hooks。
3. Demo
接下來通過一個 demo 嘗試一下 Hook 這個新特性。
先創建一個function 組件——計時器 Timer,只顯示一段文本。
import React from 'react'
import ReactDOM from 'react-dom'
function Timer(){
return (
<h2> 10s passed...</h2>
)
}
ReactDOM.render(
<Timer />,
document.getElementById('main')
);
3.1 使用 useState 添加 state
介紹
const [state, setState] = useState(initialState);
useState Hook 會返回一個 state 值,以及用于更新這個state 的方法。
示例1
比如我要添加一個按鈕使得可以重置計時器的時間:
import React, {useState} from 'react'
function Timer(props){
// 獲取time state,以及更新time 的方法
// 并用 props 給 time 一個初始值
const [time, setTime] = useState(props.from)
// 重置計時器
var resetTimer=()=>{
setTime(0)
}
return (
<div>
<h2>{time} s passed...</h2>
<button onClick={resetTimer}>Reset</button>
</div>
)
}
ReactDOM.render(
<Timer from={30}/>,
document.getElementById('main')
);
3.2 使用 useEffect 添加副作用
介紹
前面有說到,React 經常提到的副作用(Side Effect)指的是數據獲取,事件監聽,手動修改DOM 等操作,因為這些操作可能會影響其他組件,且不能在正在渲染的過程中進行。副作用的操作通常是寫在生命周期方法 componentDidMount
,componentDidUpdate
或者 componentWillUnmount
中。
在function 組件中,我們使用 useEffect Hook 來達到類似的作用。useEffect 的作用和上面說的三個生命周期方法相同,相當于把它們合并到了一個 API 中。
useEffect(
() => {
// do some thing here...
// add side effects here
// 副作用操作卸載這里
return () => {
// clean up here
// 清除定時器,解除監聽等操作寫在這里
};
},
[dependencies]
);
useEffect 默認會在每次渲染完成后被調用。它可以接受兩個參數:
第一個參數是一個function,相當于 componentDidMount 或 componentDidUpdate。這個function 還可以返回另一個用于清理的 function,后者的作用相當于 componentWillUnmount 。
第二個參數是一個數組,只有當數組中的依賴發生變化時,useEffect 才會觸發。如果這個傳了一個空數組
[]
,那就相當于 componentDidMount 和 componentWillUnmount 的結合,副作用操作只會觸發一次。如果傳了個不為空的數組[value]
,那就相當于 componentDidUpdate 和 componentWillUnmount 的結合,且只有當value
變化時,才會觸發。
在接下來的例子中會詳細說明 useEffect 的用法。
示例2
接著計時器的例子,接下來使用 useEffect,添加定時器,使 Timer 不斷更新:
import React, {useState, useEffect} from 'react'
function Timer(props){
const [time, setTime] = useState(props.from)
var resetTimer=()=>{
setTime(0)
}
// 每次渲染后,如果time 發生變化,就會觸發
useEffect(()=>{
// 設置定時器,每秒把時間加一
var interval = setInterval(()=>{
setTime(time+1)
},1000)
// 返回一個清理方法
return ()=>{
// 清除定時器
if(interval) clearInterval(interval)
}
},[time]) // 依賴 time
return (
<div>
<h2>{time} s passed...</h2>
<button onClick={resetTimer}>Reset</button>
</div>
)
}
需要注意的是,上面的 useEffect中,作為第一個參數,我傳遞了一個 function:
()=>{
var interval = setInterval(()=>{
setTime(time+1)
},1000)
// clean-up
return ()=>{
if(interval) clearInterval(interval)
}
}
在這個方法中做兩件事:設置定時器,讓時間每秒更新;返回一個 clean-up 方法,清除定時器。作為第二個參數,我傳遞了[time]
,也就是說這個effect 是依賴 time 變化的,當 time 改變了,effect 才會被觸發。
因為設置了定時器,time 每秒都會更新,那么這個effect 每秒會被觸發一次。其結果就是,雖然時間確實每秒遞增,但實際上每次觸發這個effect,都會新建一個定時器,然后這個定時器被清除,然后再新建,再清除······這是因為:
The clean-up function runs before the component is removed from the UI to prevent memory leaks. Additionally, if a component renders multiple times (as they typically do), the previous effect is cleaned up before executing the next effect.
按照 Hook API 中說到的, clean-up 方法會在組件被移除時執行,以避免內存泄漏。
劃重點:當一個組件多次渲染時,新的 effect 會把舊的 effect 清理掉。
因此每次 effect 觸發時,會先清理掉前一個effect 創建的定時器,然后再創建一個新的定時器。這當然不是正常的定時器用法!我們只需要一個定時器,一直存在,只要不需要更新時間時,再清理掉它。
示例3
換句話說,本來我們是想在 componentDidMount
中定義一個定時器,卻定義在componentDidUpdate
中了,導致定時器沒必要的重復創建和清除。
怎么改成只創建一個定時器呢?按照 useEffect 的第二個參數的說明,如果我們傳遞一個空數組[]
不就可以嗎?但實際上,如果配置了上文中提到的 Linter 工具eslint-plugin-react-hooks
,就會發現這里不能傳遞空數組,因為useEffect 中用到了會發生變化的 time,那么第二個參數就一定要加上time。
如果第二個參數就是傳遞的[]
,會發生什么呢?定時器確實只創建一個,但每次setTime(time+1)
中的time始終是初始值30,頁面上始終是31 s passed...
。
注意,useEffect 第二個參數傳遞空數組 []
時表示:
- 這個 effect 不依賴任何 props 和 state,因此不需要重新執行;
- 在這個 effect 中的 props 和 state 會一直是初始值。(因為創建了閉包)
要讓這個 effect 不依賴 time,且定時器不重復創建,解決辦法如下:
useEffect(()=>{
var interval = setInterval(()=>{
// setTime(time+1)
// 不再依賴 time,且每次都能獲取到最新的 time 值
setTime(t=>t+1)
},1000)
return ()=>{
if(interval) clearInterval(interval)
}
},[])
關于這部分,可以閱讀 React Hook FAQ 中的講解。
3.3 使用 useCallback 和 useMemo
接下來,我要修改一下 Timer 的 props,把一個對象傳遞到 props.config:from 為起始時間,to 為結束時間。
<Timer config={{from:20,to:30}}/>
示例4
相應的修改如下,在 effect 中判斷倒計時是否結束。注意這個effect 依賴了 props.config。
function Timer(props){
const [time, setTime] = useState(props.config.from)
//......
useEffect(()=>{
var interval = setInterval(()=>{
// 獲取整個 config 對象
const config = props.config
setTime(t=>{
// 倒計時結束,清理計時器
if(t+1 >= config.to){
clearInterval(interval)
}
return t+1
})
},1000)
return ()=>{
if(interval) clearInterval(interval)
}
},[props.config]) // 依賴 props.config
// ......
}
示例5
還有一種寫法,區別在于依賴的是 props.config.to
:
useEffect(()=>{
var interval = setInterval(()=>{
setTime(t=>{
// 直接跟 props.config.to 比較
if(t+1 >= props.config.to){
clearInterval(interval)
}
return t+1
})
},1000)
return ()=>{
if(interval) clearInterval(interval)
}
},[props.config.to]) // 依賴 props.config.to
這兩種寫法有什么區別嗎?功能上沒區別,都能實現從from到to的計時。但是有一個隱患?。?/p>
示例6
當我在另一個 function 組件中引入Timer組件時,這兩種寫法就有很大區別了。
比如我寫一個簡單的 function 組件,定義一個name state,提供一個輸入框可以修改name,然后引入 Timer。
function Parent(){
const [name, setName] = useState('Evelyn');
var onChangeName = (e)=>{
setName(e.target.value)
}
return (
<div>
<input value={name} onChange={onChangeName} />
<br/>
Hello, {name}!!!
<Timer config={{from:20,to:30}}/>
</div>
)
}
對于示例4和5的兩種寫法,當計時器還么結束時,改變輸入框的內容會發生什么呢?
- 示例4:每次 name 發生變化時,Timer 中的定時器會被清理然后重新創建,也就是說 effect 被觸發了。
- 示例5: Timer 中的定時器正常,effect 沒有被重新觸發。
示例4 中的 effect 依賴了 props.config
,按道理 props.config
一直都是 {from:20,to:30}
,為什么父組件的 state 變化時,effect 會被再次觸發呢?
這是因為,父組件每次重新重新渲染時,整個function 組件中的變量等都會被重新創建,包括 <Timer config={{from:20,to:30}}/>
中的對象參數,雖然內容沒變,但是引用變了。
useEffect 是否再次觸發,依據的是第二個參數數組中的變量的引用相等性(Reference equality)。
var a = {from:20,to:30};
var b = {from:20,to:30};
a===b; // false
var d = a.to;
var e = b.to;
d===e; // true
關于 Reference equality 和 useCallback、useMemo 的關系,感興趣的話可以閱讀這篇文章 useCallback vs useMemo。
示例7
要解決示例4 的問題,我們可以使用useMemo。
useMemo返回一個記憶化(Memoized)的值,也就是保證了在兩次渲染之間這個值的引用不發生變化。在這個例子中,useMemo 依賴空數組保證了在渲染之間 {from:20,to:30}
這個對象只創建了一次,config
的引用不發生變化。
function Parent(){
const [name, setName] = useState('Evelyn');
var onChangeName = (e)=>{
setName(e.target.value)
}
// 使用useMemo 創建一個 memoized 對象
const config = useMemo(() => ({from:20,to:30}),[]);
return (
<div>
<input value={name} onChange={onChangeName} />
<br/>
Hello, {name}!!!
<Timer config={config}/>
</div>
)
}
useCallback 跟 useMemo 類似,只是 useCallback 返回的是第一個參數定義的記憶化的 function。
useCallback(fn,deps) // 返回一個記憶化的方法 fn
等同于
useMemo(()=>fn,deps)
useCallback 和 useMemo 的效果類似于shouldComponentUpdate
,避免不需要的render。更豐富的使用場景就不在此贅述了,可以自行探索。
3.4 自定義Hook
使用 Hook 的 都是 function 組件,那么如果我們想要在多個function組件中使用一部分相同的邏輯,該怎么做呢?我們可以把那部分的邏輯抽離出來,放到一個新的方法中,這也就是自定義 Hook 的過程。
示例 8
基于示例 3 的代碼,抽離出來一個 Hook useTimer(from)
,返回[time,resetTimer]
,這樣就能在別的 function 組件中復用 useTimer 里的邏輯了。
function Timer(props){
// 調用自定義的 Hook
const [time, resetTimer] = useTimer(props.from)
return (
<div>
<h2>{time} s passed...</h2>
<button onClick={resetTimer}>Reset</button>
</div>
)
}
// 抽離出來定時器的邏輯,定義一個新的Hook
function useTimer(from){
const [time, setTime] = useState(from)
const resetTimer = ()=>{
setTime(0)
}
useEffect(()=>{
var interval = setInterval(()=>{
setTime(t=>t+1)
},1000)
return ()=>{
if(interval) clearInterval(interval)
}
},[])
return [time, resetTimer]
}
示例9
比如在示例 8 的基礎上,我再定義一個定時器,但是是倒計時,并且記錄回合數。這時候可以復用自定義的 useTimer Hook。
// 正計時器
function Timer(props){
const [time, resetTimer] = useTimer(props.from)
return (
<div>
<h1>Timer 1</h1>
<h2>{time}s passed...</h2>
<button onClick={resetTimer}>Reset Timer</button>
</div>
)
}
// 定義新的組件,倒計時,記錄回合數,可重置
function Timer2(props){
const [time, resetTimer] = useTimer(0) // 調用自定義Hook useTimer
const [round, setRound] = useState(0) // 回合數
// 重置回合數和倒計時
const resetRound = ()=>{
setRound(0)
resetTimer()
}
useEffect(()=>{
// 如果倒計時結束,自動重新倒計時,并更新回合數
if(time >= props.timePerRound){
resetTimer()
setRound(r=>r+1)
}
}, [time, props.timePerRound, resetTimer])
return (
<div>
<h1>Timer 2</h1>
<h2>Round {round}</h2>
{/* 顯示倒計時 */}
<h2>{props.timePerRound - time}s before the next round...</h2>
<button onClick={resetRound}>Reset Round</button>
</div>
)
}
function Timers(){
return (
<div>
<Timer from={0} />
<Timer2 timePerRound={10} />
</div>
)
}
效果:
可以明顯地看出:
- 抽離前后代碼的行為沒有改變,只是變得可以復用了而已。
- 自定義 Hook 的名稱以 use 開頭。
- 兩個組件Timer 和 Timer2 雖然都使用了useTimer Hook,但不共享state,自定義 Hook 中的 state 和 effect 都是完全隔離的。
只要理解了 Hook API,自定義 Hook 還是很簡單的。
Custom Hooks are a convention that naturally follows from the design of Hooks, rather than a React feature.
但不得不說,自定義 Hook 提供了共享代碼邏輯的靈活性,這在以前的 React 組件中是不可能的。我們可以編寫涵蓋各種用例的自定義 Hook,比如表單處理,動畫,事件訂閱,計時器等等。
4. 參考閱讀
- Hooks at a Glance:https://reactjs.org/docs/hooks-overview.html
- Hooks API Reference:https://reactjs.org/docs/hooks-reference.html
- Hooks FAQ:https://reactjs.org/docs/hooks-faq.html
- Dan Abramov 寫的一些demo:https://codesandbox.io/u/gaearon/sandboxes
- Dan Abramov 在 React Conf 2018 上介紹 Hook 的演講視頻:https://youtu.be/dpw9EHDh2bM