Skip to content

大文件上传

毕业已经半年了,至今在真正产品级的项目里面我还没有遇到过文件上传的需求,唯一遇到的文件上传的需求还是在自己简单的毕设中有上传用户头像的这个小功能,那时候我只是一个axios.post(),可能因为图片都比较小吧,成功了,但是也有很多的 bug:

  • 文件不能过大,一过大直接奔溃
  • 每次上传时,查看系统的任务管理器就会发现 cpu 资源暴增。

显然单纯的axios.post()不是解决的方案,也一直听大牛们说:分片上传断点续传。终于找到一篇非常好的掘金帖子]敲起来!Koa2 + Vue3 演练大文件分片上传,好在 vue3 和 koa2 我都还算比较熟,会使用,非常的符合自己的技术栈,就跟着大牛的步骤敲下来,收获颇丰,估借此机会进行总结。

文件上传思路

前端分片上传,后端接收,上传完成之后后端合并前端的切片恢复源文件。

前端

  • 前端在通过input或其他方式拿到文件时先将文件进行切片

    文件file是 Blob 二进制对象 可以使用 file.slice 实现文件的切片,再将切片的一个Blob文件切片对象通过spark-md5这个库进行生成Hash值,这个是为了标识,每个文件生成的hash值是唯一的!!!最后利用 FileReader 来读取文件切片

    js
    const chunkSize = 5 * 1024 * 1024 // 每个文件分片的大小是5M
    
    // currentFile是当前获取的文件
    const handleUploadFile = async () => {
    	let fileList = createChunkList(currentFile.value.raw, chunkSize)
    	fileHash.value = await createMD5(fileList)
    }
    
    // 对文件进行切片
    const createChunkList = (file, chunkSize) => {
    	const fileChunkList = []
    	let cur = 0
    	while (cur < file.size) {
    		// file 是Blob二进制对象 可以使用 file.slice 实现文件的切片上传
    		fileChunkList.push(file.slice(cur, cur + chunkSize))
    		cur += chunkSize
    	}
    	return fileChunkList
    }
    
    // 生成文件哈希
    import SparkMD5 from 'spark-md5'
    const createMD5 = fileChunkList => {
    	return new Promise((resolve, reject) => {
    		const slice =
    			File.prototype.slice ||
    			File.prototype.mozSlice ||
    			File.prototype.webkitSlice
    		const chunks = fileChunkList.length
    		let currentChunk = 0
    		let spark = new SparkMD5.ArrayBuffer()
    		let fileReader = new FileReader()
    		// 读取文件切片
    		fileReader.onload = function (e) {
    			spark.append(e.target.result)
    			currentChunk++
    			if (currentChunk < chunks) {
    				loadChunk()
    			} else {
    				// 读取切片,返回最终文件的Hash值
    				resolve(spark.end())
    			}
    		}
    
    		fileReader.onerror = function (e) {
    			reject(e)
    		}
    
    		function loadChunk() {
    			fileReader.readAsArrayBuffer(fileChunkList[currentChunk])
    		}
    
    		loadChunk()
    	})
    }
    const chunkSize = 5 * 1024 * 1024 // 每个文件分片的大小是5M
    
    // currentFile是当前获取的文件
    const handleUploadFile = async () => {
    	let fileList = createChunkList(currentFile.value.raw, chunkSize)
    	fileHash.value = await createMD5(fileList)
    }
    
    // 对文件进行切片
    const createChunkList = (file, chunkSize) => {
    	const fileChunkList = []
    	let cur = 0
    	while (cur < file.size) {
    		// file 是Blob二进制对象 可以使用 file.slice 实现文件的切片上传
    		fileChunkList.push(file.slice(cur, cur + chunkSize))
    		cur += chunkSize
    	}
    	return fileChunkList
    }
    
    // 生成文件哈希
    import SparkMD5 from 'spark-md5'
    const createMD5 = fileChunkList => {
    	return new Promise((resolve, reject) => {
    		const slice =
    			File.prototype.slice ||
    			File.prototype.mozSlice ||
    			File.prototype.webkitSlice
    		const chunks = fileChunkList.length
    		let currentChunk = 0
    		let spark = new SparkMD5.ArrayBuffer()
    		let fileReader = new FileReader()
    		// 读取文件切片
    		fileReader.onload = function (e) {
    			spark.append(e.target.result)
    			currentChunk++
    			if (currentChunk < chunks) {
    				loadChunk()
    			} else {
    				// 读取切片,返回最终文件的Hash值
    				resolve(spark.end())
    			}
    		}
    
    		fileReader.onerror = function (e) {
    			reject(e)
    		}
    
    		function loadChunk() {
    			fileReader.readAsArrayBuffer(fileChunkList[currentChunk])
    		}
    
    		loadChunk()
    	})
    }
  • 生成一个FormData数组,里面包含每个对象包含

    • file(切片),
    • chunkHash 切片对应的哈希
    • filehash 一整个文件对应的哈希
    js
    import { ref, computed } from 'vue'
    
    let fileHash = ref(null)
    let chunkFormData = ref([])
    
    const handleUploadFile = async () => {
    	let fileList = createChunkList(currentFile.value.raw, chunkSize)
    	fileHash.value = await createMD5(fileList)
    
    	let chunkList = fileList.map((file, index) => {
    		return {
    			file: file,
    			chunkHash: fileHash.value + '-' + index, // chunk哈希
    			fileHash: fileHash.value, // 文件哈希
    		}
    	})
    
    	chunkFormData.value = chunkList.map(chunk => {
    		let formData = new FormData()
    		formData.append('chunk', chunk.file)
    		formData.append('chunkHash', chunk.chunkHash)
    		formData.append('fileHash', chunk.fileHash)
    		return { formData }
    	})
    }
    import { ref, computed } from 'vue'
    
    let fileHash = ref(null)
    let chunkFormData = ref([])
    
    const handleUploadFile = async () => {
    	let fileList = createChunkList(currentFile.value.raw, chunkSize)
    	fileHash.value = await createMD5(fileList)
    
    	let chunkList = fileList.map((file, index) => {
    		return {
    			file: file,
    			chunkHash: fileHash.value + '-' + index, // chunk哈希
    			fileHash: fileHash.value, // 文件哈希
    		}
    	})
    
    	chunkFormData.value = chunkList.map(chunk => {
    		let formData = new FormData()
    		formData.append('chunk', chunk.file)
    		formData.append('chunkHash', chunk.chunkHash)
    		formData.append('fileHash', chunk.fileHash)
    		return { formData }
    	})
    }
  • 通过Promise.all()并发请求,已达到最快的上传速度。前端的切片上传就完成了。

    js
    const handleUploadFile = async () => {
    	//...
    	chunkFormData.value = chunkList.map(chunk => {
    		let formData = new FormData()
    		formData.append('chunk', chunk.file)
    		formData.append('chunkHash', chunk.chunkHash)
    		formData.append('fileHash', chunk.fileHash)
    		return { formData }
    	})
    	// Promise.all() 并发请求
    	Promise.all(
    		chunkFormData.value.map(data => {
    			return new Promise((resolve, reject) => {
    				postFile(data.formData, uploadProgress(data))
    					.then(res => {
    						resolve(res)
    					})
    					.catch(err => {
    						reject(err)
    					})
    			})
    		})
    	)
    }
    const handleUploadFile = async () => {
    	//...
    	chunkFormData.value = chunkList.map(chunk => {
    		let formData = new FormData()
    		formData.append('chunk', chunk.file)
    		formData.append('chunkHash', chunk.chunkHash)
    		formData.append('fileHash', chunk.fileHash)
    		return { formData }
    	})
    	// Promise.all() 并发请求
    	Promise.all(
    		chunkFormData.value.map(data => {
    			return new Promise((resolve, reject) => {
    				postFile(data.formData, uploadProgress(data))
    					.then(res => {
    						resolve(res)
    					})
    					.catch(err => {
    						reject(err)
    					})
    			})
    		})
    	)
    }
  • Promise.all()执行成功之后,请求合并文件接口,通知后端对文件进行合并。

    js
    const handleUploadFile = async () => {
    	// ...
    	Promise.all(
    		chunkFormData.value.map(data => {
    			return new Promise((resolve, reject) => {
    				postFile(data.formData, uploadProgress(data))
    					.then(res => {
    						resolve(res)
    					})
    					.catch(err => {
    						reject(err)
    					})
    			})
    		})
    	).then(res => {
    		// 所有的分片都上传完成之后  调用合并文件接口 传递文件名 文件哈希 chunk大小
    		mergeFile({
    			fileName: currentFile.value.name,
    			fileHash: fileHash.value,
    			chunkSize: chunkSize,
    		}).then(res => {
    			ConstantSourceNode.log(res)
    		})
    	})
    }
    const handleUploadFile = async () => {
    	// ...
    	Promise.all(
    		chunkFormData.value.map(data => {
    			return new Promise((resolve, reject) => {
    				postFile(data.formData, uploadProgress(data))
    					.then(res => {
    						resolve(res)
    					})
    					.catch(err => {
    						reject(err)
    					})
    			})
    		})
    	).then(res => {
    		// 所有的分片都上传完成之后  调用合并文件接口 传递文件名 文件哈希 chunk大小
    		mergeFile({
    			fileName: currentFile.value.name,
    			fileHash: fileHash.value,
    			chunkSize: chunkSize,
    		}).then(res => {
    			ConstantSourceNode.log(res)
    		})
    	})
    }

