React Gantt - TanStack Query 教程
本教程将引导你使用 Vite 构建一个带有 TypeScript 的 React 应用,集成 DHTMLX React Gantt 组件,并使用 TanStack Query 管理服务器端状态。一个小型的 Zustand 存储处理本地 UI 状态 —— 撤销/重做历史与缩放配置。
本教程的重点是客户端集成:TanStack Query 如何获取数据、mutations 如何绑定到 Gantt 的 data.save 回调,以及查询缓存如何作为 Gantt 数据的唯一来源。在演示中,后端设计故意保持精简 —— 使用一个本地 JSON 文件作为存储,而不是真实数据库。这足以演示一个可工作的 REST API,而不需要引入无关的基础设施。在生产应用中,你可以将其替换 为你选择的任意持久存储解决方案。
前提条件
- 具备 React、TypeScript、Vite 和 TanStack Query 的基础知识
- 建议:阅读 integr.../state-management-basics.md 以了解数据绑定模式以及本教程所依赖的
data.save回调。
快速设置 - 创建项目
在开始之前,请安装 Node.js。
创建一个 Vite React + TypeScript 项目:
npm create vite@latest react-gantt-tanstack-query-demo -- --template react-ts
cd react-gantt-tanstack-query-demo
现在让我们安装所需的依赖。
- 对于 npm:
npm install @tanstack/react-query zustand @mui/material @mui/icons-material @emotion/react @emotion/styled express cors
- 对于 yarn:
yarn add @tanstack/react-query zustand @mui/material @mui/icons-material @emotion/react @emotion/styled express cors
我们还需要一些开发依赖来带 TypeScript 运行 Express 后端服务器:
- 对于 npm:
npm install -D tsx nodemon @types/express @types/node
- 对于 yarn:
yarn add -D tsx nodemon @types/express @types/node
接着我们需要安装 React Gantt 包。
安装 React Gantt
按 React Gantt 安装指南 的说明安装 React Gantt。
在本教程中我们使用评估包:
npm install @dhtmlx/trial-react-gantt
或
yarn add @dhtmlx/trial-react-gantt
如果你已经使用 Professional 包,请在命令和导入中将 @dhtmlx/trial-react-gantt 替换为 @dhx/react-gantt。
在 package.json 中添加以下脚本,以便你可以分别启动后端和前端:
"scripts": {
"dev": "vite",
"start:server": "nodemon --exec tsx src/server.ts"
}
为了让 Gantt 占满整个文档主体空间,你需要移除 src 文件夹中的 App.css 和 index.css 的默认样式,并在 index.css 中添加以下样式:
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
#root {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
设置示例数据与配置
创建 src/seed/Seed.ts,包含 Gantt 的缩放配置:
import type { GanttConfig } from '@dhtmlx/trial-react-gantt';
export type ZoomLevel = 'day' | 'month' | 'year';
export const defaultZoomLevels: NonNullable<GanttConfig['zoom']> = {
current: 'day',
levels: [
{ name: 'day', scale_height: 27, min_column_width: 80, scales: [{ unit: 'day', step: 1, format: '%d %M' }] },
{
name: 'month',
scale_height: 50,
min_column_width: 120,
scales: [
{ unit: 'month', format: '%F, %Y' },
{ unit: 'week', format: 'Week #%W' },
],
},
{ name: 'year', scale_height: 50, min_column_width: 30, scales: [{ unit: 'year', step: 1, format: '%Y' }] },
],
};
也创建 src/seed/data.json,其中包含后端将提供的初始任务和链接数据:
{
"tasks": [
{
"id": 1,
"text": "Office itinerancy",
"type": "project",
"start_date": "2025-04-02T00:00:00.000Z",
"duration": 17,
"progress": 0.4,
"parent": 0,
"open": true
},
{
"id": 2,
"text": "Office facing",
"type": "project",
"start_date": "2025-04-02T00:00:00.000Z",
"duration": 8,
"progress": 0.6,
"parent": 1,
"open": true
},
{
"id": 3,
"text": "Furniture installation",
"type": "project",
"start_date": "2025-04-11T00:00:00.000Z",
"duration": 8,
"progress": 0.6,
"parent": 1,
"open": true
},
{
"id": 4,
"text": "The employee relocation",
"type": "project",
"start_date": "2025-04-13T00:00:00.000Z",
"duration": 5,
"progress": 0.5,
"parent": 1,
"open": true
},
{
"id": 5,
"text": "Interior office",
"type": "task",
"start_date": "2025-04-03T00:00:00.000Z",
"duration": 7,
"progress": 0.6,
"parent": 2
},
{
"id": 6,
"text": "Air conditioners check",
"type": "task",
"start_date": "2025-04-03T00:00:00.000Z",
"duration": 7,
"progress": 0.6,
"parent": 2
},
{
"id": 7,
"text": "Workplaces preparation",
"type": "task",
"start_date": "2025-04-12T00:00:00.000Z",
"duration": 8,
"progress": 0.6,
"parent": 3
},
{
"id": 8,
"text": "Preparing workplaces",
"type": "task",
"start_date": "2025-04-14T00:00:00.000Z",
"duration": 5,
"progress": 0.5,
"parent": 4
},
{
"id": 9,
"text": "Workplaces importation",
"type": "task",
"start_date": "2025-04-21T00:00:00.000Z",
"duration": 4,
"progress": 0.5,
"parent": 4
},
{
"id": 10,
"text": "Workplaces exportation",
"type": "task",
"start_date": "2025-04-27T00:00:00.000Z",
"duration": 3,
"progress": 0.5,
"parent": 4
}
],
"links": [
{ "id": 2, "source": 2, "target": 3, "type": "0" },
{ "id": 3, "source": 3, "target": 4, "type": "0" },
{ "id": 7, "source": 8, "target": 9, "type": "0" }
]
}
构建后端服务器
下面的服务器示例仅用于演示便利性,并非生产级推荐。它把所有数据存储在一个 JSON 文件中,使你可以在不设置数据库的情况下完成整套教程。当你构建生产应用时,请将其替换为任何真实的持久化层——PostgreSQL、MongoDB、云端 API 等。客户端的 TanStack Query 集成无论后端使用何种后端都保持一致。
创建 src/server.ts。这个轻量级的 Express 服务器读取并写入一个 JSON 文件,以模拟真正的 REST API:
import express from 'express';
import cors from 'cors';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import os from 'os';
const app = express();
app.use(express.json());
app.use(cors());
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const SEED_PATH = join(__dirname, 'seed', 'data.json');
const DB_PATH = join(os.tmpdir(), 'gantt-tanstack-demo-db.json');
const PORT = 3001;
// 启动时将种子数据复制到运行时位置,以使种子数据保持原样
if (!fs.existsSync(DB_PATH)) {
fs.copyFileSync(SEED_PATH, DB_PATH);
}
interface Task {
id: string | number;
[key: string]: unknown;
}
interface Link {
id: string | number;
[key: string]: unknown;
}
interface DB {
tasks: Task[];
links: Link[];
}
const readDB = (): DB => JSON.parse(fs.readFileSync(DB_PATH, 'utf-8'));
const writeDB = (data: DB) => fs.writeFileSync(DB_PATH, JSON.stringify(data, null, 2));
app.get('/data', (_req, res) => {
res.json(readDB());
});
app.post('/tasks', (req, res) => {
const db = readDB();
const task = req.body as Task;
const newTask = { ...task, id: `DB_ID:${task.id}` };
db.tasks.push(newTask);
writeDB(db);
res.json(newTask);
});
app.put('/tasks/:id', (req, res) => {
const db = readDB();
db.tasks = db.tasks.map((t) => (String(t.id) === req.params.id ? { ...t, ...req.body } : t));
writeDB(db);
res.sendStatus(200);
});
app.delete('/tasks/:id', (req, res) => {
const db = readDB();
db.tasks = db.tasks.filter((t) => String(t.id) !== req.params.id);
writeDB(db);
res.sendStatus(200);
});
app.post('/links', (req, res) => {
const db = readDB();
const link = req.body as Link;
const newLink = { ...link, id: `DB_ID:${link.id}` };
db.links.push(newLink);
writeDB(db);
res.json(newLink);
});
app.put('/links/:id', (req, res) => {
const db = readDB();
db.links = db.links.map((l) => (String(l.id) === req.params.id ? { ...l, ...req.body } : l));
writeDB(db);
res.sendStatus(200);
});
app.delete('/links/:id', (req, res) => {
const db = readDB();
db.links = db.links.filter((l) => String(l.id) !== req.params.id);
writeDB(db);
res.sendStatus(200);
});
app.listen(PORT, () => console.log(`Server running on ${PORT}`));
该服务器暴露了以下端点:
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /data | 返回所有任务和链接 |
| POST | /tasks | 创建任务,分配一个稳定的 DB id |
| PUT | /tasks/:id | 更新任务 |
| DELETE | /tasks/:id | 删除任务 |
| POST | /links | 创建链接,分配一个稳定的 DB id |
| PUT | /links/:id | 更新链接 |
| DELETE | /links/:id | 删除链接 |
当创建一个任务或链接时,服务器会将客户端生成的 id 前缀为 DB_ID: 并返回新记录。Gantt 组件使用返回的 id 来更新其内部引用。
创建 API 层
创建 src/api.ts,其中包含 TanStack Query 将调用的纯 fetch 基函数:
import { type Link, type SerializedTask } from '@dhtmlx/trial-react-gantt';
const BASE = window.location.origin;
async function request(url: string, options?: RequestInit) {
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(`${options?.method ?? 'GET'} ${url} failed: ${res.status}`);
}
return res;
}
export const fetchData = async () => {
const res = await request(`${BASE}/data`);
return await res.json();
};
export const createTask = async (task: SerializedTask) => {
const res = await request(`${BASE}/tasks`, {
method: 'POST',
body: JSON.stringify(task),
headers: { 'Content-Type': 'application/json' },
});
return await res.json();
};
export const updateTask = async (task: SerializedTask) => {
await request(`${BASE}/tasks/${task.id}`, {
method: 'PUT',
body: JSON.stringify(task),
headers: { 'Content-Type': 'application/json' },
});
};
export const deleteTask = async (id: string | number) => {
await request(`${BASE}/tasks/${id}`, { method: 'DELETE' });
};
export const createLink = async (link: Link) => {
const res = await request(`${BASE}/links`, {
method: 'POST',
body: JSON.stringify(link),
headers: { 'Content-Type': 'application/json' },
});
return await res.json();
};
export const updateLink = async (link: Link) => {
await request(`${BASE}/links/${link.id}`, {
method: 'PUT',
body: JSON.stringify(link),
headers: { 'Content-Type': 'application/json' },
});
};
export const deleteLink = async (id: string | number) => {
await request(`${BASE}/links/${id}`, { method: 'DELETE' });
};
每个函数在遇到非 2xx 响应时都会抛出错误,这样 TanStack Query 就可以捕获错误并触发其 onError 处理程序。
构建控制工具栏组件
在 src/components/Toolbar.tsx 中新增一个 Toolbar 组件:
import Divider from '@mui/material/Divider';
import ButtonGroup from '@mui/material/ButtonGroup';
import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
import Button from '@mui/material/Button';
import type { ZoomLevel } from '../seed/Seed';
export interface ToolbarProps {
onUndo?: () => void;
onRedo?: () => void;
canUndo?: boolean;
canRedo?: boolean;
onZoom?: (level: ZoomLevel) => void;
currentZoom?: ZoomLevel;
}
export default function Toolbar({
onUndo,
onRedo,
canUndo = false,
canRedo = false,
onZoom,
currentZoom = 'month',
}: ToolbarProps) {
return (
<div style={{ display: 'flex', justifyContent: 'start', padding: '0px 0px 20px', gap: '10px' }}>
<ButtonGroup>
<Button onClick={() => onUndo?.()} disabled={!canUndo}>
<UndoIcon />
</Button>
<Button onClick={() => onRedo?.()} disabled={!canRedo}>
<RedoIcon />
</Button>
</ButtonGroup>
<Divider orientation="vertical"></Divider>
<ButtonGroup>
<Button onClick={() => onZoom?.('day')} variant={currentZoom === 'day' ? 'contained' : 'outlined'}>
Day
</Button>
<Button onClick={() => onZoom?.('month')} variant={currentZoom === 'month' ? 'contained' : 'outlined'}>
Month
</Button>
<Button onClick={() => onZoom?.('year')} variant={currentZoom === 'year' ? 'contained' : 'outlined'}>
Year
</Button>
</ButtonGroup>
</div>
);
}
该工具栏接受以下属性:
canUndo/canRedo- 基于历史记录栈长度的布尔标志,用于启用或禁用撤销/重做按钮。onUndo/onRedo- 在父组件中触发撤销/重做逻辑的回调。onZoom- 用户点击缩放按钮时更新缩放级别的回调。currentZoom- 表示活动的缩放级别,以便正确的按钮显示为contained。
在 main.tsx 中设置 TanStack Query
用 QueryClientProvider 将应用包裹起来,使每个组件都可以访问 TanStack Query 客户端:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const client = new QueryClient();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={client}>
<App />
</QueryClientProvider>
</StrictMode>,
);
QueryClient 在渲染树之外只创建一次,以防止在每次渲染时重新创建。
创建主 Gantt 组件
创建 src/components/GanttComponent.tsx。在这里,TanStack Query 负责驱动所有数据操作。
导入与初始设置
import { useMemo, useRef, useCallback } from 'react';
import ReactGantt, {
type ReactGanttProps,
type Link,
type ReactGanttRef,
type SerializedTask,
} from '@dhtmlx/trial-react-gantt';
import '@dhtmlx/trial-react-gantt/dist/react-gantt.css';
import Toolbar from './Toolbar';
import { fetchData, createTask, updateTask, deleteTask, createLink, updateLink, deleteLink } from '../api';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { type Snapshot, useGanttStore } from '../store';
import { type ZoomLevel } from '../seed/Seed';
使用 useQuery 获取数据
export default function DemoTanstackQuery() {
const ganttRef = useRef<ReactGanttRef>(null);
const queryClient = useQueryClient();
const {
data: fetchedData,
isLoading,
isError,
error,
} = useQuery<{ tasks: SerializedTask[]; links: Link[] }>({ queryKey: ['data'], queryFn: fetchData });
const { tasks, links } = fetchedData || { tasks: [], links: [] };
useQuery 会在组件挂载时从服务器获取所有任务和链接。结果会在 TanStack Query 的缓存中以 ['data'] 为键进行存储。
isLoading—— 初始获取时为真isError/error—— 获取失败时会填充- 使用空数组作为回退 (
fetchedData || { tasks: [], links: [] }) 确保 Gantt 在第一次响应到来之前也能接收有效的 props
读取 Zustand 状态
const { undo, redo, setZoom, config, recordHistory, past, future } = useGanttStore();
只有 UI 相关的状态来自 Zustand —— 缩放配置和撤销/重做历史栈。任务和链接则存放在 TanStack Query 缓存中,而不是 Zustand。