news 2026/5/8 6:33:30

基于Next.js与Redis的全栈待办应用:架构设计与工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于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)或浏览器 API(window)时,才在文件顶部添加‘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 文件只会包含你实际使用过的样式,体积非常小。

为何引入 Flowbite?Tailwind 提供了强大的“原材料”(工具类),但构建一致的、可访问的交互式组件(如模态框、下拉菜单、日期选择器)仍然需要大量工作。Flowbite 填补了这个空白。它是一套基于 Tailwind 构建的开源 UI 组件库。项目很可能用它来快速搭建了应用中的一些复杂交互元素,比如用于设置截止日期的日期选择器、或者提醒设置的下拉菜单。这避免了重复造轮子,保证了 UI 的交互质量和一致性。

注意事项:Flowbite 的集成方式。Flowbite 可以通过 npm 安装,但其交互组件(需要 JavaScript 的)通常需要初始化。你需要确保在客户端入口文件(比如app/layout.tsx如果是客户端组件,或者一个单独的客户端脚本)中导入并初始化 Flowbite。通常的写法是import ‘flowbite’;。如果发现日期选择器弹不出来或下拉菜单不工作,首先检查这一步是否到位。

2.3 后端与数据层:PostgreSQL 与 Redis 的职责分离

这是整个应用架构的核心。项目清晰地使用了两种数据库,各司其职。

PostgreSQL (v14) – 系统的“硬盘”:作为主要的关系型数据库,它负责存储所有需要持久化、结构化且关系复杂的数据。根据功能推断,其数据库 schema 至少会包含以下几张表:

  1. User:用户表(如果支持多用户)。
  2. TodoList:任务清单表,包含标题、创建时间、归档状态等字段。
  3. TodoItem:任务项表,与TodoList关联,包含内容、完成状态、截止日期等字段。
  4. Reminder:提醒表,与TodoItem关联,包含触发时间、通知方式等。

使用 Prisma 作为 ORM(从prisma migrate命令可知),使得用 TypeScript 类型安全地操作数据库变得非常优雅。Prisma Schema 不仅定义了数据库结构,还能自动生成强类型的 Client,在编码时就能获得智能提示和类型检查,极大减少了运行时错误。

Redis (v7) – 系统的“内存”与“闹钟”:Redis 在这里扮演了两个关键角色:

  1. 缓存:频繁访问但更新不频繁的数据,比如用户的公开清单信息,可以缓存在 Redis 中,减轻 PostgreSQL 的压力,提升响应速度。
  2. 提醒调度器:这是更巧妙的用法。设置任务提醒的本质是一个“延时任务”。当用户创建一个未来时间的提醒时,系统可以将这个提醒任务(包含触发时间和任务ID)放入 Redis 的 Sorted Set(有序集合)中,以触发时间为分数(score)。然后,需要一个后台工作进程(Worker)定期(例如每秒)轮询这个 Sorted Set,检查是否有到期的任务(分数小于当前时间戳)。一旦发现,就取出任务ID,执行发送通知等逻辑,并从集合中移除。这种方式比在 PostgreSQL 中轮询WHERE reminder_time <= NOW()要高效得多,是处理延时任务的经典模式。

深度解析:Redis 有序集合实现提醒。假设一个任务ID是task:123,需要在北京时间1739452800000(一个时间戳)触发。工作流程如下:

  1. 创建提醒时:ZADD reminders 1739452800000 “task:123”
  2. 后台 Worker 循环执行:ZRANGEBYSCORE reminders -inf <current_timestamp> WITHSCORES,获取所有已到期的任务。
  3. 对每个获取到的任务ID,处理业务逻辑(如发送Web通知、邮件)。
  4. 处理成功后: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 而不是 Cypress?Playwright 由微软开发,支持 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: aIU0Ys5hrBPho647FLBpzl+Q37IM5mQhTgUhTqt25mE= 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:

