手记

ReactJS结合TypeScript、Vite、Redux和TanStack (React Query) 实战教程

1. Redux 与 React Query (TanStack) 对比分析

使用 ReduxThunkReact Query (TanStack) 结合使用,乍一看似乎有些多余,但每个工具都有自己独特的优点,具体如下:

使用 Thunk 的 Redux:

  • 管理状态: Redux 提供了一个集中的状态存储来管理你的应用,允许你在组件之间共享和管理状态。
  • 自定义逻辑和控制: 使用 Thunk,你可以在 Redux 动作中添加自定义逻辑来处理副作用(如异步 API 调用),从而控制更新的时间和方式。
  • 适用于复杂的 UI 状态: Redux 适合管理复杂的 UI 状态,这些状态不仅依赖于远程数据——比如 UI 开关、表单数据和认证状态。

    1. React Query (TanStack):
  • 优化的数据抓取: React Query 专门用于管理来自服务器的数据(这些数据可能与 UI 状态不同步,因此需要管理)。它可以自动处理数据的获取、缓存、同步和更新,从而节省大量样板代码的编写。
  • 缓存和后台刷新: React Query 自动缓存数据,并可以在后台刷新数据以确保数据保持最新,从而减少手动更新的需要。
  • 自动重试和数据管理: React Query 包括自动重试、错误处理等功能,并可根据自定义策略将数据标记为“失效”或“有效”。

什么时候该用哪一种:

  • Redux 更适合管理 前端状态 和与 UI 相关的状态。
  • React Query 更适合处理 API 数据,因为它简化了异步数据的生命周期。

实际上,ReduxReact 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,并通过 ReduxReact Query 处理 CRUD 操作。
  • 后端: 使用 Node.jsExpress 创建端点,从一个 .json 文件中获取、添加、更新和删除数据。

3. 设置项:

1. 使用 Express 来搭建后端

  1. 创建一个名为后端的新的目录 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
  1. 创建一个 db.json – 一个简单的 JSON 文件来存储这些条目 (server/db.json):
{
  "items": [
    { "id": 1, "title": "样品1" },
    { "id": 2, "title": "样品2" }
  ]
}
  1. 创建 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}`);
    });
  1. 启动后端服务器:
运行这个命令来启动服务器:node server.js    

2.: 使用 ViteTypeScriptReduxReact Query 搭建前端

  1. 开始 Vite 项目:
npm命令用于创建一个使用Vite构建的React项目,使用react-ts模板和react-redux-query-example作为项目名。
cd react-redux-query-example
  1. 安装所需的依赖项:
运行以下命令来安装这些库:npm install @reduxjs/toolkit react-redux redux-thunk axios @tanstack/react-query
  1. 项目结构如下:
    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. 前端实现

  1. api/apiClient.ts - API 客户端 - Axios 实例对象
这里我们导入了axios库,并创建了一个axios客户端。我们设置了基础URL为'http://localhost:3001',并将内容类型设置为'application/json'。最后,我们导出了默认的apiClient。
  1. 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));
    }
  });
};
  1. 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;
  1. 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;
  1. 样式 (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);
          }
        }
  1. main.tsx - 用于设置提供者(Provider):ReduxReact 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 专注于从服务器获取并缓存数据,使前端与后端保持同步。

这种设置提供了一个功能完备的应用程序,ReduxReact Query 有效地管理客户端和服务器端的状态,使它们相辅相成。

如果你觉得这有帮助,点个赞或者留个言吧!如果你觉得这篇帖子能帮助到其他人,也别忘了分享哦!非常感谢大家的支持!😃

0人推荐
随时随地看视频
慕课网APP