世外天堂

世外天堂

代码、番剧与灵感共振的赛博据点

首页前端大文件上传分片断点续传

前端大文件上传分片/断点续传/并发控制实现

📅 2026年5月10日✍️ 2984 字⏱ 10 分钟阅读 👁 -- 次阅读

前端大文件上传分片、断点续传、暂停上传、并发上传

分片上传

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?

  1. 校验文件完整性
  2. 避免重复上传

前端如何计算 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>

世外天堂

代码、番剧与灵感共振的赛博据点

© 2026 小翼·访客 --·浏览 --