关键点解析

  1. 镜像选择:使用了-alpine版本的镜像,这是基于 Alpine Linux 的轻量级版本,可以显著减少镜像体积,加速拉取和启动速度。
  2. 密码安全:数据库密码直接写在 compose 文件中。这对于本地开发是方便的,但对于生产环境是绝对危险的。生产环境必须通过环境变量或密钥管理服务注入。项目 README 中给出的密码是一个复杂的字符串,这比简单的password要好,但在真正的项目中,即使是开发环境,也建议使用.env文件并通过env_file配置项引入。
  3. 数据持久化:使用了 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:aIU0Ys5hrBPho647FLBpzl+Q37IM5mQhTgUhTqt25mE=@localhost:5432/todoist?schema=public” REDIS_URL=“redis://localhost:6379”

这里有一个至关重要的步骤:运行数据库迁移。光有数据库服务不行,还需要在里面创建符合 Prisma Schema 定义的表结构。执行:

npx prisma migrate dev

这个命令会做几件事:

  1. 读取prisma/schema.prisma文件。
  2. 与已连接的数据库对比,生成一个迁移文件(如果 schema 有变化)。
  3. 将这个迁移文件应用到数据库,创建或更新表。
  4. 同时会运行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 解决常见的本地开发连接问题

即使按照步骤操作,你也可能会遇到连接失败的问题。以下是一些排查思路:

  1. 数据库连接拒绝:首先确认 Docker 容器是否真的在运行:docker-compose ps。如果 PostgreSQL 状态不是Up,查看日志:docker-compose logs postgres。常见原因是端口冲突。你可以尝试修改docker-compose.yml中的端口映射,比如将“5432:5432”改为“5433:5432”,同时记得更新.env中的DATABASE_URL
  2. Prisma 迁移失败:错误信息可能五花八门。首先确保你的.env文件位于项目根目录,并且变量名正确。其次,尝试重置数据库(注意:这会清空所有数据!):
    # 先停止并删除容器和卷 docker-compose down -v # 重新启动 docker-compose up -d # 重新运行迁移 npx prisma migrate dev
  3. Redis 连接问题:Node.js 应用连接 Redis 通常使用ioredisredis包。确保你的应用代码中使用的 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确保了删除一个清单时,其下的所有任务和提醒会被自动删除,避免了数据孤儿。
  • 时间戳createdAtupdatedAt是审计和排序的必备字段。
  • 二进制目标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/apiapp/api路由,直接在组件调用的地方定义逻辑。
  • 渐进增强:即使 JavaScript 被禁用,表单仍能提交(虽然体验会降级)。
  • 类型安全:结合 Zod 进行验证,从表单到数据库都有类型保障。

实操心得:revalidatePathredirect。在 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 应用运行。项目可以使用bullagenda等库,或者自己实现一个简单的轮询脚本。这里展示一个使用 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 容器来管理其进程。

重要提醒:生产环境考虑。上述简单轮询适用于轻量级应用。对于高并发场景,需要考虑:

  1. 并发控制:多个 Worker 实例可能同时处理同一个任务,需要使用 Redis 锁(如SETNX)或更专业的任务队列(如 BullMQ)。
  2. 错误处理与重试:网络或通知服务失败时,应有重试机制和死信队列。
  3. 可观测性:记录处理日志和指标,方便监控。
  4. 资源管理:确保 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/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ‘20’ cache: ‘npm’ - run: npm ci - run: npm run build - run: npm test env: DATABASE_URL: “postgresql://todoist:testpassword@localhost:5432/todoist_test?schema=public” 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/checkout@v4 - uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-args: ‘--prod’ # 部署到生产环境

流程解读

  1. 触发条件:当代码推送到main分支或创建指向main的 Pull Request 时触发。
  2. 测试任务(test)
    • 在 Ubuntu 最新版环境中运行。
    • 使用services配置启动测试用的 PostgreSQL 和 Redis 容器,这与本地开发环境高度一致。
    • 安装依赖、构建项目、运行测试,并传递测试数据库的环境变量。
  3. 部署任务(deploy)
    • 仅在test任务成功且是推送到main分支时运行。
    • 使用amondnet/vercel-action这个社区 Action 来执行 Vercel 部署命令。
    • 所需的VERCEL_TOKENVERCEL_ORG_IDVERCEL_PROJECT_ID需要预先在 GitHub 仓库的 Settings -> Secrets and variables -> Actions 中设置。

