用 Vercel Blob 做图片上传
学习目标
到本节课结束,学员应当能够:
- 通过提示词开一个 Vercel Blob 存储,让 AI 把它端到端接进现有的 Next.js 应用,包含 server-only 的上传 token 。
- 用大白话描述一个上传 UX —— 拖放、预览、大小上限、内容类型过滤 —— 然后拿到一个能跑的实现。
- 把"二进制往返验证"当作一项工程必修技能(选文件 → 上传 → URL 落 Neon → 从 Vercel Blob CDN 渲染)。
核心主题
- Blob 存储是什么(讲到新手够用就行):URL 指向文件,不指向 HTML。
- 为什么浏览器永远 不 应该直接把文件传给你的服务器(提示:内存成本 + 攻击面)—— 签名上传 token 怎么解决这两个问题。
- 提前一周的心智模型:第 7 周你的留言条目会触发 Slack 通知。今天把 schema 弄干净,那时就不痛了。
工具 / 技术栈
| 工具 | 本周角色 |
|---|---|
| Vercel Blob | 文件存储;CDN 背书的 URL;免费额度很大方。 |
@vercel/blob SDK | server action 用它来下发上传 token。 |
| Drizzle ORM | 给现有的 guestbook_messages 加一列 image_url。 |
| Cursor | 写所有东西;你只描述 + 验证。 |
课堂计划
| 时间 | 活动 |
|---|---|
| 0 – 15 分钟 | 回顾与 Check-in。 这周谁有朋友 / 同学注册并在留言簿上发了消息?每人 30 秒:分享一条最喜欢的。 |
| 15 – 40 分钟 | 概念讲解。 服务器下发的上传 token。为什么浏览器直接和 Vercel Blob 对话(不经过你服务器)。内容类型过滤作为一层安全防护。"DB 里存 URL"为什么完胜"DB 里存 blob"。 |
| 40 – 75 分钟 | 现场演示。 讲师扩展自己的留言簿,让它能接收一张表情包。眼看 5 秒之内,一张 10 MB 的图变成 40 KB 的缩略图 + 800 px 的全图上传。 |
| 75 – 105 分钟 | 动手 Lab。 学员扩展自己的留言簿。"发表情包"是整学期到目前为止最好玩的一节——会笑场。 |
| 105 – 120 分钟 | Q&A + 收尾。 谁的上传莫名失败的,当场调一下——通常是 content-type 不对。 |
动手 Lab
任务。 下课时,登录的访客可以在留言里附一张图。图片在文字上方 inline 显示,点开看全图,你的 Vercel Blob 控制台能看到每一次上传。
阶段 1 —— 开 Blob 存储 + 扩展 schema
请通过 Vercel CLI 给我
my-portfolio项目启用 Vercel Blob。存储建好以后:
- 把生成的
BLOB_READ_WRITE_TOKEN通过 CLI 加到我.env.local和 Vercel(production + preview)。- 在我的 Next.js 项目里安装
@vercel/blob。- 给现有的
guestbook_messages表加一个新列image_url(text,可空)。生成 Drizzle 迁移,给我看一下,然后 push 到 Neon。- 确认新列在表上、token 在三个环境里都设了。
.env.local里有BLOB_READ_WRITE_TOKEN=...。vercel env ls显示 token 在 production 和 preview 都设了。- Drizzle 迁移 SQL 里包含
ADD COLUMN image_url。 - Cursor 跑一条
SELECT column_name FROM information_schema.columns WHERE table_name = 'guestbook_messages'(或等价查询)能列出image_url。
阶段 2 —— 上传的 server action
在
/api/upload加一个 Next.js route handler,用@vercel/blob的handleUpload下发 client upload tokens。约束:
- 只允许已登录用户取 token。没 session 一律返回 401。
- 只接受
image/png、image/jpeg、image/webp、image/gif这几种 content-type。- 文件大小最大 4 MB。
- 生成的文件名前缀带用户 id,方便我以后按用户清理。
不接受浏览器直接把图传给 Next.js 服务器——浏览器拿着你下发的 token,直传 Vercel Blob。
- Cursor 在聊天里讲清楚两步流程:浏览器问服务器要 token、 浏览器上传到 Blob、浏览器把拿到的 URL 再交给第二个 server action。
- Cursor 用
curl在没 session 的情况下打/api/upload,返回 401。
阶段 3 —— 扩展留言表单
扩展第 4 周做的留言簿表单,让它支持可选的图片:
- 在 Post 按钮旁边加一个小回形针 / 图片图标按钮。
- 点击打开文件选择器。选完立即显示一张 预览缩略图(客户端,上传之前)。用户可以移除并换一张再发。
- 点 Post 时,如果挂了图:先去
/api/upload拿 token,直传到 Vercel Blob,拿到返回的 public URL,然后调消息 server action 把body和imageUrl一起提交。- 上传中禁用 Post 按钮,在缩略图旁边显示一个小 spinner。
- 上传失败时,textarea 里的文字保留住(别让用户白写),并显示 toast:"Image upload failed — try again or post without an image."
server action 里把
imageUrl写到新列。消息列表里把图渲在文字上方,最大高度 240 px,圆角对齐站点主题,加一层淡阴影。点击在 lightbox 里全屏打开,按 Esc 关闭。
- localhost:附一张 PNG,看到缩略图,发出去——它带着图出现在列表里。
- 刷新——还在。
- 在另一个浏览器(无痕)里看,不登录也能看到这张图(URL 是公开的)。
- 检查 Vercel Blob 控制台 —— 文件就在那里,前缀是你的 user id。
阶段 4 —— 边缘情况
把上传再加固一些。加三件事:
- server 端检查到达 token 这一步的文件 实际 是允许的 content-type(看 magic bytes,不要只看
Content-Type头)。- 客户端守卫:如果选的文件超过 4 MB,在浏览器里压缩(用
browser-image-compression之类)再上传。提示一个 toast:"Your 8 MB photo was compressed to 1.2 MB. Tap to post."- 在 我自己 的每条消息上加一个小小的 删除 按钮(×)。点击弹确认,然后删除该行 + 删除 blob。别人的消息上不显示删除按钮。
- 试着上传一张 20 MB 的照片——客户端压完,toast 出现,发送成功。
- 把一 个
.exe改名为.png上传——server 拒掉。 - 删掉自己一条消息——它消失;查 Blob 控制台确认文件也没了。
- 在别人的消息上看不到删除按钮。
阶段 5 —— 上线 & 庆祝
按合理的颗粒 commit,push 到 GitHub,让 Vercel 完成部署。上线后,在生产 URL 里登录,发一条带俏皮文案的表情包,确认整套流程在生产端都正常。
- 生产留言簿现在支持图片上传。
- 你的 Vercel Blob 控制台显示生产环境的上传。
- Neon 控制台显示有行的
image_url列已被填上。
生产环境的上传报
403 Forbidden,但本地是好的。请检查BLOB_READ_WRITE_TOKEN是不是真在 Vercel production 环境里设了(不仅仅是 preview),如果缺就补上。重新部署,确认。
整个上传流程中,打开 DevTools → Network。看两个不同的请求:先是 POST /api/upload(很小,只是 metadata),然后是 POST https://*.blob.vercel-storage.com/...(真正的文件字节)。理解这种"两步"形状很关键——你接下来的整个职业生涯里,每一个现代上传流程都是这个形状。
本周作业
做 / 交付。
- 你线上站点的留言簿现在支持图片上传。
- 至少 5 张真实图片(来自你朋友 / 同学)已上传。
- 删除只对自己的消息生效。
要求。
- 直传 Blob(图片字节不经过你的 Next.js 服务器)。
- 强制 content-type + 大小上限。
- 一张 Vercel Blob 控制台截图,显示带前缀的文件名。
- 一段 30 秒的上传流程屏幕录屏。
提交。 第 6 周开课前,把线上 URL + 截图 + 录屏发到 Slack。
资源
| 文档 | 视频 | 仓库 |
|---|---|---|
| Vercel Blob —— 客户端上传 | 讲师 demo:"直传 8 分钟搞定" | vercel/examples/blob —— 参考模式 |
@vercel/blob —— handleUpload 与 put | ||
browser-image-compression —— npm |
真实世界应用
文件上传出现在你将来要做的每一个产品里。早早把"直传 bucket"模式刻进肌肉记忆——而不是那种朴素的"字节走我服务器"模式——能帮你避开 90% 的生产事故。Figma、Notion、Linear 这些公司的上传,正是这么搭的。
在简历上加这一行:"Implemented direct-to-CDN uploads with signed tokens, enforced server-side MIME validation, and client-side compression — handling 20 MB inputs at 1.2 MB payloads." 这一句在面试里就是金句。
踩坑提醒 & 小贴士
- "上传成功,但图在手机上倒了 90°。" iPhone 在图里嵌了 EXIF 朝向。问 Cursor:"在压缩里要么 strip EXIF,要么尊重朝向。"
- "
fetch到 /api/upload 返回 413。" Next.js body parser 限制。问 Cursor:"提高 upload 这条 route 的 body size 上限——只这一条,其它路由保持默认。" - "留言簿上图片加载特别慢。" 你直接渲染了原图。问:"用 Next.js Image 组件,配合合适的 sizes 和 placeholder blur。"
- "删除按钮工作了,但 Blob 文件还在。" 漏了二步删。说:"先删 Blob 文件,再 删 DB 行;如果 Blob 删除失败,不要删行。"
- "Blob 免费额度爆了。" 测试文件传太多。问 Cursor:"清掉所有 24 小时前上传、且对应 DB 行已经没了的 Blob 文件。"
没报错,就是没出现——加临时日志:"在上传流程的每一步加 console.log,让我看到具体哪一步把文件丢了。" Bug 修完把日志删了。