Vue 基础体系 · 第 37/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 文件上传:选择、拖拽、分片、进度、取消和重试

文件上传表面上只是把一个 File 交给服务器,实际却包含了多个不同层次的问题:

  • 选择:如何从 <input type="file"> 得到 File
  • 拖拽:如何处理 dragoverdrop 和拖入的文件对象;
  • 分片:如何把一个大文件切成多个可独立传输的字节范围;
  • 进度:如何把每个分片的进度合并成整个文件的进度;
  • 取消:如何中止当前 HTTP 请求,并让状态机停止后续分片;
  • 重试:哪些失败可以重试,重试时如何避免重复写入或破坏文件。

Vue 只负责组件状态和界面更新。真正的上传能力来自浏览器的 FileBlob.slice()XMLHttpRequestAbortController 等 Web API,以及服务器对分片协议的支持。


一、先确定上传协议

1. File 是什么

用户通过文件选择框或拖拽得到的是一个 File 对象。它继承自 Blob,因此具备:

interface File extends Blob {
  readonly name: string
  readonly lastModified: number
}

常用属性和方法如下:

file.name          // 原始文件名,例如 "video.mp4"
file.size          // 文件大小,单位是字节
file.type          // 浏览器推断出的 MIME 类型,可能为空或不可靠
file.lastModified  // 最后修改时间戳
file.slice(start, end)

file.slice(start, end) 返回一个新的 Blob,其中:

  • start 是包含在范围内的起始字节;
  • end 是排除在范围外的结束位置;
  • 范围表示为 [start, end)

例如,一个大小为 10 字节的文件:

file.slice(0, 4) // 包含第 0、1、2、3 字节,共 4 字节
file.slice(4, 10) // 包含第 4 到第 9 字节,共 6 字节

这里的 end 不是最后一个字节的下标。这是分片代码最常见的边界错误之一。

2. 分片上传需要服务器配合

前端单独把文件切片,并不会自动产生断点续传能力。服务器必须知道:

  1. 哪个上传任务正在进行;
  2. 当前分片属于哪个文件;
  3. 当前分片的序号或字节范围;
  4. 已经接收了哪些分片;
  5. 何时可以按照正确顺序合并;
  6. 如何处理重复分片和失效上传任务。

下面采用一个明确的协议:

请求 作用
POST /api/uploads/init 创建或恢复上传任务
PUT /api/uploads/:uploadId/parts/:index 上传一个分片
POST /api/uploads/:uploadId/complete 通知服务器合并分片

初始化请求:

{
  "name": "video.mp4",
  "size": 52428800,
  "type": "video/mp4",
  "chunkSize": 8388608
}

服务器返回:

{
  "uploadId": "u_abc123",
  "uploadedParts": [0, 1]
}

uploadedParts 表示服务器已经完整保存的分片序号。前端可以跳过这些分片,这才是续传,而不是简单地从上一次进度百分比继续计算。

上传第 2 个分片时:

PUT /api/uploads/u_abc123/parts/2
Content-Type: application/octet-stream
Content-Range: bytes 16777216-25165823/52428800

请求体就是这一段分片的二进制内容。

完成请求:

{
  "name": "video.mp4",
  "size": 52428800,
  "parts": [0, 1, 2, 3, 4, 5, 6]
}

服务器必须再次校验:

  • 分片是否齐全;
  • 分片大小和字节范围是否符合预期;
  • 合并后的文件大小是否等于客户端声明的大小;
  • 当前用户是否拥有这个 uploadId
  • 文件名和 MIME 类型是否经过安全处理。

客户端传入的文件名、类型和大小都不能视为可信数据。


二、从文件大小推导分片范围

设:

  • 文件大小为 SS 字节;
  • 分片大小为 CC 字节;
  • 分片序号为 ii,从 0 开始;
  • 分片总数为 NN

分片总数是:

N=SCN = \left\lceil \frac{S}{C} \right\rceil

ii 个分片的范围为:

starti=i×Cstart_i = i \times C

endi=min(starti+C,S)end_i = \min(start_i + C, S)

其中 end_i 是排除位置,因此分片字节数为:

lengthi=endistartilength_i = end_i - start_i

例如,文件大小为 20 MiB,分片大小为 8 MiB

分片序号 start end 实际大小
0 0 8 MiB 8 MiB
1 8 MiB 16 MiB 8 MiB
2 16 MiB 20 MiB 4 MiB

最后一个分片通常小于设定的分片大小。

如果文件为空,Math.ceil(0 / C) 得到 0。此时不能假设一定存在第 0 个分片,协议需要允许服务器直接完成一个零字节文件,或者约定上传一个空分片。


三、选择文件和拖拽文件

1. 文件选择

最基本的模板是:

<input type="file" @change="onFileChange" />