5.3 部署到 Vercel 的生产环境配置

将 Next.js 应用部署到 Vercel 非常顺畅,但有几个生产环境特有的点需要注意:

  1. 环境变量:在 Vercel 项目仪表板的Settings -> Environment Variables中,添加生产环境的DATABASE_URLREDIS_URL切勿使用本地 Docker 的地址,你需要一个云数据库服务(如 Supabase、Neon、Aiven for PostgreSQL)和云 Redis 服务(如 Upstash、Redis Cloud)。
  2. Prisma 与构建优化:在 Vercel 的构建命令中,需要确保 Prisma Client 被正确生成。项目的package.jsonbuild脚本很可能已经是prisma generate && prisma migrate deploy && next buildprisma migrate deploy会应用所有未执行的迁移。
  3. Serverless 函数超时:如果你的 Server Actions 或 API 路由执行时间较长(如处理大量数据),需要注意 Vercel Serverless 函数的默认超时时间(Hobby 计划为 10 秒,Pro 计划为 15 秒)。对于耗时操作(如发送大量邮件),应考虑将其移至后台任务(如使用 Vercel Cron Jobs 触发一个无服务器函数,或使用第三方任务队列)。
  4. 后台 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 默认会管理连接池。
  • 冷启动与 Prisma:Prisma Client 在冷启动时生成可能会增加函数初始化时间。考虑在构建时预先生成 Client,或使用像@prisma/client/edge这样的预览版(如果适用)。
  • 静态资源:将图标(如public/cloud-icon.svg)等静态资源放在public目录下,Vercel 会自动处理。
  • 自定义域名:项目使用todoist.cloud域名。你需要在 Vercel 项目设置中添加自定义域名,并在你的域名注册商处配置 CNAME 记录指向 Vercel 提供的地址。

通过这套从本地开发、测试到自动化部署的完整流程,一个现代、健壮的全栈待办事项应用就具备了持续交付的能力。它不仅是一个可用的产品,更是一个展示了当前前端最佳实践的、高质量的学习范本。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/8 6:25:47

工业传感器(NPN/PNP)与三极管应用技术总结手册

一、 核心物理特性:谁在“推”?谁在“吸”? 传感器是 NPN 还是 PNP,本质上描述了其输出级三极管的安装位置和极性。 特性 PNP 型 (源型 - Source) NPN 型 (漏型 - Sink) 物理隐喻 推手:把电流从 $VCC$ 往外推 吸尘器:把电流从外面吸入 $GND$ 内部开关连接 连接到 电源正…

作者头像 李华
网站建设 2026/5/8 6:25:34

量化交易执行引擎QuantClaw:从架构设计到实战部署全解析

1. 项目概述&#xff1a;量化交易策略的“爪子”是什么&#xff1f;如果你在GitHub上搜索过量化交易相关的开源项目&#xff0c;大概率会看到过“QuantClaw”这个名字。乍一看&#xff0c;这个项目标题有点意思——“Quant”是量化&#xff0c;“Claw”是爪子&#xff0c;合起来…

作者头像 李华
网站建设 2026/5/8 6:20:31

内存级向量检索库memsearch:原理、实战与性能调优

1. 项目概述&#xff1a;向量检索的“内存级”加速方案最近在折腾RAG&#xff08;检索增强生成&#xff09;应用时&#xff0c;向量数据库的检索延迟成了性能瓶颈。尤其是在处理高并发、低延迟的在线服务场景&#xff0c;即使是最优的索引&#xff0c;一次检索也常常需要几十到…

作者头像 李华
网站建设 2026/5/8 6:17:45

生产级文本嵌入推理引擎TEI:从模型服务化到高性能部署实战

1. 项目概述&#xff1a;从模型服务到生产级嵌入推理引擎如果你在AI应用开发&#xff0c;特别是涉及大语言模型或检索增强生成&#xff08;RAG&#xff09;的领域工作过&#xff0c;那么“模型服务化”这个痛点你一定深有体会。我们训练或微调出一个表现优异的文本嵌入模型&…

作者头像 李华