文件上传 - 入门篇

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)
  })
})
相关推荐
弓.长.2 小时前
React Native 鸿蒙跨平台开发:实现一个计时器工具
javascript·react native·react.js
Dragon Wu2 小时前
ReactNative MMKV和React Native Keychain存储本地数据
javascript·react native·react.js·前端框架
Never_Satisfied2 小时前
在JavaScript / HTML中,cloneNode()方法详细指南
开发语言·javascript·html
—Qeyser2 小时前
Flutter组件 - BottomNavigationBar 底部导航栏
开发语言·javascript·flutter
hxjhnct2 小时前
CSS 伪类和伪元素
前端·javascript·css
❆VE❆2 小时前
【css】打造倾斜异形按钮:CSS radial-gradient 与抗锯齿实战解析
前端·javascript·css
followYouself2 小时前
ViewPager+Fragment
android·前端
37方寸2 小时前
前端基础知识(HTML、CSS)
前端·css·html
u1301302 小时前
深入解析二维码技术与前端生成方案
前端·二维码