前端大文件上传分片、断点续传、暂停上传、并发上传
分片上传
const chunkSize = 1024 * 1024 // 1MB
function createChunks(file) {
const chunks = []
let start = 0
while (start < file.size) {
chunks.push(file.slice(start, start + chunkSize))
start += chunkSize
}
return chunks
}
上传
async function uploadChunk(chunk: Blob, index: number) {
const form = new FormData()
form.append("chunk", chunk)
form.append("index", index.toString())
return axios.post("/upload", form)
}
暂停上传
//单个
const controller = new AbortController()
axios.post(
"/upload",
formData,
{
signal: controller.signal
}
)
controller.abort()
//多个
const controllers = []
const controller = new AbortController()
controllers.push(controller)
axios.post(url, form, {
signal: controller.signal
})
controllers.forEach(c => c.abort())
继续上传
const uploaded = [0,1,2]
chunks
.map((chunk,index)=>({chunk,index}))
.filter(item=>!uploaded.includes(item.index))
为什么需要 hash/md5?
- 校验文件完整性
- 避免重复上传
前端如何计算 hash?
npm i spark-md5
import SparkMD5 from 'spark-md5'
async function calculateHash(file: File) {
return new Promise((resolve) => {
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.onload = (e) => {
spark.append(e.target?.result as ArrayBuffer)
resolve(spark.end())
}
})
}
查询已上传分片
const res = await fetch(`/check?hash=${fileHash}`)
const uploadedChunks = await res.json()//[0,1,2]
只上传缺失部分
async function uploadFile(file) {
const chunks = createChunks(file)
const uploadedChunks = await getUploadedChunks()
for (let index = 0; index < chunks.length; index++) {
//核心
if (uploadedChunks.includes(index)) {
continue
}
const formData = new FormData()
formData.append('file', chunks[index])
formData.append('hash', fileHash)
formData.append('index', index)
await fetch('/upload', {
method: 'POST',
body: formData
})
}
await mergeChunks()
}
通知后端合并
await fetch('/merge', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
hash: fileHash,
fileName: file.name
})
})
并发上传
async function uploadPool(tasks, limit = 3) {
const pool = []//当前正在上传的Promise
for (const task of tasks) {
//上传完成以后,把自己从pool里面删除
const p = task().then(() => {
pool.splice(pool.indexOf(p), 1)
})
pool.push(p)
if (pool.length >= limit) {
//谁先完成,我就继续
await Promise.race(pool)
}
}
await Promise.all(pool)
}
上传进度怎么算?
onUploadProgress中拿到loaded和total
现代框架用哪些库?
ali-oss
import OSS from 'ali-oss'
const client = new OSS({
region: 'oss-cn-hangzhou',
accessKeyId: 'xxx',
accessKeySecret: 'xxx',
bucket: 'test',
})
await client.multipartUpload(
file.name,
file,
{
parallel: 4,
partSize: 1024 * 1024,
progress(p) {
console.log(p)
},
}
)
simple-uploader.js
<template>
<uploader
:options="options"
@file-success="fileSuccess"
>
<uploader-btn>选择文件</uploader-btn>
<uploader-list />
</uploader>
</template>
<script setup>
const options = {
target: '/api/upload',
chunkSize: 2 * 1024 * 1024,
testChunks: true
}
function fileSuccess() {
console.log('上传完成')
}
</script>
参考完整封装代码
// useUploader.ts - 简洁版
import { ref, reactive, computed } from 'vue'
import axios from 'axios'
import SparkMD5 from 'spark-md5'
// ============ 工具函数 ============
const CHUNK_SIZE = 1 * 1024 * 1024 // 1MB
// 创建分片
function createChunks(file: File, chunkSize: number = CHUNK_SIZE): Blob[] {
const chunks = []
let start = 0
while (start < file.size) {
chunks.push(file.slice(start, start + chunkSize))
start += chunkSize
}
return chunks
}
// 计算文件 Hash
function calculateHash(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.onload = (e) => {
spark.append(e.target?.result as ArrayBuffer)
resolve(spark.end())
}
reader.onerror = () => reject(new Error('文件读取失败'))
})
}
// 获取存储 key
function getStorageKey(fileId: string): string {
return `upload_${fileId}`
}
// 保存已上传的分片
function saveProgress(fileId: string, chunks: Set<number>): void {
sessionStorage.setItem(getStorageKey(fileId), JSON.stringify([...chunks]))
}
// 加载已上传的分片
function loadProgress(fileId: string): Set<number> {
const data = sessionStorage.getItem(getStorageKey(fileId))
return data ? new Set(JSON.parse(data)) : new Set()
}
// ============ 核心模块 ============
export function useUploader(options: {
url: string
chunkSize?: number
concurrency?: number
onProgress?: (progress: number) => void
onComplete?: () => void
onError?: (error: Error) => void
}) {
const {
url,
chunkSize = CHUNK_SIZE,
concurrency = 3,
onProgress,
onComplete,
onError
} = options
// 状态
const file = ref<File | null>(null)
const fileId = ref('')
const chunks = ref<Blob[]>([])
const uploaded = ref<Set<number>>(new Set())
const isUploading = ref(false)
const isPaused = ref(false)
const isDone = ref(false)
// 控制
let abortController: AbortController | null = null
let uploadTask: Promise<void> | null = null
// 计算进度
const progress = computed(() => {
const total = chunks.value.length
if (total === 0) return 0
return Math.round((uploaded.value.size / total) * 100)
})
// 初始化文件
async function init(fileObj: File) {
reset()
file.value = fileObj
fileId.value = `${fileObj.name}_${fileObj.size}_${fileObj.lastModified}`
// 分片
chunks.value = createChunks(fileObj, chunkSize)
// 加载已上传的
const saved = loadProgress(fileId.value)
saved.forEach(idx => {
if (idx < chunks.value.length) uploaded.value.add(idx)
})
// 检查是否已完成
if (uploaded.value.size === chunks.value.length && chunks.value.length > 0) {
isDone.value = true
onComplete?.()
}
}
// 上传单个分片
async function uploadChunk(chunk: Blob, index: number): Promise<void> {
const form = new FormData()
form.append('chunk', chunk)
form.append('index', String(index))
form.append('fileId', fileId.value)
form.append('total', String(chunks.value.length))
await axios.post(url, form, {
signal: abortController?.signal,
headers: { 'Content-Type': 'multipart/form-data' }
})
uploaded.value.add(index)
saveProgress(fileId.value, uploaded.value)
onProgress?.(progress.value)
}
// 并发池
async function uploadPool(tasks: (() => Promise<void>)[]) {
const pool: Promise<void>[] = []
for (const task of tasks) {
if (isPaused.value || !isUploading.value) break
const p = task().catch(err => {
// 重试一次
return task()
})
pool.push(p)
if (pool.length >= concurrency) {
await Promise.race(pool)
// 清理已完成
const results = await Promise.allSettled(pool)
pool.length = 0
// 如果有失败的,继续
results.forEach(r => {
if (r.status === 'rejected') {
console.warn('分片上传失败,已重试')
}
})
}
}
await Promise.allSettled(pool)
}
// 执行上传
async function executeUpload() {
const pending: number[] = []
chunks.value.forEach((_, i) => {
if (!uploaded.value.has(i)) pending.push(i)
})
if (pending.length === 0) {
isDone.value = true
isUploading.value = false
onComplete?.()
return
}
const tasks = pending.map(i => () => uploadChunk(chunks.value[i], i))
await uploadPool(tasks)
// 继续上传未完成的
if (!isPaused.value && isUploading.value) {
await executeUpload()
}
}
// 开始上传
async function start() {
if (!file.value) {
onError?.(new Error('请先选择文件'))
return
}
if (isDone.value) return
// 恢复暂停
if (isPaused.value) {
isPaused.value = false
await executeUpload()
return
}
// 全新上传
isUploading.value = true
isPaused.value = false
abortController = new AbortController()
try {
await executeUpload()
} catch (err: any) {
if (err?.message?.includes('abort')) return
onError?.(err)
}
}
// 暂停
function pause() {
if (isUploading.value && !isPaused.value) {
isPaused.value = true
abortController?.abort()
abortController = null
saveProgress(fileId.value, uploaded.value)
}
}
// 恢复
function resume() {
if (isPaused.value) {
start()
}
}
// 重置
function reset() {
pause()
isUploading.value = false
isPaused.value = false
isDone.value = false
file.value = null
fileId.value = ''
chunks.value = []
uploaded.value = new Set()
if (fileId.value) {
sessionStorage.removeItem(getStorageKey(fileId.value))
}
}
// 取消
function cancel() {
pause()
isUploading.value = false
}
return {
// 状态
file: readonly(file),
progress: readonly(progress),
isUploading: readonly(isUploading),
isPaused: readonly(isPaused),
isDone: readonly(isDone),
uploadedCount: computed(() => uploaded.value.size),
totalCount: computed(() => chunks.value.length),
// 方法
init,
start,
pause,
resume,
cancel,
reset
}
}
组件中使用方式
<template>
<input type="file" @change="onFileChange" />
<div v-if="status !== 'idle'">
<p>状态:{{ status }}</p>
<p>进度:{{ (progress * 100).toFixed(1) }}%</p>
<progress :value="progress" max="1" />
<button :disabled="!isUploading" @click="pause">暂停</button>
<button :disabled="!isPaused" @click="resume">继续</button>
</div>
<p v-if="errorMsg" style="color: red">{{ errorMsg }}</p>
</template>
<script setup lang="ts">
import { useUploader } from './useUploader'
const { status, progress, errorMsg, isPaused, isUploading, start, pause, resume } = useUploader({
chunkSize: 1024 * 1024,
concurrency: 3,
})
async function onFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
try {
const result = await start(file)
console.log(result.instant ? '秒传成功' : '上传并合并完成')
} catch (err) {
console.error('上传出错', err)
}
}
</script>
