vue 大文件切片上传

612 阅读6分钟

大文件为什么要切片上传

  • 前端上传文件很大时,会出现各种问题,比如连接超时了,网断了,都会导致上传失败

  • 服务端限制了单次上传文件的大小

项目实际场景

  • 客户端需要上传一个算法包文件到服务器,这个算法包实测 3.7G

  • nginx配置文件 上传文件大小最大值为100M

切片上传原理

  • 通过file.slice将大文件chunks切成许多个大小相等的chunk

  • 将每个chunk上传到服务器

  • 服务端接收到许多个chunk后,合并为chunks

第一版

  • 先对文件按指定大小进行切片
 /**
   * file: 需要切片的文件
   * chunkSize: 每片文件大小,1024*1024=1M
   */
        chunkSlice(file, chunkSize) {
            const chunks = [],
                size = file.size,
                total = Math.ceil(size / chunkSize)
            for (let i = 0; i < size; i += chunkSize) {
                chunks.push({
                    total,
                    blob: file.slice(i, i + chunkSize),
                })
            }
            return chunks
        }
  • 处理切片后的文件,后端想要我传给他一个json对象,所以使用readAsDataURL读取文件

  • 这里使用了一个插件spark-md5来生成每个切片的MD5

        async handleFile(chunks) {
            const res = []
            for (const item of chunks) {
                const { bytes, md5 } = await this.addMark(item.blob)
                item.blob = bytes
                item.md5 = md5
                res.push(md5)
            }
            return res
        },
        // 使用FileReader读取每一片数据,并生成MD5编码
        async addMark(chunk) {
            return new Promise((resolve, reject) => {
                const reader = new FileReader()
                const spark = new SparkMD5()

                reader.readAsDataURL(chunk)
                reader.onload = function (e) {
                    const bytes = e.target.result
                    spark.append(bytes)

                    const md5 = spark.end()
                    resolve({ bytes, md5 })
                }
            })
        },
  • 组装数据,包括每一片的排列顺序index,总共切了多少片total,文件IDfileID,每一片的md5编码md5,每一片数据fileData
        mergeData(chunks) {
            const fileId = this.getUUID()
            const data = []
            for (let i = 0; i < chunks.length; i++) {
                const obj = {
                    fileId,
                    fileData: chunks[i].blob,//每片切片的数据
                    fileIndex: i + 1,//每片数据索引
                    fileTotal: chunks[i].total + '',
                    md5: chunks[i].md5,
                }

                data.push(obj)
            }
            return { data, fileId }
        },
  • 上传文件,这里使用并发上传文件,提升文件上传速度
const chunks = chunkSlice(file,1024*1024)
this.handleFile(chunks)
const data = this.mergeData(chunks)

for(let i = 0; i < data.length; i++){
    this.uplload(data[i])
}

第一版遇到的问题

  • 文件太大,切片太小,上传接口的timeout太短,并发请求时,全都在pendding,导致请求出错

第一版问题解决

  • 对上传文件接口的timeout修改,调整时长,大一点

  • 限制每次并发的数量,我用的是500个每次

第二版,切片 + web worker

  • 为什么要使用web worker

  • 在生成文件MD5编码时,需要读文件,是一个I/O操作,会阻塞页面,文件太大,导致页面卡死

  • 将耗时操作转移到worker线程,主页面就不会卡住

vue2,使用worker

  • yarn add worker-loader

image.png

  • vue.config.js 配置
    // vue.config.js
    chainWebpack(config) {
        config.module.rule('worker')
            .test(/\.worker\.js$/)
            .use('worker-loader')
            .loader('worker-loader')
            // .options({ inline: 'fallback' })// 这个配置是个坑,不要加
    },
  • 新建file.worker.js
// file.worker.js
import SparkMD5 from 'spark-md5'

