Skip to Content
⏱ 本页预计时间
阅读 14 分钟 · 练习 60 分钟

4.2 数据库与 CRUD 操作

上一节你学会了用别人的 API,这一节学会存储和管理自己的数据

🎯

学完这节你会

  • 理解什么是数据库(用 Excel 比喻)
  • 会创建数据库表
  • 会做 CRUD 操作(增删改查)
  • 做一个完整的 Todo List 或留言板

什么是数据库?用 Excel 比喻秒懂

数据库就像一个超级 Excel 表格

ID用户名邮箱创建时间
1张三[email protected]2025-01-01
2李四[email protected]2025-01-02

区别是:

  • ✅ Excel:你自己在电脑上编辑
  • ✅ 数据库:服务器上存储,可以多人同时访问
  • ✅ 数据库更快、更安全、支持复杂查询

CRUD 是什么?

CRUD = Create(增)、Read(查)、Update(改)、Delete(删)

操作SQL 关键词对应 HTTP 方法就像 Excel 里
INSERTPOST新增一行
SELECTGET查找数据
UPDATEPUT/PATCH修改单元格
DELETEDELETE删除一行

选择数据库:Supabase vs Vercel Postgres

小白友好的两个选择:

Supabase - 新手最友好

优势

  • ✅ 免费额度大(500MB 数据库,50MB 文件存储)
  • ✅ 有可视化界面,像操作 Excel 一样
  • ✅ 自带用户认证、文件存储
  • ✅ 实时数据库(WebSocket)

缺点

  • ❌ 需要单独注册账号
  • ❌ 服务器在国外(可能需要科学上网)

适合

  • 想快速上手
  • 需要用户登录功能
  • 要做实时应用

本节选择 Supabase(因为新手更友好,有可视化界面)


实战:Todo List 完整开发

我们从零开始做一个完整的 Todo List,包含所有 CRUD 操作。

步骤 1:创建 Supabase 项目

  1. 访问 Supabase 
  2. 注册并登录
  3. 点击「New Project」
  4. 填写:
    • Name: my-todo-app
    • Database Password: 设置一个密码(记住它!)
    • Region: 选最近的(如 Singapore)
  5. 等待项目创建(约 2 分钟)

步骤 2:创建数据表

  1. 进入项目,点击左侧「Table Editor」
  2. 点击「Create a new table」
  3. 填写表信息:
Table name: todos Columns: - id (bigint, primary key) - 自动创建 - created_at (timestamptz) - 自动创建 - task (text) - 手动添加,Required 打勾 - completed (bool) - 手动添加,Default value: false - user_id (uuid) - 暂时不加(等学了认证再说)
  1. 点击「Save」

恭喜!你的第一张数据表创建好了!

步骤 3:手动添加测试数据

在 Table Editor 里点击「Insert」→「Insert row」,添加几条测试数据:

task: 学习 API 调用 completed: false task: 做一个 Todo App completed: true

步骤 4:获取 API 密钥

  1. 点击左侧「Project Settings」→「API」
  2. 复制两个关键信息:
    • Project URLhttps://xxxxx.supabase.co
    • anon public keyeyJxxx...很长一串

步骤 5:在 Next.js 项目中配置

安装 Supabase 客户端:

pnpm install @supabase/supabase-js

.env.local 添加:

NEXT_PUBLIC_SUPABASE_URL=你的Project_URL NEXT_PUBLIC_SUPABASE_ANON_KEY=你的anon_public_key

步骤 6:创建 Supabase 客户端

创建 lib/supabase.ts

import { createClient } from '@supabase/supabase-js' const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! export const supabase = createClient(supabaseUrl, supabaseKey)

步骤 7:创建 Todo List 页面

创建 app/todos/page.tsx

