基于Next.js与Redis的全栈待办应用:架构设计与工程实践
1. 项目概述一个现代全栈待办事项应用的构建实录最近在 GitHub 上看到一个名为todoist.cloud的开源项目它是一个基于 Next.js 的全栈待办事项应用。作为一个长期在任务管理工具上反复横跳的开发者我立刻被它简洁的技术栈和完整的功能吸引住了。这个项目不仅用上了 Next.js 15、TypeScript、Tailwind CSS 这些现代前端利器后端还集成了 PostgreSQL 和 Redis从数据库到缓存从组件测试到端到端测试一应俱全。更关键的是它提供了一个近乎“开箱即用”的 Docker Compose 开发环境这对于想快速上手全栈开发或者学习现代 Web 技术栈的朋友来说简直是个宝藏。这个项目本质上是一个功能完整的个人任务管理中心。你可以创建多个任务清单来分类管理不同的事务比如“工作项目”、“购物清单”、“学习计划”。在每个清单里你可以添加具体的任务项设置截止日期甚至为重要任务安排提醒。任务完成后可以标记清单不用了可以归档或删除。整个应用的设计思路非常清晰用 PostgreSQL 可靠地存储所有核心数据用户、清单、任务用 Redis 这种内存数据库来高效地处理和触发定时提醒。这种架构选择在小型到中型应用中非常典型既保证了数据的持久性又兼顾了实时性要求较高的功能性能。我花了一些时间不仅部署了它的线上 demo还在本地完整地跑通了它的开发、测试和构建流程。接下来我将以一名全栈开发者的视角为你深度拆解这个项目的设计思路、技术实现细节并分享在复现和探索过程中积累的一手实操经验与避坑指南。无论你是想学习如何构建一个类似的现代 Web 应用还是正在为自己的项目寻找技术选型参考相信这篇内容都能给你带来实实在在的启发。2. 技术栈选型与架构设计解析当我们决定要构建一个现代化的待办事项应用时技术选型是第一步也是最关键的一步。todoist.cloud的选择体现了一种务实且高效的“全栈 React”思路下面我们来逐一拆解每个技术选型背后的逻辑。2.1 前端框架为什么是 Next.js 15这个项目选择了 Next.js 15.2.2 作为核心框架这绝非偶然。对于一款需要良好 SEO虽然待办事项应用个人使用居多但公开分享清单也是一种场景、需要服务端渲染以提升首屏速度、并且 API 路由与前端页面紧密耦合的应用来说Next.js 是目前 React 生态中最成熟的一体化解决方案。App Router 与 Server Components 的实践项目采用了 Next.js 15 默认的 App Router。与旧的 Pages Router 相比App Router 最大的优势在于对 React Server Components 的原生支持。这意味着我们可以在服务端直接获取数据、渲染组件然后将静态的 HTML 发送到客户端。对于任务列表这种数据驱动型页面这能极大减少客户端的 JavaScript 包体积提升加载性能。我在查看app/page.tsx时也证实了这一点页面组件大概率是一个 Server Component直接通过 Prisma Client 从数据库获取清单数据。内置的优化与基础设施Next.js 开箱即用地解决了大量工程化问题。图片优化next/image、字体优化next/font项目使用了 Vercel 的 Geist 字体、代码拆分、按需编译等开发者无需额外配置。这对于一个希望快速迭代、保持代码简洁的项目来说减少了大量维护成本。实操心得App Router 的学习曲线。如果你是从 Pages Router 迁移过来或者初次接触 Server Components需要理解一些新概念如async组件、use client指令、服务端与客户端组件的边界。我的建议是先明确一个原则默认所有组件都是服务端组件只有在需要用到 React 状态useState、生命周期useEffect或浏览器 APIwindow时才在文件顶部添加‘use client’指令将其转换为客户端组件。2.2 样式与 UI 组件Tailwind CSS 与 Flowbite 的组合拳样式方面项目选择了Tailwind CSS 3.4作为实用优先的 CSS 框架并搭配了Flowbite 2.3作为组件库。Tailwind CSS 的价值在构建这类拥有大量交互状态任务项的悬停、完成态、选中态的应用时Tailwind 的原子化 CSS 类极大地提升了开发效率。你不需要在 CSS 文件和 JSX 文件之间反复切换也不需要为每个细微的样式变体绞尽脑汁地想类名。例如一个任务项完成和未完成的样式差异可能只需要在元素上动态添加或移除line-through text-gray-400这样的类即可。此外Tailwind 与 Next.js 的集成非常顺畅支持 JIT即时编译模式最终生成的 CSS 文件只会包含你实际使用过的样式体积非常小。为何引入 FlowbiteTailwind 提供了强大的“原材料”工具类但构建一致的、可访问的交互式组件如模态框、下拉菜单、日期选择器仍然需要大量工作。Flowbite 填补了这个空白。它是一套基于 Tailwind 构建的开源 UI 组件库。项目很可能用它来快速搭建了应用中的一些复杂交互元素比如用于设置截止日期的日期选择器、或者提醒设置的下拉菜单。这避免了重复造轮子保证了 UI 的交互质量和一致性。注意事项Flowbite 的集成方式。Flowbite 可以通过 npm 安装但其交互组件需要 JavaScript 的通常需要初始化。你需要确保在客户端入口文件比如app/layout.tsx如果是客户端组件或者一个单独的客户端脚本中导入并初始化 Flowbite。通常的写法是import ‘flowbite’;。如果发现日期选择器弹不出来或下拉菜单不工作首先检查这一步是否到位。2.3 后端与数据层PostgreSQL 与 Redis 的职责分离这是整个应用架构的核心。项目清晰地使用了两种数据库各司其职。PostgreSQL (v14) – 系统的“硬盘”作为主要的关系型数据库它负责存储所有需要持久化、结构化且关系复杂的数据。根据功能推断其数据库 schema 至少会包含以下几张表User用户表如果支持多用户。TodoList任务清单表包含标题、创建时间、归档状态等字段。TodoItem任务项表与TodoList关联包含内容、完成状态、截止日期等字段。Reminder提醒表与TodoItem关联包含触发时间、通知方式等。使用 Prisma 作为 ORM从prisma migrate命令可知使得用 TypeScript 类型安全地操作数据库变得非常优雅。Prisma Schema 不仅定义了数据库结构还能自动生成强类型的 Client在编码时就能获得智能提示和类型检查极大减少了运行时错误。Redis (v7) – 系统的“内存”与“闹钟”Redis 在这里扮演了两个关键角色缓存频繁访问但更新不频繁的数据比如用户的公开清单信息可以缓存在 Redis 中减轻 PostgreSQL 的压力提升响应速度。提醒调度器这是更巧妙的用法。设置任务提醒的本质是一个“延时任务”。当用户创建一个未来时间的提醒时系统可以将这个提醒任务包含触发时间和任务ID放入 Redis 的 Sorted Set有序集合中以触发时间为分数score。然后需要一个后台工作进程Worker定期例如每秒轮询这个 Sorted Set检查是否有到期的任务分数小于当前时间戳。一旦发现就取出任务ID执行发送通知等逻辑并从集合中移除。这种方式比在 PostgreSQL 中轮询WHERE reminder_time NOW()要高效得多是处理延时任务的经典模式。深度解析Redis 有序集合实现提醒。假设一个任务ID是task:123需要在北京时间1739452800000一个时间戳触发。工作流程如下创建提醒时ZADD reminders 1739452800000 “task:123”。后台 Worker 循环执行ZRANGEBYSCORE reminders -inf current_timestamp WITHSCORES获取所有已到期的任务。对每个获取到的任务ID处理业务逻辑如发送Web通知、邮件。处理成功后ZREM reminders “task:123”。 这种方式是可靠且可扩展的。项目中的docker-compose.yml同时启动 PostgreSQL 和 Redis正是为这个完整的数据流提供基础设施。2.4 测试与质量保障Jest、React Testing Library 与 Playwright一个严肃的开源项目必须包含完善的测试。该项目建立了三层测试体系单元测试Jest针对工具函数、自定义 Hooks、业务逻辑进行隔离测试。位于src/__tests__/utils/。组件与集成测试Jest React Testing Library测试 React 组件的行为和交互。这是前端测试的重点确保 UI 按预期渲染和响应。位于src/__tests__/components/和src/__tests__/api/。端到端测试Playwright模拟真实用户操作从打开浏览器、创建清单、添加任务到标记完成进行全流程测试。位于e2e-tests/。它确保了各个模块集成在一起后能正常工作。为什么选择 Playwright 而不是 CypressPlaywright 由微软开发支持 Chromium、Firefox 和 WebKit 三大浏览器引擎且测试执行速度通常更快。它的 API 设计现代对异步操作处理友好并且自带强大的代码生成器和调试工具。从项目提供的npm run test:e2e:ui命令来看它充分利用了 Playwright 的 UI 模式进行可视化调试这对编写和排查 E2E 测试非常有帮助。3. 本地开发环境搭建与深度配置指南纸上得来终觉浅绝知此事要躬行。要真正理解一个项目最好的方式就是把它跑起来。todoist.cloud项目通过 Docker Compose 提供了一键式的本地开发环境这大大降低了上手门槛。但在这个过程中有一些细节和潜在问题需要我们特别注意。3.1 利用 Docker Compose 实现环境隔离项目根目录下的docker-compose.yml文件是本地开发的基石。我们来看一下它的典型结构version: ‘3.8’ services: postgres: image: postgres:14-alpine environment: POSTGRES_USER: todoist POSTGRES_PASSWORD: aIU0Ys5hrBPho647FLBpzlQ37IM5mQhTgUhTqt25mE POSTGRES_DB: todoist ports: - “5432:5432” volumes: - postgres_data:/var/lib/postgresql/data redis: image: redis:7-alpine ports: - “6379:6379” volumes: - redis_data:/var/lib/redis/data volumes: postgres_data: redis_data:关键点解析镜像选择使用了-alpine版本的镜像这是基于 Alpine Linux 的轻量级版本可以显著减少镜像体积加速拉取和启动速度。密码安全数据库密码直接写在 compose 文件中。这对于本地开发是方便的但对于生产环境是绝对危险的。生产环境必须通过环境变量或密钥管理服务注入。项目 README 中给出的密码是一个复杂的字符串这比简单的password要好但在真正的项目中即使是开发环境也建议使用.env文件并通过env_file配置项引入。数据持久化使用了 Docker 的命名卷postgres_data,redis_data来持久化数据库数据。这意味着即使你运行docker-compose down数据也不会丢失除非你加上-v参数来删除卷。这保证了开发状态的连续性。启动与连接 在项目根目录下运行docker-compose up -d两个服务就会在后台启动。此时PostgreSQL 就在本地的 5432 端口Redis 在 6379 端口。你需要确保这些端口没有被其他程序占用。3.2 环境变量与 Prisma 配置的衔接服务起来后Next.js 应用需要知道如何连接它们。这就是.env文件的作用。项目要求你创建或更新.env文件内容如下DATABASE_URL“postgresql://todoist:aIU0Ys5hrBPho647FLBpzlQ37IM5mQhTgUhTqt25mElocalhost:5432/todoist?schemapublic” REDIS_URL“redis://localhost:6379”这里有一个至关重要的步骤运行数据库迁移。光有数据库服务不行还需要在里面创建符合 Prisma Schema 定义的表结构。执行npx prisma migrate dev这个命令会做几件事读取prisma/schema.prisma文件。与已连接的数据库对比生成一个迁移文件如果 schema 有变化。将这个迁移文件应用到数据库创建或更新表。同时会运行prisma generate根据最新的 schema 生成或更新 Prisma Client 类型定义这样你的 TypeScript 代码才能获得正确的类型提示。踩坑记录迁移命令的差异。prisma migrate dev用于开发环境它会创建迁移记录并直接应用。而生产环境部署时应使用prisma migrate deploy它只应用已存在的迁移文件而不会创建新的。项目在package.json的构建脚本build中很可能包含了prisma generate prisma migrate deploy这正是为生产环境准备的。在本地开发时如果你修改了schema.prisma务必使用dev命令。3.3 解决常见的本地开发连接问题即使按照步骤操作你也可能会遇到连接失败的问题。以下是一些排查思路数据库连接拒绝首先确认 Docker 容器是否真的在运行docker-compose ps。如果 PostgreSQL 状态不是Up查看日志docker-compose logs postgres。常见原因是端口冲突。你可以尝试修改docker-compose.yml中的端口映射比如将“5432:5432”改为“5433:5432”同时记得更新.env中的DATABASE_URL。Prisma 迁移失败错误信息可能五花八门。首先确保你的.env文件位于项目根目录并且变量名正确。其次尝试重置数据库注意这会清空所有数据# 先停止并删除容器和卷 docker-compose down -v # 重新启动 docker-compose up -d # 重新运行迁移 npx prisma migrate devRedis 连接问题Node.js 应用连接 Redis 通常使用ioredis或redis包。确保你的应用代码中使用的 Redis 客户端版本与 Redis 7 兼容。检查REDIS_URL的格式是否正确。完成以上步骤后运行npm run dev打开http://localhost:3000你应该就能看到应用界面了。如果页面能打开但无法加载数据请打开浏览器开发者工具的“网络”选项卡查看 API 请求是否返回错误这能帮你快速定位是前端请求问题还是后端 API/数据库问题。4. 核心功能模块实现与代码剖析环境跑通后我们来深入代码内部看看几个核心功能是如何实现的。由于项目代码较长我们聚焦于设计模式和关键代码片段。4.1 数据模型设计与 Prisma Schema一切始于数据模型。我们可以在prisma/schema.prisma中窥见整个应用的数据结构核心。以下是一个根据项目功能推断的、高度可能的 Schema 设计// prisma/schema.prisma generator client { provider “prisma-client-js” binaryTargets [“native”, “rhel-openssl-3.0.x”] // 适配不同部署环境 } datasource db { provider “postgresql” url env(“DATABASE_URL”) } model User { id String id default(cuid()) email String unique name String? lists TodoList[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model TodoList { id String id default(cuid()) title String description String? archived Boolean default(false) userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) items TodoItem[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model TodoItem { id String id default(cuid()) content String completed Boolean default(false) dueDate DateTime? // 截止日期 todoListId String todoList TodoList relation(fields: [todoListId], references: [id], onDelete: Cascade) reminders Reminder[] // 一个任务可以有多个提醒 createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Reminder { id String id default(cuid()) triggerAt DateTime // 提醒触发时间 notified Boolean default(false) // 是否已发送通知 todoItemId String todoItem TodoItem relation(fields: [todoItemId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) }设计亮点关系清晰User-TodoList-TodoItem-Reminder构成了清晰的一对多关系链通过relation注解定义外键。级联删除onDelete: Cascade确保了删除一个清单时其下的所有任务和提醒会被自动删除避免了数据孤儿。时间戳createdAt和updatedAt是审计和排序的必备字段。二进制目标binaryTargets配置确保了 Prisma Engine 能在你的本地开发机native和常见的 Linux 生产环境如 RHEL上运行。4.2 使用 Next.js Server Actions 处理表单交互在 Next.js 15 的 App Router 中处理表单提交和数据变更推荐使用Server Actions。它允许你在服务端直接定义函数来处理表单数据无需创建单独的 API 路由。这大大简化了数据流。假设我们有一个创建新任务清单的表单组件这是一个客户端组件因为需要交互// app/components/CreateListForm.tsx ‘use client’; import { useActionState } from ‘react’; // Next.js 15 集成了 React 的 useActionState import { createTodoList } from ‘/app/actions’; // 导入 Server Action export function CreateListForm() { // useActionState 用于管理 Action 的状态pending, error, data const [state, formAction, isPending] useActionState(createTodoList, null); return ( form action{formAction} input type“text” name“title” placeholder“List Title” required / textarea name“description” placeholder“Description (optional)” / button type“submit” disabled{isPending} {isPending ? ‘Creating…’ : ‘Create List’} /button {state?.error p className“text-red-500”{state.error}/p} {state?.success p className“text-green-500”List created!/p} /form ); }对应的 Server Action 定义在app/actions.ts中// app/actions.ts ‘use server’; // 标记这是 Server Action import { revalidatePath } from ‘next/cache’; import { prisma } from ‘/lib/prisma’; // 假设有一个封装好的 Prisma Client 实例 import { z } from ‘zod’; // 用于数据验证 // 定义数据验证模式 const createListSchema z.object({ title: z.string().min(1, ‘Title is required’), description: z.string().optional(), }); export async function createTodoList(prevState: any, formData: FormData) { // 1. 验证输入 const validatedFields createListSchema.safeParse({ title: formData.get(‘title’), description: formData.get(‘description’), }); if (!validatedFields.success) { return { errors: validatedFields.error.flatten().fieldErrors, message: ‘Missing Fields. Failed to Create List.’, }; } const { title, description } validatedFields.data; try { // 2. 获取当前用户假设通过 cookie/session const userId await getCurrentUserId(); // 需要实现的辅助函数 // 3. 插入数据库 await prisma.todoList.create({ data: { title, description, userId, }, }); // 4. 重新验证清单页面路径触发数据更新 revalidatePath(‘/dashboard’); // 假设列表在 /dashboard 页面 return { success: true }; } catch (error) { console.error(‘Failed to create list:’, error); return { error: ‘Database error: Failed to create list.’ }; } }Server Actions 的优势简化架构无需创建pages/api或app/api路由直接在组件调用的地方定义逻辑。渐进增强即使 JavaScript 被禁用表单仍能提交虽然体验会降级。类型安全结合 Zod 进行验证从表单到数据库都有类型保障。实操心得revalidatePath与redirect。在 Server Action 中你不能直接使用useRouter进行客户端跳转。正确的做法是在 Action 执行成功后调用revalidatePath来清除特定路径的缓存使 Next.js 重新获取服务端数据。如果需要导航到新页面可以返回一个状态由客户端组件通过useEffect监听并调用router.push。或者在 Next.js 14.2 中可以直接在 Server Action 中使用redirect(‘/new-path’)函数。4.3 实现 Redis 驱动的智能提醒系统提醒功能是这个项目的亮点。如前所述它利用 Redis 的有序集合实现。我们来看一个简化的实现方案。首先需要一个用于操作 Redis 的客户端封装// lib/redis.ts import { Redis } from ‘ioredis’; const getRedisClient () { const url process.env.REDIS_URL; if (!url) throw new Error(‘REDIS_URL is not defined’); return new Redis(url); }; // 单例模式避免重复创建连接 const globalForRedis globalThis as unknown as { redis: Redis }; export const redis globalForRedis.redis || getRedisClient(); if (process.env.NODE_ENV ! ‘production’) globalForRedis.redis redis;然后在创建或更新任务提醒时将其加入 Redis 有序集合// app/actions/reminders.ts ‘use server’; import { redis } from ‘/lib/redis’; import { prisma } from ‘/lib/prisma’; export async function scheduleReminder(todoItemId: string, triggerAt: Date) { // 1. 在 PostgreSQL 中创建提醒记录 const reminder await prisma.reminder.create({ data: { triggerAt, todoItemId, }, }); // 2. 将提醒ID和触发时间戳存入 Redis Sorted Set // 使用 reminder.id 作为成员触发时间的时间戳毫秒作为分数 const score triggerAt.getTime(); await redis.zadd(‘scheduled_reminders’, score, reminder.id); return reminder; }最关键的部分是后台 Worker它需要独立于 Next.js 应用运行。项目可以使用bull、agenda等库或者自己实现一个简单的轮询脚本。这里展示一个使用 Node.jssetInterval的基本概念// scripts/reminder-worker.js (或 .ts) import { Redis } from ‘ioredis’; import { prisma } from ‘/lib/prisma’; // 注意Worker 需要能访问到 Prisma import { sendNotification } from ‘/lib/notification’; // 假设的通知发送函数 const redis new Redis(process.env.REDIS_URL); async function processDueReminders() { const now Date.now(); // 获取所有分数触发时间小于等于当前时间的提醒ID const reminderIds await redis.zrangebyscore(‘scheduled_reminders’, ‘-inf’, now); for (const reminderId of reminderIds) { try { // 1. 从数据库获取完整的提醒和任务信息 const reminder await prisma.reminder.findUnique({ where: { id: reminderId }, include: { todoItem: { include: { todoList: true } } }, }); if (!reminder || reminder.notified) { // 可能已被处理或删除从 Redis 中移除 await redis.zrem(‘scheduled_reminders’, reminderId); continue; } // 2. 执行通知逻辑发送邮件、浏览器推送等 await sendNotification({ to: reminder.todoItem.todoList.user.email, subject: Reminder: ${reminder.todoItem.content}, body: Your task “${reminder.todoItem.content}” is due!, }); // 3. 更新数据库标记为已通知 await prisma.reminder.update({ where: { id: reminderId }, data: { notified: true }, }); // 4. 从 Redis 有序集合中移除已处理的提醒 await redis.zrem(‘scheduled_reminders’, reminderId); console.log(Processed reminder: ${reminderId}); } catch (error) { console.error(Failed to process reminder ${reminderId}:, error); // 可以选择将失败的任务放入另一个集合进行重试或人工检查 } } } // 每 10 秒检查一次 setInterval(processDueReminders, 10 * 1000); console.log(‘Reminder worker started.’);这个 Worker 脚本需要单独运行例如在package.json中添加“worker”: “node scripts/reminder-worker.js”并在生产环境中使用 PM2 或 Docker 容器来管理其进程。重要提醒生产环境考虑。上述简单轮询适用于轻量级应用。对于高并发场景需要考虑并发控制多个 Worker 实例可能同时处理同一个任务需要使用 Redis 锁如SETNX或更专业的任务队列如 BullMQ。错误处理与重试网络或通知服务失败时应有重试机制和死信队列。可观测性记录处理日志和指标方便监控。资源管理确保 Worker 进程崩溃后能自动重启。5. 测试策略与持续集成部署实战一个健壮的项目离不开完善的测试和自动化部署。todoist.cloud项目在这方面做了很好的示范我们来看看如何将这些实践应用到自己的项目中。5.1 构建坚固的测试金字塔项目的测试结构遵循经典的“测试金字塔”模型。单元测试Jest位于src/__tests__/utils/测试纯函数或简单模块。例如一个格式化日期的工具函数// src/utils/formatDate.ts export function formatDueDate(date: Date): string { // … 格式化逻辑 } // src/__tests__/utils/formatDate.test.ts import { formatDueDate } from ‘/utils/formatDate’; describe(‘formatDueDate’, () { it(‘formats today’s date correctly’, () { const today new Date(‘2024-02-15T10:30:00’); expect(formatDueDate(today)).toBe(‘Today, 10:30 AM’); }); it(‘formats tomorrow’s date correctly’, () { const tomorrow new Date(‘2024-02-16T14:00:00’); expect(formatDueDate(tomorrow)).toBe(‘Tomorrow, 2:00 PM’); }); });组件与 API 集成测试Jest React Testing Library这是前端测试的重点。React Testing Library 鼓励你像用户一样测试组件而不是测试实现细节。// src/__tests__/components/TodoItem.test.tsx import { render, screen, fireEvent } from ‘testing-library/react’; import userEvent from ‘testing-library/user-event’; import { TodoItem } from ‘/components/TodoItem’; describe(‘TodoItem’, () { const mockOnToggle jest.fn(); const mockOnDelete jest.fn(); it(‘renders the task content’, () { render(TodoItem content“Buy milk” completed{false} onToggle{mockOnToggle} onDelete{mockOnDelete} /); expect(screen.getByText(‘Buy milk’)).toBeInTheDocument(); }); it(‘calls onToggle when checkbox is clicked’, async () { const user userEvent.setup(); render(TodoItem content“Buy milk” completed{false} onToggle{mockOnToggle} onDelete{mockOnDelete} /); const checkbox screen.getByRole(‘checkbox’); await user.click(checkbox); expect(mockOnToggle).toHaveBeenCalledTimes(1); }); it(‘shows strikethrough style when completed’, () { render(TodoItem content“Buy milk” completed{true} onToggle{mockOnToggle} onDelete{mockOnDelete} /); const textElement screen.getByText(‘Buy milk’); expect(textElement).toHaveClass(‘line-through’); // 假设完成态有 line-through 类 }); });对于 API 路由在 App Router 中是app/api/或 Server Actions测试需要模拟请求和数据库。// src/__tests__/api/lists/route.test.ts import { GET } from ‘/app/api/lists/route’; import { prisma } from ‘/lib/prisma’; import { createMocks } from ‘node-mocks-http’; // 用于模拟请求对象 jest.mock(‘/lib/prisma’); describe(‘GET /api/lists’, () { it(‘returns all lists for the user’, async () { const mockLists [{ id: ‘1’, title: ‘Work’ }]; (prisma.todoList.findMany as jest.Mock).mockResolvedValue(mockLists); // 模拟一个带有用户认证上下文的请求 const { req } createMocks({ method: ‘GET’, }); // 这里需要模拟 Next.js 的 auth() 或类似获取用户的方式通常通过 Mock const response await GET(req); const data await response.json(); expect(response.status).toBe(200); expect(data).toEqual({ lists: mockLists }); }); });端到端测试Playwright模拟真实用户场景。项目中的e2e-tests/目录下会有类似以下的测试// e2e-tests/homepage.spec.ts import { test, expect } from ‘playwright/test’; test(‘should create a new todo list’, async ({ page }) { // 1. 导航到应用首页 await page.goto(‘http://localhost:3000’); // 2. 点击“新建清单”按钮 await page.getByRole(‘button’, { name: /new list/i }).click(); // 3. 在弹窗或表单中填写信息 await page.getByLabel(‘List Title’).fill(‘My Groceries’); await page.getByRole(‘button’, { name: /create/i }).click(); // 4. 断言新清单出现在页面上 await expect(page.getByText(‘My Groceries’)).toBeVisible(); });运行 E2E 测试前需要确保开发服务器和数据库服务正在运行。Playwright 测试会启动一个真实的浏览器进行操作。5.2 配置 GitHub Actions 实现 CI/CD项目使用 GitHub Actions 实现了自动化测试和部署。查看.github/workflows/目录下的 YAML 文件我们可以学习其配置。一个典型的ci-cd.yml工作流可能包含以下步骤name: CI/CD Pipeline on: push: branches: [ main ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:14-alpine env: POSTGRES_USER: todoist POSTGRES_PASSWORD: testpassword POSTGRES_DB: todoist_test options: - --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 redis: image: redis:7-alpine ports: - 6379:6379 steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: ‘20’ cache: ‘npm’ - run: npm ci - run: npm run build - run: npm test env: DATABASE_URL: “postgresql://todoist:testpasswordlocalhost:5432/todoist_test?schemapublic” REDIS_URL: “redis://localhost:6379” deploy: needs: test if: github.ref ‘refs/heads/main’ github.event_name ‘push’ runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: amondnet/vercel-actionv25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-args: ‘--prod’ # 部署到生产环境流程解读触发条件当代码推送到main分支或创建指向main的 Pull Request 时触发。测试任务test在 Ubuntu 最新版环境中运行。使用services配置启动测试用的 PostgreSQL 和 Redis 容器这与本地开发环境高度一致。安装依赖、构建项目、运行测试并传递测试数据库的环境变量。部署任务deploy仅在test任务成功且是推送到main分支时运行。使用amondnet/vercel-action这个社区 Action 来执行 Vercel 部署命令。所需的VERCEL_TOKEN、VERCEL_ORG_ID、VERCEL_PROJECT_ID需要预先在 GitHub 仓库的 Settings - Secrets and variables - Actions 中设置。5.3 部署到 Vercel 的生产环境配置将 Next.js 应用部署到 Vercel 非常顺畅但有几个生产环境特有的点需要注意环境变量在 Vercel 项目仪表板的Settings - Environment Variables中添加生产环境的DATABASE_URL和REDIS_URL。切勿使用本地 Docker 的地址你需要一个云数据库服务如 Supabase、Neon、Aiven for PostgreSQL和云 Redis 服务如 Upstash、Redis Cloud。Prisma 与构建优化在 Vercel 的构建命令中需要确保 Prisma Client 被正确生成。项目的package.json中build脚本很可能已经是prisma generate prisma migrate deploy next build。prisma migrate deploy会应用所有未执行的迁移。Serverless 函数超时如果你的 Server Actions 或 API 路由执行时间较长如处理大量数据需要注意 Vercel Serverless 函数的默认超时时间Hobby 计划为 10 秒Pro 计划为 15 秒。对于耗时操作如发送大量邮件应考虑将其移至后台任务如使用 Vercel Cron Jobs 触发一个无服务器函数或使用第三方任务队列。后台 Worker 的部署提醒系统的后台 Worker 脚本reminder-worker.js不能直接部署在 Vercel 的无服务器函数上因为它需要长期运行。你有几个选择使用单独的服务器在一台始终在线的服务器如 DigitalOcean Droplet、AWS EC2上运行 Worker。使用容器化服务将 Worker 打包成 Docker 镜像部署到 Google Cloud Run、AWS ECS 或 Fly.io 等支持常驻容器的服务。使用云函数定时触发器将轮询逻辑改为由云函数的定时触发器如 Vercel Cron Jobs、AWS EventBridge每分钟调用一次每次调用时处理一批到期的提醒。这更符合无服务器架构但需要重新设计 Worker 的逻辑使其变成无状态的批处理任务。部署避坑指南数据库连接池在 Serverless 环境下每个函数实例都可能创建新的数据库连接。务必使用连接池并设置合理的connection_limit。Prisma 默认会管理连接池。冷启动与 PrismaPrisma Client 在冷启动时生成可能会增加函数初始化时间。考虑在构建时预先生成 Client或使用像prisma/client/edge这样的预览版如果适用。静态资源将图标如public/cloud-icon.svg等静态资源放在public目录下Vercel 会自动处理。自定义域名项目使用todoist.cloud域名。你需要在 Vercel 项目设置中添加自定义域名并在你的域名注册商处配置 CNAME 记录指向 Vercel 提供的地址。通过这套从本地开发、测试到自动化部署的完整流程一个现代、健壮的全栈待办事项应用就具备了持续交付的能力。它不仅是一个可用的产品更是一个展示了当前前端最佳实践的、高质量的学习范本。