后端

  • 后端接收一个个的chunk(切片),根据filehash文件哈希生成一个对应的目录,目录下存放一个个的chunk

    js
    const fsExtra = require('fs-extra')
    const path = require('path')
    const router = require('koa-router')()
    const UPLOAD_DIR = path.resolve(__dirname, '..', 'files') // 文件存储在上一级目录的 files 文件夹下
    
    router.post('/file', async ctx => {
    	// 切片得从files字段获取,不在body中 基于 koa-body插件
    	const file = ctx.request.files.chunk
    	// 获取文件Hash和切片序号
    	const body = ctx.request.body
    	const fileHash = body.fileHash
    	const chunkHash = body.chunkHash
    	const chunkDir = `${UPLOAD_DIR}/${fileHash}`
    	const chunkIndex = chunkHash.split('-')[1]
    	const chunkPath = `${UPLOAD_DIR}/${fileHash}/${chunkIndex}`
    
    	// 不存在目录,则新建目录
    	if (!fsExtra.existsSync(chunkDir)) {
    		await fsExtra.mkdirs(chunkDir)
    	}
    
    	// 这里的file.path为上传切片的临时地址
    	await fsExtra.move(
    		file.path,
    		path.resolve(chunkDir, chunkHash.split('-')[1])
    	)
    
    	ctx.body = { code: 200, result: '接收成功' }
    })
    const fsExtra = require('fs-extra')
    const path = require('path')
    const router = require('koa-router')()
    const UPLOAD_DIR = path.resolve(__dirname, '..', 'files') // 文件存储在上一级目录的 files 文件夹下
    
    router.post('/file', async ctx => {
    	// 切片得从files字段获取,不在body中 基于 koa-body插件
    	const file = ctx.request.files.chunk
    	// 获取文件Hash和切片序号
    	const body = ctx.request.body
    	const fileHash = body.fileHash
    	const chunkHash = body.chunkHash
    	const chunkDir = `${UPLOAD_DIR}/${fileHash}`
    	const chunkIndex = chunkHash.split('-')[1]
    	const chunkPath = `${UPLOAD_DIR}/${fileHash}/${chunkIndex}`
    
    	// 不存在目录,则新建目录
    	if (!fsExtra.existsSync(chunkDir)) {
    		await fsExtra.mkdirs(chunkDir)
    	}
    
    	// 这里的file.path为上传切片的临时地址
    	await fsExtra.move(
    		file.path,
    		path.resolve(chunkDir, chunkHash.split('-')[1])
    	)
    
    	ctx.body = { code: 200, result: '接收成功' }
    })
  • 当接收到前端发起的合并文件请求之后,通过Stream流的方式来合并文件。这里西药分别创建可读流和可写流。

    js
    router.get('/mergeFile', async ctx => {
    	const { fileName, fileHash, chunkSize } = ctx.request.query
    	const chunkDir = path.resolve(UPLOAD_DIR, fileHash)
    	// console.log("params", fileName, fileHash, chunkSize);
    	// 读取文件夹下所有的分片
    	const chunkPaths = await fsExtra.readdir(chunkDir)
    	const chunkNumber = chunkPaths.length
    	let count = 0
    	// 切片排序 防止乱序
    	chunkPaths.sort((a, b) => a - b)
    	chunkPaths.forEach((chunk, index) => {
    		const chunkPath = path.resolve(UPLOAD_DIR, fileHash, chunk)
    		// 创建可写流
    		const writeStream = fsExtra.createWriteStream(fileHash + fileName, {
    			start: index * chunkSize,
    			end: (index + 1) * chunkSize,
    		})
    		// 创建可读流
    		const readStream = fsExtra.createReadStream(chunkPath)
    		readStream.on('end', () => {
    			// 删除切片文件
    			fsExtra.unlinkSync(chunkPath)
    			count++
    			// 删除切片文件夹
    			if (count === chunkNumber) {
    				fsExtra.rmdirSync(chunkDir)
    				let uploadedFilePath = path.resolve(
    					__dirname,
    					'..',
    					fileHash + fileName
    				)
    				fsExtra.move(uploadedFilePath, UPLOAD_DIR + '/' + fileHash + fileName)
    			}
    		})
    		readStream.pipe(writeStream)
    	})
    	ctx.body = { code: 200, result: '接收成功' }
    })
    router.get('/mergeFile', async ctx => {
    	const { fileName, fileHash, chunkSize } = ctx.request.query
    	const chunkDir = path.resolve(UPLOAD_DIR, fileHash)
    	// console.log("params", fileName, fileHash, chunkSize);
    	// 读取文件夹下所有的分片
    	const chunkPaths = await fsExtra.readdir(chunkDir)
    	const chunkNumber = chunkPaths.length
    	let count = 0
    	// 切片排序 防止乱序
    	chunkPaths.sort((a, b) => a - b)
    	chunkPaths.forEach((chunk, index) => {
    		const chunkPath = path.resolve(UPLOAD_DIR, fileHash, chunk)
    		// 创建可写流
    		const writeStream = fsExtra.createWriteStream(fileHash + fileName, {
    			start: index * chunkSize,
    			end: (index + 1) * chunkSize,
    		})
    		// 创建可读流
    		const readStream = fsExtra.createReadStream(chunkPath)
    		readStream.on('end', () => {
    			// 删除切片文件
    			fsExtra.unlinkSync(chunkPath)
    			count++
    			// 删除切片文件夹
    			if (count === chunkNumber) {
    				fsExtra.rmdirSync(chunkDir)
    				let uploadedFilePath = path.resolve(
    					__dirname,
    					'..',
    					fileHash + fileName
    				)
    				fsExtra.move(uploadedFilePath, UPLOAD_DIR + '/' + fileHash + fileName)
    			}
    		})
    		readStream.pipe(writeStream)
    	})
    	ctx.body = { code: 200, result: '接收成功' }
    })

