【解决方案】从 0 到 1 实现“分片+断点续传”完整方案
一、为什么“传统上传”在大文件面前完全失效?
我们先算笔账:
- 传统上传会把整个文件读进浏览器内存(比如 10GB 文件 = 10GB 内存占用);
- 浏览器的内存上限一般是4GB(Chrome 默认单进程内存限制);
- 服务器默认的请求体大小限制是1MB(Nginx 的
client_max_body_size默认值)。
这三个“硬限制”直接把大文件上传判了“死刑”:
- 内存爆炸:10GB 文件直接撑爆浏览器内存,轻则卡死,重则崩溃;
- 重试成本高:传 99% 断网 = 前功尽弃,重新传 10GB = 再等 30 分钟;
- 服务器拒收:即使浏览器没崩,服务器也会直接拦截“超大请求体”。
二、核心原理:用“分蛋糕”思路解决大文件上传
大文件上传的本质是**“将‘一次性传输’拆成‘多次小传输’”**,流程像“分蛋糕 → 递蛋糕 → 拼蛋糕”:
三、前端实现:从“选文件”到“传分片”的完整流程(Vue3 实战)
我们用Vue3 + Vite + Axios实现一个“能看进度、能断点续传”的大文件上传组件,直接贴实际项目代码:
1. 组件结构:贴近真实项目的 UI 设计
先写一个带文件选择器、进度条、开始 / 取消按钮的组件(BigFileUpload.vue):
<template> <div class="upload-box"> <!-- 文件选择器:限制大文件类型 --> <input type="file" @change="handleFileSelect" accept=".mp4,.psd,.zip,.pdf" class="file-input" /> <!-- 操作按钮 --> <button @click="startUpload" :disabled="!selectedFile || isUploading" class="upload-btn" > {{ isUploading ? '上传中...' : '开始上传' }} </button> <button @click="cancelUpload" :disabled="!isUploading" class="cancel-btn" > 取消上传 </button> <!-- 实时进度条 --> <div class="progress-bar"> <div class="progress-inner" :style="{ width: `${progress}%` }" ></div> </div> <div class="progress-text">进度:{{ progress }}%</div> </div></template>2. 核心逻辑:用 Vue3 setup 语法实现分片与上传
用Composition API写逻辑,重点实现文件切片、并发控制、进度跟踪:
<script setup lang="ts">import { ref, reactive } from 'vue'import axios from 'axios'import SparkMD5 from 'spark-md5' // 用于计算文件Hash(避免同名文件冲突)
// 响应式状态:记录文件、分片、进度等const selectedFile = ref<File | null>(null)const chunks: Ref<Blob[]> = ref([]) // 切割后的分片列表const progress = ref(0) // 上传进度(%)const isUploading = ref(false) // 是否正在上传const fileHash = ref('') // 文件唯一标识(文件名+大小生成)const abortController = ref(new AbortController()) // 用于取消上传
/** * 步骤1:选择文件后生成分片 */const handleFileSelect = (e: Event) => { const target = e.target as HTMLInputElement if (!target.files?.length) return
const file = target.files[0] selectedFile.value = file
// 生成文件唯一Hash(避免同名文件覆盖) fileHash.value = SparkMD5.hash(`${file.name}-${file.size}`).toString()
// 切割文件:默认5MB/片(可动态调整) const chunkSize = getDynamicChunkSize() chunks.value = createChunks(file, chunkSize) progress.value = 0 // 重置进度}
/** * 工具函数:切割文件为分片 * @param file 要切割的文件 * @param chunkSize 分片大小(默认5MB) * @returns 切割后的分片列表 */const createChunks = (file: File, chunkSize = 5 * 1024 * 1024): Blob[] => { const chunkList: Blob[] = [] let cur = 0 while (cur < file.size) { chunkList.push(file.slice(cur, cur + chunkSize)) cur += chunkSize } return chunkList}
/** * 步骤2:并发上传分片(控制3个同时传) */const startUpload = async () => { if (!selectedFile.value || !chunks.value.length) return isUploading.value = true
// 先检查已上传的分片(断点续传核心) const uploadedChunks = await checkUploadedChunks() // 过滤出未上传的分片 const needUpload = chunks.value.filter( (_, index) => !uploadedChunks.includes(index) )
if (needUpload.length === 0) { await mergeFile() // 所有分片已传,直接合并 return }
// 并发控制:同时传3个分片 const maxConcurrent = 3 const total = needUpload.length let uploaded = 0
// 用“任务池”控制并发 const taskPool = new Set() for (const [index, chunk] of needUpload.entries()) { const task = uploadChunk(chunk, index) taskPool.add(task)
// 上传成功后更新进度 task.then(() => { uploaded++ progress.value = Math.round((uploaded / total) * 100) taskPool.delete(task) })
// 任务池满了就等一个完成 if (taskPool.size >= maxConcurrent) { await Promise.race(taskPool) } }
// 等所有任务完成 await Promise.all(taskPool) await mergeFile() // 合并文件 isUploading.value = false progress.value = 100}
/** * 工具函数:上传单个分片 * @param chunk 分片文件 * @param index 分片序号 */const uploadChunk = async (chunk: Blob, index: number) => { const md5 = await calculateMD5(chunk) const formData = new FormData() formData.append('chunk', chunk) formData.append('fileHash', fileHash.value) formData.append('chunkIndex', index.toString()) formData.append('fileName', selectedFile.value!.name) formData.append('chunkMD5', md5)
const upload = async () => { return await axios.post('/api/upload-chunk', formData, { signal: abortController.value.signal, }) }
try { await withRetry(upload) } catch (error) { throw new Error(`分片${index}上传失败:${(error as Error).message}`) }}
/** * 带指数退避的重试函数 * @param fn 要执行的上传函数 * @param retries 重试次数 * @returns 上传结果 */const withRetry = async (fn: () => Promise<any>, retries = 3) => { try { return await fn() } catch (error) { if (retries === 0) throw error const delay = Math.pow(2, 3 - retries) * 1000 await new Promise((resolve) => setTimeout(resolve, delay)) return withRetry(fn, retries - 1) }}
/** * 步骤3:合并分片(所有分片传完后调用) */const mergeFile = async () => { await axios.post('/api/merge-chunk', { fileHash: fileHash.value, fileName: selectedFile.value!.name, chunkSize: 5 * 1024 * 1024, // 分片大小(需和前端一致) }) // 清理本地缓存(断点续传用) localStorage.removeItem(`uploaded-${fileHash.value}`)}
/** * 断点续传核心:检查已上传的分片 */const checkUploadedChunks = async () => { // 1. 查服务器已传分片 const res = await axios.get('/api/check-chunks', { params: { fileHash: fileHash.value }, }) const serverUploaded = res.data.uploadedChunks // 2. 查本地缓存(上次中断的分片) const localUploaded = JSON.parse( localStorage.getItem(`uploaded-${fileHash.value}`) || '[]' ) // 取交集:确保准确性 return [...new Set([...serverUploaded, ...localUploaded])]}
/** * 取消上传:用AbortController中断请求 */const cancelUpload = () => { abortController.value.abort() isUploading.value = false // 保存未完成的分片到本地(下次续传用) localStorage.setItem( `uploaded-${fileHash.value}`, JSON.stringify(chunks.value.map((_, i) => i)) )}
/** * 根据网络类型调整分片大小 * @returns 分片大小 */const getDynamicChunkSize = () => { const connection = navigator.connection as any if (!connection) return 5 * 1024 * 1024
switch (connection.effectiveType) { case '4g': return 10 * 1024 * 1024 // 4G→10MB case '3g': return 2 * 1024 * 1024 // 3G→2MB case '2g': return 1 * 1024 * 1024 // 2G→1MB default: return 5 * 1024 * 1024 // 其他→5MB }}
/** * 计算Blob的MD5 * @param blob 要计算MD5的Blob对象 * @returns MD5值 */const calculateMD5 = (blob: Blob): Promise<string> => { return new Promise((resolve) => { const reader = new FileReader() reader.readAsArrayBuffer(blob) reader.onloadend = () => { const md5 = SparkMD5.ArrayBuffer.hash( reader.result as ArrayBuffer ).toString() resolve(md5) } })}</script>
<style scoped>.upload-box { width: 400px; margin: 50px auto; padding: 20px; border: 1px solid #eee; border-radius: 8px;}
.file-input { margin-bottom: 20px;}
.upload-btn { background: #409eff; color: #fff; border: none; padding: 8px 16px; border-radius: 4px; margin-right: 10px; cursor: pointer;}
.cancel-btn { background: #f56c6c; color: #fff; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;}
.progress-bar { height: 10px; border-radius: 5px; background: #eee; margin: 10px 0;}
.progress-inner { height: 100%; border-radius: 5px; background: #409eff; transition: width 0.3s ease;}
.progress-text { text-align: center; color: #666;}</style>关键细节说明(避免踩坑)
-
文件唯一标识(fileHash):
用文件名 + 文件大小生成 Hash(SparkMD5.hash({file.size})),避免同名文件覆盖(比如“视频.mp4”可能有多个版本)。 -
并发控制:
用Set实现“任务池”,限制同时上传的分片数(默认 3 个),避免压垮服务器(1000 个分片同时发请求 = 服务器直接 502)。 -
进度跟踪:
每传完一个分片,更新progress值,用 CSS 过渡实现“平滑进度条”,用户体验更好。 -
取消与续传:
- 用
AbortController中断请求(支持浏览器原生取消); - 取消后将未完成的分片序号存到
localStorage,下次打开页面自动续传。
- 用
四、后端实现:从“存分片”到“拼文件”的生产级逻辑(Node.js + Express)
接上文:完成检查已上传分片接口
添加了类型注释以增强代码可读性。同时在开头增加注释说明该接口的作用。
// 此接口用于检查已上传的分片,核心逻辑是根据传入的fileHash查找对应的分片目录,// 获取目录下的所有文件并转化为分片序号数组返回给前端app.get('/api/check-chunks', async (req: Request, res: Response) => { const { fileHash } = req.query const chunkDir = path.join(CHUNK_DIR, fileHash)
if (!(await fs.pathExists(chunkDir))) { return res.status(200).json({ uploadedChunks: [] }) }
const chunks = await fs.readdir(chunkDir) const uploadedChunks = chunks .map((chunkName) => parseInt(chunkName)) .filter((num) => !isNaN(num))
res.status(200).json({ uploadedChunks })})进阶优化 1:分片完整性校验(避免“坏片”)
传输过程中分片可能因网络丢包损坏,需用MD5 校验确保分片完整。
前端:上传分片时附带 MD5
计算 MD5 的逻辑封装成独立的工具函数,提高复用性。添加注释说明该函数的作用。
// 此函数用于计算Blob对象的MD5值,其原理是将Blob对象转为ArrayBuffer并使用SparkMD5库进行计算const calculateMD5 = (blob: Blob): Promise<string> => { return new Promise((resolve) => { const reader = new FileReader() reader.readAsArrayBuffer(blob) reader.onloadend = () => { const md5 = SparkMD5.ArrayBuffer.hash( reader.result as ArrayBuffer ).toString() resolve(md5) } })}
// 上传分片时添加MD5参数const uploadChunk = async (chunk: Blob, index: number) => { const md5 = await calculateMD5(chunk) const formData = new FormData() formData.append('chunk', chunk) formData.append('fileHash', fileHash.value) formData.append('chunkIndex', index.toString()) formData.append('fileName', selectedFile.value!.name) formData.append('chunkMD5', md5) //...其他逻辑}后端:验证分片 MD5
在后端计算文件 MD5 的函数添加类型注释和功能注释,同时在upload-chunk接口内添加更多注释说明关键步骤。
// 此函数用于计算指定文件路径下文件的MD5值,通过读取文件内容为buffer并使用SparkMD5库进行计算const calculateMD5FromFile = async (filePath: string): Promise<string> => { const buffer = await fs.readFile(filePath) return SparkMD5.hash(buffer).toString()}
/** * 接口1:上传分片(/api/upload-chunk)优化版(加MD5校验) */app.post('/api/upload-chunk', async (req: Request, res: Response) => { const form = new multiparty.Form() form.parse(req, async (err, fields, files) => { if (err) { return res .status(500) .json({ message: '分片上传失败', error: err.message }) }
try { const fileHash = fields.fileHash[0] const chunkIndex = fields.chunkIndex[0] const chunkMD5 = fields.chunkMD5[0] // 前端传的分片MD5 const chunkFile = files.chunk[0]
// 1. 保存分片到临时目录 const chunkDir = path.join(CHUNK_DIR, fileHash) await fs.ensureDir(chunkDir) const targetPath = path.join(chunkDir, chunkIndex) await fs.move(chunkFile.path, targetPath, { overwrite: true }) // 保存分片后记录操作日志(实际项目可按需添加,这里只是示例添加) console.log(`已保存分片 ${chunkIndex} 到 ${targetPath}`)
// 2. 验证分片MD5 const actualMD5 = await calculateMD5FromFile(targetPath) if (actualMD5 !== chunkMD5) { await fs.remove(targetPath) // 删除损坏的分片 // 删除分片后记录操作日志(实际项目可按需添加,这里只是示例添加) console.log(`删除损坏的分片 ${chunkIndex} ,路径为 ${targetPath}`) return res .status(400) .json({ message: `分片${chunkIndex}损坏,请重新上传` }) }
res.status(200).json({ message: '分片上传成功' }) } catch (error) { // 捕获异常后记录错误日志(实际项目可按需添加,这里只是示例添加) console.error(`分片上传异常:${error.message}`) await fs.remove(targetPath) // 清理错误分片 res.status(500).json({ message: '分片上传失败', error: error.message }) } })})进阶优化 2:动态分片大小(适配网络状况)
网络好时用大分片(10MB),网络差时用小分片(1MB),提升上传效率。
前端:动态计算分片大小
增加对返回值为null或undefined情况的处理注释。
// 此函数用于根据网络类型动态调整分片大小// 若navigator.connection不存在或无法获取有效网络信息,返回默认的5MBconst getDynamicChunkSize = () => { const connection = navigator.connection as any if (!connection) return 5 * 1024 * 1024
switch (connection.effectiveType) { case '4g': return 10 * 1024 * 1024 // 4G→10MB case '3g': return 2 * 1024 * 1024 // 3G→2MB case '2g': return 1 * 1024 * 1024 // 2G→1MB default: return 5 * 1024 * 1024 // 其他→5MB }}
// 切割分片时用动态大小const handleFileSelect = async (e: Event) => { const target = e.target as HTMLInputElement if (!target.files?.length) return
const file = target.files[0] selectedFile.value = file
fileHash.value = SparkMD5.hash(`${file.name}-${file.size}`).toString()
const chunkSize = getDynamicChunkSize() chunks.value = createChunks(file, chunkSize) // 传入动态大小 progress.value = 0}后端:接收动态分片大小
在merge-chunk接口添加更详细注释说明逻辑步骤。
/** * 接口2:合并分片(/api/merge-chunk)优化版(支持动态分片大小) * 此接口用于将多个分片合并成完整文件,关键在于接收前端传递的动态分片大小信息 * 并据此计算每个分片的写入位置,以确保文件正确合并 */app.post('/api/merge-chunk', async (req: Request, res: Response) => { const { fileHash, fileName, chunkSize } = req.body // 接收前端传的chunkSize const chunkDir = path.join(CHUNK_DIR, fileHash) const outputPath = path.join(UPLOAD_DIR, `${fileHash}-${fileName}`) // 用fileHash避免同名覆盖
try { // 检查分片目录是否存在 if (!(await fs.pathExists(chunkDir))) { return res.status(400).json({ message: '未找到分片目录,请重新上传' }) }
const chunks = await fs.readdir(chunkDir) if (chunks.length === 0) { await fs.remove(chunkDir) return res.status(400).json({ message: '分片为空,请重新上传' }) }
// 按序号排序(确保合并顺序正确) chunks.sort((a, b) => parseInt(a) - parseInt(b))
// 合并分片:用流写入(避免内存爆炸) const writeStream = fs.createWriteStream(outputPath) for (const chunkIndex of chunks) { const chunkPath = path.join(chunkDir, chunkIndex) if (!(await fs.pathExists(chunkPath))) { throw new Error(`分片${chunkIndex}丢失,请重新上传`) }
// 计算分片的写入位置(精准拼接) const start = parseInt(chunkIndex) * chunkSize const readStream = fs.createReadStream(chunkPath)
// 管道流:将分片写入最终文件 await new Promise((resolve, reject) => { readStream.pipe(writeStream, { end: false }) readStream.on('end', resolve) readStream.on('error', reject) }) }
// 关闭写流 writeStream.end()
// 验证合并后的文件完整性(可选) const finalMD5 = await calculateMD5FromFile(outputPath) if (finalMD5 !== fileHash) { // fileHash是整个文件的MD5(前端需传) await fs.remove(outputPath) return res.status(400).json({ message: '文件合并后损坏,请重新上传' }) }
// 清理临时分片目录 await fs.remove(chunkDir)
res.status(200).json({ message: '文件合并成功', filePath: outputPath, finalMD5, }) } catch (error) { // 合并失败:清理临时文件 await fs.remove(chunkDir) if (await fs.pathExists(outputPath)) { await fs.remove(outputPath) } res.status(500).json({ message: '文件合并失败', error: error.message }) }})进阶优化 3:定时清理临时文件(避免磁盘爆炸)
未完成的上传任务会残留大量分片文件,需定时清理(比如清理超过 24 小时的分片目录)。
安装node - schedule(定时任务库):
npm install node - schedule --save实现定时清理,添加注释说明任务运行逻辑。
const schedule = require('node - schedule')
// 每天凌晨2点清理超过24小时的分片目录// 该定时任务使用node - schedule库,通过设定的cron表达式0 2 * * * 来定时执行// 逻辑是检查分片目录的创建时间,若超过24小时则删除该目录schedule.scheduleJob('0 2 * * *', async () => { try { const now = Date.now() const chunkDirs = await fs.readdir(CHUNK_DIR)
for (const dirName of chunkDirs) { const dirPath = path.join(CHUNK_DIR, dirName) const stat = await fs.stat(dirPath)
// 如果目录创建时间超过24小时(86400000毫秒) if (now - stat.birthtimeMs > 86400000) { await fs.remove(dirPath) console.log(`清理过期分片目录:${dirPath}`) } } } catch (error) { console.error('清理临时文件失败:', error.message) }})五、实际项目中的“避坑指南”
-
同名文件覆盖:用**整个文件的 MD5(fileHash)**作为唯一标识,合并后的文件命名为
${fileHash}-${fileName},避免不同文件同名覆盖。 -
大文件存储:本地存储容量有限时,用云存储(阿里云 OSS、腾讯云 COS)代替——将分片上传到 OSS,用 OSS 的
CompleteMultipartUpload接口合并,后端只需处理凭证和请求,减轻压力。 -
权限控制:上传接口加Token 校验(比如 JWT),避免非法用户上传文件:
const authMiddleware = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1] if (!token) return res.status(401).json({ message: '未授权' }) // 验证Token(用JWT) try { const decoded = jwt.verify(token, 'your - secret') req.user = decoded next() } catch (e) { res.status(401).json({ message: '无效Token' }) }}
// 给上传接口加权限验证app.post('/api/upload - chunk', authMiddleware, (req, res) => { /* ... */})六、最终效果演示:10GB PSD 文件上传成功!
- 选择文件:设计师选 10GB 的 PSD 文件,前端生成 1000 个 10MB 分片;
- 并发上传:同时传 3 个分片,进度条平滑到 50%;
- 断网续传:手动断网后重新连接,自动续传未完成的 500 个分片;
- 合并成功:后端合并分片,生成完整的 10GB PSD 文件;
- 验证文件:设计师下载后打开正常,再也不用哭着找开发!
七、总结:大文件上传的“终极公式”
大文件上传 = 分片切割 + 并发控制 + 断点续传 + 完整性校验 + 清理优化
通过以上方案,我们彻底解决了大文件上传的“卡死、重试、损坏”三大痛点,实现了稳定、高效、可续传的生产级功能。
参考文档:
- HTML5 File API:[MDN](https://developer.mozilla.org/zh - CN/docs/Web/API/File)
- Node.js fs - extra:[GitHub](https://github.com/jprichardson/node - fs - extra)
- 阿里云 OSS 分片上传:文档
如果这篇文章帮你解决了大文件上传的问题,欢迎分享给你的前端小伙伴——让我们一起告别“上传崩溃”的日子! 😊
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!