用Next.js 构建一个简单的 CRUD 应用:集成 API 路由和数据获取
作者:码力无边
在过去的几篇文章中,我们分别深入了 Next.js 的两个核心领域:前端的数据获取策略 (SSG, SSR, CSR) 和后端的 API 路由。我们学会了如何展示数据,也学会了如何创建提供数据的 API。现在,是时候将这两者串联起来,构建一个完整、动态、可交互的全栈应用了。
本文将通过一个经典的实例——一个简单的待办事项 (Todo) 列表应用——来指导你完成一个完整的 CRUD (Create, Read, Update, Delete) 流程。在这个过程中,你将综合运用到:
- API 路由:构建后端逻辑来处理数据的增删改查。
getServerSideProps
:在页面加载时获取初始的数据列表 (Read)。- 客户端数据获取 (SWR):在用户交互后,高效地更新 UI (Create, Update, Delete)。
这个项目将是你从理论走向实践的关键一步,让你真正体验到 Next.js 全栈开发的流畅与强大。
步骤一:搭建后端 - 我们的 API 路由
首先,我们需要为待办事项提供数据支持。我们将创建两个 API 端点:
/api/todos
:用于获取所有待办事项和创建新的待办事项。/api/todos/[id]
:用于更新和删除单个待办事项。
为了简单起见,我们依然使用一个内存中的数组来模拟数据库。
1. 创建 pages/api/todos.ts
// pages/api/todos.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export type Todo = {
id: number;
text: string;
completed: boolean;
};
// 模拟数据库
let todos: Todo[] = [
{ id: 1, text: '学习 Next.js API 路由', completed: true },
{ id: 2, text: '构建一个 CRUD 应用', completed: false },
{ id: 3, text: '部署到 Vercel', completed: false },
];
export default function handler(req: NextApiRequest, res: NextApiResponse) {
switch (req.method) {
case 'GET':
// 获取所有 todos
res.status(200).json(todos);
break;
case 'POST':
// 创建一个新的 todo
const { text } = req.body;
if (!text) {
return res.status(400).json({ error: 'Text is required' });
}
const newTodo: Todo = {
id: Date.now(),
text,
completed: false,
};
todos.push(newTodo);
res.status(201).json(newTodo);
break;
default:
res.setHeader('Allow', ['GET', 'POST']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
2. 创建 pages/api/todos/[id].ts
// pages/api/todos/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import type { Todo } from './'; // 从同级 index (todos.ts) 导入类型
// 这里的 todos 数组需要在模块间共享,实际项目中会用数据库
// 为了简化,我们假设这里的修改能影响到 todos.ts 中的数组
// 注意:在无服务器环境下,这种内存共享是不可靠的!仅用于演示。
// 更好的方式是从一个共享的文件或真正的数据库中导入/导出
import { todos } from './_db'; // 假设我们将 todos 移动到了一个 _db.ts 文件
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query;
const todoId = parseInt(id as string, 10);
let todoIndex = todos.findIndex((t) => t.id === todoId);
if (todoIndex === -1) {
return res.status(404).json({ error: 'Todo not found' });
}
switch (req.method) {
case 'PUT':
// 更新一个 todo (切换 completed 状态或修改文本)
const { text, completed } = req.body;
const originalTodo = todos[todoIndex];
todos[todoIndex] = { ...originalTodo, text: text ?? originalTodo.text, completed: completed ?? originalTodo.completed };
res.status(200).json(todos[todoIndex]);
break;
case 'DELETE':
// 删除一个 todo
todos.splice(todoIndex, 1);
res.status(204).end();
break;
default:
res.setHeader('Allow', ['PUT', 'DELETE']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
// 注意:为了让数据在 API 路由间共享,你需要将内存数组 `todos` 提取到一个单独的文件中(如 `lib/db.ts`)并从两个 API 文件中导入。
_db.ts
的说明: 现实中内存数组在Serverless函数间不共享,需要数据库。为模拟,可创建pages/api/_db.ts
,导出todos
数组,再在两个API文件中导入。
我们的后端现在已经准备就绪!
步骤二:构建前端 - 页面和组件
我们将创建一个主页面 pages/index.tsx
来展示和管理待办事项。
1. 页面初始数据加载 (Read - SSR)
我们希望用户打开页面时能立即看到待办事项列表,这对于 SEO 和用户体验都很好。因此,我们使用 getServerSideProps
。
// pages/index.tsx
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import type { Todo } from './api/todos'; // 复用 API 中的类型
import TodoList from '../components/TodoList';
// 从我们自己的 API 获取初始数据
export const getServerSideProps: GetServerSideProps<{ initialTodos: Todo[] }> = async () => {
const res = await fetch('http://localhost:3000/api/todos');
const initialTodos: Todo[] = await res.json();
return {
props: {
initialTodos,
},
};
};
export default function HomePage({ initialTodos }: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<div>
<h1>我的待办事项</h1>
<TodoList initialData={initialTodos} />
</div>
);
}
2. 创建交互组件 (Create, Update, Delete - CSR with SWR)
现在,我们将交互逻辑封装在一个 <TodoList />
组件中。我们将使用 SWR 来管理客户端的数据状态,它能极大地简化数据同步和 UI 更新的逻辑。
首先,安装 SWR:npm install swr
components/TodoList.tsx
import useSWR, { useSWRConfig } from 'swr';
import type { Todo } from '../pages/api/todos';
import { useState } from 'react';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export default function TodoList({ initialData }: { initialData: Todo[] }) {
const { mutate } = useSWRConfig();
const { data: todos, error } = useSWR<Todo[]>('/api/todos', fetcher, {
fallbackData: initialData, // 使用 SSR 提供的初始数据
});
const [newTodoText, setNewTodoText] = useState('');
const handleCreateTodo = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTodoText.trim()) return;
// 乐观更新 UI
const tempId = Date.now();
const optimisticData = [...(todos || []), { id: tempId, text: newTodoText, completed: false }];
mutate('/api/todos', optimisticData, false);
// 发送请求
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newTodoText }),
});
// 请求结束后,触发 SWR 重新验证以获取最新数据
mutate('/api/todos');
setNewTodoText('');
};
const handleToggleComplete = async (todo: Todo) => {
// 乐观更新
const updatedTodos = todos?.map(t => t.id === todo.id ? { ...t, completed: !t.completed } : t);
mutate('/api/todos', updatedTodos, false);
await fetch(`/api/todos/${todo.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed }),
});
mutate('/api/todos');
};
const handleDeleteTodo = async (id: number) => {
// 乐观更新
const filteredTodos = todos?.filter(t => t.id !== id);
mutate('/api/todos', filteredTodos, false);
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
mutate('/api/todos');
};
if (error) return <div>加载失败</div>;
if (!todos) return <div>加载中...</div>;
return (
<div>
<form onSubmit={handleCreateTodo}>
<input
type="text"
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="添加新的待办事项"
/>
<button type="submit">添加</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
<span onClick={() => handleToggleComplete(todo)} style={{ cursor: 'pointer' }}>
{todo.text}
</span>
<button onClick={() => handleDeleteTodo(todo.id)} style={{ marginLeft: '10px' }}>删除</button>
</li>
))}
</ul>
</div>
);
}
代码解读与核心概念
混合渲染模式:我们完美地结合了 SSR 和 CSR。页面首次加载时,通过
getServerSideProps
快速呈现内容 (SSR)。之后的所有交互(增删改),都由客户端处理 (CSR),提供了流畅的单页应用体验。SWR 的妙用:
fallbackData
: SWR 使用getServerSideProps
传来的initialData
作为初始状态,避免了客户端的二次请求。mutate
: 这是 SWR 的核心函数之一,用于手动更新缓存数据。- 乐观更新 (Optimistic UI):在
handleCreateTodo
等函数中,我们先假定 API 请求会成功,并立即更新本地 UI (mutate(..., ..., false)
)。这让应用感觉响应极快。然后,我们再发送真实的网络请求。请求完成后,再次调用mutate('/api/todos')
来与服务器的真实状态进行同步,确保数据一致性。这是一种提升用户体验的高级技巧。
总结
恭喜你!你刚刚构建了一个功能完整的 Next.js 全栈应用。通过这个项目,我们将零散的知识点串成了一条完整的价值链:
- 后端:我们使用 API 路由 创建了健壮的、符合 RESTful 风格的 API 来管理我们的数据资源。
- 前端 - 初始加载:我们使用
getServerSideProps
在服务器端获取初始数据,实现了快速的首屏加载和良好的 SEO 基础。 - 前端 - 动态交互:我们使用 CSR 模式,并借助 SWR 这样的现代化数据获取库,实现了高效、乐观的 UI 更新,提供了卓越的用户交互体验。
这个“SSR + CSR with SWR”的组合拳,是构建现代、高性能 Next.js 应用的黄金范式。它充分利用了 Next.js 在服务端和客户端的各自优势。
现在你已经具备了构建全栈应用的基础能力。在接下来的文章中,我们将继续深入 Next.js 的高级特性,比如如何使用 next/image
来优化应用的图片性能,让我们的应用不仅功能强大,而且速度飞快。敬请期待!