文件上传在前台业务中还是很常见的, 尤其是报表系统或者图床系统。
今天介绍几种常见的文件上传方式。(基于vue3 + vite + node)
1. 直接上传
直接上传适用于小体积文件,比如说图片、报表等
2. 切片上传
对于大一点的文件,如果采用直接上传就会比较耗时或者长时间占用页面,体验欠佳,这时候可以考虑切片上传。
原理: 前台根据设定的切片大小,通过slice方法将大文件分为若干份,并分别上传到后台。后台接收全部切片后,再合并成完整的文件。
前台切片
async function uploadFile() {
// 计算切片的数量
const chunkCount = Math.ceil(selectFile.value.size / chunkSize.value)
// 逐片上传
for (let i = 0; i < chunkCount; i++) {
const chunk = selectFile.value.slice(i * chunkSize.value, (i + 1) * chunkSize.value)
const formData = new FormData()
formData.append('chunk', chunk)
formData.append('index', i)
formData.append('fileName', selectFile.value.name)
formData.append('chunkCount', chunkCount)
await uploadChunk(formData)
}
}
在切片上传到后台时会被重新命名为xxxx-0.chunk、xxxx-1.chunk
const chunkName = `${fileName}-${chunkIndex}.chunk`
后台合并原理
- 使用
fs.readdirSync(chunksDir)同步读取切片目录中的所有文件名, 使用sort方法对文件名进行排序,确保文件按正确的顺序合并 - 使用
fs.createReadStream(chunkPath)创建一个读取流,用于读取切片文件的内容。 - 使用
Promise包装单个切片的处理过程, 并在所有切片处理完后,调用writeStream.end()结束写入流.
合并代码
// 合并切片函数
// chunksDir: 切片文件所在的目录路径
// finalFile: 合并后的文件将被保存的路径
async function mergeChunks(chunksDir, finalFile) {
return new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(finalFile)
writeStream.on('finish', resolve)
writeStream.on('error', reject)
// 读取所有切片文件名
const chunks = fs.readdirSync(chunksDir).sort((a, b) => {
// 确保按正确顺序合并文件: 例子: xxxxx-0.chunk, xxxxx-1.chunk, ...
return Number(a.split('-')[1]) - Number(b.split('-')[1])
})
// 使用异步函数按顺序处理每个切片
async function mergeChunks(chunks) {
for (const chunkName of chunks) {
const chunkPath = path.join(chunksDir, chunkName)
// 使用 Promise 包装单个切片的处理
await new Promise((resolveChunk, rejectChunk) => {
const readStream = fs.createReadStream(chunkPath)
readStream.on('end', () => {
fs.unlinkSync(chunkPath) // 删除已合并的切片文件
resolveChunk()
})
readStream.on('error', rejectChunk)
readStream.pipe(writeStream, { end: false })
})
}
writeStream.end() // 所有切片处理完后才结束写入流
}
// 执行合并操作
mergeChunks(chunks).catch(reject)
})
}
3. Hash + 切片上传
相比上一个的切片上传,使用hash可以为文件生成一个唯一的标识符,防止文件被篡改。同时也可用于断点续传.
这里采用了spark-md5插件生成MD5
function calculateHash(file) {
const chunkFileSize = chunkSize.value
const chunks = Math.ceil(file.size / chunkFileSize)
const spark = new SparkMD5.ArrayBuffer()
let currentChunk = 0
const fileReader = new FileReader()
const loadNext = () => {
const start = currentChunk * chunkFileSize
const end = Math.min(start + chunkFileSize, file.size)
fileReader.readAsArrayBuffer(file.slice(start, end))
}
fileReader.onload = (e) => {
spark.append(e.target.result)
currentChunk++
if (currentChunk < chunks) {
loadNext()
}
else {
fileHash.value = spark.end()
console.log('文件哈希值:', fileHash.value)
}
}
loadNext() // 触发第一次切片读取
}
使用hash后,相比上一个的后台区别,在于切片文件的命名, 其他不变
`${hash || fileName}-${chunkIndex}.chunk` // chunkName: 13143171616e4658-0.chcunk
如果遇到网络中断, 通过文件名+hash就可以准确的找到已上传的文件切片,无需再次重复上传
// 通过文件名加hash, 返回文件是否已经存在切片索引, hash是保证文件的正确性
const uploadedChunkResponse = await fetch(`http://localhost:3030/qryChunkExist?fileName=${selectFile.value.name}&hash=${fileHash.value}`)
uploadedChunks.value = await uploadedChunkResponse.json()
4. Web Worker + Hash + 切片上传
文章参考:# web-worker的基本用法并进行大文件切片上传
对于大体积的文件,使用Web Worker也是另一种方案。
Web Worker简介
Web Worker的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。
实现原理
- 将大文件切片上传的逻辑放在Web Worker中执行
- 利用浏览器的多线程能力,提高上传速度
onmessage = async function ({ data: { file, chunkSize, startIndex, endIndex } }) {
const arr = []
for (let i = startIndex; i < endIndex; i++) {
arr.push(createChunks(file, i, chunkSize))
}
const chunks = await Promise.all(arr)
// 提交线程信息
postMessage(chunks)
}
注: 在vite中使用Web Worker的规范
// 1. 通过构造器导入文件
const worker = new Worker(new URL('./worker.js', import.meta.url))
// 2. 通过查询后缀导入文件
import MyWorker from './worker?worker'
const worker = new MyWorker()
总结
目前文中的四种文件上传方式的具体项目源码,已经上传github中,欢迎大家比星👏🏻,如有不当,欢迎指正。