23.10-文件上传前后端联调

要点

  • 文件上传使用 multipart/form-data 格式
  • 后端使用 c.req.parseBody() 解析表单数据
  • 文件可以存储到 R2、S3 或本地文件系统
  • 前端需要处理文件选择、上传进度、错误处理

内容

1. 后端实现文件上传

1.1 基础文件上传

// src/index.ts
import { Hono } from 'hono'
 
const app = new Hono()
 
app.post('/api/upload', async (c) => {
  const body = await c.req.parseBody()
  const file = body['file']
 
  if (!(file instanceof File)) {
    return c.json({ error: 'No file uploaded' }, 400)
  }
 
  // 读取文件内容
  const arrayBuffer = await file.arrayBuffer()
  const buffer = new Uint8Array(arrayBuffer)
 
  // 存储到 R2
  const key = `uploads/${Date.now()}-${file.name}`
  await c.env.R2_BUCKET.put(key, buffer, {
    httpMetadata: {
      contentType: file.type,
    },
  })
 
  return c.json({
    success: true,
    url: `/files/${key}`,
    filename: file.name,
    size: file.size,
    type: file.type,
  })
})
 
export default app

1.2 多文件上传

app.post('/api/upload/multiple', async (c) => {
  const body = await c.req.parseBody()
 
  // body['files'] 可能是 File 或 File[]
  const files = body['files']
  const fileList = Array.isArray(files) ? files : [files]
 
  const results = []
 
  for (const file of fileList) {
    if (!(file instanceof File)) continue
 
    const arrayBuffer = await file.arrayBuffer()
    const buffer = new Uint8Array(arrayBuffer)
 
    const key = `uploads/${Date.now()}-${file.name}`
    await c.env.R2_BUCKET.put(key, buffer, {
      httpMetadata: {
        contentType: file.type,
      },
    })
 
    results.push({
      filename: file.name,
      size: file.size,
      type: file.type,
      url: `/files/${key}`,
    })
  }
 
  return c.json({
    success: true,
    files: results,
  })
})

1.3 文件验证

import { z } from 'zod'
 
const MAX_FILE_SIZE = 10 * 1024 * 1024  // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']
 
app.post('/api/upload', async (c) => {
  const body = await c.req.parseBody()
  const file = body['file']
 
  if (!(file instanceof File)) {
    return c.json({ error: 'No file uploaded' }, 400)
  }
 
  // 验证文件大小
  if (file.size > MAX_FILE_SIZE) {
    return c.json({
      error: `File size exceeds limit: ${MAX_FILE_SIZE / 1024 / 1024}MB`,
    }, 400)
  }
 
  // 验证文件类型
  if (!ALLOWED_TYPES.includes(file.type)) {
    return c.json({
      error: `File type not allowed: ${file.type}`,
    }, 400)
  }
 
  // 上传文件
  const arrayBuffer = await file.arrayBuffer()
  const buffer = new Uint8Array(arrayBuffer)
 
  const key = `uploads/${Date.now()}-${file.name}`
  await c.env.R2_BUCKET.put(key, buffer, {
    httpMetadata: {
      contentType: file.type,
    },
  })
 
  return c.json({
    success: true,
    url: `/files/${key}`,
  })
})

2. 前端实现文件上传

2.1 基础文件上传

// src/components/FileUpload.tsx
import { useState } from 'react'
 
export function FileUpload() {
  const [file, setFile] = useState<File | null>(null)
  const [uploading, setUploading] = useState(false)
  const [result, setResult] = useState(null)
 
  async function handleUpload() {
    if (!file) return
 
    setUploading(true)
 
    const formData = new FormData()
    formData.append('file', file)
 
    try {
      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
      })
 
      if (!response.ok) {
        throw new Error('Upload failed')
      }
 
      const data = await response.json()
      setResult(data)
    } catch (error) {
      console.error('Upload error:', error)
    } finally {
      setUploading(false)
    }
  }
 
  return (
    <div>
      <input
        type="file"
        onChange={(e) => setFile(e.target.files?.[0] || null)}
      />
      <button onClick={handleUpload} disabled={!file || uploading}>
        {uploading ? '上传中...' : '上传'}
      </button>
      {result && (
        <div>
          <p>上传成功!</p>
          <p>文件 URL: {result.url}</p>
        </div>
      )}
    </div>
  )
}

2.2 带进度的文件上传

// src/lib/upload.ts
export async function uploadFile(
  file: File,
  onProgress: (progress: number) => void
): Promise<{ url: string }> {
  const formData = new FormData()
  formData.append('file', file)
 
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
 
    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        const progress = (e.loaded / e.total) * 100
        onProgress(progress)
      }
    })
 
    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText))
      } else {
        reject(new Error(`Upload failed: ${xhr.status}`))
      }
    })
 
    xhr.addEventListener('error', () => {
      reject(new Error('Upload failed'))
    })
 
    xhr.open('POST', '/api/upload')
    xhr.send(formData)
  })
}
// 使用
import { uploadFile } from '@/lib/upload'
 
