Next.js 入门进阶——与数据库相连

197 阅读7分钟

Next.js 入门进阶——与数据库相连

引言

大家好!今天我们来聊聊如何让 Next.js 应用与数据库"牵手成功"🤝。在现代 Web 开发中,前端页面与后端数据的结合是必不可少的一环。而 Next.js 作为全栈框架,提供了优雅的方式来实现这一目标。

本文将带你使用 Next.js 和 Prisma(一个现代化的 ORM 工具)构建一个简单的待办事项应用,让你轻松掌握数据库连接的秘诀!

基础概念

什么是 Supabase?

虽然本文不使用 Supabase,但值得一提的是它作为一个开源替代 Firebase 的后端即服务(BaaS)平台,提供了数据库、认证、存储等功能,与 Next.js 也能完美配合。不过今天我们的主角是更传统的自主数据库方案。

数据库开发与 ORM 工具

直接写 SQL 语句操作数据库虽然强大,但往往繁琐且容易出错。这时候 ORM(Object-Relational Mapping)工具就派上用场了!

ORM 的优势

  • 不需要写原生 SQL,像操作对象一样操作数据库
  • 提高开发效率,减少重复代码
  • 更好的类型安全和代码提示
  • 数据库迁移和版本控制

Prisma:数据库的工程化利器

Prisma 是一个现代化的数据库工具包,它包括:

  • Prisma Client: 自动生成且类型安全的查询构建器
  • Prisma Migrate: 声明式数据建模和迁移系统
  • Prisma Studio: 可视化数据库浏览器

Prisma 不仅帮助我们操作数据库,还为数据库变更留下了清晰的历史记录,让团队协作更加顺畅。

实战:构建一个简单的待办事项应用

技术栈选择

  • Next.js: 14+ (App Router)
  • Prisma: 作为 ORM 工具
  • MySQL: 数据库(也可替换为 PostgreSQL 或 SQLite)

项目初始化

首先,让我们创建一个新的 Next.js 项目:

npx create-next-app@latest my-todos

在提示中选择以下配置: image.png

完成后,进入项目目录并安装 Prisma 相关依赖:

cd my-todos
pnpm install prisma @prisma/client

数据库配置

1. 设置环境变量

在项目根目录下的 .env 文件中,添加数据库连接字符串:

DATABASE_URL="mysql://root:your_password@localhost:3306/todos_db"

注意:这里的数据库需要是新建的,不要使用已有数据的数据库,因为 Prisma 迁移会修改数据库结构,可能导致数据丢失。

2. 初始化 Prisma

运行以下命令初始化 Prisma:

npx prisma init

这个命令会:

  • 创建 prisma 目录
  • prisma 目录下生成 schema.prisma 文件
  • 检查 .env 文件是否存在,如果不存在则创建
3. 配置数据模型

编辑 prisma/schema.prisma 文件,定义我们的待办事项模型:

// 指定使用 Prisma 的客户端生成器
generator client {
  provider = "prisma-client-js"
}

// 配置数据源(数据库连接)
datasource db {
  provider = "mysql" // 也可以是 "postgresql" 或 "sqlite"
  url      = env("DATABASE_URL") // 从环境变量读取数据库连接字符串
}

// 定义 Todo 模型(对应数据库中的表)
model Todo {
  id        Int      @id @default(autoincrement()) // 主键,自增
  title     String   // 待办事项标题
  completed Boolean  @default(false) // 完成状态,默认为 false
  createdAt DateTime @default(now()) // 创建时间,默认为当前时间
}
4. 运行数据库迁移

现在运行迁移命令,将我们的数据模型应用到数据库:

npx prisma migrate dev --name init

这个命令会:

  1. prisma/migrations 目录下创建迁移文件
  2. 执行 SQL 语句在数据库中创建表
  3. 生成 Prisma Client

image.png

为什么创建了两个表?

迁移后的数据库结构

Prisma 迁移实际上创建了两个表:

  1. Todo 表:存储我们的待办事项数据
  2. _prisma_migrations 表:Prisma 内部使用,用于记录已执行的迁移,确保数据库架构与代码定义保持一致

后端 API 实现

Next.js 使用 App Router 后,API 路由放在了 app/api 目录下。每个子目录代表一个路由端点,其中的 route.ts 文件定义了处理 HTTP 方法的函数。

