告別錯誤觀念 —— useEffect 不是生命週期 API
#前言
如標題所說,這篇文章要打破大家的觀念,介紹 useEffect
真正的用途。
在《React 思維進化》系列文章已有提到這部分,這篇文章是用自己的想法再重新梳理、解釋一次。
#React 的設計精神
首先我們要先知道,React 是宣告式設計(Declarative Programming),什麼是宣告式?
簡單來說,就是描述「是什麼」,而不是「怎麼做」。
我們只要描述「畫面要長什麼樣子」,React 會幫我們處理好變動。
例如:在真實 DOM 的操作上,React 會依據 state, props 產生虛擬 DOM,當它們有變動時,就會重新渲染元件,產生新版本的虛擬 DOM 畫面,然後將它們做比較,找出相異之處,只更新相異部分的真實 DOM。
而在 useEffect,我們也應順著宣告式設計的精神去思考,描述「目標」,而不是手動指定「怎麼一步一步達成這個目標」。因此,當使用 useEffect 時,重點不是去追究它「何時執行」的細節。
#useEffect 的用途
所以 useEffect
的用途是什麼呢?
在 React 的官方文件是這麼說的:
useEffect
is a React Hook that lets you synchronize a component with an external system.
中文直翻就是:「useEffect
是一個讓你將元件和外部系統同步化的 React Hook。」
同步化?什麼意思?
React 組件運作在自己的世界中,有自己的生命週期和狀態管理。然而,應用程序通常需要與外部系統互動,例如:
- 發 API 請求
- 手動操作 DOM
- 設置定時器
- 第三方庫
這些外部系統不了解 React 的狀態更新或重新渲染機制。而 useEffect
就像一個橋梁,讓我們能夠:
在 React 狀態變化時,更新外部系統狀態;並在外部系統變化時,更新 React 狀態,也就是文件說的「將元件和外部系統同步化」,或者再更簡單點說,就是「保持狀態資料同步」。
所以 useEffect
不是 functional component 的生命週期 API,而是用來保持狀態資料同步的。
#useEffect
能夠將副作用的處理「隔離」
副作用指的是:函式會依賴或影響函式外的某些狀態、或是與外部環境產生互動。
所以,前面所說的「同步 React 元件與外部系統」,其實就是副作用的具體情境。因此我們也可以說,useEffect
是用來處理副作用的。
我們在開發時,當然希望元件要是可預測的,給定相同的 props 和 state,應該渲染出相同的結果。
而 useEffect
就能夠處理那些「不純」的操作(副作用),例如前面所提到的發 API 請求、操作 DOM...等。
useEffect
的執行時機是在:虛擬 DOM 渲染完,真實 DOM 也掛載或更新後(當然會再依據 dependencies 決定是否執行)。
這個機制讓我們把這些副作用「隔離」起來處理,而不是混進元件的 render 裡面。
#隔離有什麼好處
1. 讓元件與外部系統同步化
由於 useEffect
執行的時機是在虛擬 DOM 渲染完、真實 DOM 更新後才執行,我們就可以確保目前得到的資料是最新的。
這個「時機」很重要,因為它確保副作用的執行能準確反映當下 React 的狀態,也才能有效地將資料「同步」到外部世界。
2. 保持元件渲染結果的一致性
我們先說明這些副作用處理可能會如何造成資料不一致:
我們知道,元件的渲染過程要是「純」的,也就是說,給定相同的 state 和 props,每次渲染都應該產生相同的 UI。
而如果某些操作會影響到 state 或依賴外部環境,這就有可能破壞這個一致性。例如:
- 如果我們直接在 render 中發送 API 請求(也就是沒有使用
useEffect
來隔離持行時機),因為 API 回應的時間不確定,就會導致每次渲染的結果不同。 - 直接操作 DOM,React 就無法追蹤這些變更,導致 UI 和實際 DOM 的狀態不同步。
把副作用放在 useEffect
中隔離處理,就可以確保元件渲染過程純粹,因為 useEffect
是在元件渲染完才執行。
因此:
- 可以避免在元件渲染過程中執行非同步操作,保持資料的一致性
- 能確保元件的 render 只依賴於 state 和 props,每次渲染都是一樣的結果
#useEffect
的 dependencies
以前,我們可能誤將 useEffect
視為生命週期 API,並嘗試用 dependencies 陣列來控制執行時機。
而現在我們知道,useEffect
的重點是同步化、處理副作用,因此理論上,重複它並不會破壞應用的正確性。如果元件和外部系統已經是同步的了,再次執行這些操作應該得到相同的結果,不會造成錯誤。
那 dependencies 是做什麼的呢?
⭐️ 它是一種效能優化手段。
若我們有正確使用 useEffect
的話,那麼重複它其實也不會怎樣,但我們當然不想要、不需要每次都執行它,所以才需要 dependencies,當 dependencies 陣列裡的值沒有改變時,就會跳過 effect 函式。
因此,dependencies 不是用來做邏輯控制的,而是一種「跳過某些不必要的執行」的效能優化。
#把 useEffect
當成生命週期 API 使用會怎麼樣
就目前來說,其實也不會出什麼大問題,畢竟很多人習慣這樣用。
但這並不是 useEffect
真正的設計目的,既然它是用來處理副作用的,若沒有正確使用它,可能會在一些細微、不容易察覺的地方引發問題,導致難以除錯的狀況。
在未來的 React 版本中,加入了 Reusable State 的概念。
Reusable State 是指:從畫面中移除元件後,仍能保留其 state 狀態,以便需要時重新 mount 後再次還原。
而當然,在每次 mount 時,元件就會再次執行副作用的處理,這意味著,在未來版本的 React 中,即使 dependencies 沒有變化,effect 函式仍有可能再次被執行。
因此,若我們把 useEffect
當成生命週期 API 使用,用「effect 函式會在某某時機執行」這樣的思考模式,就有可能會出錯。
所以為了確保元件支援上述的特性,useEffect
就必須滿足「無論被重複多少次也不會壞掉」的目標。
#總結
在開發時,要從原本「生命週期」改成「同步化」的思維方式,個人認為不太容易,會需要一些時間思考 useEffect
用新的方法該怎麼寫,不像以前「某 state 改變時就做某事」那麼直觀。
以前開發時,曾有過這樣的經驗:一個元件用了許多 useEffect
,而 dependencies 中又填入了好幾個參數,這樣錯綜複雜的執行時機依賴造成了開發、除錯上很大的困難。
因此,我認為當程式碼越來越複雜時,「同步化」的寫法確實更可靠、也更利於開發,若有正確寫好 useEffect
的話,基本上不會出錯。
而使用新的 useEffect
寫法時,我有兩個建議:
- 暫時把 dependencies 拿掉,看看是否重複執行也不會壞掉
useEffect
中用到什麼變數,就要填入 dependencies(也就是要對 dependencies 誠實,關於這部分,可以參考《React 思維進化》#5-3 維護資料的流動:不要欺騙 hooks 的 dependencies)