[Implement] 150 行實現 React Query
原始碼在這,可以參考 commit 紀錄來看看每個步驟的變化~
前言:為什麼要有 React Query?
在過去我們需要有一些全域的狀態管理庫,像是 Redux 來管理所有的全域狀態,但就會造成一些問題,像是 API 的全域資料跟 UI 的全域資料混再一起,都存在 Redux 中
這時,出現了像 React Query 這樣的套件,來把 全域的 API 資料,稱作 Server state,和 UI state 分開來,做到關注點分離 (Seperate of concern),並對 Server state 做一些背後的處理,像是
- 快取 (cache)
- 現顯示陳舊資料,在背後去更新 (stale while revalidate; SWR)
- 提供一些常用的屬性,像是
isLoading
,isError
,isSuccess
,isFetching
等等
讓我們省去過往很多處理全域 API 資料的功夫
實現 React Query API
要實現的功能
如圖片中所示,我們要讓第一隻 API call 的資料被再次訪問時,可以立即看見,並且在一段時間後去背地裡更新資料
要實現的 API
import React from 'react';
export function QueryClientProvider() {}; // 將所有的 api queries data 透過 Context 傳遞下去
export class QueryClient {}; // 儲存有所有的 api queries data
export function useQuery() {}; // custom hook,讓我們可以取得經過 stale-while-revalidate 的 api data
一些大局觀
- 我們利用 Proxy pattern 將原本的 API call 另外做 cache 等機制,或 data GC 的處理,在
createQuery
,createQueryObserver
functions 可以看見 - 我們利用 Observer pattern,讓所有使用同一個 query 的 components 都能監聽到 query 的狀態,在
createQueryObserver
中可以看見
先將 QueryClientProvider
傳入新的 Context,ReactQueryDevtools 設為 null
const context = React.createContext();
export function QueryClientProvider({ children, client }) {
return (
<context.Provider value={client}>
{children}
</context.Provider>
);
};
export function ReactQueryDevtools() {
return null;
};
Step 1:設定 useQuery
, createQuery
基本功能和屬性,讓 useQuery
可以儲存 api data 和狀態
將 useQuery
傳回基本的屬性
export function useQuery() {
return {
status: 'loading',
isLoading: false,
data: undefined,
error: undefined,
}
}
設定 createQuery
,產生一個新的 query
- 有基本的 api data 屬性
- 將現在 call api 的 function(
queryFn
) 回傳的的 promise 儲存到query.promise
中 - 如果已經有 promise 在執行,就不要去執行
queryFn
,回傳既有的 promise queryFn
執行完後,更新狀態
function createQuery({ queryKey, queryFn }) {
let query = {
promise: null,
state: {
status: 'idle',
isLoading: false,
data: undefined,
error: undefined,
},
setState: (updater) => {
query.state = updater(query.state);
},
fetch: () => {
if (!query.promise) {
query.promise = (async() => {
// 將狀態改為 loading
query.setState((state) => ({
...state,
isLoading: true
}));
try {
const data = await queryFn();
// 成功後,將狀態改為 success
// 並把 api data 儲存到 state 中
query.setState((state) => ({
...state,
status: 'success',
data,
}))
} catch (error) {
// 失敗後,將狀態改為 error
// 並把 error 儲存到 state 中
query.setState((state) => {
...state,
status: 'error',
error,
})
} finally {
// 無論成功或失敗,都將 isLoading 改為 false
query.setState((state) => {
...state,
isLoading: false,
})
}
})();
}
return query.promise;
}
}
}
Step 2:將監聽同個 api query 的 component 加進來
我們提供 subscribe
function,讓 component 可以訂閱 query 的狀態,並且在狀態改變時,通知所有訂閱者
function createQuery({ queryKey, queryFn }) {
let query = {
promise: null,
subscribers: [],
subscribe: (subscriber) => {
query.subscribers.push(subscriber);
return () => {
query.subscribers = query.subscribers
.filter((s) => s !== subscriber)
}
},
setState: (updater) => {
query.state = updater(query.state);
query.subscribers.forEach((subscriber) => subscriber.notify());
},
...
}
}
Step 3:將所有的 Queries 集中在同一個地方,並且可以透過 queryKey
找到對應的 api query
// 需要一個地放 queries,這時候就需要 QueryClient
export class QueryClient {
constructor() {
this.queries = [];
}
// 取得特定的 query
getQuery = (options) => {
// 1. 利用 queryKey 找到對應的 query
const queryHash = JSON.stringify(options.queryKey);
let query = this.queries.find((query) => query.queryHash);
// 2. 如果沒有,就建立一個新的 query
if (!query) {
query = createQuery(this, options);
this.queries.push(query);
}
return query;
}
}
function creatQuery(client, { queryKey, queryFn }) {
let query = {
queryKey,
queryHash: JSON.stringify(queryKey),
promise: null,
subscribers: [],
state: {
status: 'loading',
isLoading: true,
data: undefined,
error: undefined,
},
...
}
}
Step 4:實現 useQuery
,讓 component 可以監聽 api query 的狀態
export function useQuery({ queryKey, queryFn }) {
const client = useContext(context);
const [, forceRerender] = useReducer((state) => state + 1, 0);
const observerRef = useRef(null);
if (!observerRef.current) {
observerRef.current = createQueryObserver(
client,
{ queryKey, queryFn }
);
}
useEffect(() => {
return observerRef.current.subscribe(forceRerender);
}, [])
return observerRef.current.getResult();
}
// 將 observer 要做的事另外用一個 object 來封裝
function createQueryObserver(client, { queryKey, queryFn }) {
const query = client.getQuery({ queryKey, queryFn });
const observer = {
notify: () => {},
getResult: () => query.state,
subscribe: (callback) => {
observer.notify = callback;
const unsubscribe = query.subscribe(observer);
query.fetch();
return unsubscribe;
}
}
return observer;
}
到這邊我們 React Query 的基本功能就完成了 🥳🥳🥳,但我們還可以加上一些客製化的功能,像是 staleTime
, gcTimeout
等等
(Extra) Step 5 : 加入 staleTime
staleTime
:現在的 api 資料要保存多久
- 把上次成功取得 api 的最後的時間加到 query state 裡
- 將舊資料要保留
staleTime
的時間傳到useQuery
裡 - 在
createObserver
中,確認現在的時間與上次跟新的時間相減,有沒有超過要保留的時間,如果有,重新 fetch
export createQuery(client, { queryKey, queryFn }) {
let query = {
queryKey,
queryHash: JSON.stringify(queryKey),
promise: null,
subscribers: [],
state: {
status: 'loading',
isFetching: true,
data: undefined,
error: undefined,
},
...,
fetch: () => {
if (!query.promise) {
query.promise = (async () => {
query.setState((state) => ({
...state,
isFetching: true,
error: undefined,
}));
try {
const data = await queryFn();
query.setState((state) => ({
...state,
status: 'success',
data,
lastUpdated: Date.now(),
}))
} catch (error) {
query.setState((state) => {
...state,
status: 'error',
error,
})
} finally {
query.setState((state) => {
...state,
isLoading: false,
})
}
})();
}
return query.promise;
}
}
return promise
}
export function useQuery({
queryKye,
queryFn,
staleTime,
}) {
const client = useContext(context);
const [, forceRender] = useReducer((state) => state + 1, 0);
const observerRef = useRef(null);
if (!observerRef.current) {
observerRef.current = createQueryObserver(
client,
{
queryKey,
queryFn,
staleTime,
}
);
}
useEffect(() => {
return observerRef.current.subscribe(forceRender);
}, []);
return observerRef.current.getResult();
}
function createQueryObserver(
client,
{
queryKey,
queryFn,
staleTime,
}
) {
const query = client.getQuery({ queryKey, queryFn });
const observer = {
notify: () => {},
getResult: () => query.state,
subscribe: (callback) => {
observer.notify = callback;
const unsubscribe = query.subscribe(observe);
// 在呼叫 useQuery 的時候,執行有特定條件的 fetch
observer.fetch();
return unsubscribe;
},
fetch: () => {
const isNotFetchBefore = !query.state.lastUpdated;
const isStillFresh = (Date.now() - query.state.lastUpdated) > staleTime;
if (isNotFetchBefore || isStillFresh) {
query.fetch();
}
}
}
return observer;
}
(Extra) Step 6:加入 gcTimeout
gcTimeout
:在指定的一段時間後,將此 api data 移除掉,就算沒有在重新 fetch
- 在
useQuery
,createQueryObserve
將使用者指定的cacheTime
傳入 - 在
createQuery
中,在註冊時,當至少有一個 component 註冊該 query 時,就不要將此 query 回收掉,但沒有註冊者就會收掉
function createQuery(
client,
{
queryKey,
queryFn,
cacheTime = 5 * 60 * 1000
}
) {
let query = {
queryKey,
queryHash: JSON.stringify(queryKey),
promise: null,
subscribers: [],
gcTimeout: null,
state: {
status: 'loading',
isFetching: true,
data: undefined,
error: undefined,
},
subscribe: (subscriber) => {
query.subscribers.push(subscriber);
query.unscheduleGC();
return () => {
query.subscribers = query.subscribers.filter((s) => s !== subscriber);
// 當此 query 沒有訂閱者時,就可以 Garbage Collection
if (!query.subscribers.length) {
query.scheduleGC();
}
}
},
scheduleGC: () => {
query.gcTimeout = setTimeout(() => {
clients.queries = client.queries.filter((q) => q !== query)
}, cacheTime);
},
unscheduleGC: () => {
clearTimeout(query.gcTimeout);
},
...
}
}
export function useQuery({ ..., cacheTime }) {
...
if (!observerRef.current) {
observerRef.current = createQueryObserver(
client,
{ queryKey, queryFn, staleTime, cacheTime },
)
}
...
}
function createQueryObserver(client, { ..., cacheTime }) {
const query = client.getQuery({ queryKey, queryFn, cacheTime });
...
}
最終程式碼
import React, { useContext, useRef, useReducer, useEffect } from 'react';
const context = React.createContext();
export function QueryClientProvider({ children, client }) {
return <context.Provider value={client}>{children}</context.Provider>;
};
// 需要一個地放 queries,這時候就需要 QueryClient
export class QueryClient {
constructor() {
this.queries = [];
}
// 取得特定的 query
getQuery = (options) => {
// 1. 利用 queryKey 找到對應的 query
const queryHash = JSON.stringify(options.queryKey);
let query = this.queries.find((query) => query.queryHash === queryHash);
// 2. 如果沒有,就建立一個新的 query
if (!query) {
query = createQuery(this, options);
this.queries.push(query);
}
return query;
}
};
function createQuery(client, { queryKey, queryFn, cacheTime = 5 * 60 * 1000 }) {
let query = {
queryKey,
queryHash: JSON.stringify(queryKey),
promise: null,
subscribers: [],
gcTimeout: null,
state: {
status: 'loading',
isFetching: true,
data: undefined,
error: undefined,
// 避免重複觸發 (deduppling),如果有 queryFn 正在執行,就不要再執行一次
},
subscribe: (subscriber) => {
query.subscribers.push(subscriber);
query.unscheduleGC();
return () => {
query.subscribers = query.subscribers.filter((s) => s !== subscriber);
// 當此 query 沒有訂閱者時,就可以 Garbage Collection
if (!query.subscribers.length) {
query.scheduleGC();
}
}
},
scheduleGC: () => {
query.gcTimeout = setTimeout(() => {
client.queries = client.queries.filter((q) => q !== query);
}, cacheTime);
},
unscheduleGC: () => {
clearTimeout(query.gcTimeout);
},
setState: (updater) => {
// updater 類似 reducer,用來改變 state
query.state = updater(query.state);
query.subscribers.forEach((subscriber) => subscriber.notify());
},
fetch: () => {
if (!query.promise) {
query.promise = (async () => {
query.setState((state) => ({ ...state, isFetching: true, error: undefined }));
try {
const data = await queryFn();
query.setState((state) => ({ ...state, status: 'success', data, lastUpdated: Date.now() }));
} catch (error) {
query.setState((state) => ({ ...state, status: 'error', error }));
} finally {
query.promise = null;
query.setState((state) => ({ ...state, isFetching: false }));
}
})()
}
return query.promise;
},
}
return query;
}
export function useQuery({ queryKey, queryFn, staleTime, cacheTime }) {
const client = useContext(context);
const [, forceRender] = useReducer((state) => state + 1, 0);
const observerRef = useRef(null);
if (!observerRef.current) {
observerRef.current = createQueryObserver(client, {
queryKey,
queryFn,
staleTime,
cacheTime,
});
}
useEffect(() => {
return observerRef.current.subscribe(forceRender);
}, []);
return observerRef.current.getResult();
};
// 跟 useQuery 結合
function createQueryObserver(client, { queryKey, queryFn, staleTime = 0, cacheTime }) {
const query = client.getQuery({ queryKey, queryFn, cacheTime });
const observer = {
notify: () => {},
getResult: () => query.state,
// 在呼叫 useQuery 的時候,就會執行這個函式
subscribe: (callback) => {
observer.notify = callback;
const unsubscribe = query.subscribe(observer);
// 在呼叫 useQuery 的時候,執行有特定條件的 fetch
observer.fetch();
return unsubscribe;
},
// fetch 前先檢查保留舊資料的時間 staleTime 是否過期,過期再去 fetch
fetch: () => {
if (
!query.state.lastUpdated
|| Date.now() - query.state.lastUpdated > staleTime
) {
query.fetch();
}
}
}
return observer;
}
export function ReactQueryDevtools() {
return null;
};