Vue 基础体系 · 第 37/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 文件上传:选择、拖拽、分片、进度、取消和重试
文件上传表面上只是把一个 File 交给服务器,实际却包含了多个不同层次的问题:
- 选择:如何从
<input type="file">得到File; - 拖拽:如何处理
dragover、drop和拖入的文件对象; - 分片:如何把一个大文件切成多个可独立传输的字节范围;
- 进度:如何把每个分片的进度合并成整个文件的进度;
- 取消:如何中止当前 HTTP 请求,并让状态机停止后续分片;
- 重试:哪些失败可以重试,重试时如何避免重复写入或破坏文件。
Vue 只负责组件状态和界面更新。真正的上传能力来自浏览器的 File、Blob.slice()、XMLHttpRequest、AbortController 等 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. 分片上传需要服务器配合
前端单独把文件切片,并不会自动产生断点续传能力。服务器必须知道:
- 哪个上传任务正在进行;
- 当前分片属于哪个文件;
- 当前分片的序号或字节范围;
- 已经接收了哪些分片;
- 何时可以按照正确顺序合并;
- 如何处理重复分片和失效上传任务。
下面采用一个明确的协议:
| 请求 | 作用 |
|---|---|
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 类型是否经过安全处理。
客户端传入的文件名、类型和大小都不能视为可信数据。
二、从文件大小推导分片范围
设:
- 文件大小为 字节;
- 分片大小为 字节;
- 分片序号为 ,从
0开始; - 分片总数为 。
分片总数是:
第 个分片的范围为:
其中 end_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.files 是 FileList,可能为空。不能直接假设用户一定选择了文件,因为用户可以打开选择框后点击“取消”。
如果用户两次选择同一个文件,部分浏览器不会再次触发 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. 代码中的关键因果关系
file、status、progress、currentPart 等变量使用 ref,因为它们会在异步上传过程中变化。模板读取这些 ref 时,Vue 会自动追踪依赖并更新界面。
totalParts 和 progress 使用 computed,因为它们是由其他状态推导出来的:
totalParts由文件大小和分片大小决定;progress由每个已完成分片和当前分片的已上传字节数决定。
没有必要在每次进度事件中手动设置一个独立的总百分比。总百分比应由源数据重新计算,避免“进度变量”和真实分片状态分叉。
六、为什么上传进度使用 XMLHttpRequest
1. 上传进度与下载进度不同
XMLHttpRequest 提供:
xhr.upload.onprogress = (event) => {
event.loaded
event.total
event.lengthComputable
}
这里监听的是 xhr.upload,代表请求体上传过程。如果监听 xhr.onprogress,通常得到的是响应体下载进度,不是文件上传进度。
event.lengthComputable 为 false 时,浏览器无法可靠提供总长度,此时不能把 event.loaded / event.total 当成有效百分比。
2. fetch 的边界
普通 fetch 请求很适合:
- 初始化上传;
- 请求服务器合并;
- 发送 JSON 元数据;
- 使用
AbortController取消请求。
但在现代浏览器中,普通 fetch 并不像 XMLHttpRequest.upload.onprogress 那样提供一个普遍可用、简单的上传进度回调。因此上面的实现使用:
fetch处理初始化和完成请求;XMLHttpRequest处理需要上传进度的分片请求。
这不是 Vue 的限制,而是浏览器上传流进度 API 的差异。浏览器对请求体流和上传进度的支持会随版本变化,使用实验性能力时必须单独验证目标浏览器,不能把某个浏览器的实现当成通用规范保证。
七、如何计算总进度
如果只上传一个分片,可以使用:
但分片上传时,需要先把各分片的已上传字节相加:
整个文件的进度为:
其中:
- 是整个文件的字节数;
- 是第 个分片当前已发送的字节数;
- 已完成分片的
loaded_i等于该分片完整大小。
例如,文件有三个分片:
| 分片 | 大小 | 当前已上传 |
|---|---|---|
| 0 | 8 MiB | 8 MiB |
| 1 | 8 MiB | 4 MiB |
| 2 | 4 MiB | 0 |
则:
文件总大小为:
所以总进度为:
不能用“已完成分片数 / 总分片数”计算百分比,因为最后一个分片可能远小于其他分片。上面的例子中,完成一个分片只代表 40%,而不是 33% 或固定的一个分片比例。
重试时为什么要清零当前分片进度
假设第 1 个分片已经上传了 4 MiB,请求在最后失败。下一次重试从字节 0 重新发送,那么之前的 4 MiB 不能继续算入当前进度,否则进度会暂时超过真实发送量。
因此代码在每次尝试开始前执行:
next.set(part.index, 0)
成功后才把该分片设置为完整大小。进度回退是重试的真实表现,不应通过虚假的百分比掩盖。
如果服务器支持分片内部的字节级续传,例如 PATCH 或带偏移量的协议,则可以从已确认偏移量继续发送;这已经不是简单的“分片重试”,需要服务器返回并持久化每个分片内部的偏移量。
八、取消:停止请求和停止流程是两件事
取消上传至少需要完成两个动作:
- 取消正在进行的网络请求;
- 让后续异步步骤不再继续。
只调用 xhr.abort() 只能停止当前 XHR。如果外层代码没有检查取消状态,当前 Promise 结束后仍可能进入下一次循环,或者继续调用合并接口。
因此代码同时使用:
controller.abort()
activeXhr?.abort()
并在多个异步边界检查:
throwIfAborted(signal)
检查位置包括:
- 初始化请求前;
- 每个分片开始前;
- 重试等待结束后;
- 完成请求前。
取消与重试还存在一个竞争条件:
- 分片失败;
- 代码准备等待
2秒; - 用户点击取消;
- 等待结束后不能再次发送请求。
因此 sleep 也必须接收 AbortSignal。上例中取消会清除定时器并拒绝 Promise,外层捕获 AbortError 后进入 canceled 状态。
组件卸载时也要取消:
onUnmounted(() => {
controller?.abort()
activeXhr?.abort()
})
否则用户切换路由后,旧组件可能仍然占用网络和内存。
九、重试:不是所有错误都应该重试
1. 可以重试的情况
通常可以考虑重试:
- 网络连接中断;
- 请求超时;
- HTTP
408 Request Timeout; - HTTP
429 Too Many Requests; - HTTP
5xx服务端临时错误; - 代理或网关暂时不可用。
2. 不应盲目重试的情况
以下错误通常需要直接失败并提示用户或开发者修复:
400:请求格式错误;401、403:身份或权限问题;404:上传任务不存在;413:文件或请求体超过限制;415:媒体类型不支持;- 服务端返回“分片范围非法”。
无限重试会掩盖协议错误,也可能给服务器制造持续压力。
3. 指数退避
第 次重试的等待时间可以近似为:
例如 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
因此它可以在一次组件生命周期内取消后重新开始,但刷新页面后这些变量会丢失。
真正的断点续传需要:
- 客户端保存上传任务标识;
- 页面重新打开后重新调用初始化接口;
- 服务器根据文件身份返回已完成的分片;
- 前端跳过已完成分片。
文件身份不能只使用文件名,因为两个同名文件可能内容完全不同。常见选择包括:
文件大小 + 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 = 0、part.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 策略。跨域上传时,Authorization、Content-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%
排查顺序:
- 是否使用了
xhr.upload.onprogress; event.lengthComputable是否为true;- 请求是否真的携带了
Blob请求体; - 是否在跨域预检阶段失败;
- 是否把下载进度事件误当成上传进度。
服务器压缩响应不会改变请求体上传进度,但代理缓冲、网络环境和浏览器实现可能使进度事件不连续。进度事件不是固定频率的时钟,不能据此推算剩余时间一定准确。
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 面板,逐项确认:
POST /init返回有效的uploadId;- 每个分片请求的 URL 序号连续或符合并发预期;
Content-Range的范围没有重叠或空洞;- 请求体大小等于:
- 失败后重试次数符合上限;
- 取消后没有新的分片请求;
complete只在所有分片成功后发送;- 服务器返回的
uploadedParts能在重新初始化时被正确跳过; - 合并失败时,临时分片仍可用于恢复,而不是被前端误认为已完成。
例如,对于 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 上传组件通常把职责分成三层:
视图层
负责:
- 文件选择控件;
- 拖拽区域;
- 进度条;
- 当前状态和错误信息;
- 开始、取消、重试按钮。
上传控制层
负责:
- 初始化任务;
- 生成分片;
- 调度分片;
- 更新进度;
- 取消请求;
- 执行重试;
- 请求合并。
上例中的 startUpload、uploadPartWithRetry 就属于这一层。
协议层
负责:
- URL;
- HTTP 方法;
- 请求头;
- 请求体格式;
- 响应结构;
- 错误码;
- 任务恢复语义。
如果把这些内容都直接写在模板事件中,组件会难以测试。实际项目可以把 initializeUpload、uploadPart、completeUpload 抽到 uploadApi.ts,把调度状态保留在组合式函数中,再由组件展示状态。
Vue 的 onUnmounted 只负责组件生命周期清理,并不会自动取消你创建的网络请求。网络请求的生命周期必须由应用代码显式绑定到 AbortController。
文件选择和拖拽解决的是“如何获得 File”;分片解决的是“如何把大对象拆成可恢复的传输单元”;进度解决的是“如何从每个请求的字节状态推导整体状态”;取消解决的是“如何终止当前请求和后续流程”;重试解决的是“如何在暂时性失败后安全恢复”。
其中最重要的边界是:**前端状态只能描述客户端观察到的结果,不能替代服务器对分片完整性、权限、幂等性和最终文件内容的校验。**只有前后端共同实现了明确的分片协议,选择、拖拽、进度、取消和重试才会组成一个可靠的文件上传流程。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 与 Web Components:Custom Element、属性事件、样式和互操作
- 下一篇:Vue 数据表格工程:列模型、排序、筛选、选择、编辑和可访问性
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论