事件中的 event.target 是一个 HTMLInputElement

function onFileChange(event: Event) {
  const input = event.target as HTMLInputElement
  const file = input.files?.[0]

  if (!file) {
    return
  }

  // 使用 file
}

input.filesFileList,可能为空。不能直接假设用户一定选择了文件,因为用户可以打开选择框后点击“取消”。

如果用户两次选择同一个文件,部分浏览器不会再次触发 change。常见处理是在读取文件后重置输入框:

input.value = ''

这样下一次选择相同文件也能触发事件。

2. 拖拽

拖拽区域至少需要处理三个事件:

<div
  @dragover.prevent
  @drop.prevent="onDrop"
>
  将文件拖到这里
</div>

dragover.prevent 很重要。没有取消默认行为,浏览器可能不把当前元素视为可放置目标,drop 事件也可能不按预期触发。

拖拽文件来自:

function onDrop(event: DragEvent) {
  const file = event.dataTransfer?.files?.[0]

  if (!file) {
    return
  }

  // 使用 file
}

拖拽数据也可能包含目录、URL 或浏览器自定义数据,因此不能仅仅判断 dataTransfer 存在。实际应用还要检查 files.length 和文件类型。


四、用状态机组织上传流程

上传不是一个布尔值,而是一组有顺序约束的状态:

stateDiagram-v2
    [*] --> idle
    idle --> ready: 选择或拖入文件
    ready --> initializing: 开始上传
    initializing --> uploading: 初始化成功
    initializing --> failed: 初始化失败
    uploading --> uploading: 当前分片成功,继续下一个
    uploading --> retrying: 可重试错误
    retrying --> uploading: 等待后重试
    uploading --> completed: 所有分片完成并合并成功
    uploading --> canceled: 用户取消
    uploading --> failed: 不可重试错误
    retrying --> canceled: 用户取消
    failed --> initializing: 重新开始或恢复
    canceled --> initializing: 再次开始
    completed --> [*]

状态变化应当由上传流程统一驱动,而不是在多个按钮和事件处理器中分别修改。否则容易出现:

  • 请求已经取消,界面却又显示“上传完成”;
  • 初始化失败后仍然开始发送分片;
  • 重试计时期间用户取消,但定时器结束后仍发起请求;
  • 文件已经更换,旧请求完成后覆盖新文件的进度。

下面的实现使用 Vue 3 <script setup>、Composition API 和 TypeScript。它采用串行上传分片,先保证状态和错误路径清晰;并发上传会在后文讨论。


五、完整的 Vue 上传组件

下面的组件包含:

  • 文件选择;
  • 拖拽;
  • 文件大小校验;
  • 初始化或恢复;
  • 分片上传;
  • 单分片进度;
  • 总进度;
  • 取消;
  • 指数退避重试;
  • 已上传分片跳过。

1. 组件代码

<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'

type UploadStatus =
  | 'idle'
  | 'ready'
  | 'initializing'
  | 'uploading'
  | 'retrying'
  | 'completed'
  | 'canceled'
  | 'failed'

interface InitResponse {
  uploadId: string
  uploadedParts: number[]
}

interface PartRange {
  index: number
  start: number
  end: number
  size: number
}

class HttpError extends Error {
  constructor(
    public readonly status: number,
    message: string,
  ) {
    super(message)
    this.name = 'HttpError'
  }
}

const CHUNK_SIZE = 8 * 1024 * 1024
const MAX_FILE_SIZE = 20 * 1024 * 1024 * 1024
const MAX_RETRIES = 3

const file = ref<File | null>(null)
const status = ref<UploadStatus>('idle')
const message = ref('')
const uploadId = ref<string | null>(null)
const completedParts = ref<Set<number>>(new Set())
const partLoaded = ref<Map<number, number>>(new Map())
const currentPart = ref<number | null>(null)
const retryAttempt = ref(0)
const isDragging = ref(false)

let controller: AbortController | null = null
let activeXhr: XMLHttpRequest | null = null

const canStart = computed(() => {
  return file.value !== null &&
    !['initializing', 'uploading', 'retrying'].includes(status.value)
})

const totalParts = computed(() => {
  if (!file.value) {
    return 0
  }

  return Math.ceil(file.value.size / CHUNK_SIZE)
})

const uploadedBytes = computed(() => {
  if (!file.value) {
    return 0
  }

  let bytes = 0

  for (const part of getPartRanges(file.value)) {
    if (completedParts.value.has(part.index)) {
      bytes += part.size
      continue
    }

    bytes += Math.min(partLoaded.value.get(part.index) ?? 0, part.size)
  }

  return bytes
})

const progress = computed(() => {
  if (!file.value || file.value.size === 0) {
    return status.value === 'completed' ? 100 : 0
  }

  return Math.floor((uploadedBytes.value / file.value.size) * 100)
})

