1. 大文件上传的痛点与解决方案
前端开发中经常会遇到大文件上传的需求,比如用户上传视频、设计稿、数据库备份等场景。传统的文件上传方式在面对大文件时存在几个明显问题:
- 网络不稳定导致上传失败后需要重头开始
- 服务器对单个请求的大小限制
- 上传过程中无法暂停和恢复
- 上传进度难以准确显示
基于Vue-cli的分段上传方案能有效解决这些问题。其核心思想是将大文件切割成多个小块(chunk)分别上传,服务器接收后合并还原成完整文件。这种方式具有以下优势:
- 断点续传:即使某次上传失败,只需重传失败的分片
- 并行上传:可以同时上传多个分片提高速度
- 进度可控:可以精确计算每个分片的上传进度
- 绕过限制:每个分片大小可控,避免服务器限制
2. 项目环境搭建与配置
2.1 初始化Vue-cli项目
首先确保已安装Node.js环境,然后通过Vue CLI创建新项目:
bash复制npm install -g @vue/cli
vue create file-upload-demo
cd file-upload-demo
选择默认配置或根据需求自定义,这里我们选择包含Babel和Router的基础配置。
2.2 安装必要依赖
除了Vue基础依赖外,我们还需要:
bash复制npm install axios qs spark-md5 --save
- axios:用于HTTP请求
- qs:处理请求参数
- spark-md5:计算文件MD5值用于唯一标识
2.3 配置开发服务器
在vue.config.js中添加以下配置,解决开发时的大文件上传限制:
javascript复制module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://your-server.com',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
}
},
client: {
overlay: false
}
}
}
3. 前端核心实现逻辑
3.1 文件分片处理
文件分片是分段上传的核心环节,主要流程如下:
- 获取文件对象
- 计算文件唯一hash值
- 确定分片大小并切割文件
- 为每个分片生成唯一标识
javascript复制// 计算文件MD5
function calculateFileMD5(file) {
return new Promise((resolve) => {
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
const chunkSize = 2097152 // 2MB
const chunks = Math.ceil(file.size / chunkSize)
let currentChunk = 0
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
fileReader.onload = function(e) {
spark.append(e.target.result)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
function loadNext() {
const start = currentChunk * chunkSize
const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
}
loadNext()
})
}
3.2 分片上传控制
实现分片上传需要考虑并发控制、错误重试和进度计算:
javascript复制async function uploadFile(file) {
const fileMd5 = await calculateFileMD5(file)
const chunkSize = 5 * 1024 * 1024 // 5MB
const chunks = Math.ceil(file.size / chunkSize)
const uploadedChunks = await checkExistChunks(fileMd5, chunks)
const uploadQueue = []
for (let i = 0; i < chunks; i++) {
if (!uploadedChunks.includes(i)) {
uploadQueue.push({
chunkIndex: i,
chunkTotal: chunks,
fileMd5,
file: file.slice(i * chunkSize, (i + 1) * chunkSize)
})
}
}
// 控制并发数为3
const parallelCount = 3
const uploading = []
let uploadedCount = uploadedChunks.length
while (uploadQueue.length > 0 || uploading.length > 0) {
while (uploading.length < parallelCount && uploadQueue.length > 0) {
const task = uploadQueue.shift()
const promise = uploadChunk(task).finally(() => {
uploading.splice(uploading.indexOf(promise), 1)
})
uploading.push(promise)
}
await Promise.race(uploading)
uploadedCount++
updateProgress(uploadedCount / chunks * 100)
}
// 所有分片上传完成后通知服务器合并
await mergeChunks(file.name, fileMd5, chunks)
}
3.3 上传进度显示
使用Vue的响应式特性实现实时进度显示:
html复制<template>
<div>
<input type="file" @change="handleFileChange" />
<button @click="startUpload" :disabled="!file || uploading">
{{ uploading ? `上传中 ${progress}%` : '开始上传' }}
</button>
<div v-if="uploading" class="progress-bar">
<div :style="{ width: progress + '%' }"></div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
file: null,
uploading: false,
progress: 0
}
},
methods: {
handleFileChange(e) {
this.file = e.target.files[0]
},
async startUpload() {
if (!this.file) return
this.uploading = true
this.progress = 0
try {
await uploadFile(this.file)
this.$message.success('上传成功')
} catch (error) {
this.$message.error('上传失败: ' + error.message)
} finally {
this.uploading = false
}
},
updateProgress(percent) {
this.progress = Math.round(percent)
}
}
}
</script>
4. 服务端接口设计
4.1 检查分片接口
前端在上传前需要检查哪些分片已经上传过:
javascript复制// 检查已上传分片
async function checkExistChunks(fileMd5, totalChunks) {
const response = await axios.get('/api/check', {
params: {
fileMd5,
totalChunks
}
})
return response.data.existedChunks || []
}
服务端对应实现(Node.js示例):
javascript复制router.get('/check', async (ctx) => {
const { fileMd5, totalChunks } = ctx.query
const tempDir = path.join(uploadDir, fileMd5)
let existedChunks = []
if (fs.existsSync(tempDir)) {
existedChunks = fs.readdirSync(tempDir)
.map(name => parseInt(name.replace('.part', '')))
.filter(num => !isNaN(num))
}
ctx.body = {
existedChunks,
uploaded: existedChunks.length === parseInt(totalChunks)
}
})
4.2 分片上传接口
接收并保存文件分片:
javascript复制// 前端上传分片
async function uploadChunk({ chunkIndex, chunkTotal, fileMd5, file }) {
const formData = new FormData()
formData.append('file', file)
formData.append('chunkIndex', chunkIndex)
formData.append('fileMd5', fileMd5)
formData.append('chunkTotal', chunkTotal)
await axios.post('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
服务端实现:
javascript复制router.post('/upload', async (ctx) => {
const { chunkIndex, fileMd5 } = ctx.request.body
const file = ctx.request.files.file
const tempDir = path.join(uploadDir, fileMd5)
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true })
}
const chunkPath = path.join(tempDir, `${chunkIndex}.part`)
await fs.promises.rename(file.path, chunkPath)
ctx.body = { success: true }
})
4.3 合并分片接口
当所有分片上传完成后,前端调用合并接口:
javascript复制// 请求合并分片
async function mergeChunks(filename, fileMd5, totalChunks) {
await axios.post('/api/merge', {
filename,
fileMd5,
totalChunks
})
}
服务端合并实现:
javascript复制router.post('/merge', async (ctx) => {
const { filename, fileMd5, totalChunks } = ctx.request.body
const tempDir = path.join(uploadDir, fileMd5)
const finalPath = path.join(uploadDir, filename)
// 检查分片是否完整
const chunks = fs.readdirSync(tempDir)
if (chunks.length !== parseInt(totalChunks)) {
ctx.status = 400
ctx.body = { error: '分片不完整' }
return
}
// 按序号排序分片
chunks.sort((a, b) => parseInt(a) - parseInt(b))
// 合并文件
const writeStream = fs.createWriteStream(finalPath)
for (const chunk of chunks) {
const chunkPath = path.join(tempDir, chunk)
const buffer = fs.readFileSync(chunkPath)
writeStream.write(buffer)
fs.unlinkSync(chunkPath) // 删除分片
}
writeStream.end()
fs.rmdirSync(tempDir) // 删除临时目录
ctx.body = { success: true, path: finalPath }
})
5. 性能优化与异常处理
5.1 上传性能优化
-
动态分片大小:根据网络状况动态调整分片大小
javascript复制// 根据文件大小自动调整分片大小 function getChunkSize(fileSize) { if (fileSize > 1024 * 1024 * 1024) { // >1GB return 10 * 1024 * 1024 // 10MB } else if (fileSize > 100 * 1024 * 1024) { // >100MB return 5 * 1024 * 1024 // 5MB } else { return 1 * 1024 * 1024 // 1MB } } -
并行上传控制:根据浏览器性能调整并发数
javascript复制// 根据浏览器性能自动调整并发数 function getParallelCount() { const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) return isMobile ? 2 : 4 } -
网络自适应:根据上传速度动态调整策略
javascript复制let uploadSpeedHistory = [] function recordUploadSpeed(size, time) { const speed = size / time uploadSpeedHistory.push(speed) if (uploadSpeedHistory.length > 5) { uploadSpeedHistory.shift() } } function getAverageSpeed() { if (uploadSpeedHistory.length === 0) return 0 return uploadSpeedHistory.reduce((sum, speed) => sum + speed, 0) / uploadSpeedHistory.length }
5.2 异常处理机制
-
分片上传失败重试
javascript复制async function uploadWithRetry(task, maxRetry = 3) { let retryCount = 0 while (retryCount < maxRetry) { try { await uploadChunk(task) return true } catch (error) { retryCount++ if (retryCount >= maxRetry) { throw error } await new Promise(resolve => setTimeout(resolve, 1000 * retryCount)) } } } -
网络中断恢复
javascript复制// 保存上传状态到localStorage function saveUploadState(fileMd5, uploadedChunks) { const state = JSON.parse(localStorage.getItem('uploadStates') || '{}') state[fileMd5] = uploadedChunks localStorage.setItem('uploadStates', JSON.stringify(state)) } // 恢复上传状态 function getUploadState(fileMd5) { const state = JSON.parse(localStorage.getItem('uploadStates') || '{}') return state[fileMd5] || [] } -
服务端异常处理
javascript复制// 统一错误处理中间件 app.use(async (ctx, next) => { try { await next() } catch (err) { ctx.status = err.status || 500 ctx.body = { error: err.message, code: err.code || 'INTERNAL_ERROR' } } })
6. 安全与验证机制
6.1 文件验证
-
文件类型校验
javascript复制// 前端校验 const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf'] function isValidFileType(file) { return ALLOWED_TYPES.includes(file.type) } // 服务端校验 const fileType = require('file-type') async function validateFile(buffer) { const type = await fileType.fromBuffer(buffer) if (!ALLOWED_MIME_TYPES.includes(type.mime)) { throw new Error('不支持的文件类型') } } -
文件大小限制
javascript复制const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB if (file.size > MAX_FILE_SIZE) { throw new Error('文件大小超过限制') }
6.2 上传权限控制
-
用户认证
javascript复制// 添加JWT验证中间件 function authMiddleware(ctx, next) { const token = ctx.headers.authorization if (!token) { ctx.status = 401 throw new Error('未授权') } try { ctx.state.user = verifyToken(token) return next() } catch (err) { ctx.status = 401 throw new Error('无效令牌') } } -
上传频率限制
javascript复制const rateLimit = require('koa-ratelimit') app.use(rateLimit({ driver: 'memory', db: new Map(), duration: 60000, errorMessage: '请求过于频繁', id: (ctx) => ctx.ip, headers: { remaining: 'Rate-Limit-Remaining', reset: 'Rate-Limit-Reset', total: 'Rate-Limit-Total' }, max: 100, disableHeader: false }))
6.3 分片验证
-
分片完整性检查
javascript复制// 服务端检查分片MD5 const chunkMd5 = crypto.createHash('md5').update(buffer).digest('hex') if (chunkMd5 !== ctx.request.body.chunkMd5) { throw new Error('分片校验失败') } -
防止重复上传
javascript复制if (fs.existsSync(chunkPath)) { const existingMd5 = crypto.createHash('md5').update(fs.readFileSync(chunkPath)).digest('hex') if (existingMd5 === chunkMd5) { ctx.body = { success: true } return } }
7. 测试与调试技巧
7.1 模拟大文件上传
-
生成测试文件
javascript复制// 使用Node.js生成指定大小的测试文件 const fs = require('fs') const path = require('path') function generateTestFile(sizeMB, filename) { const size = sizeMB * 1024 * 1024 const buffer = Buffer.alloc(size, 'a') fs.writeFileSync(path.join(__dirname, filename), buffer) } generateTestFile(500, 'test-500mb.dat') -
网络限速测试
javascript复制// 使用Chrome开发者工具模拟慢速网络 // 1. 打开开发者工具(F12) // 2. 切换到Network标签 // 3. 点击Online下拉菜单 // 4. 选择"Slow 3G"或其他预设
7.2 断点续传测试
-
手动中断上传
javascript复制// 在axios请求中添加取消令牌 const CancelToken = axios.CancelToken let cancel axios.post('/api/upload', formData, { cancelToken: new CancelToken(c => { cancel = c }) }) // 测试时手动调用cancel()中断上传 -
恢复上传验证
javascript复制// 上传部分分片后刷新页面 // 重新选择相同文件,验证是否跳过已上传分片
7.3 性能监控
-
上传速度计算
javascript复制let uploadStartTime function startUploadTimer() { uploadStartTime = Date.now() } function calculateUploadSpeed(bytes) { const seconds = (Date.now() - uploadStartTime) / 1000 return (bytes / 1024 / seconds).toFixed(2) + ' KB/s' } -
内存占用监控
javascript复制// 使用Chrome Memory工具分析内存使用 // 1. 打开开发者工具(F12) // 2. 切换到Memory标签 // 3. 点击"Take snapshot"记录内存快照 // 4. 上传文件后再取快照对比
8. 实际应用中的经验分享
8.1 生产环境部署建议
-
Nginx配置优化
nginx复制client_max_body_size 1024m; proxy_read_timeout 300; proxy_connect_timeout 300; proxy_send_timeout 300; -
文件存储策略
javascript复制// 按日期分目录存储 function getUploadPath(filename) { const date = new Date() const dir = path.join( uploadDir, `${date.getFullYear()}`, `${date.getMonth() + 1}`, `${date.getDate()}` ) fs.mkdirSync(dir, { recursive: true }) return path.join(dir, filename) }
8.2 移动端适配经验
-
移动端特殊处理
javascript复制// 检测移动端环境 const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) // 移动端使用更小的分片 const chunkSize = isMobile ? 1 * 1024 * 1024 : 5 * 1024 * 1024 -
后台上传支持
javascript复制// 使用Service Worker实现后台上传 if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/upload-sw.js') }
8.3 常见问题解决方案
-
内存溢出问题
javascript复制// 使用流式处理避免内存问题 const readStream = fs.createReadStream(file.path) const writeStream = fs.createWriteStream(chunkPath) readStream.pipe(writeStream) -
文件名冲突处理
javascript复制// 为文件名添加时间戳 function getUniqueFilename(originalname) { const ext = path.extname(originalname) const name = path.basename(originalname, ext) return `${name}-${Date.now()}${ext}` } -
跨域问题解决
javascript复制// 服务端设置CORS app.use(async (ctx, next) => { ctx.set('Access-Control-Allow-Origin', '*') ctx.set('Access-Control-Allow-Methods', 'OPTIONS, GET, PUT, POST, DELETE') ctx.set('Access-Control-Allow-Headers', 'Content-Type, Authorization') await next() })