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 app1.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. 小结
文件上传前后端联调的关键点:
- 后端:使用
c.req.parseBody()解析multipart/form-data - 文件验证:验证文件大小、类型
- 存储:可以存储到 R2、S3 或本地文件系统
- 前端:使用
FormData上传文件 - 进度显示:使用
XMLHttpRequest或fetch监听上传进度 - 大文件:考虑分片上传
一句话带走:
文件上传是常见需求,后端用
parseBody()解析,前端用FormData上传,配合进度显示和文件验证,实现完整的上传体验。