[譯] #3 React Query 的優化

[譯] #3 React Query 的優化

本篇文章翻自 React Query Render Optimizations
謝謝 @TkDodo 撰寫 React Query 的優質系列文,並且開放各方翻譯。

(封面圖由 Lukasz Szmigiel 提供)

聲明:在任何的應用裡,優化渲染的部分是屬於進階的概念。React Query 在原生上已經有非常好的優化和預設行為,所以不需要再進一步地優化。許多人會放不少心思在「不需要的重複渲染」的主題上,這也是我決定要整理這篇文章的原因。不過我想重申一次,在大部分的應用中,優化渲染或許並沒有你想像中的茲事體大。重複渲染是件好事。他會讓你的應用總是在最新的狀態。相對於「缺少應該要有的渲染」,我更加可以接受「不必要的重複渲染」。關於這個主題的更多延伸,請閱讀以下兩篇文章:


當我在 [譯] #2 React Query 的資料轉換中說明 select 選項時,已經有稍微帶到效能優化的部分。然而,「就算資料沒有改變,為什麼 React Query 還是會重複渲染元件」這個問題是我最常被問到的。所以讓我再試著深入解釋。

isFetching 的轉換

我必須要誠實地說,在上個例子中有說過只當有待辦項目的數量變動時,元件才會再次渲染。

count-component
1
2
3
4
5
6
7
8
9
export const useTodosQuery = (select) =>
useQuery(['todos'], fetchTodos, { select })
export const useTodosCount = () => useTodosQuery((data) => data.length)

function TodosCount() {
const todosCount = useTodosCount()

return <div>{todosCount.data}</div>
}

每次在做背景的 refetch 時,元件會根據以下的 query 資訊重複渲染兩次:

1
2
{ status: 'success', data: 2, isFetching: true }
{ status: 'success', data: 2, isFetching: false }

這是因為 React Query 對於每個 query 會暴露許多 meta 資訊,isFetching 就是其中之一。當資料請求還在進行中的時候,這個 flag 就會維持 true。如果你想要在這種時候在畫面的背景中呈現載入中的指示時,這個會相當好用。不過如果你不用特別呈現載入中狀態的話,這個就會有點不太需要。

notifyOnChangeProps

針對這樣的情況,React Query 有提供 notifyOnChangeProps 的選項。這個會在每個觀察者中進行指定,告訴 React Query 說,只有列表中的任一 prop 有更新,才需要通知觀察者這次的變動。藉由把設定這個選項設成 [data],我們就能達到想要的優化。

optimized-with-notifyOnChangeProps
1
2
3
4
export const useTodosQuery = (select, notifyOnChangeProps) =>
useQuery(['todos'], fetchTodos, { select, notifyOnChangeProps })
export const useTodosCount = () =>
useTodosQuery((data) => data.length, ['data'])

你可以在文件中的例子-optimistic-updates-typescript 看到實際的應用。

保持同步(sync)

雖然以上的方式可以很好地運作,但是容易產生不同步的情況。但如果我們也想對錯誤進行反應的話該怎麼辦呢?或者我們想開始用 isLoading 的 flag?我們必須要讓 notifyOnChangeProps 裡的列表和我們在元件中實際使用的地方保持同步。如果我們忘記這件事,只有觀察到 data,但在渲染我們有用到 error。一旦有錯誤時,我們的元件並不會重複渲染。如果在我們自訂的 hook hard-code 的話會特別的麻煩,因為這個 hook 並不會知道元件實際上會運用到什麼:

outdated-component
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export const useTodosCount = () =>
useTodosQuery((data) => data.length, ['data'])

function TodosCount() {
// 🚨 we are using error, but we are not getting notified if error changes!
const { error, data } = useTodosCount()

return (
<div>
{error ? error : null}
{data ? data : null}
</div>
)
}

Tracked Queries

