React Hook:使用 useEffect
一、描述
Effect Hook 可以讓你能夠在 Function 組件中執行副作用(side effects):
import { useState, useEffect } from 'react'; function Example() { const [count, setCount] = useState(0); // Similar to componentDidMount and componentDidUpdate: // 類似於 componentDidMount 和 ComponentDidUpdate useEffect(() => { // Update the document title using the browser API document.title = `You clicked ${count} times`; }); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); }
上面代碼看過很多次了,是 React 文檔中 Hook 部分一直使用的計數器示例,但是多了個新的功能:把文檔標題設置為包含點擊次數的自定義消息。而這就是一個副作用。
數據獲取,設置訂閱或者手動直接更改 React 組件中的 DOM 都屬於副作用。有的人習慣成這種行為為 effects,我是比較習慣叫 side effects 也就是副作用的, 這是個概念,需要在 React 必須習慣的概念。
如果熟悉 React 類聲明周期方法,可以把 useEffect Hook 視作 componentDidMount、componentDidUpdate 和 componentWillUnmount 的組合體。
React 組件中有兩種常見的副作用:
- 需要清理的副作用
- 不需要清理的副作用。
二、需要清理的副作用
有的時候,我們希望在 React 更新 DOM 之后進行一些額外的操作。網絡請求、手動更改 DOM 以及日志記錄都是不需要清理的副作用的常見場景。因為運行之后,可以立即被銷毀掉。
下面是在 class 組件和 function 組件中分別表示這兩種副作用的使用方式:
1、在 class 組件中
在 React class 組件中, render 方法本身不應該進行副作用操作,但是我們通常是期望在 React 更新 DOM 之后執行一些有必要的副作用。
這就是為什么在 React class 中,會把副作用放在 componentDidMount 和 componentDidUpdate 中。回到計數器的示例中,如果要在 class 計數器組件中實現上面的功能,則代碼如下:
class Example extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; } componentDidMount() { document.title = `You clicked ${this.state.count} times`; } componentDidUpdate() { document.title = `You clicked ${this.state.count} times`; } render() { return ( <div> <p>You clicked {this.state.count} times</p> <button onClick={() => this.setState({ count: this.state.count + 1 })}> Click me </button> </div> ); } }
上面代碼很明顯,class 組件中,兩個生命周期中有相同的代碼(雖然 componentDidUpdate 中的內容也可以放在 click 的事件 handler 中)
這是因為在多數情況下,我們希望執行相同的副作用,無論是組件剛 mount 還是 update 之后。而從概念上來講,我們希望他在每次 render 之后發生,但是 React 類組件是沒有這種生命周期的。雖然可以把 document.title = 'You clicked' + this.state.count + ' times'; 這個操作封裝到一個方法中,但是還是需要在 componentDidMount 和 componentDidUpdate 中調用兩次。
2、使用 effect Hook 的示例
文章最頂部已經寫了下面的示例,為了分析代碼,單獨再拿到這里:
import { useState, useEffect } from 'react'; function Example() { const [count, setCount] = useState(0); useEffect(() => { document.title = `You clicked ${count} times`; }); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); }
1、useEffect 做了什么?
通過使用這個 Hook,通知 React 組件需要在渲染后執行什么操作。React 將記住傳遞的 function(把這個 function 成為 “effect”),並在執行 DOM 更新后調用這個 function。在這個效果中,主要的功能仍舊是設置 document.title,但是也可以執行數據獲取,或者是調用其他的命令式的 API。
2、為什么在組件內調用 useEffect?
在組件內使用 useEffect 是的可以直接從副作用中訪問計數器的 count 或者任何的 props。不需要使用特殊的 API 來讀取它,它已經在函數的范圍內了(通過 useState)。Hooks 擁抱 Javascript 的閉包,並且避免在 Javascript 已經提供解決方案的情況下在去引入特定的 React API。
3、每次 render 之后都會執行 useEffect 嗎?
是的!
這是默認行為,在第一次 render 之后和每次 update 之后都會運行。你可能會更容易的認為副作用發生在 “render 之后”,而不是發生在 “mount” 和 “update” 之后。不過 React 保證 DOM 在運行時副作用已經更新。
(網絡請求每次都放在這里面肯定是有問題的,因此需要定制)如果要定制 useEffect 的默認執行行為,可以參考:https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects
3、詳細代碼拆分說明
function Example() { const [count, setCount] = useState(0); useEffect(() => { document.title = `You clicked ${count} times`; });
我們通過 useState 聲明了 count state 變量,並且通知 React 需要使用 effect。
然后把一個 funcrion 傳遞給 useEffect Hook,而傳遞的這個 funcrion 就是副作用。
在我們的副作用中,使用 document.title 瀏覽器 API 設置文檔的標題,可以在 effect 中讀取最新的 count,因為 count 變量作用域就是在整個 Example function 中。當 React 渲染我們的組件時,會機主我們使用的 effect,然后在更新 DOM 后運行需要的下溝哦。每次渲染都會發生這樣的情況,包括第一次 render。
你可能會注意到,傳遞給 useEffect 的 function 在每次 render 的時候有所不同,這是故意為之的。事實上,這就是讓我們在副作用中讀取 count 值而不需要擔心這個值是舊值。每次在 re-render 的時候,都會有一個不同的副作用,來取代之前的副作用。在某種程度上,這使得副作用更像是 render 結果的一部分——每個副作用都“屬於”特殊的 render。文章后面會提到為什么這是有用的。
Tip
與 componentDidMount 和 componentDidUpdate 不同,使用 useEffect 調度的副作用不會阻塞瀏覽器更新屏幕。這使得 application 感覺上具有響應式。大多數副作用不需要同步發生。而如果需要同步進行,(比如測量布局),有一個單獨的 useLayoutEffect Hook, API 和 useEffect 相同。
三、需要清理的副作用
上面都是不需要清理的副作用,然而,有些副作用是需要去清理的。比如,肯呢過希望設置對某些外部數據源的 subscription。而在這種情況下,清理訂閱是非常重要的,這樣不會引入內存泄露。
1、使用 class 組件示例:
在 React class 中,通常會在 componentDidMount 中設置帝國與,而在 componentWillUnmount 中清楚它。比如有一個 ChatAPI 模塊,可以訂閱好友的在線狀態,在 class 組件中可能如下所示:
class FriendStatus extends React.Component { constructor(props) { super(props); this.state = { isOnline: null }; this.handleStatusChange = this.handleStatusChange.bind(this); } componentDidMount() { ChatAPI.subscribeToFriendStatus( this.props.friend.id, this.handleStatusChange ); } componentWillUnmount() { ChatAPI.unsubscribeFromFriendStatus( this.props.friend.id, this.handleStatusChange ); } handleStatusChange(status) { this.setState({ isOnline: status.isOnline }); } render() { if (this.state.isOnline === null) { return 'Loading...'; } return this.state.isOnline ? 'Online' : 'Offline'; }
請注意 componentDidMount 和 componentWillMount 需要相互對應。class 組件的生命周期強制我們去拆分這個邏輯,即使他們中的概念代碼和相同的副作用是有關的。
注意
眼里好的人可能注意到了上面的示例可能需要一個 componentDidUpdate 才能完全的正確,目前暫時忽略。
2、使用 Hooks 的示例
一開始,可能會認為需要單獨的 effect 去清理,但是添加訂閱和刪除訂閱的代碼聯系非常緊密,因此 useEffect 旨在將它保持在一起。如果你的副作用返回一個方法,則 React 則在清理時運行:
import { useState, useEffect } from 'react'; function FriendStatus(props) { const [isOnline, setIsOnline] = useState(null); function handleStatusChange(status) { setIsOnline(status.isOnline); } useEffect(() => { ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange); // Specify how to clean up after this effect: return function cleanup() { ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange); }; }); if (isOnline === null) { return 'Loading...'; } return isOnline ? 'Online' : 'Offline'; }
1、為什么從 effect 中返回一個 function?
這是 effect 可選的清理機制。每個 effect 都可以返回一個在它之后清理的 function。這使得我們能夠保持添加訂閱和刪除訂閱彼此接近的訂閱的邏輯。這同樣是 effect 的一部分。
2、React 在什么時候清理?
當組件卸載的時候,React 會執行清理工作。
然而,effect 會針對每個 render 運行而不僅僅是一次,這就是 React 在下次運行 effect 之前還清除前一個 render effect 的原因。
有兩個鏈接:
四、總結
我們已經了解了 useEffect 能夠在組件 render 之后進行不同類型的副作用。某些 effect 可能需要清理,因此可以在 effect 中返回一個 function:
useEffect(() => { ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange); }; });
而有一些 side effect 可能沒有清理的過程,因此不需要返回任何內容。
useEffect(() => { document.title = `You clicked ${count} times`; });
通過 useEffect,能夠將之前在兩個生命周期中的內容整合到一個 function 中。
五、使用 effect 的 tips
1、Tips:使用多個 effect 來分離問題
使用 Hook 的動機中包括了 class 組件的生命周期將相關的邏輯拆分的問題的解決,而在 Hook 的使用中,也能夠把多個 effect 放在 function 組件。
下面是 class 組件的相關代碼:
class FriendStatusWithCounter extends React.Component { constructor(props) { super(props); this.state = { count: 0, isOnline: null }; this.handleStatusChange = this.handleStatusChange.bind(this); } componentDidMount() { document.title = `You clicked ${this.state.count} times`; ChatAPI.subscribeToFriendStatus( this.props.friend.id, this.handleStatusChange ); } componentDidUpdate() { document.title = `You clicked ${this.state.count} times`; } componentWillUnmount() { ChatAPI.unsubscribeFromFriendStatus( this.props.friend.id, this.handleStatusChange ); } handleStatusChange(status) { this.setState({ isOnline: status.isOnline }); }
請注意設置 document.title 的邏輯如何在 componentDidMount 和 componentDidUpdate 之前拆分。而訂閱的邏輯也在 componentDidMount 和 componentDidUpdate 之間傳播。componentDidMount 包含兩個任務的代碼。
使用 Hook 解決這個問題其實就像之前使用 useState 解決問題一樣,可以使用多個 effect,然后將不相關的邏輯都拆分成不同的 effect。
function FriendStatusWithCounter(props) { const [count, setCount] = useState(0); useEffect(() => { document.title = `You clicked ${count} times`; }); const [isOnline, setIsOnline] = useState(null); useEffect(() => { ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange); }; }); function handleStatusChange(status) { setIsOnline(status.isOnline); } // ... }
Hooks 允許我們根據它正在做的事情而不是生命周期方法名稱來拆分代碼。React 將按照指定的順序應用組件使用的每個 effect。
2、說明:為什么 effect 在每次 update 都會運行
如果你習慣了使用 class 組件,你可能想知道為什么每次 re-render 之后,effect 的清理都會執行,而不是在卸載過程中只執行一次(打斷點就能知道)。
在 useState 的文章(http://www.ptbird.cn/react-hook-use-state-hook.html) 中有一個 FriendStatus 來表示好友是否在線,class 組件沖 this.props 中讀取 friend.id,在組件 mount 之后,就訂閱朋友的狀態,並在卸載期間取消訂閱:
componentDidMount() { ChatAPI.subscribeToFriendStatus( this.props.friend.id, this.handleStatusChange ); } componentWillUnmount() { ChatAPI.unsubscribeFromFriendStatus( this.props.friend.id, this.handleStatusChange ); }
但是如果 friend prop 在組件出現在屏幕上時發生了和變化,又會發生什么呢?類組件將繼續顯示不同的朋友的在線狀態,這是一個bug,並且因為取消訂閱使用了錯誤的 friend ID,卸載時還可能導致內存泄露或崩潰。
因此在類組件中,需要添加 componentDidUpdate 來處理這種情況:
componentDidMount() { ChatAPI.subscribeToFriendStatus( this.props.friend.id, this.handleStatusChange ); } componentDidUpdate(prevProps) { // Unsubscribe from the previous friend.id ChatAPI.unsubscribeFromFriendStatus( prevProps.friend.id, this.handleStatusChange ); // Subscribe to the next friend.id ChatAPI.subscribeToFriendStatus( this.props.friend.id, this.handleStatusChange ); } componentWillUnmount() { ChatAPI.unsubscribeFromFriendStatus( this.props.friend.id, this.handleStatusChange ); }
忘記處理 componentDidUpdate 是 React 應用程序中常見的錯誤。
如果使用 Hook :
function FriendStatus(props) { // ... useEffect(() => { ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange); }; });
上面的代碼如果 this.props.friend 發生了變化,也不會受到影響。
沒有用於處理 update 的特殊的代碼,因為默認情況下 useEffect 會處理它們。它們在應用下一個 effect 之前清楚之前的 effect。為了說明這一點,下面是一個訂閱和取消訂閱調用的序列,這個組件可能隨着時間的推移產生:
// Mount with { friend: { id: 100 } } props ChatAPI.subscribeToFriendStatus(100, handleStatusChange); // Run first effect // Update with { friend: { id: 200 } } props ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // Clean up previous effect ChatAPI.subscribeToFriendStatus(200, handleStatusChange); // Run next effect // Update with { friend: { id: 300 } } props ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // Clean up previous effect ChatAPI.subscribeToFriendStatus(300, handleStatusChange); // Run next effect // Unmount ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // Clean up last effect
這種默認行為確保了一致性,並防止由於缺少 update 的處理邏輯而產生 class 組件中常見的錯誤。
3、Tip:跳過 effect 優化性能
在某些情況下,每次 render 后清理或者使用 effect 可能會產生性能問題。在類組件中,可以通過 componentDidUpdate 中編寫 prevProps 或 prevState 的額外比較來解決這個問題:
componentDidUpdate(prevProps, prevState) { if (prevState.count !== this.state.count) { document.title = `You clicked ${this.state.count} times`; } }
這個要求很常見,而這種方式已經被內置到 useEffect Hook 的 API中,如果在重新渲染之間沒有更新某些值,則可以告訴 React 跳過 effect,為了實現這種方式,需要將數組作為可選的第二個參數傳遞給 useEffect:
useEffect(() => { document.title = `You clicked ${count} times`; }, [count]);// 只有在 count 發生變化的時候才會執行這個 effect
上面的例子中, [count] 作為第二個參數傳遞。如果 count = 5,然后組件如果進行了 re-render,如果 count=5,則 React 會比較前一個 render 和 下一個 render 的值。因為兩次 5 === 5,因此React 會跳過這次 effect,這是性能優化。
當 count = 6 的時候,React 會比較 5 !== 6。此時,React 會重新去調用 effect,如果數組中有多個項目,只要有一個的比較值是不相同的, React 也會執行這個 effect。
上面的作用,也同樣應用於 cleanup 的 effect:
useEffect(() => { ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange); }; }, [props.friend.id]); // 只有 props.friend.id 變化的時候才會調用 effect
未來,第二個參數可能會通過構建的時候,轉換自動添加。
注意
如果使用此優化,需要確保數組包含外部作用域隨時間變化且 effect 使用的任何值。否則,你的代碼將引用之前渲染的舊值。在 https://reactjs.org/docs/hooks-reference.html 有又關於 Hooks 優化的更多內容。
如果要運行效果並且僅將其清理一次(在 mount 和 unmount 的時候),可以把空數組 [] 作為第二個參數傳遞。這告訴React你的效果不依賴於來自props或state的任何值,所以它永遠不需要重新運行。這不會作為特殊情況進行處理 - 它直接遵循輸入數組的工作方式。雖然傳遞 [] 更接近 componentDidMount 和 componentWillUnmount 的模式,但是不建議將其作為一種習慣,如果存在訂閱的話,經常會導致錯誤。
不要忘記 React 會延遲運行 useEffect 直到瀏覽器 render 之后,所以進行額外的操作也不是問題。
文章版權:Postbird-There I am , in the world more exciting!
本文鏈接:http://www.ptbird.cn/react-hoot-useEffect.html
轉載請注明文章原始出處 !
