前端大文件分段上传;控制接口并发数量

说明:使用axios方式上传,文件不能过大,因为过多的连续Ajax请求会使后台崩溃,接口报错;所以使用分段上传的方式,减轻服务器的压力。其实就是将 文件变小,也就是通过 文件资源分块 后再上传。

问题 1:谁负责资源分块?谁负责资源整合?
前端负责分块,服务端负责整合.

问题 2:前端怎么对资源进行分块?
首先是选择上传的文件资源,接着就可以得到对应的文件对象 File,而 File.prototype.slice 方法可以实现资源的分块,当然也有人说是 Blob.prototype.slice 方法,因为 Blob.prototype.slice === File.prototype.slice.

问题 3:服务端怎么知道什么时候要整合资源?如何保证资源整合的有序性?
由于前端会将资源分块,然后单独发送请求,也就是说,原来 1 个文件对应 1 个上传请求,现在可能会变成 1 个文件对应 n 个上传请求,所以前端可以基于 Promise.all 将这多个接口整合,上传完成在发送一个合并的请求,通知服务端进行合并。

在发送请求资源时,前端会定好每个文件对应的序号,并将当前分块、序号以及文件 hash 等信息一起发送给服务端,服务端在进行合并时,通过序号进行依次合并即可。

此示例是纯前端代码,不涉及后端。
录屏.gif
第一步:使用input或者antd_upload获取文件
image.png
第二步:调接口获取文件段数,分段列表和分段尺寸;使用slice方法,分段读取文件为blob
   let dataMsg = await createMultipart({
       fileSize: file.size, // 传参数
       filename: file.name
   }).then(
       (rem) => {
           return rem.data;
       },
       (err) => {
           return upFailed(file, onUpload); // 如果接口报错,使用upFailed方法处理
       }
   );
   let urlList = dataMsg?.parts || []; // 分段列表
   let DEFAULT_SIZE = dataMsg?.partSize; // 分段尺寸

   for (let i = 0; i < urlList.length; i++) {
       let url = urlList[i]['url'];
       let fname = encodeURIComponent(file.name);
       let start = i * DEFAULT_SIZE;
       let stepFile;
       if (i === urlList.length - 1) {
          // 使用slice方法,分段读取文件为blob 
           stepFile = file.slice(start, -1); // 如果是最后一段,直接截取剩下的所有内容
       } else {
           stepFile = file.slice(start, start + DEFAULT_SIZE); // 分割文件
       }
       urlList[i]['stepFile'] = stepFile;
       urlList[i]['fname'] = fname;
       urlList[i]['uid'] = file.uid;
   }

urlList已准备好

image.png

数据说明: {
fname: '使用encodeURIComponent 编码过的文件名',
partNumber: '段数序号,合并时候使用',
stepFile: '截取的文件'
uid: 'antd组件生成的文件唯一值',
url: '上传该段文件的路径'
};

第三步:循环urlList,上传每一段文件

准备工作:单个文件上传方法

 const detalItem = ({ url, stepFile, fname, partNumber }) => {
        return new Promise((resolve, reject) => {
            fileAxios({
                url,
                method: 'PUT',
                data: stepFile,
                headers: {
                    'Content-Type': '',
                    'Content-disposition': `filename*=utf-8\'zh_cn\'${fname}`
                }
            })
                .then((res) => {
                    let str = res.headers.etag.split('"').join('');
                    resolve({ eTag: str, partNumber });
                })
                .catch((err) => {
                    reject({ eTag: '', partNumber });
                });
        });
    };


准备工作:并发上传,控制每次上传的接口数量,防止上传接口数量过多,浏览器崩溃。

参数说明:
poolLimit(数字类型):表示限制的并发数;
array(数组类型):表示任务数组;
iteratorFn(函数类型):表示迭代函数,用于实现对每个任务项进行处理,该函数会返回一个 Promise 对象或异步函数;
onUpload: 进度条