我非常地以這個 feature 自豪,因為這是我在這個函式庫中第一個主要的貢獻。如果你把 notifyOnChangeProps 設成 tracked,React Query 會對在渲染過程中會用到的欄位持續地追蹤。你也可以在全域開啟這個選項讓全部的 queries 適用這項設定。

tracked-queries
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const queryClient = new QueryClient({
defaultOptions: {
queries: {
notifyOnChangeProps: 'tracked',
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
)
}

有了這個,你再也不用思考有關重複渲染的事情。當然,追蹤使用到的欄位會需要花些運算的成本,所以當你要廣泛使用的話要確認一下。Tracked queries 也有一些限制,這也是為什麼這是的選用的 feature:

problematic-rest-destructuring
1
2
3
4
5
// 🚨 will track all fields
const { isLoading, ...queryInfo } = useQuery(...)

// ✅ this is totally fine
const { isLoading, data } = useQuery(...)
  • Tracked queries 只會在「渲染過程中」作用。如果你只是想在進行 effects 的時候存取欄位,這些欄位並不會被追蹤。以下是個邊際案例:
tracking-effects
1
2
3
4
5
6
7
8
9
10
11
const queryInfo = useQuery(...)

// 🚨 will not corectly track data
React.useEffect(() => {
console.log(queryInfo.data)
})

// ✅ fine because the dependency array is accessed during render
React.useEffect(() => {
console.log(queryInfo.data)
}, [queryInfo.data])
  • Tracked queries 在每次的渲染並不會進行重置。因此一旦你追蹤了某個欄位,在觀察者的存活期間,你將會持續地追蹤他。
no-reset
1
2
3
4
5
6
const queryInfo = useQuery(...)

if (someCondition()) {
// 🟡 we will track the data field if someCondition was true in any previous render cycle
return <div>{queryInfo.data}</div>
}

更新:從 v4 開始,在 React Query 的預設行為中,tracked queries 會被開啟。你可以透過設定 notifyOnChangeProps: 'all' 選擇性地關閉這個 feature。

結構性共享(Structural sharing)

雖然是不同的事情,但是跟渲染優化一樣重要,並且在 React Query 中預設開啟支援的是「結構性共享(Structural sharing)」。這個 feature 會確保我們在每個層級中,都能持續地參考到資料的實體。在這個範例中,假設你擁有以下這樣的資料結構:

1
2
3
4
[
{ "id": 1, "name": "Learn React", "status": "active" },
{ "id": 2, "name": "Learn React Query", "status": "todo" }
]

現在,假設我們將第一個待辦項目的 status 轉換成 done,並且我們要在背景 refetch。我們將會從後端取得完整全新的資料:

1
2
3
4
5
[
- { "id": 1, "name": "Learn React", "status": "active" },
+ { "id": 1, "name": "Learn React", "status": "done" },
{ "id": 2, "name": "Learn React Query", "status": "todo" }
]

現在 React Query 將會試圖比較新的跟舊的資料,並且盡可能地維持之前的狀態。在我們的例子中,這個待辦項目的陣列將會是以新的為主,因為我們更新了一個待辦項目。 id 為 1 的物件將會是新的,不過 id 為 2 的物件將會跟之前的狀態是一致的參考-因為他並沒有產生任何變動,React Query 只會把參考複製到新的結果上。

當我們要部分訂閱資料時,使用 selectors 會非常地方便:

optimized-selectors
1
2
3
// ✅ will only re-render if _something_ within todo with id:2 changes
// thanks to structural sharing
const { data } = useTodo(2)

如同我之前提示過的,對於 selector 來說,結構性共享會執行兩次:一次是根據 queryFn 回傳的結果來判斷是否有任何變動;再一次是 selector 函式回傳的結果。在某些情況下,尤其是當有非常龐大的資料集時,結構性共享反而會成為效能瓶頸。如果你不需要這項優化,你可以在任何的 query 中設定 structuralSharing: false 來關閉。

如果你想要了解背後運作的原理,可以看這個 replaceEqualDeep tests

作者

Yuri Tsai

發表於

2022-06-10

更新於

2022-06-20

許可協議

評論