使用 Redux、Thunk 和 React Query (TanStack) 结合使用,乍一看似乎有些多余,但每个工具都有自己独特的优点,具体如下:
使用 Thunk 的 Redux:
适用于复杂的 UI 状态: Redux 适合管理复杂的 UI 状态,这些状态不仅依赖于远程数据——比如 UI 开关、表单数据和认证状态。
什么时候该用哪一种:
实际上,Redux 和 React Query 相互补充:Redux 更适合处理 UI 状态,而 React Query 则更擅长数据获取和与远程 API 的同步,减少冗余代码量并保证数据的新鲜。
为了创建一个完整的 ReactJS + TypeScript + Vite 示例,其中包括使用 Redux (Thunk) 和 React Query (TanStack) 进行 CRUD 操作,我们将设置一个 Node.js Express 服务器,使用 JSON 文件作为数据来源。这个服务器将提供 API 端点来模拟真实的后端。快速了解一下 TypeScript 的特点
下面,概览:
React
+ Vite
+ TypeScript
,并通过 Redux
和 React Query
处理 CRUD 操作。Node.js
和 Express
创建端点,从一个 .json 文件中获取、添加、更新和删除数据。Express
来搭建后端server
,并在该目录中添加一个 db.json
文件以模拟数据存储。server/ ├── db.json └── server.js
这是服务器端的文件结构,其中 db.json
用于存储数据库配置,而 server.js
是服务器端的主脚本。服务器文件夹包含了数据库配置文件 db.json
和服务器端脚本 server.js
。
在 server/
目录下创建并初始化一个 Node.js 应用:
在终端中输入以下命令:
cd server npm init -y npm install express body-parser fs cors
db.json
– 一个简单的 JSON 文件来存储这些条目 (server/db.json
):{ "items": [ { "id": 1, "title": "样品1" }, { "id": 2, "title": "样品2" } ] }
server.js
文件 – 设置一个具有 CRUD 操作端点的 Express
服务器(在 server/server.js
文件中)。const express = require('express'); const fs = require('fs'); const path = require('path'); const cors = require('cors'); // 引入 cors 包 const app = express(); const PORT = process.env.PORT || 3001; app.use(cors()); // 启用所有路由的 CORS app.use(express.json()); const dbFilePath = path.join(__dirname, 'db.json'); // 辅助函数,用于读取和写入 db.json 文件 const readData = () => { if (!fs.existsSync(dbFilePath)) { return { items: [] }; } const data = fs.readFileSync(dbFilePath, 'utf-8'); return JSON.parse(data); }; const writeData = (data) => fs.writeFileSync(dbFilePath, JSON.stringify(data, null, 2)); // 获取所有条目 app.get('/items', (req, res) => { const data = readData(); res.json(data.items); }); // 添加新条目 app.post('/items', (req, res) => { const data = readData(); const newItem = { id: Date.now(), title: req.body.title }; data.items.push(newItem); writeData(data); res.status(201).json(newItem); }); // 更新条目 app.put('/items/:id', (req, res) => { const data = readData(); const itemIndex = data.items.findIndex((item) => item.id === parseInt(req.params.id)); if (itemIndex > -1) { data.items[itemIndex] = { ...data.items[itemIndex], title: req.body.title }; writeData(data); res.json(data.items[itemIndex]); } else { res.status(404).json({ message: '未找到条目' }); } }); // 删除条目 app.delete('/items/:id', (req, res) => { const data = readData(); data.items = data.items.filter((item) => item.id !== parseInt(req.params.id)); writeData(data); res.status(204).end(); }); app.listen(PORT, () => { console.log(`服务器运行于 http://localhost:${PORT}`); });
运行这个命令来启动服务器:node server.js
Vite
、TypeScript
、Redux
和 React Query
搭建前端npm命令用于创建一个使用Vite构建的React项目,使用react-ts模板和react-redux-query-example作为项目名。 cd react-redux-query-example
运行以下命令来安装这些库:npm install @reduxjs/toolkit react-redux redux-thunk axios @tanstack/react-query
src/ ├── api/ │ └── apiClient.ts ├── features/ │ ├── items/ │ │ ├── itemsSlice.ts │ │ └── itemsApi.ts ├── hooks/ │ └── useItems.ts ├── App.tsx ├── App.module.css ├── store.ts └── main.tsx
以下展示了src目录下的代码结构,包括api、features、hooks等文件夹及其内部文件。
api/apiClient.ts
- API 客户端 - Axios 实例对象这里我们导入了axios库,并创建了一个axios客户端。我们设置了基础URL为'http://localhost:3001',并将内容类型设置为'application/json'。最后,我们导出了默认的apiClient。
features/items/itemsSlice.ts
- 带有 Thunk 的 Redux 切片Redux Thunk
。import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import apiClient from '../../api/apiClient'; export interface Item { id: number; title: string; } interface ItemsState { items: Item[]; loading: boolean; error: string | null; } const initialState: ItemsState = { items: [], loading: false, error: null, }; // Thunks export const fetchItems = createAsyncThunk('items/fetchItems', async () => { const response = await apiClient.get('/items'); return response.data; }); const itemsSlice = createSlice({ name: 'items', initialState, reducers: { addItem: (state, action: PayloadAction<Item>) => { state.items.push(action.payload); }, updateItem: (state, action: PayloadAction<Item>) => { const index = state.items.findIndex((item) => item.id === action.payload.id); if (index !== -1) state.items[index] = action.payload; }, deleteItem: (state, action: PayloadAction<number>) => { state.items = state.items.filter((item) => item.id !== action.payload); }, }, extraReducers: (builder) => { builder .addCase(fetchItems.pending, (state) => { state.loading = true; }) .addCase(fetchItems.fulfilled, (state, action: PayloadAction<Item[]>) => { state.items = action.payload; state.loading = false; }) .addCase(fetchItems.rejected, (state, action) => { state.error = action.error.message || '无法获取项目'; state.loading = false; }); }, }); export const { addItem, updateItem, deleteItem } = itemsSlice.actions; export default itemsSlice.reducer;
features/items/itemsApi.ts
文件 - React 查询 Hooks
通过 React Query 来定义 CRUD 操作(即创建、读取、更新和删除操作)。
导入 { useQuery, useMutation, useQueryClient, UseQueryResult, UseMutationResult } from '@tanstack/react-query'; import { useDispatch } from 'react-redux'; import apiClient from '../../api/apiClient'; import { Item, addItem as addItemAction, updateItem as updateItemAction, deleteItem as deleteItemAction } from './itemsSlice'; export const useFetchItems = (): UseQueryResult<Item[], Error> => useQuery({ queryKey: ['items'], // 在 React Query 中,queryFn 在以下几种情况下会被调用: // 1. 初始加载:当使用 useQuery 钩子的组件首次挂载时。 // 2. 数据过期:当缓存中的数据被认为过期时。这由 staleTime 配置决定。 // 3. 窗口聚焦:当浏览器窗口重新获得焦点时,如果 refetchOnWindowFocus 设为 true。 // 4. 间隔刷新:在设定的时间间隔内自动刷新,如果设置了 refetchInterval。 // 5. 手动调用:通过调用 queryClient.invalidateQueries 或 queryClient.refetchQueries 等方法手动刷新查询。 // 6. 网络重新连接:当网络重新连接时,如果设置了 refetchOnReconnect 为 true。 queryFn: async (): Promise<Item[]> => { const response = await apiClient.get('/items'); return response.data; }, // 缓存保持有效的时间 // 如果 staleTime 还未达到,当再次调用 useFetchItems 时,React Query 不会触发新的请求。相反,它会从缓存中提供数据,因为数据仍然被视为“新鲜”。 // 如果在 staleTime 内服务器数据发生变化,React Query 不会自动知道这些变化,因为在缓存被标记为“过期”或手动刷新(例如,调用 queryClient.invalidateQueries 或 refetch )之前,它不会检查服务器。 staleTime: 5 * 60 * 1000, // 缓存5分钟(默认值:0) // refetchOnWindowFocus 被触发的场景 // 1. 切换标签页:用户从另一个浏览器标签页切换到应用所在的标签页时。 // 2. 切换窗口:用户从另一个应用程序窗口(例如,不同的浏览器窗口或应用程序)切换到浏览器窗口时。 // 3. 最小化/恢复:用户最小化浏览器窗口并恢复时。 // 4. 锁屏/解锁:用户锁定屏幕并解锁后,浏览器窗口重新获得焦点时。 refetchOnWindowFocus: true, // 窗口聚焦时刷新(默认值:true) // 使用 refetchInterval:定期自动轮询服务器。 refetchInterval: 10000, // 每10秒轮询一次(默认值:false) // 如果缓存中的数据仍然有效(不过期),React Query 会直接返回它,而不会发起新的 API 请求。在这种情况下,onSuccess 不会被触发,因为 queryFn 没有被执行。 // 如果 queryFn 返回的数据与现有缓存数据相同,React Query 将不会标记查询为“更新”,从而优化性能。由于没有数据变化,onSuccess 回调不会被调用。 onSuccess: (data) => { console.log("触发 onSuccess") const dispatch = useDispatch(); //dispatch(setItems(data)); }, }); export const useAddItem = (): UseMutationResult<Item, Error, { title: string }> => { const queryClient = useQueryClient(); const dispatch = useDispatch(); return useMutation({ mutationFn: async (newItem: { title: string }): Promise<Item> => { const response = await apiClient.post('/items', newItem); return response.data; }, // 只在 mutation 成功时被调用,与 onSettled 不同,后者无论 mutation 成功与否都会被调用。 onSuccess: (data) => { // 使缓存失效,创建后刷新项目 queryClient.invalidateQueries({ queryKey: ['items'] }); dispatch(addItemAction(data)); } }); }; export const useUpdateItem = (): UseMutationResult<Item, Error, { id: number; title: string }> => { const queryClient = useQueryClient(); const dispatch = useDispatch(); return useMutation({ mutationFn: async (updatedItem: { id: number; title: string }): Promise<Item> => { const response = await apiClient.put(`/items/${updatedItem.id}`, updatedItem); return response.data; }, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['items'] }); dispatch(updateItemAction(data)); } }); }; export const useDeleteItem = (): UseMutationResult<void, Error, number> => { const queryClient = useQueryClient(); const dispatch = useDispatch(); return useMutation({ mutationFn: async (id: number): Promise<void> => { await apiClient.delete(`/items/${id}`); }, onSuccess: (数据, id) => { queryClient.invalidateQueries({ queryKey: ['items'] }); dispatch(deleteItemAction(id)); } }); };
store.ts
- Redux 存储import { configureStore } from '@reduxjs/toolkit'; import itemsReducer from './features/items/itemsSlice'; // 导入 Redux 工具包并配置 store const store = configureStore({ reducer: { items: itemsReducer, }, }); export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch; export default store;
App.tsx
- 执行 CRUD 操作的主要组件import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { fetchItems, deleteItem as deleteItemAction } from './features/items/itemsSlice'; import { RootState, AppDispatch } from './store'; import { useFetchItems, useAddItem, useUpdateItem, useDeleteItem } from './features/items/itemsApi'; import styles from './App.module.css'; const App: React.FC = () => { const dispatch = useDispatch<AppDispatch>(); const { items, loading, error } = useSelector((state: RootState) => state.items); const { data: queryItems } = useFetchItems(); const addItemMutation = useAddItem(); const updateItemMutation = useUpdateItem(); const deleteItemMutation = useDeleteItem(); const [editingItem, setEditingItem] = useState<{ id: number; title: string } | null>(null); const [isLoading, setIsLoading] = useState(false); const [loadingButton, setLoadingButton] = useState<string | null>(null); useEffect(() => { dispatch(fetchItems()); }, [dispatch]); const handleAddItem = () => { setIsLoading(true); setLoadingButton('add'); const newItem = { title: 'New Item' }; addItemMutation.mutate(newItem, { // onSettled is called regardless of whether the query or mutation was successful or resulted in an error. // It is always called after the request has completed. onSettled: () => { setIsLoading(false); setLoadingButton(null); }, }); }; const handleUpdateItem = (id: number, title: string) => { setIsLoading(true); setLoadingButton(`update-${id}`); updateItemMutation.mutate({ id, title }, { onSettled: () => { setIsLoading(false); setLoadingButton(null); setEditingItem(null); }, }); }; const handleDeleteItem = (id: number) => { setIsLoading(true); setLoadingButton(`delete-${id}`); // Optimistically update the UI const previousItems = items; dispatch(deleteItemAction(id)); deleteItemMutation.mutate(id, { onSettled: () => { setIsLoading(false); setLoadingButton(null); }, onError: () => { // 如果变异失败,则还原更改。 dispatch(fetchItems()); }, }); }; if (loading) return <p>加载中...</p>; if (error) return <p>错误: {error}</p>; return ( <div className={styles.container}> <h1 className={styles.title}>项目</h1> <button onClick={handleAddItem} className={styles.button} disabled={isLoading}> 新增项目 {loadingButton === 'add' && <div className={styles.spinner}></div>} </button> <ul className={styles.list}> {(items).map((item) => ( <li key={item.id} className={styles.listItem}> {editingItem && editingItem.id === item.id ? ( <> <input type="text" value={editingItem.title} onChange={(e) => setEditingItem({ ...editingItem, title: e.target.value })} className={styles.input} /> <div className={styles.buttonGroup}> <button onClick={() => handleUpdateItem(item.id, editingItem.title)} className={styles.saveButton} disabled={isLoading}> 保存 {loadingButton === `update-${item.id}` && <div className={styles.spinner}></div>} </button> <button onClick={() => setEditingItem(null)} className={styles.cancelButton} disabled={isLoading}> 取消 </button> </div> </> ) : ( <> {item.title} <div className={styles.buttonGroup}> <button onClick={() => setEditingItem(item)} className={styles.editButton} disabled={isLoading}> 编辑 </button> <button onClick={() => handleDeleteItem(item.id)} className={styles.deleteButton} disabled={isLoading}> 删除 {loadingButton === `delete-${item.id}` && <div className={styles.spinner}></div>} </button> </div> </> )} </li> ))} </ul> </div> ); }; export default App;
App.module.css
).container { max-width: 800px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif; } .title { color: #2c3e50; font-size: 2rem; margin-bottom: 20px; text-align: center; } .button { background-color: #3498db; color: white; padding: 10px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; max-width: 130px; } .button:hover { background-color: #2980b9; } .list { list-style-type: none; padding: 0; } .listItem { padding: 10px; margin: 10px 0; border: 1px solid #bdc3c7; border-radius: 8px; background-color: #ecf0f1; display: flex; justify-content: space-between; align-items: center; } .input { padding: 5px; border: 1px solid #bdc3c7; border-radius: 4px; flex-grow: 1; margin-right: 10px; } .buttonGroup { display: flex; gap: 10px; } .editButton { background-color: #f39c12; color: white; padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; max-width: 100px; } .editButton:hover { background-color: #e67e22; } .deleteButton { background-color: #e74c3c; color: white; padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; max-width: 100px; } .deleteButton:hover { background-color: #c0392b; } .saveButton { background-color: #2ecc71; color: white; padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; max-width: 100px; } .saveButton:hover { background-color: #27ae60; } .cancelButton { background-color: #95a5a6; color: white; padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; max-width: 100px; } .cancelButton:hover { background-color: #7f8c8d; } .spinner { border: 4px solid rgba(0, 0, 0, 0.1); border-left-color: #3498db; border-radius: 50%; width: 16px; height: 16px; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }
main.tsx
- 用于设置提供者(Provider):Redux 和 React Query****import React from 'react'; import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import store from './store'; import App from './App'; const queryClient = new QueryClient(); ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <Provider store={store}> <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> </Provider> </React.StrictMode> );
Redux
管理本地状态,而 React Query
专注于从服务器获取并缓存数据,使前端与后端保持同步。这种设置提供了一个功能完备的应用程序,Redux
和 React Query
有效地管理客户端和服务器端的状态,使它们相辅相成。
如果你觉得这有帮助,点个赞或者留个言吧!如果你觉得这篇帖子能帮助到其他人,也别忘了分享哦!非常感谢大家的支持!😃