发送大文件时,大家都会用的“切片和合并”技术

2,428 阅读5分钟

前言

大概有这么一个需求:

  1. 在发送大文件的时候,提高文件的发送速度,将时间最好控制在5分钟内。
  2. 增加进度条提示。

界面截图: 1639123029.jpg

总体思路:分片上传,且每个分片都都需要有个编号,以便后端去校验。因此,每一次分片上传,都需要上传该片段的chunk,以及chunkIndexchunkTotal,和整个文件的fileHash和。同时,前后端采用arrayBuffer的blob格式来进行文件传输。等后端收到所有的切片后,前端再去发起合并请求。(网上的文章都是大体思路)

image.png

image.png

show code

项目采用react + antd框架为主

1、上传组件得到fileList,可多选文件

<Upload className="uploadbtn" action={baseUrl + '/Email/send'} showUploadList={false} beforeUpload={this.handleBeforeUpload} multiple={true} >
 <span className="iconfont iconfujian"></span>添加附件                       
</Upload>

 byteConvert(bytes) {//字节转换
        if (isNaN(bytes)) {
            return '';
        }
        var symbols = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
        var exp = Math.floor(Math.log(bytes) / Math.log(2));
        if (exp < 1) {
            exp = 0;
        }
        var i = Math.floor(exp / 10);
        bytes = bytes / Math.pow(2, 10 * i);

        if (bytes.toString().length > bytes.toFixed(2).toString().length) {
            bytes = bytes.toFixed(2);
        }
        return bytes + ' ' + symbols[i];
 }

 handleBeforeUpload = (file) => {//上传附件
        let fileList = this.state.fileList
        let size = this.byteConvert(file.size)
        file.sizeC = size
        fileList.push(file)
        this.setState({
            fileList: fileList
        })
        return false
}
                                                      

得到的fileList是个数组,格式如下:

image.png

2、接着我们就可以开心的,对这fileList一系列疯狂输出了。

点击发送后

