1. 项目概述与核心价值最近在整理自己的个人项目时翻出了一个几年前用 TypeScript 写的全栈 TODO 应用。这个项目麻雀虽小五脏俱全从后端 API 到前端界面再到数据库操作完整地走了一遍现代 Web 应用开发的流程。我给它起了个内部代号叫 “kissy24/use-cursor”听起来有点神秘其实就是个练手项目。今天把它翻出来一方面是做个技术复盘另一方面也是给刚接触全栈开发特别是想用 TypeScript 统一前后端语言的朋友们一个可以直接参考的“脚手架”。这个项目采用了 Express TypeORM SQLite 作为后端技术栈前端则是经典的 React 配合 Material-UI 组件库。整个项目结构清晰没有引入过于复杂的状态管理或构建工具非常适合作为理解全栈开发基础概念和 TypeScript 工程化实践的入门案例。这个项目解决了什么问题呢首先它展示了一个前后端都用 TypeScript 开发的最小可行产品MVP是如何组织的。很多教程要么只讲前端要么只讲后端真正把两者串联起来并且共享类型定义、统一开发体验的完整例子并不多。其次它实践了使用 TypeORM 这类 ORM对象关系映射工具来操作数据库这对于不想写原生 SQL又想保证类型安全的后端开发来说是个很舒服的选择。最后整个项目的搭建和运行非常简单依赖少配置清晰你完全可以把它当作一个模板在此基础上快速开发自己的小应用。无论你是想学习全栈开发还是需要一个简单的个人任务管理工具这个项目都能提供直接的参考价值。2. 技术栈选型与架构设计思路2.1 为什么选择这些技术当初构思这个项目时我的核心目标是用 TypeScript 实现端到端的类型安全并且让开发体验尽可能简单、一致。基于这个目标我逐一选择了以下技术栈并在这里分享一下背后的考量。后端Node.js Express TypeORM SQLiteNode.js Express这是 Node.js 生态里最经典、最轻量的 Web 框架组合。Express 的路由和中间件机制足够清晰学习成本低能让我把精力集中在业务逻辑和 TypeScript 集成上而不是框架本身的各种概念上。对于一个小型 TODO 应用来说它的性能完全过剩。TypeORM这是选型中的关键一环。传统的 Node.js 后端操作数据库要么用mysql或pg这样的驱动直接写 SQL要么用 Sequelize 这类 ORM。TypeORM 的最大优势在于它对 TypeScript 的原生支持。我可以直接定义一个 TypeScript 的Todo实体类TypeORM 不仅能根据这个类自动生成数据库表结构还能在代码的每一处查询、插入、更新都提供完整的类型提示和检查。这极大地减少了因字段名拼写错误或类型不匹配导致的运行时错误。SQLite选择 SQLite 纯粹是为了“简单”。它不需要安装和配置独立的数据库服务数据就存储在一个本地.sqlite文件中。这对于开发、测试以及像 TODO 这样的轻量级应用部署来说简直是零负担。TypeORM 可以无缝对接 SQLite让本地开发环境搭建变得异常快捷。前端React Material-UI TypeScriptReact作为最主流的前端视图库其组件化思想与 TypeScript 的接口Interface定义能完美结合。组件的 Props 和 State 都可以用 TypeScript 严格定义这在多人协作或项目维护时能清晰地约束数据流避免传递错误的数据类型。Material-UI (MUI)我不想在 UI 样式上花费太多时间但又希望应用看起来足够现代、专业。MUI 提供了一整套遵循 Material Design 设计语言的、高质量的 React 组件。我只需要像搭积木一样组合这些组件就能快速得到一个美观且响应式的界面把精力留给业务逻辑与前后端联调。TypeScript (前后端共享)这是整个项目的“灵魂”。我在后端定义了一个Todo实体接口这个接口可以通过一些工程化手段直接共享给前端。这意味着前端在调用 API、处理返回的 TODO 数据时使用的类型定义和后端数据库模型、API 响应格式是完全一致的。从根本上杜绝了前后端对数据理解不一致的“扯皮”问题。2.2 项目整体架构解析整个项目采用了经典的前后端分离架构但通过 Monorepo 的形式组织在一起便于统一管理。kissy24-use-cursor/ ├── client/ # 前端 React 应用 │ ├── public/ │ ├── src/ │ │ ├── components/ # React 组件 (TodoList, TodoItem等) │ │ ├── types/ # TypeScript 类型定义 (可共享后端) │ │ ├── App.tsx │ │ └── index.tsx │ ├── package.json │ └── tsconfig.json ├── server/ # 后端 Express 应用 │ ├── src/ │ │ ├── entity/ # TypeORM 实体 (Todo.ts) │ │ ├── routes/ # Express 路由 (todos.ts) │ │ └── index.ts # 应用入口 │ ├── package.json │ └── tsconfig.json ├── package.json (根目录) # 统一脚本命令如同时启动前后端 └── todo.sqlite # SQLite 数据库文件 (运行时生成)数据流设计用户在浏览器中操作前端界面添加、完成、删除任务。前端 React 组件发起对后端 Express API 的 HTTP 请求Fetch 或 Axios。Express 接收到请求由对应的路由处理器处理。路由处理器调用 TypeORM 的 Repository对todo.sqlite数据库进行增删改查操作。TypeORM 将数据库操作结果已转换为Todo实体对象返回给路由处理器。路由处理器将对象以 JSON 格式响应给前端。前端接收到响应更新 React 组件的状态并重新渲染 UI反馈给用户。这个流程中从数据库实体到 API 响应再到前端组件状态Todo的类型定义贯穿始终保证了整个数据流的安全与可靠。注意在实际项目中前后端共享类型需要一些配置。一种简单的方式是将后端的实体接口或类型定义文件单独放到一个目录如shared/然后通过npm link或直接引用相对路径的方式让前后端项目都依赖它。更工程化的做法是使用 Monorepo 工具如 Lerna, Nx或构建工具将共享代码编译成 npm 包。在本示例项目中为了极简我可能会选择在前后端分别定义相同的接口但这在大型项目中是不可取的务必建立可靠的共享机制。3. 后端核心实现详解3.1 数据库实体Entity定义这是 TypeORM 的核心也是我们整个应用的“数据模型”。在server/src/entity/Todo.ts中我们定义Todo实体。import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from typeorm; Entity() // 装饰器表明这是一个 TypeORM 实体对应数据库中的一张表 export class Todo { PrimaryGeneratedColumn() // 装饰器定义自增主键 ID id: number; Column({ type: text }) // 装饰器定义文本类型的列 title: string; Column({ default: false }) // 装饰器定义布尔列默认值为 false completed: boolean; CreateDateColumn() // 装饰器自动记录创建时间 createdAt: Date; UpdateDateColumn() // 装饰器自动记录更新时间 updatedAt: Date; }代码解读与注意事项装饰器DecoratorsTypeORM 大量使用了 TypeScript 装饰器来定义元数据。Entity()、Column()这些就是告诉 TypeORM“请按照我的描述来创建和管理数据库表”。PrimaryGeneratedColumn()这通常生成一个名为id的整数主键并且是自增的。这是每条记录的唯一标识。Column({ type: text })明确指定数据库字段类型为text。虽然 TypeORM 通常能从 TypeScript 的string类型推断但显式声明更稳妥特别是涉及字符串长度时如varchar(255)。Column({ default: false })为completed字段设置默认值false。这意味着当创建一条新的 TODO 时如果你不提供completed值数据库会自动将其设为false未完成。CreateDateColumn与UpdateDateColumn这两个是 TypeORM 提供的特殊列。它们会自动在插入时设置当前时间并在更新时仅UpdateDateColumn刷新时间。强烈建议为重要数据模型加上这两个时间戳对于问题排查和数据审计非常有用。实操心得在定义实体时务必和你的业务需求对齐。比如如果 TODO 需要优先级可以加一个priority: number字段如果需要分类可以加category: string或者建立另一个Category实体并配置关联关系ManyToOne。TypeORM 支持一对一、一对多、多对多等丰富关系这是它比直接写 SQL 方便的地方。3.2 Express 路由与控制器路由定义了 API 的端点Endpoint和如何处理到达这些端点的请求。我们在server/src/routes/todos.ts中实现。import { Router, Request, Response } from express; import { AppDataSource } from ../data-source; // 数据库连接源 import { Todo } from ../entity/Todo; const router Router(); const todoRepository AppDataSource.getRepository(Todo); // 获取 Todo 实体的操作仓库 // 1. 获取所有 TODO router.get(/, async (req: Request, res: Response) { try { const todos await todoRepository.find({ order: { createdAt: DESC } }); // 按创建时间倒序 res.json(todos); } catch (error) { console.error(获取 TODOs 失败:, error); res.status(500).json({ message: 服务器内部错误 }); } }); // 2. 创建新的 TODO router.post(/, async (req: Request, res: Response) { try { const { title } req.body; if (!title || title.trim() ) { return res.status(400).json({ message: 标题不能为空 }); } const newTodo todoRepository.create({ title }); // 使用仓库创建实体实例 const savedTodo await todoRepository.save(newTodo); // 保存到数据库 res.status(201).json(savedTodo); // 返回创建成功的对象包含生成的 id } catch (error) { console.error(创建 TODO 失败:, error); res.status(500).json({ message: 服务器内部错误 }); } }); // 3. 更新 TODO (标记完成/未完成或修改标题) router.put(/:id, async (req: Request, res: Response) { try { const id parseInt(req.params.id); const { title, completed } req.body; // 先查找是否存在 let todoToUpdate await todoRepository.findOneBy({ id }); if (!todoToUpdate) { return res.status(404).json({ message: 未找到该 TODO }); } // 更新字段这里做简单合并实际应根据业务需求更精细地控制 if (title ! undefined) todoToUpdate.title title; if (completed ! undefined) todoToUpdate.completed completed; const updatedTodo await todoRepository.save(todoToUpdate); // 保存更新 res.json(updatedTodo); } catch (error) { console.error(更新 TODO 失败:, error); res.status(500).json({ message: 服务器内部错误 }); } }); // 4. 删除 TODO router.delete(/:id, async (req: Request, res: Response) { try { const id parseInt(req.params.id); const deleteResult await todoRepository.delete(id); if (deleteResult.affected 0) { return res.status(404).json({ message: 未找到该 TODO }); } res.status(204).send(); // 成功删除无返回内容 } catch (error) { console.error(删除 TODO 失败:, error); res.status(500).json({ message: 服务器内部错误 }); } }); export default router;关键点解析错误处理每个路由都使用了try...catch包裹。这是生产环境的基本要求防止未处理的 Promise 拒绝导致服务器崩溃。我们向客户端返回了适当的 HTTP 状态码如 400 请求错误404 未找到500 服务器错误和 JSON 格式的错误信息。输入验证在POST /路由中我们检查了title是否为空。这是一个最基本的验证。对于更复杂的应用应该使用像class-validator这样的库配合 TypeORM 的实体装饰器进行声明式验证。TypeORM Repository 模式todoRepository提供了对Todo实体进行增删改查的各种方法find,findOneBy,create,save,delete。它的操作返回的都是Todo实体或实体数组类型安全。HTTP 状态码正确使用状态码是 RESTful API 设计的好习惯。201 Created用于创建成功204 No Content用于删除成功404 Not Found用于资源不存在。3.3 应用入口与数据库连接这是后端的起点server/src/index.ts。import reflect-metadata; // TypeORM 的依赖必须首先导入 import express from express; import cors from cors; // 处理跨域请求 import { AppDataSource } from ./data-source; import todosRouter from ./routes/todos; const app express(); const PORT process.env.PORT || 3000; // 环境变量中获取端口默认 3000 // 中间件配置 app.use(cors()); // 允许前端跨域访问 app.use(express.json()); // 解析请求体中的 JSON 数据 // 数据库初始化 AppDataSource.initialize() .then(() { console.log(数据库连接成功); // 路由挂载 app.use(/api/todos, todosRouter); // 启动服务器 app.listen(PORT, () { console.log(后端服务器运行在 http://localhost:${PORT}); }); }) .catch((error) { console.error(数据库连接失败:, error); });而>import { DataSource } from typeorm; import { Todo } from ./entity/Todo; export const AppDataSource new DataSource({ type: sqlite, // 数据库类型 database: todo.sqlite, // 数据库文件路径 synchronize: true, // 开发环境自动同步实体到数据库表结构生产环境务必关闭 logging: true, // 开启 SQL 日志方便调试 entities: [Todo], // 注册实体 // 如果有多个实体就写成 [Todo, User, ...] });重要警告synchronize: true这个配置在开发时非常方便它会根据你的实体类定义自动创建或修改数据库表结构。但是在生产环境中必须将其设置为false。因为自动同步可能导致数据丢失例如它可能会删除列或表。生产环境应该使用 TypeORM 的迁移Migration功能来管理数据库结构变更。CORS 中间件因为前端运行在localhost:3000后端运行在localhost:3000或其它端口浏览器出于安全考虑会阻止这种跨域请求。app.use(cors())简单启用了所有跨域请求对于开发没问题。在生产环境你应该配置具体的来源origin例如app.use(cors({ origin: https://yourfrontend.com }))。4. 前端核心实现详解4.1 类型定义与 API 服务层为了实现类型安全我们首先定义与后端匹配的Todo类型。可以放在client/src/types/todo.ts。// 这与后端的 Todo 实体结构基本一致 export interface Todo { id: number; title: string; completed: boolean; createdAt: string; // 注意JSON 序列化后 Date 会变成 string updatedAt: string; } // 创建新的 Todo 时需要的参数通常不需要 id 和 timestamps export type CreateTodoDto PickTodo, title; // 只取 title 属性 // 或者更明确地 // export interface CreateTodoDto { title: string; } // 更新 Todo 时需要的参数 export type UpdateTodoDto PartialPickTodo, title | completed; // title 和 completed 都是可选的接下来我们创建一个 API 服务模块来封装所有与后端通信的逻辑client/src/services/todoApi.ts。import { Todo, CreateTodoDto, UpdateTodoDto } from ../types/todo; const API_BASE_URL http://localhost:3000/api/todos; // 后端 API 地址 export const todoApi { // 获取所有 Todo async getAll(): PromiseTodo[] { const response await fetch(API_BASE_URL); if (!response.ok) { throw new Error(获取列表失败: ${response.statusText}); } return await response.json(); }, // 创建 Todo async create(todoData: CreateTodoDto): PromiseTodo { const response await fetch(API_BASE_URL, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(todoData), }); if (!response.ok) { throw new Error(创建失败: ${response.statusText}); } return await response.json(); }, // 更新 Todo async update(id: number, updateData: UpdateTodoDto): PromiseTodo { const response await fetch(${API_BASE_URL}/${id}, { method: PUT, headers: { Content-Type: application/json }, body: JSON.stringify(updateData), }); if (!response.ok) { throw new Error(更新失败: ${response.statusText}); } return await response.json(); }, // 删除 Todo async delete(id: number): Promisevoid { const response await fetch(${API_BASE_URL}/${id}, { method: DELETE, }); if (!response.ok) { throw new Error(删除失败: ${response.statusText}); } // 204 No Content 没有返回体 }, };设计思路职责分离将数据获取逻辑从 UI 组件中抽离出来使组件更专注于渲染和用户交互。这使得代码更清晰也便于未来替换底层 HTTP 库比如从fetch换成axios。类型安全每个方法都明确了参数和返回值的 TypeScript 类型。当你调用todoApi.create({ title: 学习 TypeScript })时如果传参不符合CreateTodoDto类型编译器会立即报错。错误处理这里使用throw new Error将 HTTP 错误转换为 JavaScript 异常由调用方通常是组件通过try...catch或.catch()来处理。这是一种常见的模式。4.2 主组件与状态管理在client/src/App.tsx中我们构建主要的应用组件。对于这个小应用我们使用 React 的useState和useEffectHooks 来管理状态和副作用就足够了无需引入 Redux 或 Context 等复杂状态管理库。import React, { useState, useEffect } from react; import { Container, Typography, TextField, Button, List, Paper, CircularProgress, Alert, Box, } from mui/material; import { Todo } from ./types/todo; import { todoApi } from ./services/todoApi; import TodoItem from ./components/TodoItem; // 假设有一个子组件 function App() { // 状态定义 const [todos, setTodos] useStateTodo[]([]); const [newTodoTitle, setNewTodoTitle] useState(); const [loading, setLoading] useState(false); const [error, setError] useStatestring | null(null); // 初始化组件挂载时获取 Todo 列表 useEffect(() { fetchTodos(); }, []); const fetchTodos async () { setLoading(true); setError(null); try { const data await todoApi.getAll(); setTodos(data); } catch (err) { setError(err instanceof Error ? err.message : 获取数据失败); console.error(err); } finally { setLoading(false); } }; // 处理添加新的 Todo const handleAddTodo async (e: React.FormEvent) { e.preventDefault(); if (!newTodoTitle.trim()) return; setError(null); try { const createdTodo await todoApi.create({ title: newTodoTitle }); // 乐观更新直接在前端列表中添加避免重新请求整个列表 setTodos([createdTodo, ...todos]); setNewTodoTitle(); // 清空输入框 } catch (err) { setError(err instanceof Error ? err.message : 添加失败); console.error(err); } }; // 处理切换完成状态 const handleToggleComplete async (id: number, completed: boolean) { try { const updatedTodo await todoApi.update(id, { completed: !completed }); // 更新本地状态 setTodos(todos.map(todo (todo.id id ? updatedTodo : todo))); } catch (err) { setError(err instanceof Error ? err.message : 更新状态失败); console.error(err); } }; // 处理删除 const handleDelete async (id: number) { if (!window.confirm(确定要删除这个任务吗)) return; try { await todoApi.delete(id); // 更新本地状态 setTodos(todos.filter(todo todo.id ! id)); } catch (err) { setError(err instanceof Error ? err.message : 删除失败); console.error(err); } }; return ( Container maxWidthsm sx{{ mt: 4 }} Typography varianth4 componenth1 gutterBottom aligncenter TypeScript TODO 应用 /Typography Paper elevation{3} sx{{ p: 3, mb: 3 }} form onSubmit{handleAddTodo} Box displayflex gap{1} TextField fullWidth variantoutlined label添加新任务... value{newTodoTitle} onChange{(e) setNewTodoTitle(e.target.value)} sizesmall / Button typesubmit variantcontained disabled{!newTodoTitle.trim()} 添加 /Button /Box /form /Paper {error Alert severityerror sx{{ mb: 2 }}{error}/Alert} Paper elevation{2} {loading ? ( Box displayflex justifyContentcenter p{3} CircularProgress / /Box ) : ( List {todos.map((todo) ( TodoItem key{todo.id} todo{todo} onToggleComplete{handleToggleComplete} onDelete{handleDelete} / ))} {todos.length 0 ( Typography aligncenter colortext.secondary sx{{ py: 4 }} 暂无任务添加一个吧 /Typography )} /List )} /Paper /Container ); } export default App;状态管理策略单一数据源所有 TODO 数据都存储在todos状态中这保证了 UI 是状态的一个函数渲染结果可预测。乐观更新Optimistic Update在handleAddTodo中我们假设 API 调用会成功在等待响应时就直接更新了本地状态 (setTodos([createdTodo, ...todos]))。这能带来更快的用户体验。如果 API 调用失败我们需要回滚状态并显示错误。本例中为了简化采用了等待 API 成功后再更新的“保守”策略。对于“切换完成状态”这种操作乐观更新体验提升更明显。副作用管理useEffect用于在组件加载时获取初始数据。注意它的依赖数组是空[]表示只运行一次。4.3 TodoItem 子组件与 Material-UI 应用client/src/components/TodoItem.tsx是一个展示单个 TODO 项的展示组件。import React from react; import { ListItem, ListItemIcon, ListItemText, Checkbox, IconButton, ListItemSecondaryAction, } from mui/material; import { Delete as DeleteIcon, Edit as EditIcon } from mui/icons-material; import { Todo } from ../types/todo; interface TodoItemProps { todo: Todo; onToggleComplete: (id: number, completed: boolean) void; onDelete: (id: number) void; } const TodoItem: React.FCTodoItemProps ({ todo, onToggleComplete, onDelete }) { return ( ListItem dense button // 使整个列表项可点击 onClick{() onToggleComplete(todo.id, todo.completed)} sx{{ textDecoration: todo.completed ? line-through : none, color: todo.completed ? text.disabled : text.primary, bgcolor: todo.completed ? action.selected : background.paper, }} ListItemIcon Checkbox edgestart checked{todo.completed} tabIndex{-1} // 避免双击选中文本 disableRipple // 阻止事件冒泡避免与 ListItem 的 onClick 冲突 onClick{(e) e.stopPropagation()} onChange{() onToggleComplete(todo.id, todo.completed)} / /ListItemIcon ListItemText primary{todo.title} / ListItemSecondaryAction {/* 可以在这里添加编辑按钮 */} {/* IconButton edgeend aria-labeledit sizesmall sx{{ mr: 1 }} EditIcon / /IconButton */} IconButton edgeend aria-labeldelete sizesmall onClick{() onDelete(todo.id)} DeleteIcon / /IconButton /ListItemSecondaryAction /ListItem ); }; export default TodoItem;Material-UI 使用技巧sx属性这是 MUI v5 推荐的样式方式类似于内联样式但功能更强大支持主题访问。我们用它来根据todo.completed状态动态改变文本装饰、颜色和背景。事件处理注意Checkbox的onClick中调用了e.stopPropagation()。这是因为Checkbox被包裹在可点击的ListItem内部。如果不阻止事件冒泡点击复选框会触发两次onToggleComplete。ListItemSecondaryAction这是 MUI 提供的用于在列表项右侧放置操作按钮的容器排版更美观。5. 项目配置、启动与调试全流程5.1 从零开始的环境搭建假设你从零开始复现这个项目以下是详细步骤初始化项目根目录mkdir kissy24-use-cursor cd kissy24-use-cursor npm init -y # 创建 package.json创建后端项目结构mkdir server cd server npm init -y在server目录下安装依赖npm install express cors sqlite3 typeorm reflect-metadata npm install --save-dev typescript ts-node types/node types/express types/cors nodemon初始化 TypeScript 配置npx tsc --init编辑生成的tsconfig.json确保包含以下关键配置{ compilerOptions: { target: ES2020, module: commonjs, lib: [ES2020], outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, experimentalDecorators: true, // TypeORM 需要 emitDecoratorMetadata: true, // TypeORM 需要 skipLibCheck: true }, include: [src/**/*], exclude: [node_modules] }创建前端项目 回到项目根目录使用 Create React App 并指定 TypeScript 模板npx create-react-app client --template typescript cd client npm install mui/material emotion/react emotion/styled mui/icons-material配置根目录脚本 在项目根目录的package.json中添加脚本方便同时启动前后端{ scripts: { dev:server: cd server npm run dev, dev:client: cd client npm start, dev:full: concurrently \npm run dev:server\ \npm run dev:client\ }, devDependencies: { concurrently: ^8.0.0 } }然后运行npm install concurrently安装这个开发依赖。5.2 开发服务器启动与访问按照上述配置完成后启动完整开发环境推荐 在项目根目录下运行npm run dev:full这个命令会利用concurrently同时启动后端和前端开发服务器。分别启动终端1后端在项目根目录npm run dev:server。后端服务通常运行在http://localhost:3000取决于你的server/src/index.ts中的PORT设置。终端2前端在项目根目录npm run dev:client。Create React App 默认会启动在http://localhost:3000如果端口冲突它会提示你切换到另一个端口如http://localhost:3001。访问应用 打开浏览器访问前端开发服务器的地址通常是http://localhost:3000或http://localhost:3001。你应该能看到 Material-UI 风格的 TODO 界面。尝试添加、完成、删除任务所有操作都会通过 API 与后端 SQLite 数据库交互。5.3 数据库文件与迁移首次运行当你第一次启动后端服务器时TypeORM 会根据synchronize: true的设置自动在项目根目录或你配置的路径创建todo.sqlite文件以及todo表。你不需要手动运行任何 SQL 命令。查看数据你可以使用 SQLite 命令行工具或图形化工具如 DB Browser for SQLite打开todo.sqlite文件查看todo表中的数据验证操作是否成功。生产环境警告再次强调在将应用部署到生产环境前必须将>问题现象可能原因排查步骤与解决方案后端启动报错如Cannot find module reflect-metadata依赖未正确安装或 TypeScript 配置问题。1. 在server目录下运行npm install。2. 检查server/src/index.ts第一行是否导入了import reflect-metadata。3. 确认tsconfig.json中experimentalDecorators和emitDecoratorMetadata设为true。前端访问后端 API 时报 CORS 错误。浏览器跨域请求被阻止。1. 确认后端app.use(cors())中间件已正确配置。2. 检查前端API_BASE_URL的端口是否与后端服务端口一致。3. 如果是生产环境需配置具体的origin。前端能收到数据但界面不更新。React 状态更新可能未触发重新渲染或状态更新逻辑有误。1. 检查setTodos等状态更新函数是否被正确调用。2. 使用 React 开发者工具检查组件的 Props 和 State。3. 确认更新状态时使用了新的数组/对象遵循不可变原则例如setTodos([...todos, newTodo])。创建或更新 TODO 后数据库没变化。后端 API 逻辑错误或数据库操作失败但未抛出错误。1. 在后端控制台查看 TypeORM 的 SQL 日志logging: true。2. 在 API 路由中添加更详细的try-catch打印错误信息。3. 使用 SQLite 工具直接查看todo.sqlite文件。修改了后端实体字段但数据库表未更新。synchronize: true可能在某些复杂变更下不生效或缓存问题。1.开发时可以删除todo.sqlite文件重启服务让 TypeORM 重新建表注意会丢失所有数据。2. 学习并使用 TypeORM 迁移Migration来安全地变更表结构。前端编译 TypeScript 报类型错误。前后端类型定义不一致或 API 返回的数据格式与预期不符。1. 检查client/src/types/todo.ts是否与后端Todo实体匹配。2. 在后端 API 响应处确保返回的是纯粹的实体对象或明确的 DTO避免循环引用TypeORM 实体可能有循环引用需用Exclude()装饰器或查询时使用select选项。6.2 性能与优化小技巧数据库查询优化当 TODO 数量很多时todoRepository.find()会返回所有数据。可以考虑分页// 在后端路由中 const page parseInt(req.query.page as string) || 1; const limit parseInt(req.query.limit as string) || 20; const skip (page - 1) * limit; const todos await todoRepository.find({ order: { createdAt: DESC }, skip, take: limit }); const total await todoRepository.count(); res.json({ data: todos, total, page, limit });前端请求防抖如果“添加任务”的输入框有实时保存的需求可以使用防抖debounce来避免频繁发送 API 请求。环境变量管理将数据库连接字符串、API 端口、密钥等敏感信息放入.env文件使用dotenv库读取。永远不要将敏感信息硬编码在代码中或提交到版本库。6.3 项目扩展思路这个基础项目可以作为一个起点向多个方向扩展用户认证添加User实体使用 JWTJSON Web Tokens实现登录/注册并将 TODO 与用户关联ManyToOne。更丰富的功能为 TODO 添加优先级、截止日期、标签、分类、项目分组等功能。状态管理升级当应用复杂后可以考虑引入 Zustand、Redux Toolkit 或 React Context useReducer 来管理全局状态。API 文档使用 Swagger/OpenAPI 自动生成 API 文档。单元测试与集成测试为后端路由和服务添加 Jest 测试为前端组件添加 React Testing Library 测试。容器化部署编写Dockerfile和docker-compose.yml将应用容器化便于部署到云服务器。更换数据库TypeORM 支持多种数据库。只需修改>