function getPartRanges(target: File): PartRange[] {
  const result: PartRange[] = []
  const count = Math.ceil(target.size / CHUNK_SIZE)

  for (let index = 0; index < count; index += 1) {
    const start = index * CHUNK_SIZE
    const end = Math.min(start + CHUNK_SIZE, target.size)

    result.push({
      index,
      start,
      end,
      size: end - start,
    })
  }

  return result
}

function setSelectedFile(nextFile: File) {
  if (nextFile.size > MAX_FILE_SIZE) {
    status.value = 'failed'
    message.value = '文件超过 20 GiB,无法上传'
    return
  }

  file.value = nextFile
  uploadId.value = null
  completedParts.value = new Set()
  partLoaded.value = new Map()
  currentPart.value = null
  retryAttempt.value = 0
  status.value = 'ready'
  message.value = `已选择:${nextFile.name}`
}

function onFileChange(event: Event) {
  const input = event.target as HTMLInputElement
  const selected = input.files?.[0]

  if (selected) {
    setSelectedFile(selected)
  }

  // 允许再次选择同一个文件时触发 change
  input.value = ''
}

function onDragOver() {
  isDragging.value = true
}

function onDragLeave() {
  isDragging.value = false
}

function onDrop(event: DragEvent) {
  isDragging.value = false

  const droppedFile = event.dataTransfer?.files?.[0]

  if (droppedFile) {
    setSelectedFile(droppedFile)
  }
}

function createAbortError() {
  const error = new Error('Upload canceled')
  error.name = 'AbortError'
  return error
}

function throwIfAborted(signal: AbortSignal) {
  if (signal.aborted) {
    throw createAbortError()
  }
}

function sleep(ms: number, signal: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    const timer = window.setTimeout(() => {
      signal.removeEventListener('abort', onAbort)
      resolve()
    }, ms)

    function onAbort() {
      window.clearTimeout(timer)
      signal.removeEventListener('abort', onAbort)
      reject(createAbortError())
    }

    signal.addEventListener('abort', onAbort, { once: true })
  })
}

async function fetchJson<T>(
  input: RequestInfo | URL,
  init: RequestInit,
  signal: AbortSignal,
): Promise<T> {
  throwIfAborted(signal)

  const response = await fetch(input, {
    ...init,
    signal,
  })

  if (!response.ok) {
    throw new HttpError(
      response.status,
      `HTTP ${response.status}: ${response.statusText}`,
    )
  }

  return response.json() as Promise<T>
}

async function initializeUpload(
  target: File,
  signal: AbortSignal,
): Promise<InitResponse> {
  return fetchJson<InitResponse>(
    '/api/uploads/init',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        name: target.name,
        size: target.size,
        type: target.type,
        chunkSize: CHUNK_SIZE,
      }),
    },
    signal,
  )
}

function uploadPart(
  target: File,
  part: PartRange,
  id: string,
  signal: AbortSignal,
): Promise<void> {
  return new Promise((resolve, reject) => {
    throwIfAborted(signal)

    const xhr = new XMLHttpRequest()
    activeXhr = xhr

    const body = target.slice(part.start, part.end)
    const url = `/api/uploads/${encodeURIComponent(id)}/parts/${part.index}`

    const cleanup = () => {
      signal.removeEventListener('abort', onAbort)

      if (activeXhr === xhr) {
        activeXhr = null
      }
    }

    const onAbort = () => {
      xhr.abort()
    }

    xhr.open('PUT', url)
    xhr.setRequestHeader('Content-Type', 'application/octet-stream')
    xhr.setRequestHeader(
      'Content-Range',
      `bytes ${part.start}-${part.end - 1}/${target.size}`,
    )

    xhr.upload.onprogress = (event: ProgressEvent<EventTarget>) => {
      if (!event.lengthComputable) {
        return
      }

      const next = new Map(partLoaded.value)
      next.set(part.index, Math.min(event.loaded, part.size))
      partLoaded.value = next
    }

    xhr.onload = () => {
      cleanup()

      if (xhr.status >= 200 && xhr.status < 300) {
        const next = new Map(partLoaded.value)
        next.set(part.index, part.size)
        partLoaded.value = next
        resolve()
        return
      }

      reject(new HttpError(
        xhr.status,
        `分片 ${part.index} 上传失败,HTTP ${xhr.status}`,
      ))
    }

    xhr.onerror = () => {
      cleanup()
      reject(new Error(`分片 ${part.index} 网络错误`))
    }

    xhr.ontimeout = () => {
      cleanup()
      reject(new Error(`分片 ${part.index} 请求超时`))
    }

    xhr.onabort = () => {
      cleanup()
      reject(createAbortError())
    }

    signal.addEventListener('abort', onAbort, { once: true })
    xhr.send(body)
  })
}

