[譯] #3 React Query 的優化
本篇文章翻自 React Query Render Optimizations
謝謝 @TkDodo 撰寫 React Query 的優質系列文,並且開放各方翻譯。(封面圖由 Lukasz Szmigiel 提供)
聲明:在任何的應用裡,優化渲染的部分是屬於進階的概念。React Query 在原生上已經有非常好的優化和預設行為,所以不需要再進一步地優化。許多人會放不少心思在「不需要的重複渲染」的主題上,這也是我決定要整理這篇文章的原因。不過我想重申一次,在大部分的應用中,優化渲染或許並沒有你想像中的茲事體大。重複渲染是件好事。他會讓你的應用總是在最新的狀態。相對於「缺少應該要有的渲染」,我更加可以接受「不必要的重複渲染」。關於這個主題的更多延伸,請閱讀以下兩篇文章:
- Fix the slow render before you fix the re-render
- this article by @ryanflorence about premature optimizations
當我在 [譯] #2 React Query 的資料轉換中說明 select
選項時,已經有稍微帶到效能優化的部分。然而,「就算資料沒有改變,為什麼 React Query 還是會重複渲染元件」這個問題是我最常被問到的。所以讓我再試著深入解釋。
isFetching
的轉換
我必須要誠實地說,在上個例子中有說過只當有待辦項目的數量變動時,元件才會再次渲染。
1 | export const useTodosQuery = (select) => |
每次在做背景的 refetch 時,元件會根據以下的 query 資訊重複渲染兩次:
1 | { status: 'success', data: 2, isFetching: true } |
這是因為 React Query 對於每個 query 會暴露許多 meta 資訊,isFetching
就是其中之一。當資料請求還在進行中的時候,這個 flag 就會維持 true
。如果你想要在這種時候在畫面的背景中呈現載入中的指示時,這個會相當好用。不過如果你不用特別呈現載入中狀態的話,這個就會有點不太需要。
notifyOnChangeProps
針對這樣的情況,React Query 有提供 notifyOnChangeProps
的選項。這個會在每個觀察者中進行指定,告訴 React Query 說,只有列表中的任一 prop 有更新,才需要通知觀察者這次的變動。藉由把設定這個選項設成 [data]
,我們就能達到想要的優化。
1 | export const useTodosQuery = (select, notifyOnChangeProps) => |
你可以在文件中的例子-optimistic-updates-typescript 看到實際的應用。
保持同步(sync)
雖然以上的方式可以很好地運作,但是容易產生不同步的情況。但如果我們也想對錯誤進行反應的話該怎麼辦呢?或者我們想開始用 isLoading
的 flag?我們必須要讓 notifyOnChangeProps
裡的列表和我們在元件中實際使用的地方保持同步。如果我們忘記這件事,只有觀察到 data,但在渲染我們有用到 error。一旦有錯誤時,我們的元件並不會重複渲染。如果在我們自訂的 hook hard-code 的話會特別的麻煩,因為這個 hook 並不會知道元件實際上會運用到什麼:
1 | export const useTodosCount = () => |
Tracked Queries
我非常地以這個 feature 自豪,因為這是我在這個函式庫中第一個主要的貢獻。如果你把 notifyOnChangeProps
設成 tracked
,React Query 會對在渲染過程中會用到的欄位持續地追蹤。你也可以在全域開啟這個選項讓全部的 queries 適用這項設定。
1 | const queryClient = new QueryClient({ |
有了這個,你再也不用思考有關重複渲染的事情。當然,追蹤使用到的欄位會需要花些運算的成本,所以當你要廣泛使用的話要確認一下。Tracked queries 也有一些限制,這也是為什麼這是的選用的 feature:
- 如果你使用物件的剩餘運算子進行解構,你會有效地觀察到每個欄位。一般的解構可以正常運作,只要做要做這件事:
1 | // 🚨 will track all fields |
- Tracked queries 只會在「渲染過程中」作用。如果你只是想在進行 effects 的時候存取欄位,這些欄位並不會被追蹤。以下是個邊際案例:
1 | const queryInfo = useQuery(...) |
- Tracked queries 在每次的渲染並不會進行重置。因此一旦你追蹤了某個欄位,在觀察者的存活期間,你將會持續地追蹤他。
1 | const queryInfo = useQuery(...) |
更新:從 v4 開始,在 React Query 的預設行為中,tracked queries 會被開啟。你可以透過設定 notifyOnChangeProps: 'all'
選擇性地關閉這個 feature。
結構性共享(Structural sharing)
雖然是不同的事情,但是跟渲染優化一樣重要,並且在 React Query 中預設開啟支援的是「結構性共享(Structural sharing)」。這個 feature 會確保我們在每個層級中,都能持續地參考到資料的實體。在這個範例中,假設你擁有以下這樣的資料結構:
1 | [ |
現在,假設我們將第一個待辦項目的 status
轉換成 done
,並且我們要在背景 refetch。我們將會從後端取得完整全新的資料:
1 | [ |
現在 React Query 將會試圖比較新的跟舊的資料,並且盡可能地維持之前的狀態。在我們的例子中,這個待辦項目的陣列將會是以新的為主,因為我們更新了一個待辦項目。 id 為 1 的物件將會是新的,不過 id 為 2 的物件將會跟之前的狀態是一致的參考-因為他並沒有產生任何變動,React Query 只會把參考複製到新的結果上。
當我們要部分訂閱資料時,使用 selectors 會非常地方便:
1 | // ✅ will only re-render if _something_ within todo with id:2 changes |
如同我之前提示過的,對於 selector 來說,結構性共享會執行兩次:一次是根據 queryFn 回傳的結果來判斷是否有任何變動;再一次是 selector 函式回傳的結果。在某些情況下,尤其是當有非常龐大的資料集時,結構性共享反而會成為效能瓶頸。如果你不需要這項優化,你可以在任何的 query 中設定 structuralSharing: false
來關閉。
如果你想要了解背後運作的原理,可以看這個 replaceEqualDeep tests。
[譯] #3 React Query 的優化