const chunkSlice = (file, chunkSize) => {
    const chunks = [],
        size = file.size,
        total = Math.ceil(size / chunkSize)
    for (let i = 0; i < size; i += chunkSize) {
        chunks.push({
            total,
            blob: file.slice(i, i + chunkSize),
        })
    }
    return chunks
}
const handleFile = async (chunks) => {
    const res = []
    for (const item of chunks) {
        const { bytes, md5 } = await addMark(item.blob)
        item.blob = bytes
        item.md5 = md5
        res.push(md5)
    }
    return res
}
const addMark = (chunk) => {
    return new Promise((resolve, reject) => {
        const reader = new FileReader()
        const spark = new SparkMD5()

        reader.readAsDataURL(chunk)
        reader.onload = function (e) {
            const bytes = e.target.result
            spark.append(bytes)

            const md5 = spark.end()
            resolve({ bytes, md5 })
        }
    })
}
const mergeData = (chunks, fileName, options) => {
    const fileId = getUUID() // 这里更好的方式是读整个文件的 MD5
    const data = []
    for (let i = 0; i < chunks.length; i++) {
        const obj = {
            ...options,
            suffix: '.tar.gz',
            fileId,
            fileName,
            fileData: chunks[i].blob,
            fileIndex: i + 1 + '',
            fileTotal: chunks[i].total + '',
            md5: chunks[i].md5,
        }

        data.push(obj)
    }
    return { data, fileId }
}
const getUUID = () => {
    return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
        (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
    )
}
const dataSlice = (data, step, fileId) => {
    const total = Math.ceil(data.length / step)
    let index = 1
    for (let i = 0; i < data.length; i += step) {
        const params = {
            type: 'workerFile',
            index,
            total,
            fileId,
            data: data.slice(i, i + step),
        }
        self.postMessage(params)
        index++
    }
}
self.addEventListener('error', (event) => {
    console.log('worker error', event)
})

self.addEventListener('message', async (event) => {
    // 确保接受的是我想要的消息  
    if (!event.data.type) return
    if (event.data.type != 'file') return
    console.log('worker success', event)

    const { file, chunkSize } = event.data
    const chunks = chunkSlice(file, chunkSize)
    const allMD5 = await handleFile(chunks)
    console.log(allMD5)
    // 此处 allMD5 可用来做后续的断点续传
    const { data, fileId } = mergeData(chunks, file.name)
    
    // 这里对处理好的数据进行切片,分片传递给主线程,是由于 Web Worker 试图将大量数据复制到主线程中,会导致内存溢出。
    dataSlice(data, 100, fileId)

})

image.png

这个报错一般是在使用 JavaScript Web Worker 时出现的,通常是由于 Web Worker 试图将大量数据复制到主线程中,导致内存溢出所引起的。

  • 主进程使用
// xxx.vue文件
import Worker from '@/utils/worker/file.worker.js'


const worker = new Worker()
worker.postMessage({ type: 'file', file: this.curFile, chunkSize: 1024 * 1024 })

worker.onerror = (error) => {
    console.log('main error', error)
    worker.terminate()
}

const finalData = []
worker.onmessage = async (event) => {
    console.log('main success', event)
    if (event.data.type != 'workerFile') return

    const fileId = mergeWorkerData(finalData, event.data)
    if (fileId) {
        worker.terminate()

        const status = await stepLoad(finalData, 500)

        if (!status) {
            this.$message.error('文件上传失败')
        } else {
            this.$message.success('文件上传成功')

        }

    }
}

mergeWorkerData = (res, params) => {
    res.push(...params.data)
    return params.index == params.total ? params.fileId : false
}

const stepLoad = async (data, step) => {
    const res = []
    for (let i = 0; i < data.length; i += step) {
        res.push(data.slice(i, i + step))
    }
    for (const item of res) {
        const chunkRes = await Promise.all(item.map((v) => this.$api.upload(v)))
        if (chunkRes.some((v) => v.httpCode != 0)) {
            return false
        }

        const isEnd = chunkRes.filter((v) => v.finish)
        if (isEnd.length) {
            return true
        }
    }
}