function isRetryable(error: unknown): boolean {
  if (error instanceof DOMException && error.name === 'AbortError') {
    return false
  }

  if (error instanceof Error && error.name === 'AbortError') {
    return false
  }

  if (error instanceof HttpError) {
    // 4xx 通常表示参数、权限或配额错误,不应盲目重试。
    return error.status === 408 ||
      error.status === 425 ||
      error.status === 429 ||
      error.status >= 500
  }

  // 网络断开、DNS 失败、XHR timeout 等没有 HTTP 响应的错误可重试。
  return true
}

async function uploadPartWithRetry(
  target: File,
  part: PartRange,
  id: string,
  signal: AbortSignal,
): Promise<void> {
  for (let attempt = 0; ; attempt += 1) {
    throwIfAborted(signal)

    const next = new Map(partLoaded.value)
    next.set(part.index, 0)
    partLoaded.value = next

    try {
      await uploadPart(target, part, id, signal)
      retryAttempt.value = 0
      return
    } catch (error) {
      if (!isRetryable(error) || attempt >= MAX_RETRIES) {
        throw error
      }

      retryAttempt.value = attempt + 1

      // 1 秒、2 秒、4 秒;加入少量随机抖动,避免大量客户端同时重试。
      const baseDelay = 1000 * 2 ** attempt
      const jitter = Math.floor(Math.random() * 300)

      status.value = 'retrying'
      await sleep(baseDelay + jitter, signal)
      status.value = 'uploading'
    }
  }
}

async function completeUpload(
  target: File,
  id: string,
  signal: AbortSignal,
): Promise<void> {
  const parts = getPartRanges(target).map((part) => part.index)

  await fetchJson(
    `/api/uploads/${encodeURIComponent(id)}/complete`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        name: target.name,
        size: target.size,
        parts,
      }),
    },
    signal,
  )
}

async function startUpload() {
  const target = file.value

  if (!target || !canStart.value) {
    return
  }

  controller?.abort()
  controller = new AbortController()

  const { signal } = controller

  status.value = 'initializing'
  message.value = '正在初始化上传任务'
  retryAttempt.value = 0
  completedParts.value = new Set()
  partLoaded.value = new Map()

  try {
    const initialized = await initializeUpload(target, signal)
    uploadId.value = initialized.uploadId

    const serverParts = new Set(initialized.uploadedParts)
    const validPartIndexes = new Set(
      getPartRanges(target).map((part) => part.index),
    )

    // 防止服务端返回超出当前文件范围的非法分片序号。
    completedParts.value = new Set(
      [...serverParts].filter((index) => validPartIndexes.has(index)),
    )

    for (const part of getPartRanges(target)) {
      throwIfAborted(signal)

      if (completedParts.value.has(part.index)) {
        const next = new Map(partLoaded.value)
        next.set(part.index, part.size)
        partLoaded.value = next
        continue
      }

      currentPart.value = part.index
      status.value = 'uploading'
      message.value = `正在上传分片 ${part.index + 1}/${totalParts.value}`

      await uploadPartWithRetry(target, part, initialized.uploadId, signal)

      const nextCompleted = new Set(completedParts.value)
      nextCompleted.add(part.index)
      completedParts.value = nextCompleted
    }

    currentPart.value = null
    message.value = '正在请求服务器合并文件'
    await completeUpload(target, initialized.uploadId, signal)

    status.value = 'completed'
    message.value = '上传完成'
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      status.value = 'canceled'
      message.value = '上传已取消'
      return
    }

    status.value = 'failed'
    message.value = error instanceof Error
      ? error.message
      : '上传失败'
  } finally {
    activeXhr = null
  }
}

function cancelUpload() {
  if (!controller) {
    return
  }

  controller.abort()
  activeXhr?.abort()
}

onUnmounted(() => {
  controller?.abort()
  activeXhr?.abort()
})
</script>

<template>
  <section class="uploader">
    <label
      class="drop-zone"
      :class="{ dragging: isDragging }"
      @dragover.prevent="onDragOver"
      @dragleave.prevent="onDragLeave"
      @drop.prevent="onDrop"
    >
      <input type="file" hidden @change="onFileChange" />
      <span>点击选择文件,或将文件拖到这里</span>
    </label>

    <p v-if="file">
      文件:{{ file.name }}({{ file.size }} bytes)
    </p>

    <progress
      v-if="file"
      :value="progress"
      max="100"
    />

    <p v-if="file">
      {{ progress }}%
      <span v-if="currentPart !== null">
       ,当前分片:{{ currentPart + 1 }}/{{ totalParts }}
      </span>
    </p>

    <p>{{ message }}</p>

    <button
      type="button"
      :disabled="!canStart"
      @click="startUpload"
    >
      {{ status === 'failed' || status === 'canceled' ? '重新上传' : '开始上传' }}
    </button>

    <button
      v-if="['initializing', 'uploading', 'retrying'].includes(status)"
      type="button"
      @click="cancelUpload"
    >
      取消
    </button>
  </section>
