⏱ 本页预计时间
阅读 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 里 |
|---|---|---|---|
| 增 | INSERT | POST | 新增一行 |
| 查 | SELECT | GET | 查找数据 |
| 改 | UPDATE | PUT/PATCH | 修改单元格 |
| 删 | DELETE | DELETE | 删除一行 |
选择数据库:Supabase vs Vercel Postgres
小白友好的两个选择:
本节选择 Supabase(因为新手更友好,有可视化界面)
实战:Todo List 完整开发
我们从零开始做一个完整的 Todo List,包含所有 CRUD 操作。
步骤 1:创建 Supabase 项目
- 访问 Supabase
- 注册并登录
- 点击「New Project」
- 填写:
- Name:
my-todo-app - Database Password: 设置一个密码(记住它!)
- Region: 选最近的(如 Singapore)
- Name:
- 等待项目创建(约 2 分钟)
步骤 2:创建数据表
- 进入项目,点击左侧「Table Editor」
- 点击「Create a new table」
- 填写表信息:
Table name: todos
Columns:
- id (bigint, primary key) - 自动创建
- created_at (timestamptz) - 自动创建
- task (text) - 手动添加,Required 打勾
- completed (bool) - 手动添加,Default value: false
- user_id (uuid) - 暂时不加(等学了认证再说)- 点击「Save」
恭喜!你的第一张数据表创建好了!
步骤 3:手动添加测试数据
在 Table Editor 里点击「Insert」→「Insert row」,添加几条测试数据:
task: 学习 API 调用
completed: false
task: 做一个 Todo App
completed: true步骤 4:获取 API 密钥
- 点击左侧「Project Settings」→「API」
- 复制两个关键信息:
- Project URL:
https://xxxxx.supabase.co - anon public key:
eyJxxx...很长一串
- Project URL:
步骤 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:测试
- 启动项目:
pnpm run dev - 访问
http://localhost:3000/todos - 试试所有功能:
- ✅ 添加新任务
- ✅ 标记完成
- ✅ 删除任务
如果都能正常工作,恭喜你完成了第一个 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>
)
}数据验证和安全
重要:上面的代码还不够安全!真实项目需要:
-
输入验证
if (task.length > 100) { alert('任务名太长了!') return } -
防止 XSS 攻击
- 不要直接用
dangerouslySetInnerHTML - Supabase 自动处理了 SQL 注入问题
- 不要直接用
-
Row Level Security (RLS)
- 在 Supabase 设置权限策略
- 防止用户删除别人的数据
我们在 4.3 会详细讲安全问题!
常见卡点和解决方案
新手常见问题:
-
数据更新了但页面没刷新
// 更新后重新获取数据 await supabase.from('todos').update({...}) fetchTodos() // 别忘了这行! -
Cannot read property 'map' of null// 数据还没加载时显示加载中 {!todos ? ( <p>加载中...</p> ) : ( todos.map(...) )} -
环境变量获取不到
# 修改 .env.local 后要重启服务器 Ctrl+C 停止 pnpm run dev 重启 -
Supabase 报错
JWT expiredAPI Key 过期了,去 Supabase 重新生成
快速实践建议
Week 1: Todo List
按照上面的步骤做一个完整的 Todo List
Week 2: 加功能
- 添加分类(工作/学习/生活)
- 添加优先级(高/中/低)
- 添加截止日期
Week 3: 留言板
做一个公开留言板,支持点赞
Week 4: 个人博客
做一个简单博客系统(文章 CRUD)
下一节:4.3 编写后端服务 - 学会自己设计 API 和处理复杂逻辑
学习进度0%
0/60 篇已完成
Last updated on