用 Next.js 打造全栈文件上传(S3 / Cloudinary)——从字节到云端的奇妙旅程

本文将从底层原理讲起,到完整代码实现,涵盖以下内容:

  • 文件上传的核心原理与流(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'
  • 进度不更新:

    • 使用 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 的拥塞窗口里跳舞。

祝你上传顺滑,延迟低到让时间都脸红。

相关推荐
zzz100667 小时前
Web 与 Nginx 网站服务:从基础到实践
运维·前端·nginx
良木林7 小时前
JS对象进阶
前端·javascript
muyouking118 小时前
一文吃透 CSS 伪类:从「鼠标悬停」到「斑马纹表格」的 30 个实战场景
前端·css
TE-茶叶蛋8 小时前
scss 转为原子css unocss
前端·css·scss
Sapphire~8 小时前
重学前端012 --- 响应式网页设计 CSS变量
前端·css
家里有只小肥猫8 小时前
css中的v-bind 动态变化
前端·css
超人不会飛8 小时前
LLM应用专属的Vue3 Markdown组件 🚀重磅开源!
前端·javascript·vue.js
NULL Not NULL8 小时前
ES6+新特性:现代JavaScript的强大功能
开发语言·前端·javascript
小高0078 小时前
🚄 前端人必收:5 分钟掌握 ES2025 超实用语法
前端·javascript·面试