1. 项目概述一个全栈TypeScript TODO应用最近在整理自己的个人项目库翻到了一个几年前用TypeScript写的TODO应用。这个项目虽然不大但麻雀虽小五脏俱全完整地走了一遍全栈开发的流程。它用Express和TypeORM搭后端React和Material-UI做前端数据库选了轻量级的SQLite。当时做这个的初衷很简单就是想在一个小项目里把TypeScript从前到后都用一遍看看在实际开发中会遇到哪些坑怎么解决。今天把它翻出来重新梳理一下把当时的设计思路、技术选型的考量以及开发过程中积累的一些实操经验分享出来。无论你是刚接触全栈开发想找个练手项目还是对TypeScript在全栈中的应用感兴趣希望这篇分享都能给你一些参考。2. 技术栈选型与架构设计思路2.1 为什么选择这个技术组合当时选型的时候核心诉求就两个一是学习成本可控二是技术栈要现代且主流。TypeScript是必选项因为它能显著提升代码的健壮性和可维护性尤其是在团队协作中。在这个前提下各个部分的技术选型就有了清晰的逻辑。后端Express TypeORM SQLiteExpress是Node.js生态里最老牌、最轻量的Web框架社区资源丰富中间件生态成熟。对于TODO这种CRUD为主的应用来说它完全够用不会引入不必要的复杂度。TypeORM是一个基于TypeScript的ORM它最大的好处是能让我们用TypeScript的类和装饰器来定义数据模型类型安全直接从代码层延伸到数据库层。而且它支持“代码先行”Code-First的开发模式我们可以先定义好TypeScript的Entity类然后通过迁移Migration来同步数据库结构这对快速迭代的原型项目非常友好。数据库选SQLite主要是图个方便。它不需要单独安装数据库服务数据就存在一个本地文件里部署和迁移都极其简单。对于个人项目、Demo或者开发测试环境来说SQLite是完美的选择。当然如果项目规模变大需要并发连接或者更复杂的查询换成PostgreSQL或MySQL也很容易TypeORM本身就支持多种数据库。前端React Material-UI (MUI)React的组件化思想与TypeScript简直是天作之合。用TypeScript为React组件定义Props和State的类型可以避免很多运行时错误IDE的智能提示也会变得无比强大。Material-UI现在叫MUI是一套实现了Google Material Design的React组件库。选择它意味着我们不需要从零开始设计按钮、输入框、卡片这些基础UI元素可以快速搭建出一个看起来专业、且风格统一的界面。它的主题定制能力也很强方便后期调整视觉风格。前后端通信RESTful API这是最经典、最通用的前后端分离架构。后端提供一组标准的REST API端点前端通过HTTP请求GET/POST/PUT/DELETE来消费这些接口。这种架构职责清晰前后端可以独立开发和部署。在这个项目里我们设计了五个标准的API端点对应TODO的增删改查操作。注意现在的新项目可能会考虑GraphQL或者tRPC这类更类型安全的方案。但对于入门全栈或者想快速验证想法来说RESTful API依然是理解起来最直观、生态最成熟的选择。先掌握好它再探索其他方案会更稳妥。2.2 项目目录结构解析一个清晰的项目结构是良好开发体验的开始。这个项目采用了常见的“Monorepo”风格将前后端代码放在同一个仓库里但分属不同的目录。typescript-todo-app/ ├── package.json # 根目录的package.json主要放脚本和公共依赖 ├── server/ # 后端代码 │ ├── src/ │ │ ├── entity/ # TypeORM实体数据模型 │ │ │ └── Todo.ts │ │ ├── controller/ # 控制器处理HTTP请求 │ │ │ └── todoController.ts │ │ ├── routes/ # 路由定义 │ │ │ └── todoRoutes.ts │ │ └── index.ts # 应用入口文件 │ ├── tsconfig.json # 后端TypeScript配置 │ └── package.json # 后端依赖 ├── client/ # 前端代码 │ ├── src/ │ │ ├── components/ # React组件 │ │ ├── services/ # API调用封装 │ │ ├── types/ # 前端TypeScript类型定义 │ │ └── App.tsx # 根组件 │ ├── tsconfig.json # 前端TypeScript配置 │ └── package.json # 前端依赖 └── README.md这种结构的优点是关联性强克隆一次仓库就能拿到完整的项目。根目录的package.json里可以定义一些便捷脚本比如同时启动前后端服务器。前后端各自的tsconfig.json可以根据需求进行不同的配置例如后端可能以CommonJS为模块标准而前端则用ESNext。3. 后端核心实现详解3.1 数据模型Entity的定义与TypeORM装饰器后端的核心从定义数据模型开始。在server/src/entity/Todo.ts中我们使用TypeORM的装饰器来创建一个Todo实体。import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from typeorm; Entity() // 装饰器告诉TypeORM这个类对应数据库中的一张表 export class Todo { PrimaryGeneratedColumn() // 主键且自增 id: number; Column({ type: text }) // 定义列类型为文本 title: string; Column({ default: false }) // 默认值为false completed: boolean; CreateDateColumn() // 特殊列在创建时自动设置为当前时间戳 createdAt: Date; UpdateDateColumn() // 特殊列在更新时自动更新为当前时间戳 updatedAt: Date; }这里有几个关键点装饰器语法TypeORM大量使用装饰器符号来定义元数据这种方式非常声明式代码看起来干净。类型映射id: number对应数据库的INTEGERtitle: string对应TEXTcompleted: boolean对应BOOLEAN在SQLite中其实是INTEGER0或1。TypeORM会帮我们处理好这些类型转换。自动时间戳CreateDateColumn和UpdateDateColumn是两个非常实用的装饰器。它们会自动管理记录的创建和更新时间我们不需要在业务代码里手动设置。这在审计和调试时非常有用。3.2 控制器Controller与业务逻辑控制器负责处理具体的HTTP请求。在server/src/controller/todoController.ts中我们创建了TodoController类它包含了所有TODO相关的业务逻辑。import { Request, Response } from express; import { AppDataSource } from ../data-source; // 数据库连接池 import { Todo } from ../entity/Todo; export class TodoController { private todoRepository AppDataSource.getRepository(Todo); // 获取Todo实体的Repository // 获取所有TODO async getAllTodos(req: Request, res: Response) { try { const todos await this.todoRepository.find({ order: { createdAt: DESC }, // 按创建时间倒序排列新的在前 }); res.json(todos); } catch (error) { console.error(获取TODO列表失败:, error); res.status(500).json({ message: 服务器内部错误 }); } } // 创建新的TODO async createTodo(req: Request, res: Response) { try { const { title } req.body; if (!title || title.trim() ) { return res.status(400).json({ message: 标题不能为空 }); } const todo new Todo(); todo.title title.trim(); todo.completed false; const savedTodo await this.todoRepository.save(todo); res.status(201).json(savedTodo); // 201 Created 状态码 } catch (error) { console.error(创建TODO失败:, error); res.status(500).json({ message: 服务器内部错误 }); } } // 更新TODO状态标记完成/未完成 async updateTodo(req: Request, res: Response) { try { const id parseInt(req.params.id); const { completed } req.body; if (isNaN(id)) { return res.status(400).json({ message: 无效的ID }); } if (typeof completed ! boolean) { return res.status(400).json({ message: completed字段必须为布尔值 }); } const todo await this.todoRepository.findOneBy({ id }); if (!todo) { return res.status(404).json({ message: 未找到该TODO项 }); } todo.completed completed; const updatedTodo await this.todoRepository.save(todo); // 使用save进行更新 res.json(updatedTodo); } catch (error) { console.error(更新TODO失败:, error); res.status(500).json({ message: 服务器内部错误 }); } } // 删除TODO async deleteTodo(req: Request, res: Response) { try { const id parseInt(req.params.id); if (isNaN(id)) { return res.status(400).json({ message: 无效的ID }); } const result await this.todoRepository.delete(id); if (result.affected 0) { return res.status(404).json({ message: 未找到该TODO项 }); } res.status(204).send(); // 204 No Content删除成功无需返回内容 } catch (error) { console.error(删除TODO失败:, error); res.status(500).json({ message: 服务器内部错误 }); } } }实操心得与注意事项错误处理每个方法都用try...catch包裹这是生产级应用的基本要求。不能把数据库或内部错误直接抛给前端而是返回一个友好的500错误信息并在服务器日志中记录详细错误。输入验证这是安全性和稳定性的第一道防线。对于createTodo必须检查title是否为空对于updateTodo和deleteTodo需要验证id是否为有效数字并且检查completed的类型。直接使用未经校验的用户输入是危险的。HTTP状态码正确使用状态码能让API语义更清晰。创建成功用201删除成功用204客户端错误用4xx服务器错误用5xx。Repository模式AppDataSource.getRepository(Todo)获取的是一个Repository对象它封装了所有针对Todo实体的数据库操作find, save, delete等。这是TypeORM提供的数据访问层抽象让业务逻辑代码更清晰。3.3 路由定义与应用入口控制器写好了需要用路由把它们和具体的URL路径绑定起来。在server/src/routes/todoRoutes.ts中import { Router } from express; import { TodoController } from ../controller/todoController; const router Router(); const todoController new TodoController(); router.get(/todos, todoController.getAllTodos.bind(todoController)); router.post(/todos, todoController.createTodo.bind(todoController)); router.put(/todos/:id, todoController.updateTodo.bind(todoController)); router.delete(/todos/:id, todoController.deleteTodo.bind(todoController)); export default router;这里有个细节todoController.getAllTodos.bind(todoController)。因为我们在控制器里使用了类方法当这些方法被作为回调函数传递给路由时它们的this上下文可能会丢失。使用.bind(this)可以确保在方法内部this仍然指向TodoController的实例从而能正确访问todoRepository。最后在应用入口文件server/src/index.ts中我们把所有部分组装起来import reflect-metadata; // TypeORM的依赖必须最先引入 import express from express; import cors from cors; import { AppDataSource } from ./data-source; import todoRoutes from ./routes/todoRoutes; const app express(); const PORT process.env.PORT || 3000; // 中间件配置 app.use(cors()); // 启用CORS允许前端跨域访问 app.use(express.json()); // 解析JSON格式的请求体 // 数据库连接初始化 AppDataSource.initialize() .then(() { console.log(数据库连接成功); // 挂载路由 app.use(/api, todoRoutes); // 所有TODO相关的API都挂在 /api 路径下 // 启动服务器 app.listen(PORT, () { console.log(后端服务器运行在 http://localhost:${PORT}); }); }) .catch((error) { console.error(数据库连接失败:, error); process.exit(1); // 连接失败退出进程 });关键配置解析reflect-metadata这是一个Polyfill库TypeORM的装饰器语法依赖它来存储和读取元数据。必须在所有其他导入之前引入。CORS中间件由于前端运行在localhost:3000后端运行在localhost:3000或另一个端口这是跨域请求。cors()中间件会自动在响应头中添加Access-Control-Allow-Origin: *允许前端访问。express.json()中间件这个中间件会解析请求中Content-Type为application/json的数据并将其转换为JavaScript对象挂载到req.body上。没有它req.body会是undefined。数据库连接初始化AppDataSource.initialize()是一个异步操作它负责建立与SQLite数据库的连接池。我们把它放在服务器启动之前确保数据库就绪后再开始监听请求。4. 前端核心实现与UI交互4.1 状态管理与API服务封装前端我们使用React的函数组件和Hooks。首先我们需要一个地方来集中管理TODO列表的状态并封装所有与后端API的通信。在client/src/services/todoService.ts中import { Todo } from ../types/todo; const API_BASE_URL http://localhost:3000/api; // 根据你的后端端口调整 export const todoService { // 获取所有TODO async fetchTodos(): PromiseTodo[] { const response await fetch(${API_BASE_URL}/todos); if (!response.ok) { throw new Error(获取数据失败: ${response.statusText}); } return await response.json(); }, // 创建TODO async createTodo(title: string): PromiseTodo { const response await fetch(${API_BASE_URL}/todos, { method: POST, headers: { Content-Type: application/json, }, body: JSON.stringify({ title }), }); if (!response.ok) { const errorData await response.json().catch(() ({})); throw new Error(errorData.message || 创建失败: ${response.statusText}); } return await response.json(); }, // 更新TODO切换完成状态 async updateTodo(id: number, completed: boolean): PromiseTodo { const response await fetch(${API_BASE_URL}/todos/${id}, { method: PUT, headers: { Content-Type: application/json, }, body: JSON.stringify({ completed }), }); if (!response.ok) { const errorData await response.json().catch(() ({})); throw new Error(errorData.message || 更新失败: ${response.statusText}); } return await response.json(); }, // 删除TODO async deleteTodo(id: number): Promisevoid { const response await fetch(${API_BASE_URL}/todos/${id}, { method: DELETE, }); if (!response.ok response.status ! 204) { // 注意删除成功返回204 No Content没有响应体 const errorData await response.json().catch(() ({})); throw new Error(errorData.message || 删除失败: ${response.statusText}); } }, };封装的好处关注点分离所有网络请求的逻辑都集中在这里组件里只需要调用这些方法不用关心fetch的细节和URL拼接。错误处理统一在每个方法里都检查response.ok并尝试从响应体中解析错误信息然后抛出统一的Error对象。这样在组件里可以用try...catch进行一致的错误处理。易于维护如果后端API的URL或者请求格式变了只需要修改这个文件而不用去改每一个使用API的组件。对应的TypeScript类型定义在client/src/types/todo.tsexport interface Todo { id: number; title: string; completed: boolean; createdAt: string; // 从API返回的是ISO格式的字符串 updatedAt: string; }4.2 主组件与状态逻辑核心的UI和交互逻辑在client/src/App.tsx中。我们使用React的useState和useEffectHooks来管理状态和副作用。import React, { useState, useEffect } from react; import { Container, Typography, TextField, Button, List, ListItem, ListItemText, ListItemSecondaryAction, IconButton, Checkbox, CircularProgress, Alert, Paper, } from mui/material; import { Add as AddIcon, Delete as DeleteIcon } from mui/icons-material; import { todoService } from ./services/todoService; import { Todo } from ./types/todo; function App() { // 状态定义 const [todos, setTodos] useStateTodo[]([]); const [newTodoTitle, setNewTodoTitle] useState(); const [loading, setLoading] useState(true); const [error, setError] useStatestring | null(null); const [submitting, setSubmitting] useState(false); // 初始化加载TODO列表 useEffect(() { const loadTodos async () { setLoading(true); setError(null); try { const data await todoService.fetchTodos(); setTodos(data); } catch (err) { setError(err instanceof Error ? err.message : 加载数据失败); console.error(err); } finally { setLoading(false); } }; loadTodos(); }, []); // 空依赖数组只在组件挂载时执行一次 // 处理新增TODO const handleAddTodo async () { const title newTodoTitle.trim(); if (!title) { setError(请输入TODO内容); return; } setSubmitting(true); setError(null); try { const createdTodo await todoService.createTodo(title); // 将新创建的TODO添加到列表顶部 setTodos((prev) [createdTodo, ...prev]); setNewTodoTitle(); // 清空输入框 } catch (err) { setError(err instanceof Error ? err.message : 创建失败); console.error(err); } finally { setSubmitting(false); } }; // 处理切换完成状态 const handleToggleTodo async (id: number, completed: boolean) { try { const updatedTodo await todoService.updateTodo(id, !completed); // 更新本地状态找到对应的TODO项替换 setTodos((prev) prev.map((todo) (todo.id id ? updatedTodo : todo)) ); } catch (err) { setError(err instanceof Error ? err.message : 更新失败); console.error(err); } }; // 处理删除TODO const handleDeleteTodo async (id: number) { if (!window.confirm(确定要删除这项TODO吗)) { return; } try { await todoService.deleteTodo(id); // 从本地状态中移除 setTodos((prev) prev.filter((todo) todo.id ! id)); } catch (err) { setError(err instanceof Error ? err.message : 删除失败); console.error(err); } }; // 按Enter键提交新增 const handleKeyPress (e: React.KeyboardEvent) { if (e.key Enter !submitting) { handleAddTodo(); } }; // 渲染逻辑 return ( Container maxWidthsm sx{{ mt: 4, mb: 4 }} Paper elevation{3} sx{{ p: 3 }} Typography varianth4 componenth1 gutterBottom aligncenter TypeScript TODO App /Typography {/* 错误提示 */} {error ( Alert severityerror sx{{ mb: 2 }} onClose{() setError(null)} {error} /Alert )} {/* 新增TODO输入区域 */} div style{{ display: flex, marginBottom: 20px }} TextField label添加新任务 variantoutlined fullWidth value{newTodoTitle} onChange{(e) setNewTodoTitle(e.target.value)} onKeyPress{handleKeyPress} disabled{submitting} sx{{ mr: 1 }} / Button variantcontained colorprimary onClick{handleAddTodo} disabled{submitting || !newTodoTitle.trim()} startIcon{submitting ? CircularProgress size{20} / : AddIcon /} 添加 /Button /div {/* 加载中状态 */} {loading ? ( div style{{ textAlign: center, padding: 40px }} CircularProgress / Typography variantbody2 sx{{ mt: 2 }} 加载中... /Typography /div ) : ( /* TODO列表 */ List {todos.length 0 ? ( Typography variantbody1 aligncenter sx{{ py: 4, color: text.secondary }} 暂无任务添加一个吧 /Typography ) : ( todos.map((todo) ( ListItem key{todo.id} dense sx{{ borderBottom: 1px solid #eee, textDecoration: todo.completed ? line-through : none, color: todo.completed ? text.secondary : text.primary, opacity: todo.completed ? 0.7 : 1, }} Checkbox edgestart checked{todo.completed} onChange{() handleToggleTodo(todo.id, todo.completed)} tabIndex{-1} disableRipple / ListItemText primary{todo.title} secondary{创建于: ${new Date(todo.createdAt).toLocaleString()}} / ListItemSecondaryAction IconButton edgeend aria-label删除 onClick{() handleDeleteTodo(todo.id)} sizelarge DeleteIcon / /IconButton /ListItemSecondaryAction /ListItem )) )} /List )} /Paper /Container ); } export default App;UI与交互设计要点状态驱动UI这是React的核心思想。todos、loading、error等状态的变化会自动触发组件的重新渲染更新UI。乐观更新与错误回滚在handleToggleTodo和handleDeleteTodo中我们采用的是“乐观更新”策略。即先更新本地状态setTodos让UI立刻响应然后再发送API请求。如果请求失败再捕获错误并提示用户。这能带来更流畅的用户体验。对于handleAddTodo我们采用的是“悲观更新”等服务器返回成功后再更新本地状态因为新增需要服务器的ID。用户体验细节加载状态loading状态为true时显示一个旋转的进度圈。禁用状态提交时按钮和输入框被禁用防止重复提交。键盘支持在输入框按Enter键可以直接提交。删除确认删除操作前弹出一个原生的确认对话框防止误操作。视觉反馈已完成的TODO项有删除线、颜色变淡提供清晰的视觉区分。Material-UI组件使用我们使用了MUI的Container、Paper、Typography、TextField、Button、List、Checkbox、IconButton、CircularProgress、Alert等组件。它们不仅美观而且自带了响应式设计和可访问性支持。通过sx属性MUI的系统属性可以方便地进行内联样式定制。5. 开发环境配置与项目启动5.1 项目根目录配置为了让前后端能一起跑起来我们在项目根目录的package.json里定义了一些便捷的脚本。{ name: typescript-todo-app, version: 1.0.0, private: true, scripts: { install:all: npm install cd client npm install, 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 } }脚本解析install:all一键安装前后端所有依赖。先安装根目录的主要是concurrently再安装前端的。dev:server和dev:client分别启动后端和前端的开发服务器。dev:full使用concurrently包同时启动前后端。这是开发时最常用的命令开一个终端窗口就行。concurrently是一个非常有用的工具它允许你并行运行多个npm脚本。需要先在根目录安装它npm install -D concurrently。5.2 后端开发配置后端server/目录下的关键配置server/package.json{ name: todo-server, version: 1.0.0, main: dist/index.js, scripts: { dev: nodemon --exec ts-node src/index.ts, build: tsc, start: node dist/index.js, typeorm: ts-node ./node_modules/typeorm/cli.js }, dependencies: { express: ^4.18.0, cors: ^2.8.5, sqlite3: ^5.1.0, typeorm: ^0.3.0, reflect-metadata: ^0.1.13 }, devDependencies: { types/express: ^4.17.0, types/cors: ^2.8.0, types/node: ^20.0.0, typescript: ^5.0.0, ts-node: ^10.9.0, nodemon: ^3.0.0 } }server/tsconfig.json{ compilerOptions: { target: ES2020, module: commonjs, lib: [ES2020], outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, experimentalDecorators: true, // 必须开启支持TypeORM装饰器 emitDecoratorMetadata: true // 必须开启支持TypeORM装饰器 }, include: [src/**/*], exclude: [node_modules] }关键点开发脚本npm run dev使用了nodemon和ts-node。nodemon会监听文件变化并自动重启服务器ts-node则直接运行TypeScript代码无需手动编译。这极大地提升了开发效率。TypeScript配置experimentalDecorators和emitDecoratorMetadata这两个选项必须设置为trueTypeORM的装饰器语法才能正常工作。数据库驱动sqlite3是Node.js连接SQLite数据库的驱动typeorm依赖它。server/src/data-source.ts(数据库连接配置)import { DataSource } from typeorm; import { Todo } from ./entity/Todo; export const AppDataSource new DataSource({ type: sqlite, database: database.sqlite, // 数据库文件路径 synchronize: true, // 开发环境自动同步实体到数据库生产环境请设为false logging: true, // 打印SQL日志方便调试 entities: [Todo], // 注册实体 migrations: [], // 迁移文件路径 subscribers: [], });重要警告synchronize: true在开发时非常方便它会根据你的Entity类自动创建或修改数据库表结构。但是在生产环境中务必将其设置为false否则可能导致数据丢失。生产环境应该使用TypeORM的迁移Migration功能来管理数据库结构变更。5.3 前端开发配置前端client/目录通常由create-react-appCRA或Vite等工具初始化它们已经配置好了TypeScript和开发服务器。client/package.json(关键部分){ name: todo-client, version: 0.1.0, private: true, dependencies: { react: ^18.0.0, react-dom: ^18.0.0, mui/material: ^5.0.0, emotion/react: ^11.0.0, emotion/styled: ^11.0.0, mui/icons-material: ^5.0.0 }, scripts: { start: react-scripts start, build: react-scripts build, test: react-scripts test, eject: react-scripts eject }, proxy: http://localhost:3000 // 开发服务器代理解决跨域 }开发服务器代理在client/package.json中设置proxy: http://localhost:3000后前端开发服务器localhost:3000会将所有未知的API请求比如/api/todos代理到后端服务器http://localhost:3000。这样在前端代码中API请求可以写成相对路径/api/todos而不用写全路径http://localhost:3000/api/todos既方便也避免了开发时的CORS问题。5.4 完整启动流程克隆项目并安装依赖git clone [你的仓库地址] typescript-todo-app cd typescript-todo-app npm run install:all启动完整开发环境npm run dev:full这个命令会打开两个终端窗口由concurrently管理一个运行后端默认端口3000一个运行前端默认端口3000如果冲突会提示你换端口如3001。访问应用前端界面打开浏览器访问http://localhost:3000(或http://localhost:3001)后端API可以直接用工具如curl、Postman测试http://localhost:3000/api/todos6. 常见问题、调试技巧与项目扩展思路6.1 开发中常见问题与解决方案在实际开发这个项目时我踩过一些坑这里总结一下问题1后端启动报错Cannot find module reflect-metadata现象运行npm run dev时控制台出现模块找不到的错误。原因reflect-metadata是TypeORM的peer dependency有时安装可能有问题或者没有在入口文件的最顶部引入。解决确保在server/package.json的dependencies中已安装reflect-metadata。确保在server/src/index.ts文件的最顶部在所有其他import之前有import reflect-metadata;这一行。问题2修改Entity后数据库表结构没有自动更新现象在Todo实体中添加了一个新字段如description但重启服务器后数据库里没有这个新列。原因synchronize: true只在应用启动时检查差异。如果数据库连接池已经建立修改代码后nodemon重启了Node进程但TypeORM可能没有重新执行同步。解决最彻底删除本地的database.sqlite文件然后重启服务器TypeORM会基于最新的Entity重新建表。注意这会丢失所有数据仅限开发环境更安全推荐使用TypeORM迁移。首先在>