前端文件【上传&下载】姿势大全

95 阅读6分钟

前端几乎每天都在和「文件」打交道:上传头像、导出报表、下载附件、预览图片……看似简单,但背后往往暗藏细节和坑。

  • 为什么同样是 <a download>,有时文件名生效,有时却被浏览器忽略?
  • 为什么直接访问一个图片 URL,有时会预览,有时却变成下载?
  • 为什么有些文件下载过程能看到进度,有些却看不到?
  • 浏览器到底有哪些方式可以获取、上传和下载文件?

如果你曾在这些问题上踩过坑,或者只是“能用但说不清原理”,那么这篇文章正是为你准备的。

本文将系统梳理浏览器与服务器之间的文件传输机制,从文件获取、上传到下载的完整链路出发,结合常见场景与代码示例,帮你把这些看似零散的问题一次讲清楚。

1 浏览器获取用户本地文件

在浏览器中根据不同场景,有多种获取文件的方式。

1.1 点击上传

通过点击文件表单实现上传,最基础、兼容性最好的方式。

<input type="file" id="file" />
<script>
  const file = document.getElementById('file')
  file.addEventListener('change', (e) => {
    const file = e.target.files[0]
    console.log('🚀 ~ file -->', file)
  })
</script>
<input type="file" id="dir" webkitdirectory multiple />
<script>
  const input = document.getElementById('dir');
  input.addEventListener('change', (e) => {
    const files = e.target.files;
    console.log('🚀 ~ files -->', files)
  });
</script>

1.2 拖拽上传

直接拖动文件到页面,交互体验好,符合直觉,适合批量文件。

<div id="drop" style="width:300px;height:200px;border:2px dashed #999;">
  Drop files here
</div>

<script>
  const drop = document.getElementById('drop');

  drop.addEventListener('dragover', (e) => {
    e.preventDefault();
  });

  drop.addEventListener('drop', (e) => {
    e.preventDefault();
    const files = e.dataTransfer.files;
    console.log('🚀 ~ files -->', files)
  });
</script>

1.3 粘贴上传

将剪切板文件粘贴到页面中,交互体验好,效率高,特别适合上传临时截图。

<p>在此页面 Ctrl / Cmd + V 粘贴文件</p>
<script>
  document.addEventListener('paste', (e) => {
    const items = e.clipboardData.items;
    for (const item of items) {
      if (item.kind === 'file') {
        const file = item.getAsFile();
        console.log('🚀 ~ file -->', file)
      }
    }
  });
</script>

1.4 通过媒体设备获取资源

适用于需要捕获摄像头或麦克风媒体资源时的特殊场景。

<button id="btnOpen">打开摄像头</button>
<button id="btnShot">截图</button>
<video id="video" autoplay playsinline muted></video>
<img id="preview" alt="" />
<script>
  const btnOpen = document.getElementById('btnOpen');
  const btnShot = document.getElementById('btnShot');
  const video = document.getElementById('video');
  const preview = document.getElementById('preview');

  let stream = null;
  btnOpen.addEventListener('click', async () => {
    stream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: false
    });
    video.srcObject = stream;
  });

  btnShot.addEventListener('click', async () => {
    if (!stream) throw new Error('Camera not started');

    const track = stream.getVideoTracks()[0];
    const imageCapture = new ImageCapture(track);
    const blob = await imageCapture.takePhoto();
    const url = URL.createObjectURL(blob);
    preview.src = url;

    console.log('🚀 ~ file -->', new File(
      [blob],
      `screenshot-${Date.now()}.png`,
      { type: 'image/png' }
    ))
  });
</script>

1.5 File System Access API

非标准的实验性接口,功能强大,支持读写。

<button id="openFile">打开文件</button>
<button id="openFiles">打开多个文件</button>
<button id="openDir">打开目录</button>