</template>

<style scoped>
.drop-zone {
  display: block;
  padding: 32px;
  border: 2px dashed #aaa;
  cursor: pointer;
}

.drop-zone.dragging {
  border-color: #409eff;
  background: #eef6ff;
}

progress {
  width: 100%;
}
</style>

2. 代码中的关键因果关系

filestatusprogresscurrentPart 等变量使用 ref,因为它们会在异步上传过程中变化。模板读取这些 ref 时,Vue 会自动追踪依赖并更新界面。

totalPartsprogress 使用 computed,因为它们是由其他状态推导出来的:

  • totalParts 由文件大小和分片大小决定;
  • progress 由每个已完成分片和当前分片的已上传字节数决定。

没有必要在每次进度事件中手动设置一个独立的总百分比。总百分比应由源数据重新计算,避免“进度变量”和真实分片状态分叉。


六、为什么上传进度使用 XMLHttpRequest

1. 上传进度与下载进度不同

XMLHttpRequest 提供:

xhr.upload.onprogress = (event) => {
  event.loaded
  event.total
  event.lengthComputable
}

这里监听的是 xhr.upload,代表请求体上传过程。如果监听 xhr.onprogress,通常得到的是响应体下载进度,不是文件上传进度。

event.lengthComputablefalse 时,浏览器无法可靠提供总长度,此时不能把 event.loaded / event.total 当成有效百分比。

2. fetch 的边界

普通 fetch 请求很适合:

  • 初始化上传;
  • 请求服务器合并;
  • 发送 JSON 元数据;
  • 使用 AbortController 取消请求。

但在现代浏览器中,普通 fetch 并不像 XMLHttpRequest.upload.onprogress 那样提供一个普遍可用、简单的上传进度回调。因此上面的实现使用:

  • fetch 处理初始化和完成请求;
  • XMLHttpRequest 处理需要上传进度的分片请求。

这不是 Vue 的限制,而是浏览器上传流进度 API 的差异。浏览器对请求体流和上传进度的支持会随版本变化,使用实验性能力时必须单独验证目标浏览器,不能把某个浏览器的实现当成通用规范保证。


七、如何计算总进度

如果只上传一个分片,可以使用:

P=loadedtotalP = \frac{loaded}{total}

但分片上传时,需要先把各分片的已上传字节相加:

L=i=0N1loadediL = \sum_{i=0}^{N-1} loaded_i

整个文件的进度为:

P=LS×100P = \left\lfloor \frac{L}{S} \times 100 \right\rfloor

其中:

  • SS 是整个文件的字节数;
  • loadediloaded_i 是第 ii 个分片当前已发送的字节数;
  • 已完成分片的 loaded_i 等于该分片完整大小。

例如,文件有三个分片:

分片 大小 当前已上传
0 8 MiB 8 MiB
1 8 MiB 4 MiB
2 4 MiB 0

则:

L=8+4+0=12 MiBL = 8 + 4 + 0 = 12\text{ MiB}

文件总大小为:

S=8+8+4=20 MiBS = 8 + 8 + 4 = 20\text{ MiB}

所以总进度为:

P=1220×100=60P = \left\lfloor \frac{12}{20} \times 100 \right\rfloor = 60

不能用“已完成分片数 / 总分片数”计算百分比,因为最后一个分片可能远小于其他分片。上面的例子中,完成一个分片只代表 40%,而不是 33% 或固定的一个分片比例。

重试时为什么要清零当前分片进度

假设第 1 个分片已经上传了 4 MiB,请求在最后失败。下一次重试从字节 0 重新发送,那么之前的 4 MiB 不能继续算入当前进度,否则进度会暂时超过真实发送量。

因此代码在每次尝试开始前执行:

next.set(part.index, 0)

成功后才把该分片设置为完整大小。进度回退是重试的真实表现,不应通过虚假的百分比掩盖。

如果服务器支持分片内部的字节级续传,例如 PATCH 或带偏移量的协议,则可以从已确认偏移量继续发送;这已经不是简单的“分片重试”,需要服务器返回并持久化每个分片内部的偏移量。


八、取消:停止请求和停止流程是两件事

取消上传至少需要完成两个动作:

  1. 取消正在进行的网络请求;
  2. 让后续异步步骤不再继续。

只调用 xhr.abort() 只能停止当前 XHR。如果外层代码没有检查取消状态,当前 Promise 结束后仍可能进入下一次循环,或者继续调用合并接口。

因此代码同时使用:

controller.abort()
activeXhr?.abort()

