本文是閱讀A Complete Guide to useEffect之后的個人總結,建議拜讀原文
理解hooks工作機制
可以這樣說,在使用了useState或是useEffect這樣的hooks之后,每次組件在render的時候都生成了一份本次render的state、function、effects,這些與之前或是之后的render里面的內容都是沒有關系的。而對於class component來說,state是一種引用的形式。這就造成了二者在一些表現上的不同。
來看下面這樣一段代碼:
function Counter() { const [count, setCount] = useState(0); function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + count); }, 3000); } // 多次點擊click me按鈕,然后點擊一下show alert按鈕,然后又快速點擊多次click me按鈕,alert出來的count是點擊該按鈕時的count還是最新的count?? // 實驗表明,顯示的是點擊時的按鈕,這就意味着handleAlertClick這個函數capture了被點擊時的那個count,這也就是說每一輪的count都是不一樣的 return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> <button onClick={handleAlertClick}> Show alert </button> </div> ); }
再看這樣一段代碼:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { setTimeout(() => { console.log(count) }, 3000) }) // 在3秒內快速點擊5次按鈕,控制台打出的結果是什么樣的? // 0 1 2 3 4 5 return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); }
把上述代碼改成class component的形式:
class Example extends React.Component{ constructor(props) { super(props); this.state = { count: 0, } } componentDidUpdate() { setTimeout(() => { console.log(this.state.count) }, 3000) } add = () => { const {count} = this.state; this.setState({count: count + 1}) } // 同樣的操作,打印出的結果是 5 5 5 5 5 render() { return ( <div> <button onClick={this.add}>click me</button> </div> ) } }
對於class component里面的表現,我們可以通過閉包來改變,之所以如此是因為class component里面的state隨着render是發生變化的,而useEffect里面即使使用props.count也不會有問題,因為useEffect里面的所有東西都是每次render獨立的
componentDidUpdate() { // 在class component中必須每次把count取出來 const { count } = this.state; setTimeout(() => { console.log(count) }, 3000) }
function Example(props) { useEffect(() => { setTimeout(() => { console.log(props.counter); }, 1000); }); // 在useEffect中不需要先把count從props里面取出來,每次依然是獨立的 }
可以發現,盡管useEffect里面的函數延遲執行了,但是打出的count依然是當時render里面的count,這也說明了其實每次render都是獨立的,里面有獨立的state、effects、function
// During first render function Counter() { const count = 0; // Returned by useState() // ... <p>You clicked {count} times</p> // ... } // After a click, our function is called again function Counter() { const count = 1; // Returned by useState() // ... <p>You clicked {count} times</p> // ... } // After another click, our function is called again function Counter() { const count = 2; // Returned by useState() // ... <p>You clicked {count} times</p> // ... }
下面這段話是精髓:
Inside any particular render, props and state forever stay the same. But if props and state are isolated between renders, so are any values using them (including the event handlers). They also “belong” to a particular render. So even async functions inside an event handler will “see” the same count value.
useEffect的一些注意點
來看官方文檔里面關於useEffect清除工作的示例:
function Example(props) { useEffect(() => { ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange); }; }); }
如果props從{id: 10}變化為{id: 20}那么react是怎么樣來渲染組件、怎么樣做清除工作的呢?
按照慣性思維,我們可能覺得應該是先清理之前一次render注冊的事件,然后render組件,然后再注冊本次render的事件
React cleans up the effect for {id: 10}. React renders UI for {id: 20}. React runs the effect for {id: 20}.
但實際上react並不是這樣工作的,而是像下面這樣,因為react總是在瀏覽器paint之后再去做effects相關的事情,無論是useEffect還是他返回的函數,而且清理函數也和其他函數一樣能夠capture當前的props和state,盡管在他執行時已經是新的組件render好了
React renders UI for {id: 20}. The browser paints. We see the UI for {id: 20} on the screen. React cleans up the effect for {id: 10}. React runs the effect for {id: 20}.
清理函數就像閉包一樣直接把他所屬的render的props和state消費,然后在需要執行的時候使用這些值
// First render, props are {id: 10} function Example() { // ... useEffect( // Effect from first render () => { ChatAPI.subscribeToFriendStatus(10, handleStatusChange); // Cleanup for effect from first render return () => { ChatAPI.unsubscribeFromFriendStatus(10, handleStatusChange); }; } ); // ... } // Next render, props are {id: 20} function Example() { // ... useEffect( // Effect from second render () => { ChatAPI.subscribeToFriendStatus(20, handleStatusChange); // Cleanup for effect from second render return () => { ChatAPI.unsubscribeFromFriendStatus(20, handleStatusChange); }; } ); // ... }
忘記lifecycle的觀念,擁抱synchronization
在class component里面,lifecycle是我們做一切的基礎,但是在使用react-hooks的時候,請忘記lifecycle,盡管useEffect函數很多時候達到了相似的效果
但從根本上來講,react-hooks的作用是一種同步的作用,同步函數hooks函數內的內容與外部的props以及state,所以才會在每次render之后執行useEffect里面的函數,這時可以獲取到當前render結束后的props和state,來保持一種同步
但正是由於useEffect里面的內容在每次render結束后都會執行,可能有時候內部的內容並沒有發生變化,這時就會產生冗余的render,這時候就需要引入依賴,由寫程序的人來告訴react我這個useEffect依賴了外部的那些參數,只有這些參數發生變化的時候才去執行我里面的函數。
因為react自己不知道什么時候useEffect里面的函數其實沒有發生變化。
useEffect(() => { document.title = 'Hello, ' + name; }, [name]); // Our deps
上面這段代碼相當於告訴react,我這個effect的依賴項是name這個變量,只有當name發生變化的時候才去執行里面的函數
而且這個比較是淺比較,如果state是一個對象,那么對象只要指向不發生變化,那么就不會執行effect里面的函數
譬如:
function Example() { const [count, setCount] = useState({a: 12}); useEffect(() => { console.log('effect'); return () => { console.log('clean') } }, [count]) function handleClick() { count.a++; setCount(count) } // 點擊按鈕時發現屏幕顯示的值不發生變化,而且effect里面的函數也沒有執行,所以進行的是淺比較,這點類似於pureComponent return ( <div> <p>You clicked {count.a} times</p> <button onClick={handleClick}> Click me </button> </div> ); }
關於dependency數組
如果強行欺騙react來達到跳過某些渲染之后的effect函數的話,那么可能會出現一些意想不到的后果:
如下代碼,我們想模擬一個定時器,在第一次渲染之后掛載,在組件卸載的時候取消這個定時器,那么這好像和把dependency數組設為[]的功能很像,但是如果這樣做的話,結果是定時器只加一次。
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); // 定時器只加一次的原因在於雖然setInterval函數里面的函數每秒都會執行一次,但是count值始終是初始的0,因為這個函數綁定了第一輪render之后的count值, return () => clearInterval(id); }, []); return <h1>{count}</h1>; }
如果寫成下面這樣的形式的話:
function Counter() { const [count, setCount] = useState(0); setInterval(() => { setCount(count + 1); }, 1000); // 造成的后果就是能一直更新count,但是每一輪循環都會執行上面這行代碼,定時器越來越多,然后,就卡死啦,而且每個定時器都會執行一遍,那么屏幕上的數字每秒都會在跳,可以試試看 return <h1>{count}</h1>; }
所以通過設置dependency數組來欺騙react達到自己不可告人的目的的話,很容易出現bug,而且還不容易發現,所以還是老老實實的不要騙人
要讓計時器正確工作的話,第一種方法是把dependency數組正確設置[count],但這顯然不是最好的方法,因為每一輪都會設置計時器,清除計時器。但至少定時器work了。
還有一種方法是利用functional updater,這時候你也可以不用設置dependency
useEffect(() => { const id = setInterval(() => { setCount(preCount => preCount + 1); // 此時setCount里面的函數的入參是前一次render之后的count值,所以這樣的情況下計時器可以work }, 1000); return () => clearInterval(id); }, []);
其他hooks
useContext
使用方法:
const value = useContext(myContext);
當最近的一個myContext.Provider更新的時候,這個hook就會導致當前組件發生更新
useReducer
function reducer(state, action) { switch (action.type) { case 'increment': return {count: state.count + 1}; case 'decrement': return {count: state.count - 1}; default: throw new Error(); } } function Counter() { const [state, dispatch] = useReducer(reducer, {count: 100}); // 如果此處不傳入一個initialState: {count: 100}的話,那么默認initialState就是undefined,那么后面的取值就會報錯 return ( <> Count: {state.count} <button onClick={() => dispatch({type: 'increment'})}>+</button> <button onClick={() => dispatch({type: 'decrement'})}>-</button> </> ); }
使用dispatch以后,判斷是否重新render是通過Object.is來判斷的,每次render之后返回的dispatch其實都是不變的,所以之前定時器的例子最好的解決方案就是利用useReducer來實現:
function Counter() { const [state, dispatch] = useReducer(reducer, initialState); const { count, step } = state; useEffect(() => { const id = setInterval(() => { dispatch({ type: 'tick' }); }, 1000); return () => clearInterval(id); }, [dispatch]); // 現在useEffect不依賴count,依賴的是dispatch,而dispatch在每次render之后都是不變的,所以就不會每次render之后都清除計時器再重新設置計時器 // 其實這里把dependency數組設為[]也是完全一樣的 return ( <> <h1>{count}</h1> <input value={step} onChange={e => { dispatch({ type: 'step', step: Number(e.target.value) }); }} /> </> ); } const initialState = { count: 0, step: 1, }; function reducer(state, action) { const { count, step } = state; if (action.type === 'tick') { return { count: count + step, step }; } else if (action.type === 'step') { return { count, step: action.step }; } else { throw new Error(); } }
useCallback
const memoizedCallback = useCallback( () => { doSomething(a, b); }, [a, b], ); // 返回的memoizedCallback只有當a、b發生變化時才會變化,可以把這樣一個memoizedCallback作為dependency數組的內容給useEffect
我們來看一個useEffect的dependency數組含有函數的情況:
function Counter() { const [count, setCount] = useState(0); const [a, setA] = useState(100); const fn = useCallback(() => { console.log('callback', a) }, [a]) // 可知fn是依賴於a的,只有當a發生變化的時候fn才會變化,否則每輪render的fn都是同一個 const f1 = () => { console.log('f1') } // 對於f1,每輪循環都有獨自的f1,所以相當於一直在變化,如果useEffect依賴於f1的話,每次render之后都會執行 useEffect(() => { console.log('this is effect') }, [f1]) // 當dependency數組里面是f1時,不管更新count還是a,都會執行里面的函數,打印出this is effect // 當dependency數組里面是fn時,只有更新a時才會執行該函數 return ( <> Count: {count} <button onClick={() => setCount(count + 1)}>+</button> <button onClick={() => setCount(count - 1)}>-</button> <br /> <button onClick={() => setA(a + 1)}>+</button> <button onClick={() => setA(a - 1)}>-</button> </> ); }
useMemo
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
useRef
const refContainer = useRef(initialValue);
注意:useRef返回相當於一個{current: ...}的plain object,但是和正常這樣每輪render之后直接顯式創建的區別在於,每輪render之后的useRef返回的plain object都是同一個,只是里面的current發生變化
而且,當里面的current發生變化的時候並不會引起render
補充
dependency數組里面寫函數作為dependency的情景:
function SearchResults() { const [query, setQuery] = useState('react'); // Imagine this function is also long function getFetchUrl() { return 'https://hn.algolia.com/api/v1/search?query=' + query; } // 對於這樣一個組件,如果我們改變了query,按理來說應該要重新拉取數據,但是這種寫法里面就無法實現,除非在useEffect的dependency數組里面添加一個query,但是這樣是很不明顯的,因為useEffect里面的函數只寫了一個fetchData,並沒有看到query的身影,所以query很容易被忽略,而一旦忽略就會帶來bug,所以簡單的解決方法就是把fetchData這個函數作為dependency寫進useEffect的dependency數組,但是這樣也會帶來問題,就是每次render之后,無論這次render是否改變了query,都會導致fetchData這個函數發生變化(因為每次render之后函數都是不同的),都會重新拉取數據,這是我們不想要的結果 // Imagine this function is also long async function fetchData() { const result = await axios(getFetchUrl()); setData(result.data); } useEffect(() => { fetchData(); }, []); // ... }
第一次改進,把函數直接寫進dependency數組里面:
function SearchResults() { // 🔴 Re-triggers all effects on every render function getFetchUrl(query) { return 'https://hn.algolia.com/api/v1/search?query=' + query; } useEffect(() => { const url = getFetchUrl('react'); // ... Fetch data and do something ... }, [getFetchUrl]); // 🚧 Deps are correct but they change too often useEffect(() => { const url = getFetchUrl('redux'); // ... Fetch data and do something ... }, [getFetchUrl]); // 🚧 Deps are correct but they change too often // ... }
上面這種寫法的問題就是useEffect里面的函數調用過於頻繁,再次利用useCallback進行改造:
function SearchResults() { const [query, setQuery] = useState('react'); // ✅ Preserves identity until query changes const getFetchUrl = useCallback(() => { return 'https://hn.algolia.com/api/v1/search?query=' + query; }, [query]); // ✅ Callback deps are OK // 只有當query發生變化的時候getFetchUrl才會變化 useEffect(() => { const url = getFetchUrl(); // ... Fetch data and do something ... }, [getFetchUrl]); // ✅ Effect deps are OK // ... }
useCallback本質上是添加了一層依賴檢查。它以另一種方式解決了問題 - 我們使函數本身只在需要的時候才改變,而不是去掉對函數的依賴
實際上,函數在effect里面也是一種數據流,而在class component中則不是
關於競態
function Article({ id }) { const [article, setArticle] = useState(null); useEffect(() => { let didCancel = false; // 利用didCancel這個變量來解決競態問題,如果本次render之后的請求到下次render之后才返回,那么這次render之后的didCancel以及在清理函數里面被設置為true了,就不會繼續執行 async function fetchData() { const article = await API.fetchArticle(id); if (!didCancel) { setArticle(article); } } fetchData(); return () => { didCancel = true; }; }, [id]); // ... }
作者:XJBT
鏈接:https://www.jianshu.com/p/fd17ce2d7e46
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。