第三版 - 优化

  • 项目中使用的是第一版,没有使用 worker

  • 第一版在公司内网测试时,一切正常,现场测试会出现内存过大,页面崩溃的问题

  • 上传的文件大小为 806M

  • 现网1.8g左右就会崩溃,公司测试使用的文件为2.4g 内存占用超3.5g都正常

  • 为什么现网内存不到2g就崩了呢?暂时不清楚,但是这个地方内存占用也太大了,赶快优化,解决崩溃

  • 经过一步一步的排查,发现吃内存的大头在handleFile这个函数

        async handleFile(chunks) {
            const res = []
            for (const item of chunks) {
                const { bytes, md5 } = await this.addMark(item.blob)
                item.blob = bytes
                item.md5 = md5
                res.push(md5)
            }
            return res
        },
        // 使用FileReader读取每一片数据,并生成MD5编码
        async addMark(chunk) {
            return new Promise((resolve, reject) => {
                const reader = new FileReader()
                const spark = new SparkMD5()

                reader.readAsDataURL(chunk)
                reader.onload = function (e) {
                    const bytes = e.target.result
                    spark.append(bytes)

                    const md5 = spark.end()
                    resolve({ bytes, md5 })
                }
            })
        },
  • 这个函数的作用是读取每一片文件数据,并将其转成base64,后端说他就要base64,还有每一片的md5

  • 吃内存的原因就在于这个函数保存了每一片数据转换成base64后的数据,相当于存了和副本

  • 修改为如下代码,只计算文件的md5,在上传的时候也不一次并发500条了,一条一条的来,在上传文件的时候才去将文件转换为base64

async handleFile(file) {
            return new Promise((resolve, reject) => {
                const chunkSize = 1024 * 1024
                const total = Math.ceil(file.size / chunkSize)

                const reader = new FileReader()
                const spark = new SparkMD5.ArrayBuffer()

                let start = 0
                let end = start + chunkSize

                reader.onload = function (e) {
                    console.log(
                        'index---',
                        start / (1024 * 1024),
                        '---start---',
                        start,
                        '---end---',
                        end,
                        '---size---',
                        file.size
                    )

                    spark.append(e.target.result)

                    if (end > file.size || end == file.size) {
                        const md5 = spark.end()
                        resolve({ total, md5 })
                    } else {
                        readFileChunks()
                    }
                }

                const readFileChunks = () => {
                    start += chunkSize
                    end = start + chunkSize

                    reader.readAsArrayBuffer(file.slice(start, end))
                }

                readFileChunks()
            })
        },
  • 后面的代码也有部分改动,给一份稍微完整点的代码,如下
const handleFile = async (file) => {
    return new Promise((resolve, reject) => {
        const chunkSize = 1024 * 1024
        const total = Math.ceil(file.size / chunkSize)

        const reader = new FileReader()
        const spark = new SparkMD5.ArrayBuffer()

        let start = 0
        let end = 0

        reader.onload = function (e) {
            spark.append(e.target.result)

            if (end > file.size || end == file.size) {
                const md5 = spark.end()
                resolve({ total, md5 })
            } else {
                readFileChunks()
            }
        }

        const readFileChunks = () => {
            start += chunkSize
            end = start + chunkSize

            reader.readAsArrayBuffer(file.slice(start, end))
        }

        readFileChunks()
    })
}
const stepLoad = async (options) => {
    const arr = new Array(options.fileTotal).fill(1).map((v, i) => i)
    const chunkSize = 1024 * 1024
    let start = 0
    let end = start + chunkSize
    for (const item of arr) {
        const res = await this.uptest(options, item, start, end)
        if (res.resultCode != 0) return false
        if (res.finish) return true

        start += chunkSize
        end = start + chunkSize
    }
}
const uptest = (options, item, start, end) => {
    return new Promise((resolve, reject) => {
        const red = new FileReader()

        red.onload = async (e) => {
            const params = {
                ...options,
                fileIndex: item + 1,
                fileData: e.target.result,
            }
            const res = await this.$api.uploadApi(params)
            resolve(res)
        }

        red.readAsDataURL(this.curFile.slice(start, end))
    })
}
const uploadFile = async () => {

    console.log('start...', new Date().getTime())
    const { md5, total } = await this.handleFile(this.curFile)
    console.log('fileReader...', new Date().getTime())

    const options = {
        fileId: md5,
        fileName: this.curFile.name,
        fileIndex: 1,
        fileTotal: total,
        fileData: '',
    }

    const state = await this.stepLoad(options)
    console.log(state)

    console.log('upload...', new Date().getTime())
}


uploadFile()