并在多个异步边界检查:

throwIfAborted(signal)

检查位置包括:

  • 初始化请求前;
  • 每个分片开始前;
  • 重试等待结束后;
  • 完成请求前。

取消与重试还存在一个竞争条件:

  1. 分片失败;
  2. 代码准备等待 2 秒;
  3. 用户点击取消;
  4. 等待结束后不能再次发送请求。

因此 sleep 也必须接收 AbortSignal。上例中取消会清除定时器并拒绝 Promise,外层捕获 AbortError 后进入 canceled 状态。

组件卸载时也要取消:

onUnmounted(() => {
  controller?.abort()
  activeXhr?.abort()
})

否则用户切换路由后,旧组件可能仍然占用网络和内存。


九、重试:不是所有错误都应该重试

1. 可以重试的情况

通常可以考虑重试:

  • 网络连接中断;
  • 请求超时;
  • HTTP 408 Request Timeout
  • HTTP 429 Too Many Requests
  • HTTP 5xx 服务端临时错误;
  • 代理或网关暂时不可用。

2. 不应盲目重试的情况

以下错误通常需要直接失败并提示用户或开发者修复:

  • 400:请求格式错误;
  • 401403:身份或权限问题;
  • 404:上传任务不存在;
  • 413:文件或请求体超过限制;
  • 415:媒体类型不支持;
  • 服务端返回“分片范围非法”。

无限重试会掩盖协议错误,也可能给服务器制造持续压力。

3. 指数退避

kk 次重试的等待时间可以近似为:

delayk=base×2k+jitterdelay_k = base \times 2^k + jitter

例如 base = 1000 ms 时,等待时间依次接近:

1 秒
2 秒
4 秒

代码加入随机抖动:

const jitter = Math.floor(Math.random() * 300)

这样多个客户端同时失败时,不会在同一时刻再次集中请求。

重试次数应有上限。示例中 MAX_RETRIES = 3,表示初次请求失败后最多再尝试三次。生产环境还需要根据接口的 Retry-After 响应头调整 429 的等待时间。

4. 重试必须依赖幂等协议

这里使用:

PUT /api/uploads/:uploadId/parts/:index

并要求服务器对同一个 uploadId + index 的重复请求具备幂等性:

  • 第一次写入分片;
  • 网络响应丢失;
  • 客户端以为失败并重试;
  • 服务器再次收到相同分片;
  • 服务器应覆盖、校验后接受,或返回“已经存在”,但不能把同一分片拼接两次。

如果服务器使用“每收到一次请求就追加到文件末尾”的接口,网络响应丢失后重试可能产生重复字节,前端无法仅靠 HTTP 状态修复这个问题。


十、断点续传和“刷新页面后的恢复”

当前组件在内存中保存:

uploadId
completedParts
partLoaded

因此它可以在一次组件生命周期内取消后重新开始,但刷新页面后这些变量会丢失。

真正的断点续传需要:

  1. 客户端保存上传任务标识;
  2. 页面重新打开后重新调用初始化接口;
  3. 服务器根据文件身份返回已完成的分片;
  4. 前端跳过已完成分片。

文件身份不能只使用文件名,因为两个同名文件可能内容完全不同。常见选择包括:

文件大小 + lastModified + 文件名

这只能作为弱标识。更可靠的方法是计算文件内容哈希,例如 SHA-256:

hash(file) -> 文件指纹

但计算大文件哈希会消耗 CPU 和内存带宽,通常应使用 Web Worker,或者由服务器按分片校验。不能为了“支持续传”就在主线程同步读取整个大文件,否则会阻塞界面。

初始化接口可以设计成:

{
  "name": "video.mp4",
  "size": 52428800,
  "lastModified": 1710000000000,
  "fingerprint": "sha256:...",
  "chunkSize": 8388608
}

服务器根据用户身份和文件指纹查找未完成任务,并返回:

{
  "uploadId": "u_abc123",
  "uploadedParts": [0, 1, 2]
}

如果客户端文件内容变化,即使文件名相同,也不应继续使用旧任务。服务器完成合并时仍必须校验实际内容,不能把客户端指纹当成最终安全证明。


十一、串行和并发上传的取舍

上面的代码一次只上传一个分片:

上传 0
  ↓
上传 1
  ↓
上传 2
  ↓
合并

串行方式的特点是:

  • 内存占用更容易控制;
  • 当前状态简单;
  • 取消和重试路径更容易正确;
  • 在单连接或服务端顺序处理场景下更可预测;
  • 总吞吐量可能不如有限并发。

并发上传可以同时发送多个分片:

上传 0 ─┐
上传 1 ─┼─> 全部完成 -> 合并
上传 2 ─┘