<script>
  const buttonFile = document.getElementById('openFile');
  buttonFile.addEventListener('click', async () => {
    const [fileHandle] = await window.showOpenFilePicker();
    const file = await fileHandle.getFile();
    console.log('🚀 ~ file -->', file)
  });

  const buttonFiles = document.getElementById('openFiles');
  buttonFiles.addEventListener('click', async () => {
    const fileHandles = await window.showOpenFilePicker({
      multiple: true,
    });
    const files = await Promise.all(fileHandles.map(fileHandle => fileHandle.getFile()));
    console.log('🚀 ~ files -->', files)
  });

  const buttonDir = document.getElementById('openDir');
  buttonDir.addEventListener('click', async () => {
    const dir = await window.showDirectoryPicker();
    const files = [];
    for await (const entry of dir.values()) {
      if (entry.kind === 'file') {
        files.push(await entry.getFile());
      }
    }
    console.log('🚀 ~ files -->', files)
  });
</script>

2 浏览器发送文件

拿到 File 之后,可以使用 fetch 发送,如果需要显示进度可以使用 XMLHttpRequest

请求体通常使用 multipart/form-data 编码格式,或者直接上传二进制文件数据。

  • Content-Type: multipart/form-data
  • Content-Type: application/octet-stream
  • Content-Type: [mimetype]

2.1 FormData

使用 FormData 作为请求体,最通用的方式,适配大多数后端框架与对象存储直传。

const fd = new FormData()
fd.append('biz', 'avatar')
fd.append('file', file)
const res = await fetch('/api/upload', {
  method: 'POST',
  body: fd,
})
if (res.ok) {
  const data = await res.json()
  console.log('🚀 ~ data -->', data)
}

浏览器会自动将请求编码为 multipart/form-data,并在请求头中设置合适的 boundary。

Content-Length 123456
Content-Type   multipart/form-data; boundary=----WebKitFormBoundaryA1B2C3D4

------WebKitFormBoundaryA1B2C3D4
Content-Disposition: form-data; name="biz"

avatar
------WebKitFormBoundaryA1B2C3D4
Content-Disposition: form-data; name="file"; filename="avatar.jpg"
Content-Type: image/jpeg

[binary content of JPG...]
------WebKitFormBoundaryA1B2C3D4--

2.2 二进制流

直接上传裸文件数据,实现简单,服务端无需解析 multipart,直接写入文件。

const res = await fetch('/api/upload', {
  method: 'POST',
  headers: {
    'Content-Type': file.type || 'application/octet-stream',
    'X-Filename': encodeURIComponent(file.name),
  },
  body: file,
})
if (res.ok) {
  const data = await res.json()
  console.log('🚀 ~ data -->', data)
}
Content-Length 123456
Content-Type   image/jpeg
X-Filename     avatar.jpg

3 浏览器下载文件

下载文件主要有两大类:下载服务端资源,或前端生成/操作文件数据。

3.1 基于服务端资源

服务端提供文件下载链接:

# 响应头
Content-Type: image/jpeg
Content-Disposition: attachment; filename="avatar.jpg"
# http/1.1 流式传输
Transfer-Encoding: chunked

前端可以基于静态 <a> 标签,动态创建 <a> 标签,或者通过导航 window.location/window.open 触发下载:

<a href="/api/file-stream">下载</a>
<button id="download-stream">按钮下载</button>
<button id="nav-stream">导航下载</button>

<script>
  document.getElementById('download-stream').addEventListener('click', () => {
    const a = document.createElement('a')
    a.download = 'avatar.jpg'
    a.click()
  })
  document.getElementById('nav-stream').addEventListener('click', () => {
    window.location.href = '/api/file-stream'
  })
</script>

如果服务端没有返回 Content-Disposition 响应头,浏览器需要通过 <a> 标签配合 download 属性下载文件。

<a href="/api/file-stream" download="avatar.jpg">下载</a>

如果服务端有通过 Content-Disposition 指定文件名,浏览器通过 download 属性配置的文件名无效。