进度条的显示

在大文件上传时,因为是一个耗时的操作,尤其是当用户的网络状态不佳的时候,这时候就非常需要一个优雅的进度条了,毕竟前端最重要的事情就是要给用户最友好的体验。

通过onUploadProgress事件来监听变化

在原生的ajax中,我们可以使用onProgress事件来监听变化,axios 在这基础上进行了封装了一个onUploadProgress事件,接收一个函数,当上传进度发生变化的时候就会执行这个函数,所以改变进度条显示的内容是应该写在这个事件里面。

js
import axios from 'axios'
const service = axios.create({
	baseURL: 'http://127.0.0.1:666/',
	timeout: 10000,
	// headers: { 'X-Requested-With': 'XMLHttpRequest' },
	// withCredentials: true,
})

export const postFile = (file, conf) => {
	return service({
		url: 'http://127.0.0.1:666/upload/file',
		method: 'post',
		data: file,
		// 上传进度事件 是一个函数 这个函数所处理的 就是进度条的内容
		onUploadProgress: conf,
	})
}
import axios from 'axios'
const service = axios.create({
	baseURL: 'http://127.0.0.1:666/',
	timeout: 10000,
	// headers: { 'X-Requested-With': 'XMLHttpRequest' },
	// withCredentials: true,
})