但并发会引入新的状态:

  • 每个分片都需要独立的 XHR 和取消逻辑;
  • 每个分片都要有自己的重试计数;
  • 服务器需要允许分片乱序到达;
  • 浏览器连接数、服务端限流和带宽共享会影响收益;
  • 用户取消时必须中止全部活动请求;
  • 某个分片永久失败时,其他请求如何处理需要明确。

如果要实现有限并发,通常维护一个分片任务队列,并限制同时运行的任务数,而不是直接对所有分片执行:

await Promise.all(parts.map(uploadPart))

后者可能一次创建大量请求,导致浏览器连接竞争、服务器压力和内存占用。并发数是经验参数,不能脱离目标网络、服务器限制和文件规模给出固定的性能结论。


十二、服务器端必须保证的约束

前端发送如下请求:

Content-Range: bytes 0-8388607/52428800

服务器应验证:

start = 0
end = 8388607
total = 52428800
body.length = end - start + 1

注意 Content-Range 使用的是包含式结束位置,而 Blob.slice() 使用排除式结束位置,因此代码中是:

`bytes ${part.start}-${part.end - 1}/${target.size}`

如果前端的 part.start = 0part.end = 8 MiB,HTTP 范围的最后一个字节必须是 8 MiB - 1

服务器还应:

  • 将分片写入临时目录,而不是直接覆盖最终文件;
  • 使用 uploadId 绑定当前用户和权限;
  • 对分片序号、范围、大小做校验;
  • 防止路径穿越,不能直接把用户文件名拼进文件路径;
  • 规定未完成任务的过期清理策略;
  • 在合并操作中使用锁或原子状态变更;
  • 合并成功后再把任务标记为完成;
  • 对最终文件重新计算大小或哈希;
  • 防止同一个完成请求重复合并。

一个合理的任务状态可以是:

created -> receiving -> merging -> completed
                         └------> failed

如果两个请求同时调用 complete,服务器必须避免两个合并进程同时操作同一组临时文件。


十三、文件类型、大小和安全边界

前端可以使用 accept 提供选择提示:

<input
  type="file"
  accept="image/png,image/jpeg"
/>

accept 不是安全校验。用户仍可能通过拖拽、脚本或修改文件内容绕过它。浏览器的 file.type 也不是可信的文件真实性证明,服务器应根据实际内容、文件签名和业务规则校验。

例如,一个文件可能:

文件名:avatar.png
file.type:image/png
实际内容:HTML 或恶意脚本

服务端应:

  • 限制文件大小;
  • 校验允许的扩展名和 MIME 类型;
  • 必要时检查魔数或使用安全解析器;
  • 对图片进行重新编码;
  • 将用户上传文件放在不可执行目录;
  • 使用下载响应头避免浏览器把内容当页面执行;
  • 对文件名进行规范化和编码。

上传接口还必须遵守认证、授权和 CSRF/CORS 策略。跨域上传时,AuthorizationContent-Range 和自定义请求头可能触发 CORS 预检请求,服务器需要正确返回:

Access-Control-Allow-Origin
Access-Control-Allow-Methods
Access-Control-Allow-Headers

不能通过前端代码绕过服务器的 CORS 或权限限制。


十四、常见错误及诊断方法

1. 拖拽后浏览器直接打开文件

表现: 松开鼠标后当前页面被文件内容替换。

原因: 没有在 drop 事件中调用 preventDefault()

修复:

<div @drop.prevent="onDrop">

如果拖拽区域只处理了 drop,却没有处理 dragover.prevent,某些浏览器仍可能不触发预期的放置行为。

2. 进度停在 0%

排查顺序:

  1. 是否使用了 xhr.upload.onprogress
  2. event.lengthComputable 是否为 true
  3. 请求是否真的携带了 Blob 请求体;
  4. 是否在跨域预检阶段失败;
  5. 是否把下载进度事件误当成上传进度。

服务器压缩响应不会改变请求体上传进度,但代理缓冲、网络环境和浏览器实现可能使进度事件不连续。进度事件不是固定频率的时钟,不能据此推算剩余时间一定准确。

3. 进度超过 100%

常见原因是:

  • 重试时没有清零当前分片进度;
  • 已完成分片被重复计算;
  • event.loaded 没有按当前分片大小限制;
  • 并发请求使用了错误的共享计数器。

总进度应始终从每个分片的独立状态推导,并对单个分片执行:

Math.min(event.loaded, part.size)

4. 最后一个分片上传失败

重点检查:

const end = Math.min(start + CHUNK_SIZE, file.size)
const body = file.slice(start, end)

以及请求头:

Content-Range: bytes start-(end - 1)/file.size

如果错误地发送 end 作为包含式结束位置,服务器会认为请求体少一个字节或范围不匹配。

5. 取消后仍然出现“上传完成”

