工具型文章要跳讀,而文學經典就要反覆研讀。若是說 React 0.14
版本帶來的各類生命週期能夠類比到工具型文章,那麼 16.7
帶來的 Hooks 就要像文學經典同樣反覆研讀。javascript
Hooks API 不管從簡潔程度,仍是使用深度角度來看,都大大優於以前生命週期的 API,因此必須反覆理解,反覆實踐,不然只能停留在表面原地踏步。html
相比 useState
或者自定義 Hooks 而言,最有理解難度的是 useEffect
這個工具,但願藉着 a-complete-guide-to-useeffect 一文,深刻理解 useEffect
。前端
原文很是長,因此概述是筆者精簡後的。做者是 Dan Abramov,React 核心開發者。java
unLearning,也就是學會忘記。你以前的學習經驗會阻礙你進一步學習。react
想要理解好 useEffect
就必須先深刻理解 Function Component 的渲染機制,Function Component 與 Class Component 功能上的不一樣在上一期精讀 精讀《Function VS Class 組件》 已經介紹,而他們還存在思惟上的不一樣:git
Function Component 是更完全的狀態驅動抽象,甚至沒有 Class Component 生命週期的概念,只有一個狀態,而 React 負責同步到 DOM。 這是理解 Function Component 以及 useEffect
的關鍵,後面還會詳細介紹。github
因爲原文很是很是的長,因此筆者精簡下內容再從新整理一遍。原文很是長的另外一個緣由是採用了啓發式思考與逐層遞進的方式寫做,筆者最大程度保留這個思惟框架。api
假設讀者有比較豐富的前端 & React 開發經驗,而且寫過一些 Hooks。那麼你也許以爲 Function Component 很好用,但美中不足的是,總有一些疑惑縈繞在心中,好比:瀏覽器
useEffect
代替 componentDidMount
?useEffect
取數?參數 []
表明什麼?useEffect
的依賴能夠是函數嗎?是哪些函數?useEffect
中拿到的 state 或 props 是舊的?第一個問題可能已經自問自答過無數次了,但下次寫代碼的時候仍是會忘。筆者也同樣,並且在三期不一樣的精讀中都分別介紹過這個問題:安全
但次日就忘記了,由於 用 Hooks 實現生命週期確實彆扭。 講真,若是想完全解決這個問題,就請你忘掉 React、忘掉生命週期,從新理解一下 Function Component 的思惟方式吧!
上面 5 個問題的解答就不贅述了,讀者若是有疑惑能夠去 原文 TLDR 查看。
要說清楚 useEffect
,最好先從 Render 概念開始理解。
能夠認爲每次 Render 的內容都會造成一個快照並保留下來,所以當狀態變動而 Rerender 時,就造成了 N 個 Render 狀態,而每一個 Render 狀態都擁有本身固定不變的 Props 與 State。
看下面的 count
:
function Counter() { const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </div> ); }
在每次點擊時,count
只是一個不會變的常量,並且也不存在利用 Proxy
的雙向綁定,只是一個常量存在於每次 Render 中。
初始狀態下 count
值爲 0
,而隨着按鈕被點擊,在每次 Render 過程當中,count
的值都會被固化爲 1
、2
、3
:
// 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>; // ... }
其實不只是對象,函數在每次渲染時也是獨立的。這就是 Capture Value 特性,後面遇到這種狀況就不會一一展開,只描述爲 「此處擁有 Capture Value 特性」。
解釋了爲何下面的代碼會輸出 5
而不是 3
:
const App = () => { const [temp, setTemp] = React.useState(5); const log = () => { setTimeout(() => { console.log("3 秒前 temp = 5,如今 temp =", temp); }, 3000); }; return ( <div onClick={() => { log(); setTemp(3); // 3 秒前 temp = 5,如今 temp = 5 }} > xyz </div> ); };
在 log
函數執行的那個 Render 過程裏,temp
的值能夠看做常量 5
,執行 setTemp(3)
時會交由一個全新的 Render 渲染,因此不會執行 log
函數。而 3 秒後執行的內容是由 temp
爲 5
的那個 Render 發出的,因此結果天然爲 5
。
緣由就是 temp
、log
都擁有 Capture Value 特性。
useEffect
也同樣具備 Capture Value 的特性。
useEffect
在實際 DOM 渲染完畢後執行,那 useEffect
拿到的值也遵循 Capture Value 的特性:
function Counter() { 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> ); }
上面的 useEffect
在每次 Render 過程當中,拿到的 count
都是固化下來的常量。
利用 useRef
就能夠繞過 Capture Value 的特性。能夠認爲 ref
在全部 Render 過程當中保持着惟一引用,所以全部對 ref
的賦值或取值,拿到的都只有一個最終狀態,而不會在每一個 Render 間存在隔離。
function Example() { const [count, setCount] = useState(0); const latestCount = useRef(count); useEffect(() => { // Set the mutable latest value latestCount.current = count; setTimeout(() => { // Read the mutable latest value console.log(`You clicked ${latestCount.current} times`); }, 3000); }); // ... }
也能夠簡潔的認爲,ref
是 Mutable 的,而 state
是 Immutable 的。
在組件被銷燬時,經過 useEffect
註冊的監聽須要被銷燬,這一點能夠經過 useEffect
的返回值作到:
useEffect(() => { ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange); }; });
在組件被銷燬時,會執行返回值函數內回調函數。一樣,因爲 Capture Value 特性,每次 「註冊」 「回收」 拿到的都是成對的固定值。
Function Component 不存在生命週期,因此不要把 Class Component 的生命週期概念搬過來試圖對號入座。Function Component 僅描述 UI 狀態,React 會將其同步到 DOM,僅此而已。
既然是狀態同步,那麼每次渲染的狀態都會固化下來,這包括 state
props
useEffect
以及寫在 Function Component 中的全部函數。
然而捨棄了生命週期的同步會帶來一些性能問題,因此咱們須要告訴 React 如何比對 Effect。
雖然 React 在 DOM 渲染時會 diff 內容,只對改變部分進行修改,而不是總體替換,但卻作不到對 Effect 的增量修改識別。所以須要開發者經過 useEffect
的第二個參數告訴 React 用到了哪些外部變量:
useEffect(() => { document.title = "Hello, " + name; }, [name]); // Our deps
直到 name
改變時的 Rerender,useEffect
纔會再次執行。
然而手動維護比較麻煩並且可能遺漏,所以能夠利用 eslint 插件自動提示 + FIX:
若是你明明使用了某個變量,卻沒有申明在依賴中,你等於向 React 撒了謊,後果就是,當依賴的變量改變時,useEffect
也不會再次執行:
useEffect(() => { document.title = "Hello, " + name; }, []); // Wrong: name is missing in dep
這看上去很蠢,但看看另外一個例子呢?
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>; }
setInterval
咱們只想執行一次,因此咱們自覺得聰明的向 React 撒了謊,將依賴寫成 []
。
「組件初始化執行一次 setInterval
,銷燬時執行一次 clearInterval
,這樣的代碼符合預期。」 你內心可能這麼想。
可是你錯了,因爲 useEffect 符合 Capture Value 的特性,拿到的 count
值永遠是初始化的 0
。至關於 setInterval
永遠在 count
爲 0
的 Scope 中執行,你後續的 setCount
操做並不會產生任何做用。
筆者稍稍修改了一下標題,由於誠實是要付出代價的:
useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, [count]);
你老實告訴 React 「嘿,等 count
變化後再執行吧」,那麼你會獲得一個好消息和兩個壞消息。
好消息是,代碼能夠正常運行了,拿到了最新的 count
。
壞消息有:
count
變化時都會銷燬並從新計時。上述例子使用了 count
,然而這樣的代碼很彆扭,由於你在一個只想執行一次的 Effect 裏依賴了外部變量。
既然要誠實,那隻好 想辦法不依賴外部變量:
useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []);
setCount
還有一種函數回調模式,你不須要關心當前值是什麼,只要對 「舊的值」 進行修改便可。這樣雖然代碼永遠運行在第一次 Render 中,但老是能夠訪問到最新的 state
。
你可能發現了,上面投機取巧的方式並無完全解決全部場景的問題,好比同時依賴了兩個 state
的狀況:
useEffect(() => { const id = setInterval(() => { setCount(c => c + step); }, 1000); return () => clearInterval(id); }, [step]);
你會發現不得不依賴 step
這個變量,咱們又回到了 「誠實的代價」 那一章。固然 Dan 必定會給咱們解法的。
利用 useEffect
的兄弟 useReducer
函數,將更新與動做解耦就能夠了:
const [state, dispatch] = useReducer(reducer, initialState); const { count, step } = state; useEffect(() => { const id = setInterval(() => { dispatch({ type: "tick" }); // Instead of setCount(c => c + step); }, 1000); return () => clearInterval(id); }, [dispatch]);
這就是一個局部 「Redux」,因爲更新變成了 dispatch({ type: "tick" })
因此無論更新時須要依賴多少變量,在調用更新的動做裏都不須要依賴任何變量。 具體更新操做在 reducer
函數裏寫就能夠了。在線 Demo。
Dan 也將
useReducer
比做 Hooks 的的金手指模式,由於這充分繞過了 Diff 機制,不過確實能解決痛點!
在 「告訴 React 如何對比 Diff」 一章介紹了依賴的重要性,以及對 React 要誠實。那麼若是函數定義不在 useEffect
函數體內,不只可能會遺漏依賴,並且 eslint 插件也沒法幫助你自動收集依賴。
你的直覺會告訴你這樣作會帶來更多麻煩,好比如何複用函數?是的,只要不依賴 Function Component 內變量的函數均可以安全的抽出去:
// ✅ Not affected by the data flow function getFetchUrl(query) { return "https://hn.algolia.com/api/v1/search?query=" + query; }
可是依賴了變量的函數怎麼辦?
若是非要這麼作,就用 useCallback
吧!
function Parent() { const [query, setQuery] = useState("react"); // ✅ Preserves identity until query changes const fetchData = useCallback(() => { const url = "https://hn.algolia.com/api/v1/search?query=" + query; // ... Fetch data and return it ... }, [query]); // ✅ Callback deps are OK return <Child fetchData={fetchData} />; } function Child({ fetchData }) { let [data, setData] = useState(null); useEffect(() => { fetchData().then(setData); }, [fetchData]); // ✅ Effect deps are OK // ... }
因爲函數也具備 Capture Value 特性,通過 useCallback
包裝過的函數能夠看成普通變量做爲 useEffect
的依賴。useCallback
作的事情,就是在其依賴變化時,返回一個新的函數引用,觸發 useEffect
的依賴變化,並激活其從新執行。
在 Class Component 的代碼裏,若是但願參數變化就從新取數,你不能直接比對取數函數的 Diff:
componentDidUpdate(prevProps) { // 🔴 This condition will never be true if (this.props.fetchData !== prevProps.fetchData) { this.props.fetchData(); } }
反之,要比對的是取數參數是否變化:
componentDidUpdate(prevProps) { if (this.props.query !== prevProps.query) { this.props.fetchData(); } }
但這種代碼不內聚,一旦取數參數發生變化,就會引起多處代碼的維護危機。
反觀 Function Component 中利用 useCallback
封裝的取數函數,能夠直接做爲依賴傳入 useEffect
,useEffect
只要關心取數函數是否變化,而取數參數的變化在 useCallback
時關心,再配合 eslint 插件的掃描,能作到 依賴不丟、邏輯內聚,從而容易維護。
除了函數依賴邏輯內聚以外,咱們再看看取數的全過程:
一個 Class Component 的普通取數要考慮這些點:
didMount
初始化發請求。didUpdate
判斷取數參數是否變化,變化就調用取數函數從新取數。unmount
生命週期添加 flag,在 didMount
didUpdate
兩處作兼容,當組件銷燬時取消取數。你會以爲代碼跳來跳去的,不只同時關心取數函數與取數參數,還要在不一樣生命週期裏維護多套邏輯。那麼換成 Function Component 的思惟是怎樣的呢?
筆者利用 useCallback 對原 Demo 進行了改造。
function Article({ id }) { const [article, setArticle] = useState(null); // 取數函數:只關心依賴的 id const fetchArticle = useCallback(async () => { const article = await API.fetchArticle(id); if (!didCancel) { setArticle(article); } }, [id]); // 反作用,只關心依賴了取數函數 useEffect(() => { // didCancel 賦值與變化的位置更內聚 let didCancel = false; fetchArticle(didCancel); return () => { didCancel = true; }; }, [fetchArticle]); // ... }
當你真的理解了 Function Component 理念後,就能夠理解 Dan 的這句話:雖然 useEffect
前期學習成本更高,但一旦你正確使用了它,就能比 Class Component 更好的處理邊緣狀況。
useEffect
只是底層 API,將來業務接觸到的是更多封裝後的上層 API,好比 useFetch
或者 useTheme
,它們會更好用。
原文有 9000+ 單詞,很是長。但同時也配合一些 GIF 動圖生動解釋了 Render 執行原理,若是你想用好 Function Component 或者 Hooks,這篇文章幾乎是必讀的,由於沒有人能猜到什麼是 Capture Value,然而不能理解這個概念,Function Component 也不能用的順手。
從新捋一下這篇文章的思路:
能夠看到,比寫框架更高的境界是發現代碼的美感,好比 Hooks 本是爲加強 Function Component 能力而創造,但在拋出問題-解決問題的過程當中,能夠不斷看到規則限制,換一個角度打破它,最後體會到總體的邏輯之美。
從這篇文章中也能夠讀到如何加強學習能力。做者告訴咱們,學會忘記能夠更好的理解。咱們不要拿生命週期的固化思惟往 Hooks 上套,由於那會阻礙咱們理解 Hooks 的理念。
另補充一些零碎的內容。
useEffect
在渲染結束時執行,因此不會阻塞瀏覽器渲染進程,因此使用 Function Component 寫的項目通常都有用更好的性能。
天然符合 React Fiber 的理念,由於 Fiber 會根據狀況暫停或插隊執行不一樣組件的 Render,若是代碼遵循了 Capture Value 的特性,在 Fiber 環境下會保證值的安全訪問,同時弱化生命週期也能解決中斷執行時帶來的問題。
useEffect
不會在服務端渲染時執行。
因爲在 DOM 執行完畢後才執行,因此能保證拿到狀態生效後的 DOM 屬性。
最後,提兩個最重要的點,來檢驗你有沒有讀懂這篇文章:
useEffect
的第二個參數 []
),而不是關注什麼時候觸發。你對 「一致性」 有哪些更深的解讀呢?歡迎留言回覆。
若是你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公衆號
special Sponsors
版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)