async function asyncPool(poolLimit, array, iteratorFn, onUpload) {
    const ret = []; // 存储所有的异步任务
    const executing = []; // 存储正在执行的异步任务
    for (const item of array) {
        // 结束运行
        if (endExecution.end && endExecution.uid === item?.uid) {
            return;
        }

       --------重点开始---------------
        // 调用iteratorFn函数创建异步任务
        const p = Promise.resolve().then(() => iteratorFn(item, array));
        ret.push(p); // 保存新的异步任务

        // 当poolLimit值小于或等于总任务个数时,进行并发控制
        if (poolLimit <= array.length) {
            // 当任务完成后,从正在执行的任务数组中移除已完成的任务
            // e 是个promise 。其后续的then接受的回调是 “自杀”,给executing 这个数组腾出空位
            const e = p.then(() => executing.splice(executing.indexOf(e), 1));
            executing.push(e); // 保存正在执行的异步任务
            if (executing.length >= poolLimit) {
                await Promise.race(executing); // 等待较快的任务执行完成
            }
        }
       --------重点结束---------------

       // 进度条
        onUpload &&
            onUpload({
                loaded: ret.length < array.length ? ret.length : ret.length - 1, // 等到接口合并完成,再返回100%
                total: array.length,
                uid: item['uid'],
                endAction: endAction // 如果用户删除文件,调用此函数,结束文件上传
            }); // 进度条
    }
    return Promise.all(ret);  // 集合多个返回结果
}

整合方法,开始上传

 let etags = [];
    try {
        etags = await asyncPool(5, urlList, detalItem, onUpload);  // 重点
    } catch {
        // 上传失败
        etags = [];
        endExecution.end = true;
        endExecution.uid = file?.uid;
        file['url'] = '';
        file['link'] = '';
        file['attachmentID'] = '';
        return upFailed(file, onUpload);
    }

请求中,保证5条并发数,如果5条中有请求结束了,自动补上


image.png

创建请求,请求全部发出,结束后合并文件


image.png

文件上传完的结果,etags。
eTag是每段文件的唯一值,
partNumber: 文件顺序。后端根据这个数据表来合并文件,避免顺序乱了。


image.png
第四步: 通知后端,合并文件
 let params = {
        attachmentID: dataMsg?.attachmentID,
        uploadID: dataMsg?.uploadID
    };

    if (endExecution.end && endExecution.uid === file?.uid) {
        console.log('删除文件,结束上传,调用结束上传接口,后端清除已经上传的数据');
        cancelMultipart(params);
        file['url'] = '';
        file['link'] = '';
        file['attachmentID'] = '';
        return { file: file, upResult: '' };
    }
      --------重点开始---------------
      // 调接口 传参数
      result = await completeMultipart({
            ...params,
            etags
        });
        if (etags) {
            onUpload &&
                onUpload({
                    loaded: 100,
                    total: 100,
                    uid: file['uid'],
                    endAction: endAction
                }); // 进度条
        }
        let presignedURL = result?.data?.presignedURL;
        // console.log('result', result, 'presignedURL', presignedURL);
        file['url'] = presignedURL;
        file['link'] = presignedURL;
        file['attachmentID'] = dataMsg?.attachmentID;
        file['status'] = 'done';

        --------重点结束---------------
        return { file: file, upResult: '' };

附加功能:
1 返回进度条onUpload,原理: 当前发出去的请求数,除以总条数
2 结束请求endAction,应用场景,文件正在上传中,删除文件,结束接口调用

全部代码:

React上传组件:

import React, { Component } from 'react';
import { Upload, Progress, Tooltip, Modal } from 'antd';
const { Dragger } = Upload;
export default class List extends Component {
    constructor(props) {
        super(props);
        this.state = {
            fileList: [
                // {
                //     uid: '-1',
                //     name: 'image.png',
                //     status: 'done',
                //     url:
                //         'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'
                // }
            ],
            visible: false,
            upLoading: false
        };
        this.formRef = React.createRef();
        this.littleRef = React.createRef();
        this.departmentRef = React.createRef();
        this.smalledepartmentRef = React.createRef();
    }
 // 进度条
  handleProgress = (progressEvent) => {
        const num = (progressEvent.loaded / progressEvent.total) * 100;
        let percent = num >= 100 ? 100 : num.toFixed(2) * 1;
        const { fileList } = this.state;

        this[`${progressEvent?.uid}_up`] = progressEvent;

        // if (progressEvent.loaded > 5) {
        //     progressEvent.endAction();
        // }

        this.setState({
            fileList: fileList.map((p) => {
                if (p?.uid === progressEvent?.uid) {
                    p['percent'] = percent;
                }
                return p;
            })
        });
    };
    // 删除文件
    onRemove = (file) => {
        if (!file?.status) {
            // 删除正在上传的文件,结束调用
            this[`${file?.uid}_up`] &&
                this[`${file?.uid}_up`]?.endAction &&
                this[`${file?.uid}_up`]?.endAction(file?.uid);
        }
        const { fileList } = this.state;
        let newList = fileList.filter((p) => p.uid !== file.uid);
        let loading = false;
        for (let v of newList) {
            if (!v?.status) {
                loading = true;
                break;
            }
        }
        this.setState({
            fileList: newList,
            upLoading: loading
        });
    };
    beforeUpload = (file, fileLists) => {
        console.log('打印file:', file);
        let repeat = [...this.state.fileList, ...fileLists];
        let obj = {};
        let noRepeat = repeat.reduce((pur, item) => {
            if (!obj[item?.uid]) {
                obj[item?.uid] = true;
                pur.push(item);
            }
            return pur;
        }, []);
        this.setState({ fileList: noRepeat, upLoading: true });
        commonUpload({ file, onUpload: this.handleProgress })
            .then((rem) => {
                const { fileList } = this.state;
                var data = {};
                for (var key in rem.file) {
                    data[key] = rem.file[key];
                }

                let newFilelist = fileList
                    .map((p) => {
                        if (p) {
                            if (p?.uid === data?.['uid']) {
                                p = { ...p, ...data };
                            }
                            return p;
                        }
                    })
                    .filter((p) => p?.status !== 'error');
                if (isNotEmpty(rem.file)) {
                    this.setState({
                        fileList: newFilelist
                    });
                }
            })
            .finally(() => {
                const { fileList } = this.state;
                // 批量上传完成,关闭loading
                let flag = true;
                for (let item of fileList) {
                    if (!item?.status) {
                        flag = false;
                        break;
                    }
                }
                flag && this.setState({ upLoading: false });
                // console.log('this.state.fqwFile', JSON.parse(this.state.fqwFile));
            });
        // 阻止默认上传
        return false;
      };
    render(){
             <Dragger
                           fileList={fileList}
                          className="drag-uploader"
                            onPreview={this.handlePreview} // 点击文件链接或预览图标时的回调
                            onRemove={this.onRemove}
                            multiple={true} // 支持多个文件一起上传
                          // onChange={this.onfileChange}
                          itemRender={(originNode, file, currFileList) => (
                           <UploadListItem
                                            originNode={originNode}
                                            file={file}
                                            currFileList={currFileList}
                                            fileList={fileList}
                                        />
                                    )}
                                    beforeUpload={this.beforeUpload}
                                    showUploadList={{
                                        showPreviewIcon: false,
                                        downloadIcon: true
                                    }}
                          >
                                    {fileList.length >= 15 ? null : UploadButton}
                          </Dragger>
}


进度条uploadListItem.jsx文件

/*
 * @desc   文件上传,自定义上传列表项, 带进度条
 * @author fqw
 */

import React, { Component } from 'react';
import { Progress, Tooltip } from 'antd';
import Cns from 'classnames';
import './index.scss';

const UploadListItem = ({ originNode, file, current, fileList }) => {
    const errorNode = <Tooltip title={file['response']}>{originNode.props.children}</Tooltip>;
    let have = file.percent < 100;
    return (
        <div
            className={Cns('ant-upload-draggable-list-item', have && 'progressIng')}
            style={{ cursor: 'move' }}
            key={file.percent}
        >
            {file.status === 'error' ? errorNode : originNode}
            {have && <Progress style={{ width: '100px' }} percent={file.percent} />}
        </div>
    );
};
export default UploadListItem;

fileAxios.js文件

import { message } from 'antd';
import axios from 'axios';
import { cancelMultipart } from './common';
import {
    getUserPresignedurl,
    submitFileMsg,
    createMultipart,
    completeMultipart
} from 'services/common';

// 结束运行
let endExecution = {
    end: false,
    uid: ''
};
let endAction = (uid) => {
    endExecution.end = true;
    endExecution.uid = uid;
};
let upFailed = (file, onUpload) => {
    endExecution.end = true;
    endExecution.uid = file?.uid;
    file['status'] = 'error';
    file['response'] = '上传失败,请重试';
    message.warning({
        content: `文件 ${file.name} 上传失败,请重试`,
        duration: 5
    });
    onUpload &&
        onUpload({
            loaded: 1, // 结束进度条,不显示
            total: 1,
            uid: file['uid'],
            endAction: endAction
        }); // 进度条
    return { file, upResult: false };
};
// 普通上传
const uploadFile = async (file, onUpload) => {
    // 获取上传接口的路径
    let urlRest = await getUserPresignedurl({ filename: file.name, fileSize: file.size }).then(
        (rem) => {
            if (rem.status === 200) {
                // file['uid'] = rem.data['attachmentID'];
                file = Object.assign(file, rem.data);
                return rem.data;
            }
        }
    );
    // 获取文件类型
    let fileType = file.name.split('.').slice(-1)[0];
    let typesObj = {
        jpg: 'image/jpeg',
        jpe: 'image/jpeg',
        jpeg: 'image/jpeg',
        png: 'image/png',
        gif: 'image/gif',
        bmp: 'application/x-bmp',
        wbmp: 'image/vnd.wap.wbmp',
        ico: 'image/x-icon',
        pdf: 'application/pdf',
        ppt: 'application/x-ppt',
        doc: 'application/msword',
        xls: 'application/vnd.ms-excel'
    };
    let url = urlRest.presignedURL;
    const fileAxios = axios.create();
    let fname = encodeURIComponent(file.name);
    let upBool = false;
    upBool = await fileAxios({
        url,
        method: 'PUT',
        data: file,
        headers: {
            'Content-Type': typesObj[fileType] || '',
            'Content-disposition': `filename*=utf-8\'zh_cn\'${fname}`
        },
        onUploadProgress: (arg) => {
            arg.uid = file.uid;
            onUpload(arg);
        }
    })
        .then((res) => {
            return res.status === 200;
        })
        .catch((err) => {
            return false;
        });
    // 上传失败,结束运行
    if (!upBool) {
        return upFailed(file, onUpload);
    }
    // 获取文件下载或预览链接
    let upResult = await submitFileMsg({
        filename: file.name,
        fileSize: file.size,
        attachmentID: urlRest.attachmentID
    }).then(
        (rem) => {
            file['url'] = rem.data['link'];
            file['status'] = 'done';
            file['attachmentID'] = urlRest.attachmentID;
            file = Object.assign(file, rem.data);
            return true;
        },
        (err) => {
            return false;
        }
    );
    let copy = JSON.parse(JSON.stringify(file));
    copy['name'] = file.name;
    return { file: copy, upResult };
};

// poolLimit(数字类型):表示限制的并发数;
// array(数组类型):表示任务数组;
// iteratorFn(函数类型):表示迭代函数,用于实现对每个任务项进行处理,该函数会返回一个 Promise 对象或异步函数
// onUpload: 进度条

async function asyncPool(poolLimit, array, iteratorFn, onUpload) {
    const ret = []; // 存储所有的异步任务
    const executing = []; // 存储正在执行的异步任务
    for (const item of array) {
        // 结束运行
        if (endExecution.end && endExecution.uid === item?.uid) {
            console.log('结束上传0');
            return;
        }
        // 调用iteratorFn函数创建异步任务
        const p = Promise.resolve().then(() => iteratorFn(item, array));
        ret.push(p); // 保存新的异步任务

        // 当poolLimit值小于或等于总任务个数时,进行并发控制
        if (poolLimit <= array.length) {
            // 当任务完成后,从正在执行的任务数组中移除已完成的任务
            const e = p.then(() => executing.splice(executing.indexOf(e), 1));
            executing.push(e); // 保存正在执行的异步任务
            if (executing.length >= poolLimit) {
                await Promise.race(executing); // 等待较快的任务执行完成
            }
        }

        onUpload &&
            onUpload({
                loaded: ret.length < array.length ? ret.length : ret.length - 1, // 等到接口合并完成,再返回100%
                total: array.length,
                uid: item['uid'],
                endAction: endAction
            }); // 进度条
    }
    return Promise.all(ret);
}

// 分段上传
const multiPartUpload = async (file, onUpload = null) => {
    // 获取段数
    let dataMsg = await createMultipart({
        fileSize: file.size, // 传参数
        filename: file.name
    }).then(
        (rem) => {
            return rem.data;
        },
        (err) => {
            return upFailed(file, onUpload); // 如果接口报错,使用upFailed方法处理
        }
    );
    let urlList = dataMsg?.parts || []; // 分段列表
    let DEFAULT_SIZE = dataMsg?.partSize; // 分段尺寸

    for (let i = 0; i < urlList.length; i++) {
        let url = urlList[i]['url'];
        let fname = encodeURIComponent(file.name);
        let start = i * DEFAULT_SIZE;
        let stepFile;
        if (i === urlList.length - 1) {
            stepFile = file.slice(start, -1); // 如果是最后一段的话,直接截取剩下的所有内容
        } else {
            stepFile = file.slice(start, start + DEFAULT_SIZE); // 分割文件
        }
        urlList[i]['stepFile'] = stepFile;
        urlList[i]['fname'] = fname;
        urlList[i]['uid'] = file.uid;
    }

    const fileAxios = axios.create();

    const detalItem = ({ url, stepFile, fname, partNumber }) => {
        return new Promise((resolve, reject) => {
            fileAxios({
                url,
                method: 'PUT',
                data: stepFile,
                headers: {
                    'Content-Type': '',
                    'Content-disposition': `filename*=utf-8\'zh_cn\'${fname}`
                }
            })
                .then((res) => {
                    let str = res.headers.etag.split('"').join('');
                    resolve({ eTag: str, partNumber });
                })
                .catch((err) => {
                    reject({ eTag: '', partNumber });
                });
        });
    };

    let etags = [];
    try {
        etags = await asyncPool(5, urlList, detalItem, onUpload);
    } catch {
        // 上传失败
        etags = [];
        endExecution.end = true;
        endExecution.uid = file?.uid;
        file['url'] = '';
        file['link'] = '';
        file['attachmentID'] = '';
        return upFailed(file, onUpload);
    }
    let params = {
        attachmentID: dataMsg?.attachmentID,
        uploadID: dataMsg?.uploadID
    };

    if (endExecution.end && endExecution.uid === file?.uid) {
        cancelMultipart(params);
        file['url'] = '';
        file['link'] = '';
        file['attachmentID'] = '';
        return { file: file, upResult: '' };
    }

    let result = null;
    // console.log('etags', etags);
    // 上传完合并文件
    try {
        result = await completeMultipart({
            ...params,
            etags
        });
        if (etags) {
            onUpload &&
                onUpload({
                    loaded: 100,
                    total: 100,
                    uid: file['uid'],
                    endAction: endAction
                }); // 进度条
        }
        let presignedURL = result?.data?.presignedURL;
        // console.log('result', result, 'presignedURL', presignedURL);
        file['url'] = presignedURL;
        file['link'] = presignedURL;
        file['attachmentID'] = dataMsg?.attachmentID;
        file['status'] = 'done';
    } catch {
        file['url'] = '';
        file['link'] = '';
        file['attachmentID'] = '';
        return upFailed(file, onUpload);
    }
    let copy = JSON.parse(JSON.stringify(file));
    copy['name'] = file.name;
    return { file: copy, upResult: '' };
};

export const commonUpload = ({ file, onUpload }) => {
    let fileSize = 100; // 100M
    if (file.size / 1024 / 1024 > fileSize) {
        // 当文件大于100M采用分段上传;
        return multiPartUpload(file, onUpload);
    } else {
        return uploadFile(file, onUpload);
    }
};

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容