💡浏览器导航文件链接大致过程

  1. 解析响应头
  2. 根据 Content-Type 判断 MIME 类型,决定浏览器如何处理文件
  3. 根据 Content-Disposition 决定显示还是下载
    1. inline → 默认值,在页面中显示文件内容
    2. attachment → 下载文件
  4. 下载文件时会根据 Content-Length 决定是否显示进度条
  5. 接收响应体并写入内存或文件

3.2 基于二进制数据

适用于前端生成文件或者导出 canvas 等场景。

<button id="download">下载文本</button>
<button id="downloadCanvas">下载 canvas 图片</button>
<canvas id="canvas" width="200" height="200" style="border: 1px solid #ccc"></canvas>

<script>
  document.getElementById('download').addEventListener('click', () => {
    const blob = new Blob(['Hello world'], { type: 'text/plain' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = 'hello.txt'
    a.click()
    URL.revokeObjectURL(url)
  })

  const canvas = document.getElementById('canvas')
  const ctx = canvas.getContext('2d')
  ctx.fillStyle = '#4a90d9'
  ctx.fillRect(20, 20, 160, 160)
  ctx.fillStyle = '#fff'
  ctx.font = '20px sans-serif'
  ctx.fillText('Canvas', 60, 105)
  document.getElementById('downloadCanvas').addEventListener('click', () => {
    canvas.toBlob((blob) => {
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = 'canvas.png'
      a.click()
      URL.revokeObjectURL(url)
    }, 'image/png')
  })
</script>

3.3 基于 data URL

适用于极小文本数据,特定场景有用。

<button id="download">下载 SVG</button>

<script>
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
  <rect fill="#4a90d9" width="200" height="200"/>
  <text x="100" y="110" text-anchor="middle" fill="#fff" font-size="24">Hello SVG</text>
</svg>`
  document.getElementById('download').addEventListener('click', () => {
    const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
    const a = document.createElement('a')
    a.href = dataUrl
    a.download = 'hello.svg'
    a.click()
  })
</script>

3.4 File System Access API

非标准的实验性接口,直接保存到指定位置。

<button id="download">下载文本</button>

<script>
  document.getElementById('download').addEventListener('click', async () => {
    const handle = await window.showSaveFilePicker({
      suggestedName: 'data.txt',
    })
    const writable = await handle.createWritable()
    await writable.write('Hello World!')
    await writable.close()
  })
</script>

4 服务端返回文件

NodeJS 一般可以通过 BufferStream 的方式返回文件。

4.1 非流式下载(buffered download)

同步阻塞,服务器将整个文件读入内存。

ctx.body = fs.readFileSync('girl.jpg')

服务端会返回 Content-Length,浏览器可以据此显示下载进度。

4.2 流式下载(streaming download)

异步文件流,分块读取,适合大文件。

ctx.body = fs.createReadStream('girl.jpg')

流式传输不适合使用 Content-Length,所以不需要返回。

💡流式传输时 HTTP/1.1 和 HTTP/2 区别

HTTP/1.1 默认使用分块传输 Transfer-Encoding: chunked。因为 HTTP/1.1 使用纯文本 + 字节流的格式实现流式传输,浏览器要么提前知道 Content-Length,要么在服务器主动关闭连接时才算结束。当响应长度“不可能提前知道”时,就只能靠 chunked 解决:遇到 chunk-size 为 0 时即整个响应结束。

HTTP/2 的数据帧机制知道什么时候传输结束,因此不支持 chunked

📝 总结

本文介绍了浏览器与服务器之间文件传输的完整流程:

  • 获取文件:支持表单上传、拖拽、粘贴、设备采集和 File System Access API 等多种方式
  • 发送文件:FormData 最通用,二进制流更高效
  • 下载文件:服务器链接、Blob 对象、data URL 和 File System Access API 各有适用场景
  • 服务端返回:小文件用 Buffer,大文件用 Stream

选择合适的文件传输方案需要综合考虑:文件大小、用户体验、浏览器兼容性、服务器资源等因素。