Web應用中加載數據時需要處理的問題:
- 根據不同的加載狀態顯示不同的 UI 組件
- 減少對相同數據重複發送請求
- 使用樂觀更新,提升用戶體驗
- 在用戶與 UI 交互時,管理快取的生命週期
以上這些問題 RTKQ 都可以幫助我們處理。
首先,可以直接通過 RTKQ 向 Server 發送請求加載數據,並且 RTKQ 會自動對數據進行快取,避免重複發送不必要的請求。其次 RTKQ 在發送請求時會根據請求不同的狀態返回不同的值,我們可以通過這些值來監視請求發送的過程並隨時停止。
使用 RTKQ 之前需要了解如何使用 RTK
安裝 RTK
RTKQ 已經集成在 Redux Toolkit (RTK) 中,所以安裝 RTK 即可:
# NPM
npm install @reduxjs/toolkit
# Yarn
yarn add @reduxjs/toolkit
創建 API Service
創建一個 services/student.js
,使用 createApi
創建一個 API service:
reducerPath
: Api的標示, 確保唯一性即可, 默認值為api
。baseQuery
: 搭配fetchBaseQuery
指定查詢的基礎信息, 類似 axios.create 的作用。最基礎的是設置 baseUrl,也就是 API 的基礎網址。endpoints
: 定義 endpoints, 用來指定 Api 的路徑, 類似 axios.get, axios.post… builder 為請求的構建器。
// src/services/student.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
const studentApi = createApi({
// Api的標示, 確保唯一性即可, 默認值為 api
reducerPath: 'studentApi',
// 指定查詢的基礎信息, 類似 axios.create 的作用
baseQuery: fetchBaseQuery({
baseUrl: 'http://localhost:3001'
}),
// 定義 endpoints, 用來指定 Api 的路徑, 類似 axios.get, axios.post...
// builder 為請求的構建器
endpoints: (builder) => ({
// ...
})
})
endpoints 就是配置 API 路徑,GET 請求使用 builder.query
,PUT, PATCH, DELETE, POST 請求使用 builder.mutation
:
...
endpoints: (builder) => ({
getStudents: builder.query({
query: () => '/students', // 請求的 API 路徑
transformResponse: (response) => {
// 可以在這裡預先處理資料
return response
}
}),
getStudentById: builder.query({
query: (id) => `/student/${id}`,
keepUnusedDataFor: 0 // 快取數據沒在使用的有效期(秒),設為 0 代表不使用快取,默認是 60 秒。
}),
updateStudent: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/student/${id}`,
method: 'PUT',
body: patch
})
}),
postStudent: builder.mutation({
query: (body) => ({
url: '/student',
method: 'POST',
body
})
}),
deleteStudent: builder.mutation({
query: (id) => ({
url: `/student/${id}`,
method: 'DELETE'
})
})
})
Api service 創建後 RTKQ 會根據各種方法自動生成對應的 hooks,通過這些 hooks 可以向 server 發送請求。
hooks 的命名規則:
- getStudents =>
useGetStudentsQuery
- getStudentById =>
useGetStudentByIdQuery
- updateStudent =>
useUpdateStudentMutation
- postStudent =>
usePostStudentMutation
const studentApi = ....
export const {
useGetStudentsQuery,
useGetStudentByIdQuery,
useUpdateStudentMutation,
usePostStudentMutation
} = studentApi
export default studentApi
hook 的名字是有命名規則的,不是自己想怎麼取就怎麼取,可以打印 studentApi 出來看一下包含哪些 hooks。
創建 Store
使用 configureStore
創建 store,將 RTKQ service studentApi
的 reducer 放入 store 中並且需要額外配置 middleware。
RTKQ service 自帶 middleware,在 store 的 middleware 中加入剛剛新增的 API middleware:
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit'
import studentApi from '../services/student'
const store = configureStore({
reducer: {
[studentApi.reducerPath]: studentApi.reducer
},
middleware: (getDefaultMiddleware) => {
return getDefaultMiddleware().concat(studentApi.middleware)
}
})
export default store
補充:
- Reducer 是 Redux 中用來管理狀態的函數。在 Redux 中所有的狀態更新都由 reducer 來處理。
- Middleware 允許你在 dispatch action 和 reducer 之間執行額外的邏輯,加入 API middleware 可以處理 API 的快取。
getDefaultMiddleware
是一個函數,它返回 RTK 預設的 middleware 列表。所以這段的意思就是將studentApi.middleware
添加到默認的 middleware 列表中。
打開 index.jsx
(或 main.jsx
) 在 App 組件之外添加 Provider,並傳入 剛剛創建好的 store:
// src/main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import App from './App.jsx'
import store from './store'
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
)
使用 json-server 模擬 API
json-server 是一個可以快速模擬 REST API 的工具。
npm install -g json-server
在 src 底下建立 mock/db.json
檔案,然後貼上以下數據:
{
"students": [
{ "id": 1, "name": "Tom", "age": 20, "gender": "male" },
{ "id": 2, "name": "Jelly", "age": 27, "gender": "female" },
{ "id": 3, "name": "Victoria", "age": 33, "gender": "female" },
{ "id": 4, "name": "Kevin", "age": 16, "gender": "male" },
{ "id": 5, "name": "May", "age": 27, "gender": "female" },
{ "id": 6, "name": "Peter", "age": 45, "gender": "male" }
]
}
students 就是 endpoint 名稱
接著啟動 json-server
cd src/mock
json-server --watch db.json
預設 port 是 3000,如果衝突了可以在後面加上 --port 3001
開啟 http://localhost:3000/
就能看見我們模擬的 API
使用 hook 發送 CRUD 請求
GET 請求
引入 RTKQ 自動生成的 API hook,試著打印出來 useGetStudentsQuery()
看看回傳的內容:
import {
useGetStudentsQuery,
useGetStudentByIdQuery,
useUpdateStudentMutation,
usePostStudentMutation
} from './services/student'
const App = () => {
const students = useGetStudentsQuery()
console.log(students)
return <></>
}
hook 會回傳一個物件,物件中主要包括:
- currentData: 表示截至上次成功取得或查詢解析的數據,保存來自最新成功請求的資訊。
- data: 表示 query 或 mutation 返回的最新數據,無論是來自快取還是網路請求。
- isError, isFetching, isLoading, isSuccess… API 請求的狀態。
- refetch: 重新請求的函數。
data 跟 currentData 比較類似,data 是最新的數據,無論請求是否已經成功。而 currentData 則是當前參數的最新一次請求成功的數據,因此如果要回退到當前參數最新成功的請求結果,需要用到 currentData。
根據 isLoading, isSuccess 可以為請求狀態做不同的 UI 顯示:
import {
useGetStudentsQuery,
useGetStudentByIdQuery,
useUpdateStudentMutation,
usePostStudentMutation
} from './services/student'
const App = () => {
const students = useGetStudentsQuery()
if (students.isLoading) {
return (
<div>loading...</div>
)
}
return (
<div>
{students.isSuccess && students.data.map(student => (
<p key={student.id}>{student.name}-{student.age}-{student.gender}</p>
))}
</div>
)
}
selectFromResult
useGetStudentsQuery 能傳入第二個參數,第二個參數為一個物件,物件中提供 selectFromResult
用於處理結果,類似於 API 中設置的 transformResponse
,不過 selectFromResult 使用場景較少。
const students = useGetStudentsQuery(null, {
selectFromResult: result => {
if (result.data) {
result.data = result.data.filter(student => student.age > 18)
}
return result
}
})
pollingInterval
如果想一段時間發送一次請求,可以設置 pollingInterval,單位為毫秒。
const students = useGetStudentsQuery(null, {
pollingInterval: 2000
})
skip
若希望在特定情況下不發送請求,可以使用 skip。比如若 id 不存在,則不發送請求:
const { data, isSuccess } = useGetStudentByIdQuery(id, {
skip: !id
}
refetchOnMountOrArgChange
是否每次都重新發送請求,設置 false 正常使用快取,設置 true 則不使用快取。
或者也可以設置成數值,代表請求有效期,若快取數據沒在使用的時間超過有效期(秒)則重新請求。
const { data, isSuccess } = useGetStudentByIdQuery(id, {
refetchOnMountOrArgChange: true,
})
refetchOnFocus & refetchOnReconnect
- refetchOnFocus 用於設置是否在獲取焦點時重新發送請求
- refetchOnReconnect 則是設置是否在重新連接時重新發送請求
const { data, isSuccess } = useGetStudentByIdQuery(id, {
refetchOnFocus: false, // 是否在獲取焦點時重新發送請求
refetchOnReconnect: false, // 是否在重新連接時重新發送請求
})
這兩個選項需要搭配 setupListeners(store.dispatch)
才會生效。
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import studentApi from '../services/student'
const store = configureStore({
reducer: {
[studentApi.reducerPath]: studentApi.reducer
},
middleware: (getDefaultMiddleware) => {
return getDefaultMiddleware().concat(studentApi.middleware)
}
})
setupListeners(store.dispatch) // 設置以後 RTKQ 將會支持 refetchOnFocus, refetchOnReconnect
export default store
POST, PUT, DELETE 請求
mutation hook 會回傳一個陣列,第一個元素為 dispatch action,第二個元素則是 action 的結果:
import { Student, StudentForm } from './components'
import { useGetStudentsQuery, usePostStudentMutation, useUpdateStudentMutation, useDeleteStudentMutation } from './services/student'
import './App.css'
const App = () => {
const students = useGetStudentsQuery()
const [deleteStudent, deleteStudentResult] = useDeleteStudentMutation()
const [addStudent, addStudentResult] = usePostStudentMutation()
const [updateStudent, updateStudentResult] = useUpdateStudentMutation()
const onSubmit = (type, data) => {
try {
if (type === 'add') {
addStudent({ name: data.name, age: Number(data.age), gender: data.gender })
console.log(addStudentResult);
} else if (type === 'edit') {
updateStudent({ ...data, age: Number(data.age) })
console.log(updateStudentResult)
} else if (type === 'delete') {
deleteStudent(data.id)
console.log(deleteStudentResult)
}
} catch (err) {
console.error(err)
}
}
...
}
export default App
result 是一個物件,其中包括請求的狀態和 reset function:
一般來說新增、編輯、刪除之後,要重新加載才能看見最新的數據,但 RTKQ 有一個很妙的參數,只要設置之後不需要自行重新請求。
自動重新請求最新數據
在 getStudents
API 設置 providesTags: ['Student']
,然後在新增、編輯和刪除的 API 設置 invalidatesTags: ['Student']
就可以在新增、編輯和刪除請求成功後自動重新調用 getStudents
:
- providesTags: 設定 API 的快取 tag,值為陣列。
- 元素可以是單純的字串,如
['Student']
,或物件[{ type: 'Student', id: 'List' }]
,或者函數(result, error, id) => [{ type: 'Student', id }]
- invalidatesTags: 一旦請求成功 invalidatesTags 會使使用該 tag 的相關快取資料失效,使用該 tag 的 API 就會重新加載。
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
const studentApi = createApi({
reducerPath: 'studentApi',
baseQuery: fetchBaseQuery({
baseUrl: 'http://localhost:3001',
}),
endpoints: (builder) => ({
getStudents: builder.query({
query: () => '/students',
providesTags: (result) => result
? [
{ type: 'Student', id: 'LIST' },
...result.map(({ id }) => ({ type: 'Student', id })),
]
: [{ type: 'Student', id: 'LIST' }]
}),
getStudentById: builder.query({
query: (id) => `/students/${id}`,
keepUnusedDataFor: 60,
providesTags: (result, error, id) => [{ type: 'Student', id }]
}),
updateStudent: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/students/${id}`,
method: 'PUT',
body: patch
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Student', id }]
}),
postStudent: builder.mutation({
query: (body) => ({
url: '/students',
method: 'POST',
body
}),
invalidatesTags: [{ type: 'Student', id: 'LIST' }]
}),
deleteStudent: builder.mutation({
query: (id) => ({
url: `/students/${id}`,
method: 'DELETE'
}),
invalidatesTags: (result, error, id) => [{ type: 'Student', id }]
})
})
})
export const {
useGetStudentsQuery,
useGetStudentByIdQuery,
useUpdateStudentMutation,
usePostStudentMutation,
useDeleteStudentMutation
} = studentApi
export default studentApi
如果希望手動重新請求,也是可以直接使用 students.refetch()
const students = useGetStudentsQuery()
const [deleteStudent] = useDeleteStudentMutation()
const [addStudent] = usePostStudentMutation()
const [updateStudent] = useUpdateStudentMutation()
const onSubmit = (type, data) => {
try {
if (type === 'add') {
addStudent({ name: data.name, age: Number(data.age), gender: data.gender })
} else if (type === 'edit') {
updateStudent({ ...data, age: Number(data.age) })
} else if (type === 'delete') {
deleteStudent(data.id)
}
students.refetch()
} catch (err) {
console.error(err)
}
}
這個快取 tag 稍微有點難理解,可以查看官方文檔,有比較詳細的說明:https://redux-toolkit.js.org/rtk-query/usage/automated-refetching
程式碼分離(Code splitting)
可以先使用 createApi
定義一個基礎的 API Service:
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"
export const baseApi = createApi({
baseQuery: fetchBaseQuery({
baseUrl: 'http://localhost:3001',
// 如果需要設置所有 API 的 headers,可以使用 headers 或者 prepareHeaders
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token
if (token) {
headers.set('authorization', `Bearer ${token}`)
}
return headers
},
headers: {
'Content-Type': 'application/json'
}
}),
endpoints: () => ({})
})
其餘的 API Service 則可以使用 injectEndpoints
將 endpoints 注入到原始 API 中:
import { baseApi } from "./api"
const studentApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getStudents: builder.query({
query: () => '/students',
providesTags: (result) => result
? [
{ type: 'Student', id: 'LIST' },
...result.map(({ id }) => ({ type: 'Student', id })),
]
: [{ type: 'Student', id: 'LIST' }]
}),
getStudentById: builder.query({
query: (id) => `/students/${id}`,
keepUnusedDataFor: 60,
providesTags: (result, error, id) => [{ type: 'Student', id }]
}),
updateStudent: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/students/${id}`,
method: 'PUT',
body: patch
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Student', id }]
}),
postStudent: builder.mutation({
query: (body) => ({
url: '/students',
method: 'POST',
body
}),
invalidatesTags: [{ type: 'Student', id: 'LIST' }]
}),
deleteStudent: builder.mutation({
query: (id) => ({
url: `/students/${id}`,
method: 'DELETE'
}),
invalidatesTags: (result, error, id) => [{ type: 'Student', id }]
})
}),
})
export const {
useGetStudentsQuery,
useGetStudentByIdQuery,
useUpdateStudentMutation,
usePostStudentMutation,
useDeleteStudentMutation
} = studentApi
錯誤處理中間件
可以使用 isRejectedWithValue
建立錯誤處理的函數:
import { isRejectedWithValue } from '@reduxjs/toolkit'
export const errorHandling =
(api) => (next) => (action) => {
if (isRejectedWithValue(action)) {
const { error, status } = action.payload
console.log(status, error)
}
return next(action)
}
並且將錯誤處理函數加入 middleware 列表:
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import { baseAPI } from '../services/api'
import { errorHandling } from '../services/middleware'
const store = configureStore({
reducer: {
[baseAPI.reducerPath]: baseAPI.reducer
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(errorHandling, baseAPI.middleware),
})
setupListeners(store.dispatch)
export default store
如此一來當 API 請求失敗時,就能獲取到該請求相關的資料,以作相應的處理。