'use client' import { useEffect, useState } from 'react' import { supabase } from '@/lib/supabase' type Todo = { id: number task: string completed: boolean created_at: string } export default function TodosPage() { const [todos, setTodos] = useState<Todo[]>([]) const [newTask, setNewTask] = useState('') const [loading, setLoading] = useState(false) // 读取(Read)- 获取所有 todos const fetchTodos = async () => { const { data, error } = await supabase .from('todos') .select('*') .order('created_at', { ascending: false }) if (error) { console.error('获取失败:', error) } else { setTodos(data || []) } } // 增加(Create)- 添加新 todo const addTodo = async () => { if (!newTask.trim()) return setLoading(true) const { data, error } = await supabase .from('todos') .insert([{ task: newTask, completed: false }]) .select() if (error) { console.error('添加失败:', error) } else { setTodos([...data, ...todos]) setNewTask('') } setLoading(false) } // 更新(Update)- 切换完成状态 const toggleTodo = async (id: number, completed: boolean) => { const { error } = await supabase .from('todos') .update({ completed: !completed }) .eq('id', id) if (error) { console.error('更新失败:', error) } else { fetchTodos() // 重新获取数据 } } // 删除(Delete) const deleteTodo = async (id: number) => { const { error } = await supabase .from('todos') .delete() .eq('id', id) if (error) { console.error('删除失败:', error) } else { setTodos(todos.filter(todo => todo.id !== id)) } } // 页面加载时获取数据 useEffect(() => { fetchTodos() }, []) return ( <div className="max-w-2xl mx-auto p-8"> <h1 className="text-3xl font-bold mb-6">我的 Todo List</h1> {/* 添加新任务 */} <div className="flex gap-2 mb-6"> <input type="text" value={newTask} onChange={(e) => setNewTask(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && addTodo()} placeholder="输入新任务..." className="flex-1 border p-2 rounded" /> <button onClick={addTodo} disabled={loading} className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50" > {loading ? '添加中...' : '添加'} </button> </div> {/* Todo 列表 */} <div className="space-y-2"> {todos.map((todo) => ( <div key={todo.id} className="flex items-center gap-2 p-3 border rounded hover:bg-gray-50" > <input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo.id, todo.completed)} className="w-5 h-5" /> <span className={`flex-1 ${todo.completed ? 'line-through text-gray-500' : ''}`}> {todo.task} </span> <button onClick={() => deleteTodo(todo.id)} className="text-red-500 hover:text-red-700" > 删除 </button> </div> ))} {todos.length === 0 && ( <p className="text-center text-gray-500 py-8">还没有任务,添加一个吧!</p> )} </div> </div> ) }

步骤 8:测试

  1. 启动项目:pnpm run dev
  2. 访问 http://localhost:3000/todos
  3. 试试所有功能:
    • ✅ 添加新任务
    • ✅ 标记完成
    • ✅ 删除任务

如果都能正常工作,恭喜你完成了第一个 CRUD 应用!


CRUD 操作详解

让我们深入理解每个操作:

1. Create(增加)

// 基本语法 const { data, error } = await supabase .from('表名') .insert([{ 字段1: 值1, 字段2: 值2 }]) .select() // 返回插入的数据 // 实际例子 const { data, error } = await supabase .from('todos') .insert([ { task: '学习 CRUD', completed: false }, { task: '做项目', completed: false } ]) .select()

2. Read(查询)

// 查询所有 const { data } = await supabase .from('todos') .select('*') // 带条件查询 const { data } = await supabase .from('todos') .select('*') .eq('completed', true) // 查询已完成的 // 排序 const { data } = await supabase .from('todos') .select('*') .order('created_at', { ascending: false }) // 最新的在前 // 分页 const { data } = await supabase .from('todos') .select('*') .range(0, 9) // 前 10 条 // 查询单条 const { data } = await supabase .from('todos') .select('*') .eq('id', 123) .single() // 只返回一条

3. Update(更新)

// 基本语法 const { error } = await supabase .from('表名') .update({ 字段: 新值 }) .eq('id', 要更新的ID) // 实际例子 const { error } = await supabase .from('todos') .update({ completed: true }) .eq('id', 123) // 更新多个字段 const { error } = await supabase .from('todos') .update({ task: '新的任务名', completed: true }) .eq('id', 123)

4. Delete(删除)

// 基本语法 const { error } = await supabase .from('表名') .delete() .eq('id', 要删除的ID) // 删除多条(慎用!) const { error } = await supabase .from('todos') .delete() .eq('completed', true) // 删除所有已完成的

常见查询操作

Supabase 支持很多查询条件:

// 等于 .eq('column', 'value') // 不等于 .neq('column', 'value') // 大于 / 小于 .gt('age', 18) // 大于 .lt('age', 60) // 小于 .gte('age', 18) // 大于等于 .lte('age', 60) // 小于等于 // 包含(数组) .in('status', ['active', 'pending']) // 模糊搜索 .like('task', '%学习%') // 包含"学习" .ilike('task', '%学习%') // 不区分大小写 // 多条件组合 .eq('completed', false) .like('task', '%重要%') .order('created_at', { ascending: false }) .limit(10)

实战进阶:留言板

在 Todo List 基础上,我们再做一个留言板,学会更多技巧。

步骤 1:创建留言表

在 Supabase Table Editor 创建新表:

Table name: messages Columns: - id (bigint, primary key) - created_at (timestamptz) - author (text) - Required - content (text) - Required - likes (int4) - Default: 0

步骤 2:创建留言板页面

创建 app/guestbook/page.tsx

'use client' import { useEffect, useState } from 'react' import { supabase } from '@/lib/supabase' type Message = { id: number author: string content: string likes: number created_at: string } export default function Guestbook() { const [messages, setMessages] = useState<Message[]>([]) const [author, setAuthor] = useState('') const [content, setContent] = useState('') const fetchMessages = async () => { const { data } = await supabase .from('messages') .select('*') .order('created_at', { ascending: false }) if (data) setMessages(data) } const addMessage = async () => { if (!author.trim() || !content.trim()) { alert('请填写完整信息') return } const { error } = await supabase .from('messages') .insert([{ author, content, likes: 0 }]) if (error) { console.error('添加失败:', error) } else { setAuthor('') setContent('') fetchMessages() } } const addLike = async (id: number, currentLikes: number) => { await supabase .from('messages') .update({ likes: currentLikes + 1 }) .eq('id', id) fetchMessages() } useEffect(() => { fetchMessages() }, []) return ( <div className="max-w-2xl mx-auto p-8"> <h1 className="text-3xl font-bold mb-6">留言板</h1> {/* 发表留言 */} <div className="border rounded p-4 mb-6"> <input type="text" value={author} onChange={(e) => setAuthor(e.target.value)} placeholder="你的名字" className="w-full border p-2 rounded mb-2" /> <textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="写点什么..." rows={3} className="w-full border p-2 rounded mb-2" /> <button onClick={addMessage} className="bg-blue-500 text-white px-4 py-2 rounded" > 发表留言 </button> </div> {/* 留言列表 */} <div className="space-y-4"> {messages.map((msg) => ( <div key={msg.id} className="border rounded p-4"> <div className="flex justify-between items-start mb-2"> <div> <p className="font-bold">{msg.author}</p> <p className="text-sm text-gray-500"> {new Date(msg.created_at).toLocaleString('zh-CN')} </p> </div> <button onClick={() => addLike(msg.id, msg.likes)} className="flex items-center gap-1 text-red-500 hover:text-red-700" > ❤️ {msg.likes} </button> </div> <p className="text-gray-700">{msg.content}</p> </div> ))} </div> </div> ) }

数据验证和安全

重要:上面的代码还不够安全!真实项目需要:

  1. 输入验证

    if (task.length > 100) { alert('任务名太长了!') return }
  2. 防止 XSS 攻击

    • 不要直接用 dangerouslySetInnerHTML
    • Supabase 自动处理了 SQL 注入问题
  3. Row Level Security (RLS)

    • 在 Supabase 设置权限策略
    • 防止用户删除别人的数据

我们在 4.3 会详细讲安全问题!


常见卡点和解决方案

新手常见问题

  1. 数据更新了但页面没刷新

    // 更新后重新获取数据 await supabase.from('todos').update({...}) fetchTodos() // 别忘了这行!
  2. Cannot read property 'map' of null

    // 数据还没加载时显示加载中 {!todos ? ( <p>加载中...</p> ) : ( todos.map(...) )}
  3. 环境变量获取不到

    # 修改 .env.local 后要重启服务器 Ctrl+C 停止 pnpm run dev 重启
  4. Supabase 报错 JWT expired

    API Key 过期了,去 Supabase 重新生成

快速实践建议

Week 1: Todo List

按照上面的步骤做一个完整的 Todo List

Week 2: 加功能

  • 添加分类(工作/学习/生活)
  • 添加优先级(高/中/低)
  • 添加截止日期

Week 3: 留言板

做一个公开留言板,支持点赞

Week 4: 个人博客

做一个简单博客系统(文章 CRUD)


下一节4.3 编写后端服务 - 学会自己设计 API 和处理复杂逻辑

学习进度0%
0/60 篇已完成
Last updated on