export const postFile = (file, conf) => {
	return service({
		url: 'http://127.0.0.1:666/upload/file',
		method: 'post',
		data: file,
		// 上传进度事件 是一个函数 这个函数所处理的 就是进度条的内容
		onUploadProgress: conf,
	})
}

这里多提一句,发请求时过去我喜欢直接axios.get()或者axios.post()这样其实并不灵活,我们可以是使用axios.create()创建一个axios实例,再对这个实例进行一些配置是更加灵活的。

js
const uploadProgress = item => {
	// 返回一个 函数  这个函数会在上传进度更改时触发
	// 处理进度条的显示
}

Promise.all(
	chunkFormData.value.map(data => {
		return new Promise((resolve, reject) => {
			postFile(data.formData, uploadProgress(data))
				.then(res => {
					resolve(res)
				})
				.catch(err => {
					reject(err)
				})
		})
	})
)
const uploadProgress = item => {
	// 返回一个 函数  这个函数会在上传进度更改时触发
	// 处理进度条的显示
}

Promise.all(
	chunkFormData.value.map(data => {
		return new Promise((resolve, reject) => {
			postFile(data.formData, uploadProgress(data))
				.then(res => {
					resolve(res)
				})
				.catch(err => {
					reject(err)
				})
		})
	})
)

总结

这么一套代码下来,跟着大佬的代码写下来,加上一路解决 bug 写了快一下午,终于是完整的写出来了,收获很大,应付面试应该是没有问题了。