const DEFAULT_CHUNK_SIZE = 100 * 1024;
const MAX_CHUNK_COUNT = 30;

  //生成uuid,用于判断是哪次邮件的发送
    guuid() {
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
            var r = Math.random() * 16 | 0,
                v = c == 'x' ? r : (r & 0x3 | 0x8);
            return v.toString(16);
        });
    }

    //计算hash值
    computeHash = (fileChunks) => {
        return new Promise((resolve, reject) => {
            const hashWorker = new Worker('./hash.js'); //创建实例
            hashWorker.postMessage({ fileChunks }); //向work进程发送消息,发送被分片后的数组对象
            //主进程接受消息
            hashWorker.onmessage = (e) => {
                const { percentage, hash } = e.data;
                if (hash) {
                    resolve(hash);
                }
            };
        });
    }

    //创建区块(分成多少块)
    createChunks = (file, chunkSize = DEFAULT_CHUNK_SIZE) => {
        const fileChunkList = [];
        let cur = 0;
        while (cur < file.size) {
            fileChunkList.push({ fileChunk: file.slice(cur, cur + chunkSize) });
            cur += chunkSize;
        }
        return fileChunkList;
    }
    
    //定义区块的大小
    fileChunkSize(fileList) {
        if (fileList.length === 0) return;
        const chunkCount = Math.ceil(fileList.size / DEFAULT_CHUNK_SIZE);
        if (chunkCount > MAX_CHUNK_COUNT) {
            return Math.ceil(fileList.size / MAX_CHUNK_COUNT);
        } else {
            return DEFAULT_CHUNK_SIZE;
        }
    }
    
    //上传切片
    uploadChunks = async (chunks) => {
        let modifyNum = 0
        if (chunks.length < 1) return;
        this.setState({
            chunks: chunks
        })
        let reqList = chunks.map(({ chunk, chunkIndex, fileHash, chunkSize, filename, chunkTotal }) => {
            let formData = new FormData();
            formData.append('chunk', chunk);
            formData.append('chunkIndex', chunkIndex);
            formData.append('fileHash', fileHash);
            formData.append('filename', filename);
            formData.append('EMID', this.state.uuid);
            formData.append('chunkTotal', chunkTotal);
            formData.append('chunkSize', chunkSize);
            return { formData, chunkIndex };
        }).map(({ formData, chunkIndex }, index) => {
            return sendPartitionFile(formData).then(res => {
                if (res.code === 200) {
                    modifyNum ++
                    //设置进度条
                    this.setState({
                        stepPercent: Math.floor(modifyNum / chunks.length * 100)
                    })
                } else {
                    //如果code不为200,抛错误到外面的catch
                    throw '系统错误,请重发'
                }
            }).catch(err =>{
                //接受到错误,抛错误到外面的catch
                throw '系统错误,请重发'
            })
        });

        // 发送切片
        await Promise.all(reqList).then(res => {
        }).catch(err => {
            //接受到错误,在这里终止断js的进程程序,不再继续往下走了
            message.error('系统错误,请重发');
            this.setState({
                chunks: [],
                stepPercent: 0,
                saveEmailLoading: false // 点发送、存草稿按钮时的loading
            })
            throw '系统错误,请重发'
        });

        if (reqList.length === chunks.length) {
                // 发送合并请求
                await this.mergeRequest(this.state.uuid);
                this.setState({
                    saveEmailLoading: false // 点发送、存草稿按钮时的loading
                })
                message.success('发送成功');
                this.setState({
                    fileAffixId: '',
                    uuid: '',
                    chunks: [],
                    stepPercent: 0,
                    fileList: [],
                    receiptTreechecked: [],
                    carbonTreechecked: [],
                    receiptCheckedList: [],
                    receiptCheckedIDList: [],
                    carbonCheckedList: [],
                    carbonCheckedIDList: [],
                    themeTitle: '',
                    editorContent: '',
                    receiptSelectName: [], // 收件人展示框显示的机构和人员名称
                    carbonSelectName: [] // 抄送人展示框显示的机构和人员名称
                })
            }
        }
    
    //合并切片的请求
    mergeRequest = async (uuid) => {
        let data = {
            emid: uuid,
            affids: this.state.fileAffixId,
            title: this.state.themeTitle,
            content: this.state.editorContent,
            userid: store.getState().userId,
            username: store.getState().userName,
            receuser: null,
            receusername: null,
            copyuser: null,
            copyusername: null,
        }
        data.receusername = this.state.receiptCheckedList.join(',') // 人员的名字
        data.receuser = this.state.receiptCheckedIDList.join(',') // 收件人id
        data.copyusername = this.state.carbonCheckedList.join(',')
        data.copyuser = this.state.carbonCheckedIDList.join(',')

        return mergeFile(data).then(res => {
            console.log(res);
            if (res.code === 200) {
            } else {
                message.error(res.msg);
                this.setState({
                    saveEmailLoading: false // 点发送、存草稿按钮时的loading
                })
            }
        }).catch(err =>{
            message.error(res.msg);
            this.setState({
                saveEmailLoading: false // 点发送、存草稿按钮时的loading
            })
        })

    }
    
SendEmail = async (TYPE) => {//发送邮件
   let fileList = this.state.fileList;
   if (fileList.length > 0) {
            //循环整个fileList,对所有文件进行分片
            for (let i = 0; i < fileList.length; i++) {
                let chunkSize = this.fileChunkSize(fileList[i]);  //获得每个文件的总size
                const fileChunkList = this.createChunks(fileList[i], chunkSize); //每个文件分成多少块。返回一个对象数组。
                let fileHashRef = { current: {} };
                let fileAffixIdArr = this.state.fileAffixId.split(',')
                fileHashRef.current = await this.computeHash(fileChunkList); //计算hash值
                this.setState({
                    fileHash: fileHashRef.current
                })
                const primaryFileChunks = fileChunkList.map(
                    ({ fileChunk }, index) => ({
                        fileHash: fileHashRef.current,
                        chunk: fileChunk,
                        chunkIndex: `${index}`,
                        percent: 0,
                        chunkSize: chunkSize,
                        filename: fileList[i].name,
                        chunkTotal: fileChunkList.length
                    })
                );
                let chunkArr = [];
                chunkArr = primaryFileChunks.map(
                    ({ fileHash, chunk, chunkIndex, percent, filename, chunkTotal }) => ({
                        fileHash,
                        chunk,
                        chunkIndex,
                        percent: 100,
                        chunkSize: chunkSize,
                        filename: fileList[i].name,
                        chunkTotal: chunkTotal
                    })
                );
                chunkArrTotal = chunkArrTotal.concat(chunkArr);
            }
            await this.uploadChunks(chunkArrTotal);
        }
 }

