前言
自從 React 16.8 版本正式釋出 React Hooks 以來已經過去一個多月了,而在這之前國內外對於 Hooks API 的討論也一直是如火如荼地進行著。有些人覺得 Hooks API 很好用,而有些人卻對它感到十分困惑。但 Dan Abramov 說過,就像 React 在 2013 年剛出來的時候一樣,Hooks API 也需要時間被開發者們接受和理解。為了加深自己對 React Hooks 的認識,於是便有了將相關資料整理成文的想法。本文主要是記錄自己在學習 React Hooks 時認為比較重要的點和常見的坑,當然也會記錄相關的最佳實踐以便自己更加熟練地掌握此種 mental model ( 心智模型 ) 。如果你還不瞭解 React Hooks ,請先移步到官方文件學習。
React Hooks 基本原理
元件中的每次 render 都有其特定且獨立的 props 和 state ( 可以把每一次 render 看作是函式元件的再次呼叫 ),如果元件中含有定時器、事件處理器、其他的 API 甚至 useEffect ,由於閉包的特性,在它們內部的程式碼都會立即捕獲當前 render 的 props 和 state ,而不是最新的 props 和 state 。
讓我們先來看一個最簡單的例子,然後你就能夠立刻理解上面那段話的意思了。
// 先觸發 handleAlertClick 事件
// 然後在 3 秒內增加 count 至 5
// 最後 alert 的結果仍為 0
function Counter() {
const [count, setCount] = useState(0)
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + count)
}, 3000)
}
// 最後的 document.title 為 5
useEffect(
() => {
document.title = `You clicked ${count} times`
}
)
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
<button onClick={handleAlertClick}>
Show alert
</button>
</div>
)
}
複製程式碼
雖然最後 alert 的結果為 0 ,但我們會發現最後的 document.title
卻是 5 。瞭解 Hooks API 的人都知道,這是因為 useEffect 中的 effect 函式會在元件掛載和每次元件更新之後進行呼叫,所以我們獲取到的 count 是最後一次 render 的 state ,它的值為 5 。如果我們給 useEffect 加上第二個引數 []
,那最後我們的 document.title
就會是 0 ,這是因為此時的 useEffect 不依賴任何值,所以相應的 effect 函式只會在元件掛載的時候被呼叫一次。說了這麼多,不如給一張圖解釋的清楚,下面的圖完美詮釋了 useEffect 與 React Hooks 生命週期的聯絡。
從這張圖中我們可以清楚地看到,每次 effect 函式呼叫之前都會先呼叫 cleanup 函式,而且 cleanup 函式只會在元件更新和元件解除安裝的時候呼叫,那麼這個 cleanup 函式有什麼作用呢?讓我們來看一段程式碼。
useEffect(() => {
let didCancel = false
const fetchData = async () => {
const result = await axios(url)
if (!didCancel) {
setData(result.data)
}
}
fetchData()
// 這裡 return 的便是我們的 cleanup 函式
return () => {
didCancel = true
}
}, [url])
複製程式碼
這段程式碼解決了在網路請求中常見的競態問題。假設我們沒有呼叫 cleanup 函式,當我們連續呼叫兩次 effect 函式時,由於請求資料到達時間的不確定,如果第一次請求的資料後到達,雖然我們想在瀏覽器螢幕上呈現的是第二次請求的資料,但結果卻只會是第一次請求的資料。再一次的,由於閉包的特性,當我們執行 didCancel = true
時,在前一次的 effect 函式中 setData(result)
就無法被執行,競態問題也便迎刃而解。當然 cleanup 函式還有很多常見的應用場景,例如清理定時器、訂閱源等。
上面那張圖還有幾個值得我們注意的點:
- 父元件的重渲染、state 或 context 的改變都會造成元件的更新。
- 在 useLayoutEffect 中的 effect 函式是在 DOM 更新渲染到瀏覽器螢幕之前呼叫的,如果我們要執行有副作用的程式碼,一般只用 useEffect 而不是 useLayoutEffect 。
- 傳遞給 useState 和 useReducer 的引數若為函式,則只會在元件掛載時呼叫一次。
然後我們來講下 useEffect 的第二個引數:
它用於跟前一次 render 傳入的 deps ( 依賴 ) 進行比較,為的是避免不必要的 effect 函式再次執行。useEffect 的執行機制應該是先比較 deps ,若有不同則執行先前的 cleanup 函式,然後再執行最新的 effect 函式,若相同則跳過上面的兩個步驟。如果要用函式作為 useEffect 的第二個引數,則需要使用 useCallback ,其作用是為了避免該函式在元件更新時再次被建立,從而使 useEffect 第二個引數的作用失效。
在這裡我的理解是由於兩個同名函式比較時總會返回 false ,而且使用 useCallback 也需要第二個引數,因此我猜測 React 最終還是以值的比較來達到“快取”函式的效果。
var a = function foo () {}
var b = function foo () {}
a === b // false
複製程式碼
為了方便理解,下面是一個使用 useCallback 的例子。
// 使用 useCallback,並將其傳遞給子元件
function Parent() {
const [query, setQuery] = useState('react')
// 只有當 query 改變時,fetchData 才會發生改變
const fetchData = useCallback(() => {
const url = 'https://hn.algolia.com/api/v1/search?query=' + query
}, [query])
return <Child fetchData={fetchData} />
}
function Child({ fetchData }) {
let [data, setData] = useState(null)
useEffect(() => {
fetchData().then(setData)
}, [fetchData])
}
複製程式碼
React Hooks 網路請求最佳實踐
最後我們要實現的功能:
- 動態請求
- 載入狀態
- 錯誤處理
- 競態處理
下面是以三種不同的方式實現的例子。
常規 Hook
function App() {
const [data, setData] = useState({ hits: [] })
const [query, setQuery] = useState('redux')
const [url, setUrl] = useState(
'http://hn.algolia.com/api/v1/search?query=redux'
)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
useEffect(() => {
let didCancel = false
const fetchData = async () => {
setIsError(false)
setIsLoading(true)
try {
const result = await axios(url)
if (!didCancel) {
setData(result.data)
}
} catch (error) {
if (!didCancel) {
setIsError(true)
}
}
setIsLoading(false)
}
fetchData()
return () => {
didCanel = true
}
}, [url])
return (
<>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button
type="button"
onClick={() =>
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
}
>
Search
</button>
{isError && <div>Something went wrong ...</div>}
{isLoading ? (
<div>Loading ...</div>
) : (
<ul>
{data.hits.map(item => (
<li key={item.id}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
)}
</>
)
}
複製程式碼
抽象 custom Hook
const useDataApi = (initialUrl, initialData) => {
const [data, setData] = useState(initialData)
const [url, setUrl] = useState(initialUrl)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
useEffect(() => {
let didCancel = false
const fetchData = async () => {
setIsError(false)
setIsLoading(true)
try {
const result = await axios(url)
if (!didCancel) {
setData(result.data)
}
} catch (error) {
if (!didCancel) {
setIsError(true)
}
}
setIsLoading(false)
}
fetchData()
return () => {
didCanel = true
}
}, [url])
const doFetch = url => {
setUrl(url)
}
return { data, isLoading, isError, doFetch }
}
function App() {
const [query, setQuery] = useState('redux')
const { data, isLoading, isError, doFetch } = useDataApi(
'http://hn.algolia.com/api/v1/search?query=redux',
{ hits: [] }
)
return (
<>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button
type="button"
onClick={() =>
doFetch(`http://hn.algolia.com/api/v1/search?query=${query}`)
}
>
Search
</button>
{isError && <div>Something went wrong ...</div>}
{isLoading ? (
<div>Loading ...</div>
) : (
<ul>
{data.hits.map(item => (
<li key={item.id}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
)}
</>
)
}
複製程式碼
使用 useReducer
const dataFetchReducer = (state, action) => {
switch (action.type) {
case 'FETCH_INIT':
return {
...state,
isLoading: true,
isError: false
}
case 'FETCH_SUCCESS':
return {
...state,
isLoading: false,
isError: false,
data: action.payload,
}
case 'FETCH_FAILURE':
return {
...state,
isLoading: false,
isError: true,
}
default:
throw new Error()
}
}
const useDataApi = (initialUrl, initialData) => {
const [url, setUrl] = useState(initialUrl)
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
data: initialData,
})
useEffect(() => {
let didCancel = false
const fetchData = async () => {
dispatch({ type: 'FETCH_INIT' })
try {
const result = await axios(url)
if (!didCancel) {
dispatch({ type: 'FETCH_SUCCESS', payload: result.data })
}
} catch (error) {
if (!didCancel) {
dispatch({ type: 'FETCH_FAILURE' })
}
}
}
fetchData()
return () => {
didCancel = true
}
}, [url])
const doFetch = url => {
setUrl(url)
}
return { ...state, doFetch }
}
複製程式碼
常見場景 React Hooks 實現
生命週期
元件掛載時呼叫
const onMount = () => {
// ...
}
useEffect(() => {
onMount()
}, [])
複製程式碼
元件解除安裝時呼叫
const onUnmount = () => {
// ...
}
useEffect(() => {
return () => onUnmount()
}, [])
複製程式碼
使用 useRef 獲取 state
獲取元件最新的 state
function Message() {
const [message, setMessage] = useState('')
const latestMessage = useRef('')
useEffect(() => {
latestMessage.current = message
}, [message])
const showMessage = () => {
alert('You said: ' + latestMessage.current)
}
const handleSendClick = () => {
setTimeout(showMessage, 3000)
}
const handleMessageChange = (e) => {
setMessage(e.target.value)
}
return (
<>
<input value={message} onChange={handleMessageChange} />
<button onClick={handleSendClick}>Send</button>
</>
)
}
複製程式碼
獲取元件前一次的 state
function Counter() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count)
return <h1>Now: {count}, before: {prevCount}</h1>
}
function usePrevious(value) {
const ref = useRef()
useEffect(() => {
ref.current = value
})
return ref.current
}
複製程式碼
使用 useMemo 避免元件重渲染
function Parent({ a, b }) {
const child1 = useMemo(() => <Child1 a={a} />, [a])
const child2 = useMemo(() => <Child2 b={b} />, [b])
return (
<>
{child1}
{child2}
</>
)
}
複製程式碼
使用 useImperativeHandle 轉發 ref
function ParentInput() {
const inputRef = useRef(null)
useEffect(() => {
inputRef.current.focus()
}, [])
return (
<div>
<ChildInput ref={inputRef} />
</div>
)
}
function ChildInput(props, ref) {
const inputRef = useRef(null)
useImperativeHandle(ref, () => inputRef.current)
return <input type="text" name="child input" ref={inputRef} />
}
複製程式碼
利用 Hooks 實現簡單的狀態管理
藉助 Hooks 和 Context 我們可以輕鬆地實現狀態管理,下面是我自己實現的一個簡單狀態管理工具,已釋出到 npm 上,後續可能有大的改進,感興趣的可以關注下 ?。
目前的原始碼只有幾十行,所以給出的是 TS 的版本。
import * as React from 'react'
type ProviderProps = {
children: React.ReactNode
}
export default function createChrox (
reducer: (state: object, action: object) => object,
initialState: object
) {
const StateContext = React.createContext<object>({})
const DispatchContext = React.createContext<React.Dispatch<object>>(() => {})
const Provider: React.FC<ProviderProps> = props => {
const [state, dispatch] = React.useReducer(reducer, initialState)
return (
<DispatchContext.Provider value={dispatch}>
<StateContext.Provider value={state}>
{props.children}
</StateContext.Provider>
</DispatchContext.Provider>
)
}
const Context = {
state: StateContext,
dispatch: DispatchContext
}
return {
Context,
Provider
}
}
複製程式碼
下面是利用該狀態管理工具實現的一個 counter 的例子。
// reducer.js
export const initialState = {
count: 0
}
export const countReducer = (state, action) => {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 }
case 'decrement':
return { ...state, count: state.count - 1 }
default:
return { ...state }
}
}
複製程式碼
入口檔案 index.js
import React, { useContext } from 'react'
import { render } from 'react-dom'
import createChrox from 'chrox'
import { countReducer, initialState } from './reducer'
const { Context, Provider } = createChrox(countReducer, initialState)
const Status = () => {
const state = useContext(Context.state)
return (
<span>{state.count}</span>
)
}
const Decrement = () => {
const dispatch = useContext(Context.dispatch)
return (
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
)
}
const Increment = () => {
const dispatch = useContext(Context.dispatch)
return (
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
)
}
const App = () => (
<>
<Decrement />
<Status />
<Increment />
</>
)
render(
<Provider>
<App />
</Provider>,
document.getElementById('root')
)
複製程式碼
從上面可以看到我是基於 useReducer + useContext 來實現的狀態管理,至於為什麼要這樣做,那是因為這樣做有兩個主要的好處:
- 當我們的 state 變得複雜起來,比如是一個巢狀著很多子數值型別的物件。使用 useReducer ,我們可以通過編寫 reducer 函式 ( 如果 state 足夠複雜甚至可以先拆分 reducer 最後再進行合併 ) 來輕鬆地實現狀態管理。
- useReducer 返回的
dispatch
函式只會在元件掛載的時候初始化,而在之後的元件更新中並不會發生改變 ( 值得注意的是 useRef 也具有相同的特性 ) ,因此它相當於一種更好的 useCallback 。當遇到很深的元件樹時,我們可以通過兩個不同的 Context 將 useReducer 返回的state
和dispatch
分離,這樣如果元件樹底層的某個元件只需要dispatch
函式而不需要state
,那麼當dispatch
函式呼叫時該元件是不會被重新渲染的,由此我們便達到了效能優化的效果。
結語
寫完整篇文章,往回看發現 React Hooks 確實是一種獨特的 mental model ,憑藉著這種“可玩性”極高的模式,我相信開發者們肯定能探索出更多的最佳實踐。不得不說 2019 年是 React 團隊帶給開發者驚喜最多的一年,因為僅僅是到今年中期,React 團隊就會發布 Suspense、React Hooks、Concurrent Mode 三個重要的 API ,而這一目標早已實現了一半。也正是因為這個富有創造力的團隊,讓我此生無悔入 React ?。
參考內容:
Making setInterval Declarative with React Hooks