1. 获取所有待办事项和创建新待办事项

image.png

创建 src/app/api/todos/route.ts

import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// 处理 POST 请求 - 创建新待办事项
export async function POST(req: Request) {
  try {
    const { title } = await req.json();
    
    // 输入验证
    if (!title || title.trim() === '') {
      return NextResponse.json(
        { error: '标题不能为空' },
        { status: 400 }
      );
    }
    
    const todo = await prisma.todo.create({
      data: {
        title: title.trim()
      }
    });
    
    return NextResponse.json(todo);
  } catch (error) {
    return NextResponse.json(
      { error: '创建待办事项失败' },
      { status: 500 }
    );
  }
}

// 处理 GET 请求 - 获取所有待办事项
export async function GET() {
  try {
    const todos = await prisma.todo.findMany({
      orderBy: {
        createdAt: "desc" // 按创建时间降序排列
      }
    });
    
    return NextResponse.json(todos);
  } catch (error) {
    return NextResponse.json(
      { error: '获取待办事项失败' },
      { status: 500 }
    );
  }
}
2. 更新和删除待办事项

创建 src/app/api/todos/[id]/route.ts

[id] 目录的作用:Next.js 使用方括号 [] 表示动态路由段。这里的 [id] 可以匹配任何值,并通过 params.id 访问。

import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// 处理 PATCH 请求 - 更新待办事项状态
export async function PATCH(
  req: Request,
  { params }: { params: { id: string } }
) {
  try {
    const { completed } = await req.json();
    
    // 验证 ID 是否有效
    if (isNaN(Number(params.id))) {
      return NextResponse.json(
        { error: '无效的待办事项 ID' },
        { status: 400 }
      );
    }
    
    const todo = await prisma.todo.update({
      where: { id: Number(params.id) },
      data: { completed }
    });
    
    return NextResponse.json(todo);
  } catch (error) {
    return NextResponse.json(
      { error: '更新待办事项失败' },
      { status: 500 }
    );
  }
}

// 处理 DELETE 请求 - 删除待办事项
export async function DELETE(
  req: Request,
  { params }: { params: { id: string } }
) {
  try {
    // 验证 ID 是否有效
    if (isNaN(Number(params.id))) {
      return NextResponse.json(
        { error: '无效的待办事项 ID' },
        { status: 400 }
      );
    }
    
    const todo = await prisma.todo.delete({
      where: { id: Number(params.id) }
    });
    
    return NextResponse.json(todo);
  } catch (error) {
    return NextResponse.json(
      { error: '删除待办事项失败' },
      { status: 500 }
    );
  }
}

现在,你可以使用 API 测试工具(如 Apifox、Postman 或 Thunder Client)测试这些接口是否正常工作。

前端界面实现

类型定义

首先,创建类型定义文件 src/types/todo.ts

export interface Todo {
  id: number;
  title: string;
  completed: boolean;
  createdAt: string;
}
主页面组件

编辑 src/app/page.tsx

'use client' 的作用:Next.js 默认是服务端组件,但我们需要使用 React 的 useState、useEffect 等客户端特性,所以需要在文件顶部添加 'use client' 指令,告知 Next.js 这是一个客户端组件。

'use client';
import { useEffect, useState } from 'react';
import { Todo } from '@/types/todo';