第四版 - worker - 优化

  • 第三版读取文件MD5耗时太长

  • 这一版加上worker,缩短读取文件时长

  • 我准备了一个10.5g的测试文件

  • 还是先看一下没有使用worker的代码的文件读取耗时

  • 目录结构

image.png

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="./sparkMD5.js"></script>
</head>

<body>
    <input type="file" id="input">
    <script>
        const input = document.getElementById('input')
        input.addEventListener('change', (e) => {
            const file = e.target.files[0]
            console.log('fileSize', Math.ceil(file.size / 1024 / 1024) + 'MB')

            const size = 1024 * 1024,
                total = Math.ceil(file.size / size)

            const option = { file, size, total }

            getFileMD5(option)
        })


        const getFileMD5 = ({ file, size, total }) => {
            const spark = new SparkMD5.ArrayBuffer(); //追加数组缓冲区。
            const fileReader = new FileReader()

            let sIndex = 0, eIndex = sIndex + size, count = 0;

            fileReader.onload = (e => {
                spark.append(e.target.result);

                count++
                if (count == total) {
                    const fileHash = spark.end();
                    console.log(fileHash);
                } else {
                    sIndex = eIndex
                    eIndex = sIndex + size
                    fileReader.readAsArrayBuffer(file.slice(sIndex, eIndex))
                }
            })
            fileReader.readAsArrayBuffer(file.slice(sIndex, eIndex))
        }

    </script>
</body>

</html>
  • 不使用worker花费136s,内存基本上不增加 image.png

  • 下面是使用worker的代码

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="./sparkMD5.js"></script>
</head>

<body>
    <input type="file" id="input">
    <script>
        const input = document.getElementById('input')
        input.addEventListener('change', (e) => {
            const file = e.target.files[0]
            console.log('fileSize', Math.ceil(file.size / 1024 / 1024) + 'MB')

            const size = 1024 * 1024,
                total = Math.ceil(file.size / size)

            const option = { file, size, total }

            getFileMD5_worker(option)
        })

        const getFileMD5_worker = ({ file, size, total }) => {
            const cpus = navigator.hardwareConcurrency || 4;//当前电脑cpu内核数量
            const threadSize = Math.ceil(total / cpus)

            for (let i = 0; i < cpus; i++) {
                const worker = new Worker('./worker.js')

                worker.postMessage({
                    file,
                    start: i * threadSize,
                    end: (i + 1) * threadSize > total ? total : (i + 1) * threadSize,
                    size
                })
                worker.onerror = (error) => {
                    console.log('main error', error)
                    worker.terminate()
                }
                worker.onmessage = (e) => {
                    console.log(e)
                    worker.terminate()
                }
            }
        }

    </script>
</body>

</html>
// worker.js

self.onmessage = (e) => {
    importScripts('sparkMD5.js')
    const spark = new SparkMD5.ArrayBuffer(); 

    const {
        file,
        start,
        end,
        size
    } = e.data

    let sIndex = start * size, eIndex = sIndex + size, count = 0;

    const fileReader = new FileReader()
    fileReader.onload = (e => {
        spark.append(e.target.result);

        count++
        if (start + count == end) {
            const fileHash = spark.end();
            self.postMessage(fileHash)
        } else {
            sIndex = eIndex
            eIndex = sIndex + size
            fileReader.readAsArrayBuffer(file.slice(sIndex, eIndex))
        }
    })

    fileReader.readAsArrayBuffer(file.slice(sIndex, eIndex))
}
  • 使用worker耗时22s,我的电脑16核,开了16个线程,内存增加1个g左右

image.png

总结

  • worker引入脚本或三方库可以使用importScript(),但是我没弄成功,一使用importScript()就会报错,Renference: importScript() xxxxxxxxxxxx,如果你们弄出来了,或者知道为什么,可以在下面留言

  • html原生代码里面使用importScript()正常,项目里面使用importScript(),会导致worker读取文件路径出错,所以需要插件解决读取路径问题

  • 减少worker线程数量会增加耗时,减少内存消耗,根据具体情况来选择要不要使用worker,开几个线程

文章参考

- # 使用 Web Workers

- # C V大法:让你用最简单的方式使用Vue2 + Web Worker + js-xlsx 解析excel数据

- # vue项目中worker的使用及worker内引入第三方库