本文将从底层原理讲起,到完整代码实现,涵盖以下内容:
- 文件上传的核心原理与流(stream)处理
- Next.js 服务器端(App Router)接口实现
- 上传到 AWS S3(直传 & 中转)
- 上传到 Cloudinary(直传 & 中转)
- 安全性、性能与体验优化
- 前端组件:多文件、进度条、拖拽上传
- 常见坑与 Debug 心法
示例代码使用 JS(非 TS),并基于 Next.js App Router(/app 目录)。如果你还在 Pages Router,也能举一反三。
1. 原理速写:从浏览器到云端的路径规划
-
客户端如何传文件?
- 使用
<input type="file" />
或拖拽,读取成FormData
,通过fetch
发送给后端(中转上传);或者客户端直传到云(直传)。
- 使用
-
直传 vs 中转:
- 直传:浏览器直接对接云存储;需要后端签名(签名 URL 或签名参数),节省服务器带宽与时延。
- 中转:文件先发到自家服务器,再由服务器上传到云,适合需要权限校验、病毒扫描、处理(如压缩/裁剪/打水印)等。
-
为什么要流?
- 流是节省内存的关键。大文件不该整块读进内存。边读边写,像水管一样顺着流走,服务器不爆内存,CPU 不背锅。
-
安全要点:
- 严格限制 MIME、扩展名与大小
- 使用临时凭证或短效签名
- 后端不要信任前端传的 Content-Type
- 隔离存储桶的公开权限,只让需要的 Key 可访问
-
命名与幂等:
- 使用随机前缀 + 哈希 + 时间戳,避免覆盖与碰撞
- 返回唯一存储路径,便于后续引用与删除
2. 项目初始化与环境变量
-
初始化项目:
npx create-next-app@latest my-uploader
- 选择 App Router
-
环境变量(.env.local,永不提交):
-
S3:
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- AWS_REGION
- S3_BUCKET_NAME
-
Cloudinary:
- CLOUDINARY_CLOUD_NAME
- CLOUDINARY_API_KEY
- CLOUDINARY_API_SECRET
- 可选:CLOUDINARY_UPLOAD_PRESET(若使用 unsigned upload)
-
小贴士:在 Vercel 上要设置同名环境变量,注意 Preview 和 Production 环境分别配置。
3. 后端:Next.js Route Handler 基础(App Router)
Next.js 13+ 的路由文件位于 app/api/*/route.js
。我们将实现两个后端接口:
- 中转上传(服务器接收二进制后转存到云)
- 直传签名(返回 S3 签名 URL 或 Cloudinary 签名参数)
在 App Router 中,处理 multipart/form-data 可以直接用 request.formData()
。
4. 文件校验与工具函数
创建 lib/upload-utils.js
:
javascript
import crypto from 'node:crypto';
export function randomKey(prefix = '') {
const rand = crypto.randomBytes(8).toString('hex');
const ts = Date.now();
return `${prefix}${ts}-${rand}`;
}
export function sanitizeFilename(name = 'file') {
return name.replace(/[^\w.-]/g, '_');
}
export const ALLOWED_MIME = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'application/pdf'
]);
export const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
export function assertFileSafe(file) {
if (!file) throw new Error('No file provided');
if (!ALLOWED_MIME.has(file.type)) {
throw new Error(`Disallowed mime type: ${file.type || 'unknown'}`);
}
if (file.size > MAX_SIZE_BYTES) {
throw new Error(`File too large: ${file.size} > ${MAX_SIZE_BYTES}`);
}
}
5. 中转上传到 S3(服务器接收再上传)
安装依赖:
npm i @aws-sdk/client-s3
创建 app/api/upload/s3/route.js
:
javascript
import { NextResponse } from 'next/server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { randomKey, sanitizeFilename, assertFileSafe } from '@/lib/upload-utils';
const s3 = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
}
});
export async function POST(request) {
try {
const form = await request.formData();
const file = form.get('file'); // type: File (Web API)
assertFileSafe(file);
const ext = file.name ? '.' + sanitizeFilename(file.name).split('.').pop() : '';
const objectKey = `uploads/${randomKey()}${ext}`;
// Next.js File -> ArrayBuffer
const buffer = Buffer.from(await file.arrayBuffer());
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME,
Key: objectKey,
Body: buffer,
ContentType: file.type,
ACL: 'private' // 建议私有,访问通过签名 URL
}));
const location = `s3://${process.env.S3_BUCKET_NAME}/${objectKey}`;
return NextResponse.json({ ok: true, key: objectKey, location, mime: file.type });
} catch (err) {
console.error(err);
return NextResponse.json({ ok: false, error: String(err.message || err) }, { status: 400 });
}
}
export const runtime = 'nodejs'; // 确保有 Node 能力
export const dynamic = 'force-dynamic';
说明:
- 将 File 转为 Buffer 上传,简单直接。
- 大文件建议使用分片上传(Multipart Upload),但初学用 PutObject 足矣。
- ACL 设为 private,后续用签名 URL 暴露访问权限。
6. 直传到 S3:生成预签名 URL
依赖:
npm i @aws-sdk/s3-request-presigner
路由:app/api/upload/s3-signed-url/route.js
,生成一次性 PutObject 的签名 URL。
javascript
import { NextResponse } from 'next/server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomKey, sanitizeFilename, ALLOWED_MIME, MAX_SIZE_BYTES } from '@/lib/upload-utils';
const s3 = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
}
});
export async function POST(request) {
try {
const { filename, mime, size } = await request.json();
if (!ALLOWED_MIME.has(mime)) throw new Error('Disallowed mime');
if (size > MAX_SIZE_BYTES) throw new Error('File too large');
const ext = filename ? '.' + sanitizeFilename(filename).split('.').pop() : '';
const key = `uploads/${randomKey()}${ext}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME,
Key: key,
ContentType: mime,
ACL: 'private'
});
const url = await getSignedUrl(s3, command, { expiresIn: 60 }); // 60 秒有效
return NextResponse.json({ ok: true, url, key });
} catch (e) {
console.error(e);
return NextResponse.json({ ok: false, error: String(e.message || e) }, { status: 400 });
}
}
export const runtime = 'nodejs';
前端拿到 url
后,直接对该 URL 发送 PUT 请求即可直传。
7. 中转上传到 Cloudinary
安装:
npm i cloudinary
路由:app/api/upload/cloudinary/route.js
php
import { NextResponse } from 'next/server';
import { v2 as cloudinary } from 'cloudinary';
import { randomKey, assertFileSafe } from '@/lib/upload-utils';
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET
});
export async function POST(request) {
try {
const form = await request.formData();
const file = form.get('file');
assertFileSafe(file);
const arrayBuffer = await file.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
const dataUri = `data:${file.type};base64,${base64}`;
const folder = 'uploads';
const public_id = randomKey();
const result = await cloudinary.uploader.upload(dataUri, {
folder,
public_id,
resource_type: 'auto',
overwrite: false
});
return NextResponse.json({
ok: true,
publicId: result.public_id,
version: result.version,
url: result.secure_url,
width: result.width,
height: result.height,
bytes: result.bytes,
format: result.format
});
} catch (err) {
console.error(err);
return NextResponse.json({ ok: false, error: String(err.message || err) }, { status: 400 });
}
}
export const runtime = 'nodejs';
8. 直传到 Cloudinary(客户端直传,后端签名)
Cloudinary 推荐生成签名或使用 unsigned preset。这里演示签名路由:
- 客户端将要上传的参数(public_id、timestamp、folder 等)发给后端
- 后端用 API Secret 签名
- 客户端拿签名直接 POST 到 Cloudinary
路由:app/api/upload/cloudinary-sign/route.js
javascript
import { NextResponse } from 'next/server';
import crypto from 'node:crypto';
function sign(paramsToSign, apiSecret) {
const keys = Object.keys(paramsToSign).sort();
const str = keys.map(k => `${k}=${paramsToSign[k]}`).join('&') + apiSecret;
return crypto.createHash('sha1').update(str).digest('hex');
}
export async function POST(request) {
try {
const body = await request.json();
const { folder = 'uploads', public_id, timestamp } = body;
if (!timestamp) throw new Error('Missing timestamp');
const payload = {
folder,
public_id,
timestamp,
// 例如:eager、transformation 等需要一并纳入签名
};
const signature = sign(payload, process.env.CLOUDINARY_API_SECRET);
return NextResponse.json({
ok: true,
cloudName: process.env.CLOUDINARY_CLOUD_NAME,
apiKey: process.env.CLOUDINARY_API_KEY,
signature,
...payload
});
} catch (e) {
console.error(e);
return NextResponse.json({ ok: false, error: String(e.message || e) }, { status: 400 });
}
}
export const runtime = 'nodejs';
客户端直传到 Cloudinary 的 URL 为:
https://api.cloudinary.com/v1_1/<cloud_name>/auto/upload
FormData 字段需包含:file、api_key、timestamp、signature、folder、public_id(可选)、resource_type 可通过 endpoint 控制。
9. 前端组件:拖拽、多文件、进度条(Fetch + XHR)
我们做一个能同时兼容三种模式的小组件:
- 中转上传到 S3
- 直传到 S3(签名 URL)
- 中转上传到 Cloudinary
- 直传到 Cloudinary(签名)
创建 app/page.js
:
javascript
'use client';
import { useState, useRef, useCallback } from 'react';
function humanFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
export default function Home() {
const [files, setFiles] = useState([]);
const [logs, setLogs] = useState([]);
const inputRef = useRef(null);
const [mode, setMode] = useState('s3-direct'); // 's3-proxy' | 'cld-proxy' | 'cld-direct'
const addLog = (msg) => setLogs((l) => [msg, ...l]);
const onPick = (e) => {
setFiles(Array.from(e.target.files || []));
};
const onDrop = (e) => {
e.preventDefault();
const dt = e.dataTransfer;
const fs = Array.from(dt.files || []);
setFiles(fs);
};
const onDragOver = (e) => e.preventDefault();
async function uploadS3Proxy(file) {
const fd = new FormData();
fd.set('file', file);
const res = await fetch('/api/upload/s3', { method: 'POST', body: fd });
const json = await res.json();
if (!json.ok) throw new Error(json.error || 'Upload failed');
return json;
}
async function uploadS3Direct(file, onProgress) {
// 1) ask backend for signed URL
const meta = {
filename: file.name,
mime: file.type || 'application/octet-stream',
size: file.size
};
const r1 = await fetch('/api/upload/s3-signed-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta)
});
const j1 = await r1.json();
if (!j1.ok) throw new Error(j1.error || 'sign failed');
// 2) PUT to signed URL using XHR to get progress
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', j1.url, true);
xhr.setRequestHeader('Content-Type', meta.mime);
xhr.upload.onprogress = (evt) => {
if (evt.lengthComputable && onProgress) {
onProgress(Math.round((evt.loaded / evt.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`S3 upload failed: ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(file);
});
return { ok: true, key: j1.key };
}
async function uploadCloudinaryProxy(file) {
const fd = new FormData();
fd.set('file', file);
const res = await fetch('/api/upload/cloudinary', { method: 'POST', body: fd });
const json = await res.json();
if (!json.ok) throw new Error(json.error || 'Upload failed');
return json;
}
async function uploadCloudinaryDirect(file, onProgress) {
// 1) get signature
const payload = {
folder: 'uploads',
public_id: undefined,
timestamp: Math.floor(Date.now() / 1000)
};
const r = await fetch('/api/upload/cloudinary-sign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'sign failed');
// 2) POST file to Cloudinary
const fd = new FormData();
fd.set('file', file);
fd.set('api_key', j.apiKey);
fd.set('timestamp', String(j.timestamp));
if (j.folder) fd.set('folder', j.folder);
if (j.public_id) fd.set('public_id', j.public_id);
fd.set('signature', j.signature);
const url = `https://api.cloudinary.com/v1_1/${j.cloudName}/auto/upload`;
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.upload.onprogress = (evt) => {
if (evt.lengthComputable && onProgress) {
onProgress(Math.round((evt.loaded / evt.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Cloudinary upload failed: ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(fd);
});
return { ok: true };
}
const onUpload = useCallback(async () => {
if (!files.length) return;
for (const file of files) {
addLog(`Uploading ${file.name} (${humanFileSize(file.size)}) via ${mode}...`);
try {
let progress = 0;
const onProgress = (p) => {
progress = p;
addLog(`Progress ${file.name}: ${p}%`);
};
let result;
if (mode === 's3-proxy') result = await uploadS3Proxy(file);
else if (mode === 's3-direct') result = await uploadS3Direct(file, onProgress);
else if (mode === 'cld-proxy') result = await uploadCloudinaryProxy(file);
else if (mode === 'cld-direct') result = await uploadCloudinaryDirect(file, onProgress);
addLog(`✅ Done: ${file.name} ${result?.key ? `(key=${result.key})` : ''}`);
} catch (e) {
addLog(`❌ Failed: ${file.name}: ${e.message}`);
}
}
}, [files, mode]);
return (
<main style={{ maxWidth: 720, margin: '2rem auto', padding: '0 1rem', fontFamily: 'ui-sans-serif, system-ui' }}>
<h1>📦 Next.js 全栈上传演示</h1>
<p style={{ color: '#666' }}>
支持:S3 直传/中转、Cloudinary 直传/中转。拖拽文件或点击选择。
</p>
<div style={{ margin: '1rem 0' }}>
<label>
选择模式:
<select value={mode} onChange={e => setMode(e.target.value)} style={{ marginLeft: 8 }}>
<option value="s3-direct">S3 直传(推荐大文件)</option>
<option value="s3-proxy">S3 中转</option>
<option value="cld-direct">Cloudinary 直传</option>
<option value="cld-proxy">Cloudinary 中转</option>
</select>
</label>
</div>
<div
onDrop={onDrop}
onDragOver={onDragOver}
style={{
border: '2px dashed #999',
padding: '2rem',
borderRadius: 12,
textAlign: 'center',
background: '#fafafa'
}}
>
<p>🖱️ 拖拽文件到此,或</p>
<button onClick={() => inputRef.current?.click()} style={{ padding: '0.5rem 1rem' }}>
选择文件
</button>
<input ref={inputRef} hidden type="file" multiple onChange={onPick} />
</div>
{!!files.length && (
<div style={{ marginTop: '1rem' }}>
<h3>待上传文件</h3>
<ul>
{files.map(f => (
<li key={f.name + f.lastModified}>
📄 {f.name} · {humanFileSize(f.size)} · {f.type || 'unknown'}
</li>
))}
</ul>
<button onClick={onUpload} style={{ padding: '0.5rem 1rem' }}>开始上传</button>
</div>
)}
<div style={{ marginTop: '2rem' }}>
<h3>日志</h3>
<ul>
{logs.map((l, i) => <li key={i}>{l}</li>)}
</ul>
</div>
</main>
);
}
说明:
- 用 XHR 获取上传进度;fetch 尚不原生支持上传进度。
- 多模式开关便于在不同云与策略间切换。
- UI 朴素但好用,适合内网与管理面板。
10. 访问控制与安全策略
-
S3:
- 存储桶默认私有;通过 CloudFront 或签名 URL 暴露访问。
- 直传仅允许指定前缀(例如
uploads/
),避免恶意覆盖关键对象。 - 限制 Content-Type 与大小,并对用户身份做鉴权(如应用会话、JWT)。
-
Cloudinary:
- 使用上传 preset 可进一步控制安全策略与自动转换。
- 只在服务端保管 API Secret,签名路由必须鉴权。
-
通用:
- 做服务端 MIME 嗅探(如 file-type 库)而非只信任 file.type。
- 对文件名去毒:过滤路径穿越、奇异 unicode、过长名称。
- 记录审计日志:用户 ID、时间、IP、对象 Key。
11. 性能与成本优化
- 大文件用直传,服务器只参与"签名",不吃带宽。
- S3 使用分片上传(Multipart Upload),支持断点续传与并行分片。
- Cloudinary 可开启自动格式(auto format)与质量(auto quality),显著节省流量。
- 前端做并发控制(例如一次 3 个),避免把浏览器和网络打爆。
- 缓存签名的元信息短暂时间(例如 30 秒)以减少签名请求。
12. 常见坑与 Debug 心法
-
403 AccessDenied:
- 检查 IAM 权限(s3:PutObject、s3:PutObjectAcl)
- 检查桶名、区域、路径前缀
-
415 Unsupported Media Type:
- 请求头 Content-Type 是否正确,S3 直传 PUT 要设一致
-
CORS:
- 为 S3 桶配置 CORS(允许 PUT、允许来自你域名的 origin)
- Cloudinary 默认接口支持 CORS,但本地端口跨源要注意
-
Next.js 边缘运行时:
- 需要 Node 能力时指定
export const runtime = 'nodejs'
- 需要 Node 能力时指定
-
进度不更新:
- 使用 XHR 而不是 fetch 上传可获取进度事件
-
大文件爆内存:
- 中转上传尽量改用流式(Node Readable -> S3),或直接采用直传
13. 彩蛋:流式中转(更省内存)
如果你确实要中转大文件,可用 Node 原生流 + AWS SDK 的 Upload
(来自 @aws-sdk/lib-storage
)以边读边传:
javascript
// app/api/upload/s3-stream/route.js
import { NextResponse } from 'next/server';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { randomKey, assertFileSafe } from '@/lib/upload-utils';
export const runtime = 'nodejs';
const s3 = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
}
});
export async function POST(request) {
try {
const form = await request.formData();
const file = form.get('file');
assertFileSafe(file);
// 把 Web File 转 Node Readable
const stream = file.stream(); // ReadableStream
// Node 18+ 兼容:可直接传给 Upload Body
const key = `uploads/${randomKey()}`;
const uploader = new Upload({
client: s3,
params: {
Bucket: process.env.S3_BUCKET_NAME,
Key: key,
Body: stream,
ContentType: file.type,
ACL: 'private'
}
});
await uploader.done();
return NextResponse.json({ ok: true, key });
} catch (e) {
console.error(e);
return NextResponse.json({ ok: false, error: String(e.message || e) }, { status: 400 });
}
}
这版不会把整个文件缓存在内存,适合中转超大文件。
14. 收尾:把"传输链路"当成一条河
- 前端:选择器、拖拽、进度条,像修堤坝的工人,控制流速;
- 后端:签名、校验、限流,像水闸;
- 云存储:像水库,严密管理权限与出入口;
- 我们做的,是"让水有序地流",既不溢出,也不干涸。🌊
当你把文件从用户手里安全地搬进云端,前端用户看到的是一个百分比在增长,而你知道,那是无数包数据在 TCP 的拥塞窗口里跳舞。
祝你上传顺滑,延迟低到让时间都脸红。