3、函数讲解以及遇到的难点:

(1)guuid函数,主要是后端用来辨别你是哪一次发的邮件。

(2)computeHash函数,这里运用了Worker进程,因为js是单线程的,所有任务只能在一个线程上完成,一次只能做一件事。Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。(所以这里hash计算的过程交给了Worker 线程去处理。

(3)hash.js的代码如下:

/* eslint-disable no-restricted-globals */
/* eslint-disable @typescript-eslint/no-explicit-any*/
//selft用法是Worker进程的内置用法
self.importScripts('./spark-md5.min.js'); //引入了md5.js文件,读者可自行百度搜索文件
self.onmessage = (e) => {
	const { fileChunks } = e.data; //拿到从主进程发过来的消息
	const spark = new self.SparkMD5.ArrayBuffer();
	let percentage = 0,
		count = 0;
	const loadNext = (index) => {
		const reader = new FileReader();
		reader.readAsArrayBuffer(fileChunks[index].fileChunk);
		reader.onload = (e) => {
			count++;
			spark.append(e.target.result);
			if (count === fileChunks.length) {//直到遍历完后,返回文件的hash
				self.postMessage({
					percentage: 100,
					hash: spark.end(),
				});
				self.close();
			} else {
				percentage += 100 / fileChunks.length;
				self.postMessage({
					percentage,
				});

				loadNext(count);
			}
		};
	};
        //迭代遍历
	loadNext(0);
};

(4)fileChunkSize 函数定义每个分片的大小,如果觉得文件特别大,可以通过改变MAX_CHUNK_COUNT的数量,进而去改变分片的大小。

(5)难点:分片上传的时候,由于采用了map方法,所有的分片请求都并发上去了。那么问题了就来了,如果某一个切片出错,或者某一个切片的网络不好了呢。那咋办?读者肯定会说重新发送这个切片呗,这其实也可以做到哈!但我们这里的做法是直接抛错,然后中止运行程序,不让程序继续往下走了。 有个骚操作,通过throw 一层一层的往外抛,等抛到promise.all的时候的catch捕捉之后,就会终止程序的运行。后面不会再发送合并切片的请求了。(throw和promise.all 完美结合)


reqList.map(({ formData, chunkIndex }, index) => {
            return sendPartitionFile(formData).then(res => {
                if (res.code === 200) {
                    modifyNum ++
                    //设置进度条
                    this.setState({
                        stepPercent: Math.floor(modifyNum / chunks.length * 100)
                    })
                } else {
                    //如果code不为200,抛错误到外面的catch
                    throw '系统错误,请重发'
                }
            }).catch(err =>{
                //接受到错误,抛错误到外面的catch
                throw '系统错误,请重发'
            })
        });
         // 发送切片
        await Promise.all(reqList).then(res => {
        }).catch(err => {
            //接受到错误,在这里终止断js的进程程序,不再继续往下走了
            message.error('系统错误,请重发');
            this.setState({
                chunks: [],
                stepPercent: 0,
                saveEmailLoading: false // 点发送、存草稿按钮时的loading
            })
            throw '系统错误,请重发'
        });

(6)bug: 用了Worker进程,会报错误Uncaught SyntaxError: Unexpected token <错误。针对react项目的代码可能需要经过webpack打包。所以引入外部worker.js可能导致路径不对,可以将外部worker.js放在项目根目录,和html同一个目录,这样创建worker的时候就不会报错了!测试有效。