export default function Home() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState('');
  const [loading, setLoading] = useState(false);

  // 组件挂载时获取待办事项列表
  useEffect(() => {
    fetchTodos();
  }, []);

  // 获取所有待办事项
  const fetchTodos = async () => {
    try {
      setLoading(true);
      const res = await fetch('/api/todos');
      if (!res.ok) throw new Error('获取数据失败');
      
      const data = await res.json();
      setTodos(data);
    } catch (error) {
      console.error('Error fetching todos:', error);
      alert('获取待办事项失败');
    } finally {
      setLoading(false);
    }
  };

  // 添加新待办事项
  const addTodo = async () => {
    if (input.trim() === '') {
      alert('请输入待办事项内容');
      return;
    }

    try {
      const res = await fetch('/api/todos', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          title: input,
        }),
      });
      
      if (!res.ok) throw new Error('添加失败');
      
      const newTodo = await res.json();
      setTodos([newTodo, ...todos]);
      setInput('');
    } catch (error) {
      console.error('Error adding todo:', error);
      alert('添加待办事项失败');
    }
  };

  // 删除待办事项
  const deleteTodo = async (id: number) => {
    if (!confirm('确定要删除这个待办事项吗?')) return;

    try {
      const res = await fetch(`/api/todos/${id}`, {
        method: 'DELETE',
      });
      
      if (!res.ok) throw new Error('删除失败');
      
      setTodos(todos.filter(todo => todo.id !== id));
    } catch (error) {
      console.error('Error deleting todo:', error);
      alert('删除待办事项失败');
    }
  };

  // 切换待办事项完成状态
  const toggleTodo = async (id: number, completed: boolean) => {
    try {
      const res = await fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          completed: !completed,
        }),
      });
      
      if (!res.ok) throw new Error('更新失败');
      
      const updatedTodo = await res.json();
      setTodos(todos.map(todo => 
        todo.id === id ? updatedTodo : todo
      ));
    } catch (error) {
      console.error('Error updating todo:', error);
      alert('更新待办事项失败');
    }
  };

  return (
    <main className="min-h-screen bg-gray-100 py-8">
      <div className="max-w-xl mx-auto bg-white p-6 rounded-lg shadow-md">
        <h1 className="text-2xl font-bold text-gray-800 mb-6">我的待办事项</h1>
        
        {/* 添加新待办事项的表单 */}
        <div className="flex gap-2 mb-6">
          <input
            type="text"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyPress={(e) => e.key === 'Enter' && addTodo()}
            placeholder="添加新的待办事项..."
            className="flex-1 p-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            disabled={loading}
          />
          <button
            onClick={addTodo}
            disabled={loading}
            className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 disabled:opacity-50"
          >
            添加
          </button>
        </div>
        
        {/* 待办事项列表 */}
        {loading && todos.length === 0 ? (
          <div className="text-center py-4">加载中...</div>
        ) : todos.length === 0 ? (
          <div className="text-center py-4 text-gray-500">
            暂无待办事项,添加一个吧!
          </div>
        ) : (
          <ul className="space-y-3">
            {todos.map(todo => (
              <li
                key={todo.id}
                className="flex justify-between items-center p-3 border border-gray-200 rounded-md hover:bg-gray-50"
              >
                <div className="flex items-center">
                  <input
                    type="checkbox"
                    checked={todo.completed}
                    onChange={() => toggleTodo(todo.id, todo.completed)}
                    className="h-5 w-5 text-blue-500 rounded focus:ring-blue-400 mr-3"
                  />
                  <span
                    className={`cursor-pointer select-none ${
                      todo.completed ? 'line-through text-gray-500' : 'text-gray-800'
                    }`}
                    onClick={() => toggleTodo(todo.id, todo.completed)}
                  >
                    {todo.title}
                  </span>
                </div>
                
                <button
                  onClick={() => deleteTodo(todo.id)}
                  disabled={loading}
                  className="text-red-500 hover:text-red-700 disabled:opacity-50"
                  title="删除待办事项"
                >
                  <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
                  </svg>
                </button>
              </li>
            ))}
          </ul>
        )}
      </div>
    </main>
  );
}

总结

通过这个简单的待办事项应用,我们学习了:

  1. Prisma 的基本概念和使用方法

    • 使用 npx prisma init 初始化 Prisma
    • 定义数据模型并在 schema.prisma 中配置
    • 使用 npx prisma migrate dev --name init 创建数据库迁移
  2. Next.js API 路由的实现

    • app/api 目录下创建后端接口
    • 使用动态路由 [id] 处理单个资源的操作
  3. 前端与后端的交互

    • 使用 fetch API 调用后端接口
    • 客户端组件需要使用 'use client' 指令
    • 实现 CRUD(创建、读取、更新、删除)操作
  4. 错误处理和用户体验

    • 添加加载状态和错误提示
    • 用户操作确认机制

这个项目虽然简单,但涵盖了 Next.js 与数据库相连的核心概念。你可以在此基础上继续扩展,比如添加用户认证、分类标签、截止日期等功能。

提示:本文所有代码已上传至 GitHub 仓库,欢迎 Star 和 Fork!