一、为什么“传统上传”在大文件面前完全失效?
我们先算笔账:
- 传统上传会把整个文件读进浏览器内存(比如 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不存在或无法获取有效网络信息,返回默认的5MB
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
}
}
// 切割分片时用动态大小
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 分片上传:文档
如果这篇文章帮你解决了大文件上传的问题,欢迎分享给你的前端小伙伴——让我们一起告别“上传崩溃”的日子! 😊
- 本文链接:https://fridolph.top/posts/2024-10-12__upload-file
- 版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 许可协议。