[譯] useEffect 有時候會在瀏覽器繪製(Paint)之前觸發
useEffect
應該在瀏覽器渲染( paint()
)之後執行,以防止阻塞更新。但您知道它並沒有保證一定在渲染之後觸發?在 useLayoutEffect
中更新狀態(state
)會導致同一次渲染中的 useEffect
在渲染之前執行,這是為了有效率的處理佈局的效果。感到困惑嗎?
來看看一個普通的流程如下:
- React;渲染 Virtual DOM,Effects 排程,更新實際 DOM
- 執行
useLayoutEffect
- React;釋放控制,瀏覽器渲染新的 DOM
- 執行
useEffect
首先,React 文件並沒有說明具體準確 useEffect
觸發時機;在佈局和渲染之後,在一個延遲時間期間。因此我一直都以為就是類似 setTimeout(effect, 3)
的方式,但其實是使用 MessageChannel
的技巧 。
而文件上更有趣的內容:
雖然 useEffect 會延遲直到瀏覽器繪製(paint)完成,它保證會在下一次新的渲染之前觸發。React 會在下次更新之前刷新之前的 Effects
這是一個不錯的保證 - 您可以確保更新不會不見。但這也意味著 Effect 是有可能在瀏覽器 paint
之前觸發的。
如果
- Effect 會在下一次更新開始之前會被刷新
- 更新是可以在瀏覽器
paint
之前觸發,例如使用useLayoutEffect
,然後 Effect 必然在下次更新之前刷新,即在paint
之前刷新;
下面是圖示:
- React 更新 1;渲染 Virtual DOM,排程 Effect,更新 DOM
- 執行
useLayoutEffect
- 更新
state
,造成重新渲染 - 執行
useEffect
- React 更新 2;
- 從第二次更新執行
useLayoutEffect
- React 釋放控制,瀏覽器渲染新的 DOM
- 執行第二次更新的
useEffect
這不是一個非常罕見的例子;避免在 useEffect
直接更新狀態,因為狀態更新會更新 DOM,並且在渲染之後會先得到一個舊的畫面然後更新,導致畫面閃爍。
舉例來說:我們建置一個自適應的輸入欄位,如果欄位寬大於 200px
則多渲染一個清除按鈕。我們需要實際的 DOM 來得知寬。
1 | const ResponsiveInput = ({ onClear, ...props }) => { |
我們已經利用 useEffect
延遲了 addEventListener
希望它在 paint
之後在加入監聽事件,但由於 useLayoutEffect
更新了狀態導致被強制在 paint
之前執行。(範例 Sandbox)
useLayoutEffect
不是唯一強制提早 Effect 的地方,Refs 例如 <div ref={HERE} />
,requestAnimationFrame
迴圈, Microtasks 排程一樣會導致提早。
但某些情況下渲染流程沒有最佳化其實也沒那麼糟糕。誰會在乎呢?但了解工具的限制對您還是很有幫助的;
下面有 4 個可以學習的地方:
不要太依賴 useEffect 在更新之後觸發
即使您知道問題所在,但還是很難確保某些 useEffect
不受 useLayoutEffect
狀態更新的影響:
- 我的元件沒有使用
useLayoutEffect
。但您確定其他函式庫的 Hook 呢?例如usePopper
- 我的元件只使用內建的 Hook,但
useContext
或上層元件的 re-render 有可能造成 uLE 狀態更新 - 我的元件只有
useEffect
和搭配memo()
。但 Effect 有全域刷新的情況,因此一個在paint
之前的狀態更新,其子元件還是會受到影響。
現在您可能會考慮不在 useLayoutEffect
更新狀態,但那有點困難。比較好的建議是不要太依賴 useEffect
會在渲染之後觸發,就像 useMemo
也沒有 100% 穩定參考。如果您希望使用者在渲染之後看到某些畫面, useEffect
不是最好的選擇,嘗試 requestAnimationFrame
或 postMessage
。
反過來,假設您沒有聽說在 useEffect
更新 DOM,然後測試看看有沒有閃爍。
不要浪費時間拆分 Layout Effects
遵循 useEffect
和 useLayoutEffect
的指導原則,我們可能會把一個 Side-effect 分拆到 useLayoutEffect
和 useEffect
就像上面的範例
1 | // DOM update = layout effect |
但我們知道這樣並沒有什麼差異,兩個 Effect 都會在下次渲染之前刷新。如果我們假設 useEffect
會在渲染之後觸發,您可以 100% 確保在兩個 Effect 之間尺寸不會被變更嗎?如果不是那就讓邏輯全部放在 useLayoutEffect
就好了
1 | useLayoutEffect(() => { |
不要在 useLayoutEffect
更新狀態
這是很好的建議,但說的比做的簡單。在 useEffect
更新狀態也是很糟糕因為閃爍會造成 UX 體驗不好。
有時候狀態更新可以被 useRef
完全取代。更新 ref
不會造成重新渲染,Effect 可以如預期的執行。這裡有篇文章可以參考
如果可以盡量不要依賴 useEffect
。
繞過狀態更新
如果您發現特定 useLayoutEffect
造成問題,試著繞過狀態更新,直接操作 DOM。
1 | const clearRef = useRef(); |
手動管理 DOM 更新通常比較複雜且容易出錯的,因此在性能很糟的情況下再使用這個技巧。
本文探討了 useEffect
有時候會在 paint
之前執行,常見的原因是在 useLayoutEffect
更新狀態,因為它在 paint
之前重新渲染導致 Effect 必須得提早執行。這些意味著:
- 在
useLayoutEffect
更新狀態不利於效能,但有時候沒有其他替代方案 - 不要依賴
useEffect
會在渲染後觸發這個點 - 在
useEffect
更新 DOM 會造成閃爍 - - 將
useLayoutEffect
部分邏輯移到useEffect
對效能是沒有意義的 - 在性能問題下,手動 uLE 操控 DOM 的一個理由
參考
[譯] useEffect 有時候會在瀏覽器繪製(Paint)之前觸發
https://andyyou.github.io/2021/11/18/use-effect-trigger-before-paint/