Web端上传大文件的多种方式

536 阅读4分钟

文件上传在前台业务中还是很常见的, 尤其是报表系统或者图床系统。

今天介绍几种常见的文件上传方式。(基于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.chunkxxxx-1.chunk

 const chunkName = `${fileName}-${chunkIndex}.chunk`

后台合并原理

  1. 使用 fs.readdirSync(chunksDir) 同步读取切片目录中的所有文件名, 使用 sort 方法对文件名进行排序,确保文件按正确的顺序合并
  2. 使用 fs.createReadStream(chunkPath) 创建一个读取流,用于读取切片文件的内容。
  3. 使用 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 线程完成计算任务,再把结果返回给主线程。

实现原理

  1. 将大文件切片上传的逻辑放在Web Worker中执行
  2. 利用浏览器的多线程能力,提高上传速度
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中,欢迎大家比星👏🏻,如有不当,欢迎指正。

github.com/WCPing/web-…

image.png