1. Redux 与 React Query (TanStack) 对比分析
使用 Redux、Thunk 和 React Query (TanStack) 结合使用,乍一看似乎有些多余,但每个工具都有自己独特的优点,具体如下:
使用 Thunk 的 Redux:
- 管理状态: Redux 提供了一个集中的状态存储来管理你的应用,允许你在组件之间共享和管理状态。
- 自定义逻辑和控制: 使用 Thunk,你可以在 Redux 动作中添加自定义逻辑来处理副作用(如异步 API 调用),从而控制更新的时间和方式。
-
适用于复杂的 UI 状态: Redux 适合管理复杂的 UI 状态,这些状态不仅依赖于远程数据——比如 UI 开关、表单数据和认证状态。
- React Query (TanStack):
- 优化的数据抓取: React Query 专门用于管理来自服务器的数据(这些数据可能与 UI 状态不同步,因此需要管理)。它可以自动处理数据的获取、缓存、同步和更新,从而节省大量样板代码的编写。
- 缓存和后台刷新: React Query 自动缓存数据,并可以在后台刷新数据以确保数据保持最新,从而减少手动更新的需要。
- 自动重试和数据管理: React Query 包括自动重试、错误处理等功能,并可根据自定义策略将数据标记为“失效”或“有效”。
什么时候该用哪一种:
- Redux 更适合管理 前端状态 和与 UI 相关的状态。
- React Query 更适合处理 API 数据,因为它简化了异步数据的生命周期。
实际上,Redux 和 React Query 相互补充:Redux 更适合处理 UI 状态,而 React Query 则更擅长数据获取和与远程 API 的同步,减少冗余代码量并保证数据的新鲜。
2.\ 项目介绍
为了创建一个完整的 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 文件中获取、添加、更新和删除数据。
3. 设置项:
1. 使用 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
2.: 使用 Vite
、TypeScript
、Redux
和 React Query
搭建前端
- 开始 Vite 项目:
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等文件夹及其内部文件。
4. 前端实现
api/apiClient.ts
- API 客户端 - Axios 实例对象
这里我们导入了axios库,并创建了一个axios客户端。我们设置了基础URL为'http://localhost:3001',并将内容类型设置为'application/json'。最后,我们导出了默认的apiClient。
features/items/itemsSlice.ts
- 带有 Thunk 的 Redux 切片
定义一个 Redux 切片,用于处理 CRUD 操作,并使用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>
);
5. 解释和概述
- Redux with Thunks: 管理本地和乐观主义的状态更新。
- React Query: 高效地处理服务器数据获取和同步。
- Combined Usage: 两者可以无缝协作。
Redux
管理本地状态,而React Query
专注于从服务器获取并缓存数据,使前端与后端保持同步。
这种设置提供了一个功能完备的应用程序,Redux
和 React Query
有效地管理客户端和服务器端的状态,使它们相辅相成。
如果你觉得这有帮助,点个赞或者留个言吧!如果你觉得这篇帖子能帮助到其他人,也别忘了分享哦!非常感谢大家的支持!😃