function FileUploadWithProgress() {
  const [file, setFile] = useState<File | null>(null)
  const [progress, setProgress] = useState(0)
  const [uploading, setUploading] = useState(false)
 
  async function handleUpload() {
    if (!file) return
 
    setUploading(true)
    setProgress(0)
 
    try {
      const result = await uploadFile(file, setProgress)
      console.log('Upload success:', result)
    } catch (error) {
      console.error('Upload error:', error)
    } finally {
      setUploading(false)
    }
  }
 
  return (
    <div>
      <input type="file" onChange={(e) => setFile(e.target.files?.[0] || null)} />
      <button onClick={handleUpload} disabled={!file || uploading}>
        {uploading ? `上传中 ${progress.toFixed(0)}%` : '上传'}
      </button>
      {uploading && (
        <div className="progress-bar">
          <div style={{ width: `${progress}%` }} />
        </div>
      )}
    </div>
  )
}

3. 文件预览

// src/components/ImageUpload.tsx
import { useState } from 'react'
 
export function ImageUpload() {
  const [file, setFile] = useState<File | null>(null)
  const [preview, setPreview] = useState<string | null>(null)
 
  function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0]
 
    if (file) {
      setFile(file)
 
      // 创建预览 URL
      const reader = new FileReader()
      reader.onloadend = () => {
        setPreview(reader.result as string)
      }
      reader.readAsDataURL(file)
    }
  }
 
  return (
    <div>
      <input type="file" accept="image/*" onChange={handleFileChange} />
      {preview && (
        <div>
          <img src={preview} alt="Preview" style={{ maxWidth: '300px' }} />
          <button onClick={() => { setFile(null); setPreview(null) }}>
            移除
          </button>
        </div>
      )}
    </div>
  )
}

4. 拖拽上传

// src/components/DragDropUpload.tsx
import { useState } from 'react'
 
export function DragDropUpload() {
  const [file, setFile] = useState<File | null>(null)
  const [dragging, setDragging] = useState(false)
 
  function handleDragOver(e: React.DragEvent) {
    e.preventDefault()
    setDragging(true)
  }
 
  function handleDragLeave() {
    setDragging(false)
  }
 
  function handleDrop(e: React.DragEvent) {
    e.preventDefault()
    setDragging(false)
 
    const file = e.dataTransfer.files[0]
    if (file) {
      setFile(file)
    }
  }
 
  return (
    <div
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
      style={{
        border: `2px dashed ${dragging ? '#0070f3' : '#ccc'}`,
        padding: '40px',
        textAlign: 'center',
      }}
    >
      {file ? (
        <p>已选择:{file.name}</p>
      ) : (
        <p>拖拽文件到此处,或点击选择文件</p>
      )}
      <input
        type="file"
        onChange={(e) => setFile(e.target.files?.[0] || null)}
        style={{ marginTop: '10px' }}
      />
    </div>
  )
}

5. 分片上传(大文件)

// src/lib/chunked-upload.ts
const CHUNK_SIZE = 5 * 1024 * 1024  // 5MB
 
export async function uploadLargeFile(
  file: File,
  onProgress: (progress: number) => void
) {
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
  const uploadId = crypto.randomUUID()
 
  for (let i = 0; i < totalChunks; i++) {
    const start = i * CHUNK_SIZE
    const end = Math.min(start + CHUNK_SIZE, file.size)
    const chunk = file.slice(start, end)
 
    const formData = new FormData()
    formData.append('chunk', chunk)
    formData.append('uploadId', uploadId)
    formData.append('chunkIndex', i.toString())
    formData.append('totalChunks', totalChunks.toString())
    formData.append('filename', file.name)
 
    await fetch('/api/upload/chunk', {
      method: 'POST',
      body: formData,
    })
 
    onProgress(((i + 1) / totalChunks) * 100)
  }
 
  // 合并分片
  const response = await fetch('/api/upload/merge', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ uploadId, filename: file.name }),
  })
 
  return response.json()
}

6. 小结

文件上传前后端联调的关键点:

  1. 后端:使用 c.req.parseBody() 解析 multipart/form-data
  2. 文件验证:验证文件大小、类型
  3. 存储:可以存储到 R2、S3 或本地文件系统
  4. 前端:使用 FormData 上传文件
  5. 进度显示:使用 XMLHttpRequestfetch 监听上传进度
  6. 大文件:考虑分片上传

一句话带走:

文件上传是常见需求,后端用 parseBody() 解析,前端用 FormData 上传,配合进度显示和文件验证,实现完整的上传体验。