大文件分片上传技术详解
1. 引言
在Web应用开发中,大文件上传一直是一个具有挑战性的问题。传统的文件上传方式对于大文件来说存在诸多问题,比如上传时间长、网络不稳定导致上传失败需要重新上传、服务器内存占用高等。为了解决这些问题,分片上传技术应运而生。本文将详细介绍基于Vue 3实现的大文件分片上传解决方案,包括前端分片、并发控制、暂停/继续/取消等核心功能的实现原理和代码解析。
2. 大文件上传面临的挑战
在开始讲解技术实现之前,让我们先了解大文件上传面临的主要挑战:
- 上传时间长:大文件上传需要较长时间,用户体验较差
- 网络不稳定:上传过程中网络波动可能导致上传失败
- 服务器压力:大文件直接上传会占用服务器大量内存
- 上传控制:需要支持暂停、继续和取消上传的功能
- 上传进度:需要准确显示上传进度以提升用户体验
- 并发控制:需要合理控制并发请求数量以平衡速度和资源消耗
3. 核心技术原理
为解决上述问题,我们采用以下技术方案:
graph TD A[大文件] --> B[前端分片] B --> C[计算文件Hash] C --> D[并发上传分片] D --> E{所有分片上传完成?} E -->|是| F[合并分片] E -->|否| D D --> G[暂停/继续/取消] G --> D
3.1 分片上传基本流程
- 文件分片:将大文件切分为固定大小的小块
- 计算文件Hash:使用SparkMD5计算整个文件的唯一标识
- 上传分片:并发上传多个分片,提高上传效率
- 合并分片:所有分片上传完成后,服务器端合并分片
- 上传控制:支持暂停、继续和取消上传操作
4. 代码实现解析
4.1 文件分片与Hash计算
文件分片和Hash计算是比较耗时的操作,为避免阻塞主线程,我们使用Web Worker处理:
sequenceDiagram participant 主线程 participant Web Worker 主线程->>Web Worker: 发送文件和分片大小 Web Worker->>Web Worker: 创建文件分片 Web Worker->>Web Worker: 计算文件Hash Web Worker-->>主线程: 返回文件Hash和分片列表
Web Worker代码实现:
// 创建文件切片
function createFileChunk(file, chunkSize) {
return new Promise((resolve, reject) => {
let fileChunkList = []
let cur = 0
while (cur < file.size) {
fileChunkList.push({ chunkFile: file.slice(cur, cur + chunkSize) })
cur += chunkSize
}
resolve(fileChunkList)
})
}
// 计算文件Hash
async function calculateChunksHash(fileChunkList) {
const spark = new self.SparkMD5.ArrayBuffer();
let percentage = 0
let count = 0
async function loadNext(index) {
if (index >= fileChunkList.length) {
return spark.end()
}
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsArrayBuffer(fileChunkList[index].chunkFile)
reader.onload = (e) => {
count++
spark.append(e.target.result)
percentage += 100 / fileChunkList.length
self.postMessage({ percentage })
resolve(loadNext(index + 1))
}
reader.onerror = reject
})
}
try {
const fileHash = await loadNext(0)
self.postMessage({ percentage: 100, fileHash, fileChunkList })
self.close()
} catch (err) {
self.postMessage({ name: 'error', data: err })
self.close()
}
}
4.2 文件上传流程
以下是文件上传的完整流程:
flowchart TB A[选择文件] --> B[文件分片] B --> C[计算文件Hash] C --> D[创建上传任务] D --> E[并发上传分片] E --> F{所有分片上传完成?} F -->|是| G[请求合并分片] F -->|否| E G --> H[上传完成]
核心代码实现:
// 输入框change事件
const hanldeUploadFile = async (e) => {
const fileEle = e.target
if (!fileEle || !fileEle.files || fileEle.files.length === 0) {
return false
}
const files = fileEle.files
// 多文件
Array.from(files).forEach(async (item, i) => {
const file = item
// 初始化上传任务
let inTaskArrItem = reactive({
id: new Date() + i,
state: 0, // 0是什么都不做,1文件处理中,2是上传中,3是暂停,4是上传完成,5上传中断,6是上传失败
fileHash: '',
fileName: file.name,
fileSize: file.size,
allChunkList: [], // 所有请求的数据
whileRequests: [], // 正在请求中的请求个数
finishNumber: 0, //请求完成的个数
errNumber: 0, // 报错的个数,超过3个就是直接上传中断
percentage: 0, // 单个文件上传进度条
cancel: null, // 用于取消切片上传接口
})
uploadFileList.value.push(inTaskArrItem)
// 开始处理解析文件
inTaskArrItem.state = 1
if (file.size === 0) {
inTaskArrItem.state = 6
pauseUpload(inTaskArrItem, false)
}
// 计算文件hash
const { fileHash, fileChunkList } = await useWorker(file)
// 文件名处理
let baseName = ''
const lastIndex = file.name.lastIndexOf('.')
if (lastIndex === -1) {
baseName = file.name
}
baseName = file.name.slice(0, lastIndex)
// 处理文件hash(文件名+hash确保唯一性)
inTaskArrItem.fileHash = `${fileHash}${baseName}`
inTaskArrItem.state = 2
// 创建分片上传任务
inTaskArrItem.allChunkList = fileChunkList.map((item, index) => {
return {
fileHash: `${fileHash}${baseName}`,
fileSize: file.size,
fileName: file.name,
index: index,
chunkFile: item.chunkFile,
chunkHash: `${fileHash}-${index}`,
chunkSize: chunkSize,
chunkNumber: fileChunkList.length,
finish: false,
}
})
// 开始上传分片
uploadSignleFile(inTaskArrItem)
})
}
4.3 分片上传控制
为了提高上传效率同时又不过度占用资源,我们实现了动态并发控制:
graph TD A[开始上传] --> B[计算当前并发数] B --> C[取出部分分片进行上传] C --> D[上传成功?] D -->|是| E[更新进度] D -->|否,重试<3次| F[重试上传] D -->|否,重试>=3次| G[中断上传] E --> H{所有分片完成?} H -->|是| I[请求合并分片] H -->|否| J[继续上传] F --> C J --> C
核心实现代码:
// 单个文件上传
const uploadSignleFile = (taskArrItem) => {
// 不需要上传或正在上传中,直接返回
if (
taskArrItem.allChunkList.length === 0 ||
taskArrItem.whileRequests.length > 0
) {
return false
}
// 动态计算并发数
const isTaskArrIng = uploadFileList.value.filter(
(itemB) => itemB.state === 1 || itemB.state === 2
)
maxRequest.value = Math.ceil(6 / isTaskArrIng.length)
// 取出部分分片进行上传
let whileRequest = taskArrItem.allChunkList.slice(-maxRequest.value)
taskArrItem.whileRequests.push(...whileRequest)
if (taskArrItem.allChunkList.length > maxRequest.value) {
taskArrItem.allChunkList.splice(-maxRequest.value)
} else {
taskArrItem.allChunkList = []
}
// 单个分片请求
const uploadChunk = async (needObj) => {
const fd = new FormData()
const {
fileHash, fileSize, fileName, index,
chunkFile, chunkHash, chunkSize, chunkNumber,
} = needObj
fd.append('fileHash', fileHash)
fd.append('fileSize', String(fileSize))
fd.append('fileName', fileName)
fd.append('index', String(index))
fd.append('chunkFile', chunkFile)
fd.append('chunkHash', chunkHash)
fd.append('chunkSize', String(chunkSize))
fd.append('chunkNumber', String(chunkNumber))
const res = await uploadFile(fd, (onCancelFunc) => {
needObj.cancel = onCancelFunc
}).catch(() => {})
// 暂停/中断状态不继续处理
if (taskArrItem.state === 3 || taskArrItem.state === 5) {
return false
}
// 处理上传失败
if (!res || res.code === -1) {
taskArrItem.errNumber++
if (taskArrItem.errNumber > 3) {
pauseUpload(taskArrItem, false) // 上传中断
} else {
uploadChunk(needObj) // 继续重试
}
} else if (res.code === 0) {
// 上传成功
taskArrItem.errNumber > 0 ? taskArrItem.errNumber-- : 0
taskArrItem.finishNumber++
needObj.finish = true
signleFileProgress(needObj, taskArrItem) // 更新进度
// 从请求中数组移除该分片
taskArrItem.whileRequests = taskArrItem.whileRequests.filter(
(item) => item.chunkFile !== needObj.chunkFile
)
// 检查是否所有分片都已上传完成
if (taskArrItem.finishNumber === chunkNumber) {
handleMerge(taskArrItem)
} else {
uploadSignleFile(taskArrItem)
}
}
}
// 开始上传
for (const item of whileRequest) {
uploadChunk(item)
}
}
4.4 暂停、继续与取消功能
为提升用户体验,我们实现了暂停、继续和取消功能:
sequenceDiagram participant 用户 participant 前端 participant 服务器 用户->>前端: 点击暂停按钮 前端->>前端: 修改文件状态为暂停 前端->>服务器: 取消正在进行的请求 用户->>前端: 点击继续按钮 前端->>前端: 修改文件状态为上传中 前端->>服务器: 继续上传未完成分片 用户->>前端: 点击取消按钮 前端->>前端: 取消上传并从列表移除 前端->>服务器: 取消所有未完成请求
核心实现代码:
// 暂停上传
const pauseUpload = (taskArrItem, elsePause = true) => {
if (![4, 6].includes(taskArrItem.state)) {
if (elsePause) {
taskArrItem.state = 3 // 暂停
} else {
taskArrItem.state = 5 // 中断
}
}
taskArrItem.errNumber = 0
// 取消正在请求的所有接口
if (taskArrItem.whileRequests.length > 0) {
for (const itemB of taskArrItem.whileRequests) {
itemB.cancel ? itemB.cancel() : ''
}
}
}
// 继续上传
const resumeUpload = (taskArrItem) => {
taskArrItem.state = 2 // 上传中
taskArrItem.allChunkList.push(...taskArrItem.whileRequests)
taskArrItem.whileRequests = []
uploadSignleFile(taskArrItem)
}
// 取消单个
const cancelSingle = async (taskArrItem) => {
pauseUpload(taskArrItem)
uploadFileList.value = uploadFileList.value.filter(
(itemB) => itemB.fileHash !== taskArrItem.fileHash
)
}
// 全部取消
const cancelAll = () => {
for (const item of uploadFileList.value) {
pauseUpload(item)
}
uploadFileList.value = []
}
5. 关键技术难点与解决方案
5.1 如何计算文件分片
文件分片的计算是大文件上传的核心,需要考虑以下因素:
分片大小的选择:
- 分片太小:请求次数增多,服务器压力大
- 分片太大:单个请求时间长,失败重试代价高
- 本示例选择1MB作为平衡点:
const chunkSize = 1 * 1024 * 1024
分片计算方式:
javascriptfunction createFileChunk(file, chunkSize) { let fileChunkList = [] let cur = 0 while (cur < file.size) { fileChunkList.push({ chunkFile: file.slice(cur, cur + chunkSize) }) cur += chunkSize } return fileChunkList }
5.2 如何高效计算文件Hash
文件Hash计算是分片上传的基础,但计算大文件Hash会阻塞主线程:
- 使用Web Worker:在单独线程中计算,不影响UI交互
- 增量计算:逐个处理分片并累加Hash,而不是一次性读取整个文件
- 进度反馈:通过postMessage向主线程报告计算进度
5.3 并发控制策略
上传并发数控制是优化性能的关键:
动态并发控制:根据当前上传文件数量动态调整每个文件的并发数
javascript// 动态计算并发数 const isTaskArrIng = uploadFileList.value.filter( (itemB) => itemB.state === 1 || itemB.state === 2 ) maxRequest.value = Math.ceil(6 / isTaskArrIng.length)
并发与性能平衡:
- 并发数太小:上传速度慢
- 并发数太大:可能导致网络拥塞或服务器压力过大
- 总并发控制在浏览器限制范围内(通常为6个同域名请求)
6. 常见问题与解答
Q1: 为什么需要使用Web Worker计算文件Hash?
A: 计算大文件的Hash是CPU密集型操作,在主线程中执行会导致UI冻结,影响用户体验。Web Worker可以在后台线程中执行这些操作,保持UI响应性。
Q2: 如何处理网络波动导致的上传失败?
A: 我们实现了重试机制和错误计数:
if (!res || res.code === -1) {
taskArrItem.errNumber++
if (taskArrItem.errNumber > 3) {
pauseUpload(taskArrItem, false) // 超过三次失败则中断
} else {
uploadChunk(needObj) // 否则继续重试
}
}
Q3: 分片大小如何确定?过大或过小会有什么影响?
A: 分片大小是权衡上传效率和稳定性的关键因素:
- 分片过小:会产生大量HTTP请求,增加服务器负担和请求开销
- 分片过大:单次请求时间长,失败时重试成本高
- 本项目选择1MB作为分片大小,这是经验值,在大多数网络环境下都能取得较好的平衡
Q4: 文件上传状态管理是如何实现的?
A: 我们使用状态码管理文件上传的不同阶段:
// 0是什么都不做
// 1是文件处理中
// 2是上传中
// 3是暂停
// 4是上传完成
// 5是上传中断
// 6是上传失败
这种状态管理使得UI可以根据不同状态显示不同的界面元素,提升用户体验。
Q5: 如何处理最后一个分片可能小于设定大小的情况?
A: 我们的分片算法自动处理这种情况:
while (cur < file.size) {
fileChunkList.push({ chunkFile: file.slice(cur, cur + chunkSize) })
cur += chunkSize
}
当最后一个分片不足chunkSize大小时,file.slice会自动处理边界,返回实际大小的分片。
Q6: 如何实现取消正在进行的上传请求?
A: 我们使用AbortController API来取消正在进行的请求:
// 上传文件方法
export function uploadFile(data, onCancel) {
const controller = new AbortController()
const signal = controller.signal
const request = service({
url: '/upload',
method: 'post',
data,
signal, // 将signal传递给请求
})
// 提供取消方法给调用者
if (typeof onCancel === 'function') {
onCancel(() => controller.abort())
}
return request
}
// 调用取消功能
pauseUpload() {
if (taskArrItem.whileRequests.length > 0) {
for (const itemB of taskArrItem.whileRequests) {
itemB.cancel ? itemB.cancel() : ''
}
}
}
Q7: 如何计算和显示上传进度?
A: 我们通过记录已完成分片数与总分片数的比例来计算进度:
// 更新单个文件进度条
const signleFileProgress = (needObj, taskArrItem) => {
taskArrItem.percentage = Number(
((taskArrItem.finishNumber / needObj.chunkNumber) * 100).toFixed(2)
)
}
这种方式比监听XHR的upload.onprogress更稳定,因为它是基于成功上传的分片数量计算的,不会受到网络波动的影响。
7. 总结
本文详细介绍了基于Vue 3实现的大文件分片上传解决方案,包括前端分片、并发控制、暂停/继续/取消等核心功能的实现原理和代码解析。这种方案有效解决了大文件上传的诸多问题,提高了用户体验和系统稳定性。
未来可以进一步优化的方向包括:
- 断点续传功能:支持页面刷新或关闭后继续上传,需要持久化已上传分片信息
- 秒传功能:对于服务器已存在的文件实现秒传,避免重复上传
- 文件Hash计算优化:可以采用抽样Hash算法减少计算量
- 上传速度自适应:根据网络状况动态调整分片大小和并发数
- 前端压缩:上传前对文件进行压缩处理
- 加密传输:增加传输加密保证数据安全
大文件上传是Web应用中常见且具有挑战性的问题,通过合理的技术方案可以显著提升用户体验。希望本文对你实现高效、稳定的大文件上传功能有所帮助。