通常说明只中止了 XHR,没有中止外层流程,或者没有在下一个分片开始前检查 AbortSignal

需要同时确认:

  • controller.abort() 被调用;
  • 当前 XHR 被 abort()
  • sleep 能被取消;
  • 循环前调用 throwIfAborted(signal)
  • AbortError 不会进入普通成功分支。

6. 重试后文件大小变大

这通常不是 Vue 状态问题,而是服务器分片写入协议不是幂等的。检查服务器是否:

  • uploadId + partIndex 覆盖或去重;
  • 在响应丢失后允许相同分片再次上传;
  • 合并时严格按照分片序号读取;
  • 没有把每次重试都追加到同一个临时文件尾部。

十五、空文件、超大文件和网络切换

空文件

空文件没有分片范围。前端需要和服务器约定以下一种行为:

  • 初始化后直接调用 complete
  • 创建一个长度为 0 的特殊分片;
  • 初始化接口直接创建完成文件。

不能让客户端进入“等待第 0 个分片”的死循环。

超大文件

JavaScript 的 number 可以精确表示的整数范围有限。现代浏览器的 File.size 通常在可用范围内,但如果业务涉及极大对象或服务端使用更大整数,应考虑:

  • 使用服务器返回的字符串形式长度;
  • 使用 BigInt 处理字节偏移;
  • 确认 Content-Range 解析器支持大整数;
  • 不要在 JSON 中让不同语言的整数类型发生精度丢失。

网络切换

从 Wi-Fi 切换到移动网络、系统休眠、浏览器后台限速,都可能产生网络错误或长时间无进度事件。客户端应把“没有进度事件”与“请求已经失败”区分开来:

  • 没有新进度不代表请求失败;
  • timeout 需要显式配置并根据业务决定;
  • 真正失败后再进入重试;
  • 重试请求必须重新读取同一个 Blob,不能依赖已经消费完的流。

File 和由 slice() 生成的 Blob 可以重复用于多个 XHR 请求,这也是基于分片重试的基础。


十六、用浏览器开发者工具验证上传链路

验证一个分片上传实现时,不要只看页面上的百分比。打开开发者工具的 Network 面板,逐项确认:

  1. POST /init 返回有效的 uploadId
  2. 每个分片请求的 URL 序号连续或符合并发预期;
  3. Content-Range 的范围没有重叠或空洞;
  4. 请求体大小等于:

    endstartend - start

  5. 失败后重试次数符合上限;
  6. 取消后没有新的分片请求;
  7. complete 只在所有分片成功后发送;
  8. 服务器返回的 uploadedParts 能在重新初始化时被正确跳过;
  9. 合并失败时,临时分片仍可用于恢复,而不是被前端误认为已完成。

例如,对于 20 MiB 文件和 8 MiB 分片,应该看到三个分片请求:

part 0: bytes 0-8388607/20971520
part 1: bytes 8388608-16777215/20971520
part 2: bytes 16777216-20971519/20971520

第三个请求体大小应为:

20971520 - 16777216 = 4194304 bytes

这组结果同时验证了分片计算和 Content-Range 的边界转换。


十七、组件设计中的职责边界

一个可维护的 Vue 上传组件通常把职责分成三层:

视图层

负责:

  • 文件选择控件;
  • 拖拽区域;
  • 进度条;
  • 当前状态和错误信息;
  • 开始、取消、重试按钮。

上传控制层

负责:

  • 初始化任务;
  • 生成分片;
  • 调度分片;
  • 更新进度;
  • 取消请求;
  • 执行重试;
  • 请求合并。

上例中的 startUploaduploadPartWithRetry 就属于这一层。

协议层

负责:

  • URL;
  • HTTP 方法;
  • 请求头;
  • 请求体格式;
  • 响应结构;
  • 错误码;
  • 任务恢复语义。

如果把这些内容都直接写在模板事件中,组件会难以测试。实际项目可以把 initializeUploaduploadPartcompleteUpload 抽到 uploadApi.ts,把调度状态保留在组合式函数中,再由组件展示状态。

Vue 的 onUnmounted 只负责组件生命周期清理,并不会自动取消你创建的网络请求。网络请求的生命周期必须由应用代码显式绑定到 AbortController


文件选择和拖拽解决的是“如何获得 File”;分片解决的是“如何把大对象拆成可恢复的传输单元”;进度解决的是“如何从每个请求的字节状态推导整体状态”;取消解决的是“如何终止当前请求和后续流程”;重试解决的是“如何在暂时性失败后安全恢复”。

其中最重要的边界是:**前端状态只能描述客户端观察到的结果,不能替代服务器对分片完整性、权限、幂等性和最终文件内容的校验。**只有前后端共同实现了明确的分片协议,选择、拖拽、进度、取消和重试才会组成一个可靠的文件上传流程。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。