1.1 核心概念
什么是 multipart/form-data?
当我们上传文件时,HTTP 请求需要使用 multipart/form-data 编码方式。这种方式可以将文件数据和普通表单字段一起发送。
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxk
------WebKitFormBoundary7MA4YWxk
Content-Disposition: form-data; name="file"; filename="example.png"
Content-Type: image/png
[二进制文件内容]
------WebKitFormBoundary7MA4YWxk--
1.2 前端实现
在前端 JavaScript 中,我们利用 FormData 接口来构建上述的 multipart 数据结构。它能自动处理数据的序列化工作。
在发送请求时,关键点在于不要手动设置请求头。浏览器检测到 FormData 实例后,会自动配置正确的 Content-Type 并附带必要的 boundary 参数。
typescript
/**
* 基础文件上传
*/
const uploadFile = async (file: File) => {
// 1. 创建 FormData 对象
const formData = new FormData()
// 2. 添加文件到 FormData
// 'file' 是后端接收时的字段名
formData.append('file', file)
// 3. 发送请求
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
// 注意:不要手动设置 Content-Type
// 浏览器会自动设置正确的 Content-Type 和 boundary
})
// 4. 处理响应
if (!response.ok) {
throw new Error(`上传失败: ${response.status}`)
}
return response.json()
}
重要提示 :不要手动设置 Content-Type 请求头!浏览器会自动设置包含 boundary 的正确值。如果手动设置,boundary 会丢失,导致后端无法解析。
1.3 后端实现
Node.js 原生处理 multipart 数据流较为复杂,通常引入中间件 multer 来处理。该中间件负责解析请求体流,并将二进制数据写入磁盘或内存。
下方的代码配置了存储引擎,显式指定了文件的存储路径和命名规则(包含防止文件名冲突的时间戳处理和中文乱码修正):
typescript
import express from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs-extra'
const app = express()
// 上传目录
const UPLOAD_DIR = './uploads'
// 确保目录存在
fs.ensureDirSync(UPLOAD_DIR)
// 配置 multer 存储
const storage = multer.diskStorage({
// 指定存储目录
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR)
},
// 指定文件名
filename: (req, file, cb) => {
// 解决中文文件名乱码问题
const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8')
// 添加时间戳避免重名
const uniqueName = `${Date.now()}-${originalName}`
cb(null, uniqueName)
}
})
const upload = multer({ storage })
// 上传接口
app.post('/api/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({
success: false,
error: '没有上传文件'
})
}
res.json({
success: true,
data: {
filename: req.file.filename,
originalname: req.file.originalname,
size: req.file.size,
mimetype: req.file.mimetype,
url: `/files/${req.file.filename}`
}
})
})
// 静态文件服务(用于访问上传的文件)
app.use('/files', express.static(UPLOAD_DIR))
app.listen(3000, () => {
console.log('Server running on http://localhost:3000')
})
1.4 Vue 组件示例
在视图层,我们需要处理文件选择、上传状态(Loading)以及结果展示三个核心逻辑。
这段代码展示了如何通过 ref 获取 DOM 元素,并在异步操作期间锁定按钮状态,防止用户重复提交:
Vue
<template>
<div class="upload-container">
<input
type="file"
ref="fileInput"
@change="handleFileChange"
:disabled="uploading"
/>
<button @click="handleUpload" :disabled="!selectedFile || uploading">
{{ uploading ? '上传中...' : '上传' }}
</button>
<div v-if="result" class="result">
<p>上传成功!</p>
<p>文件名:{{ result.filename }}</p>
<p>大小:{{ formatSize(result.size) }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const uploading = ref(false)
const result = ref<any>(null)
const handleFileChange = (e: Event) => {
const input = e.target as HTMLInputElement
selectedFile.value = input.files?.[0] || null
}
const handleUpload = async () => {
if (!selectedFile.value) return
uploading.value = true
try {
const res = await uploadFile(selectedFile.value)
result.value = res.data
} catch (error) {
console.error('上传失败:', error)
alert('上传失败')
} finally {
uploading.value = false
}
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
return (bytes / 1024 / 1024).toFixed(2) + ' MB'
}
</script>
1.5 常见 Bug 与解决方案
| Bug 现象 | 原因分析 | 解决方案 |
|---|---|---|
| 文件名乱码(显示为 ??? 或乱字符) | 中文文件名编码问题,multer 默认使用 latin1 编码 | 后端使用 Buffer.from(filename, 'latin1').toString('utf8') 转换 |
| 请求体为空,后端收不到文件 | 手动设置了 Content-Type 请求头 | 删除手动设置的 Content-Type,让浏览器自动处理 |
| 大文件上传超时 | 默认超时时间太短 | 配置更长的超时时间,或使用分片上传 |
| 文件保存失败 | uploads 目录不存在 | 启动时使用 fs.ensureDirSync() 创建目录 |
| CORS 跨域错误 | 前后端不同源 | 后端配置 CORS 中间件 app.use(cors()) |
1.6 测试用例
typescript
import { describe, it, expect } from 'vitest'
describe('基础文件上传', () => {
it('应该成功上传文本文件', async () => {
const file = new File(['Hello World'], 'test.txt', { type: 'text/plain' })
const result = await uploadFile(file)
expect(result.success).toBe(true)
expect(result.data.filename).toContain('test.txt')
})
it('应该正确返回文件信息', async () => {
const content = 'Test content'
const file = new File([content], 'info.txt', { type: 'text/plain' })
const result = await uploadFile(file)
expect(result.data.size).toBe(content.length)
expect(result.data.mimetype).toBe('text/plain')
})
it('空文件应该正常处理', async () => {
const file = new File([], 'empty.txt', { type: 'text/plain' })
const result = await uploadFile(file)
expect(result.data.size).toBe(0)
})
})