前端文件上传

  主要记录一下多文件上传(一次请求)、分片和断点续传。

多文件上传

  多文件上传一般可以通过两种方式实现,一是多请求,即发送多次上传请求,每次请求只携带一个文件;二是单请求,即一次请求携带所有需上传的文件。

  下面主要介绍单请求方式的实现。相比于多次请求,单请求可以较简单的统计上传进度,简洁的传递额外参数(无需重复传递),但单请求需要更复杂的异常处理。

前端代码

handleFileChange(event) {
    console.log(event.target.files);
    let postFiles = Array.prototype.slice.call(event.target.files);
    this.uploadAction(postFiles);
},
uploadAction(files) {
    const formData = new FormData();
    files.forEach((file, index) => {
        // 这里的参数名一定要和后端统一
        // files 会是一个数组
        formData.append("files", file);
        
        // 如果文件需要区分不同文件,可以为文件设置不同的参数名
        // 但一定要跟后端统一接收文件的参数名
        // formData.append("files" + index, file);
    });

    const xhr = new XMLHttpRequest();

    xhr.open("POST", "http://127.0.0.1:8888/multipleFile");
    xhr.upload.onprogress = (event) => {
        if (event.total > 0) {
            this.percentage = parseInt(
                (event.loaded / event.total) * 100,
                10
            );
        }
    };

    xhr.send(formData);
    // 可以用来取消上传
    this.xhr = xhr;
},

服务端代码

const formidable = require('formidable');
const fs = require('fs');

// 保存文件
const saveFile = function (file) {
    return new Promise((resolve, reject) => {
        if (file && file.size > 0) {
            // 组装文件的存储地址
            const file_path = './public/' + new Date().getTime() + file.name;

            // 为文件改名字,同时也改了存储位置
            // 注意 rename 方法不支持跨盘移动,比如不能从 C 盘转存到 D 盘
            fs.rename(file.path, file_path, (err) => {
                if (err) {
                    reject({
                        name: file.name,
                        message: '文件保存失败',
                        status: 'failed',
                        error: err
                    });
                } else {
                    resolve({
                        url: `http://127.0.0.1:8888${file_path.slice(1)}`,
                        name: file.name,
                        size: file.size,
                        status: 'success',
                    });
                }
            });
        }
    })
}

// 多文件上传,也支持单文件
const handleUpload = function (request, response) {
    let form = new formidable.IncomingForm({
        maxFileSize: 2 * 1024 * 1024 * 1024, // 文件最大字节
        multiples: true, // 允许多文件
        keepExtensions: true, // 保留文件后缀
        allowEmptyFiles: false, // 不允许上传空文件
    });
    form.parse(request, (err, fields, files) => {
        if (err) {
            response.status(400).send({
                message: '参数解析失败:' + err,
                status: 400
            });
            return;
        }
        if (Array.isArray(files.files)) {
            let saveFileProimseList = [];
            files.files.forEach((file) => {
                saveFileProimseList.push(saveFile(file));
            })

            // saveFileProimseList 中所有的 Promise 结束
            Promise.allSettled(saveFileProimseList).then(results => {
                let successful = [], failed = [];
                results.forEach(result => {
                    if (result.status === 'rejected') {
                        failed.push(result.reason);
                    } else {
                        successful.push(result.value);
                    }
                })
                if (successful.length > 0) {
                    response.status(200).send({
                        successful,
                        failed,
                        message: `${successful.length} 个文件上传成功,${failed.length} 个文件上传失败`,
                        status: 200
                    });
                } else {
                    response.status(400).send({
                        successful,
                        failed,
                        message: '文件上传失败',
                        status: 400
                    });
                }
            })
        } else if (files.files) {
            saveFile(files.files)
                .then(res => {
                    response.status(200).send({
                        successful: [res],
                        failed: [],
                        message: '文件上传成功',
                        status: 200
                    });
                })
                .catch(err => {
                    response.status(400).send({
                        successful: [],
                        failed: [err],
                        message: '文件上传失败',
                        status: 400
                    });
                })
        } else {
            response.status(400).send({
                message: '未接收到文件',
                status: 400
            });
        }
    })
}

分片和断点续传

  上传大文件的时候往往需要用到分片和断点续传。

  所谓分片,就是将一个大的文件分割成一片片小的文件块,然后分别上传,服务端在接收到所有的文件块之后,再将其按照正确的次序合并成一个完成的文件。分片上传可以利用浏览器的并发请求,提升文件的上传速率。

  断点续传是分片上传功能上的一个延伸,其原理是前端或后端记住已经上传的文件块,然后下次上传的时候过滤掉已经上传的,只上传未上传的块,已达到续传的目的。断点续传主要是为了避免大文件上传过程,意外或主动中断后,复传时文件块重复上传的问题。

文件分片

  File 对象继承自 Blob 对象,所以可以直接用 slice() 方法截取文件块,该方法返回一个新的Blob 对象。

createFileChunk(file, chunkSize = SIZE) {
    let start = 0,
        total = file.size,
        chunkList = [];

    while (start < total) {
        chunkList.push(file.slice(start, start + chunkSize));
        start += chunkSize;
    }

    return chunkList;
},

计算文件 hash

  计算 hash 主要是为了区分文件,下面的代码将演示如何利用 spark-md5 计算文件的 hash。另外,计算大文件的 hash 是一个比较耗时的操作,为了避免页面的卡顿,可以使用工作者线程(web-worker)执行计算 hash 的操作。

hash.js

// web-worker
self.importScripts('spark-md5.min.js')

self.onmessage = e => {
    // 接受主线程的通知
    const { chunkList } = e.data;
    const spark = new self.SparkMD5.ArrayBuffer();
    let progress = 0, count = 0;

    const loadNext = index => {
        const reader = new FileReader();
        reader.readAsArrayBuffer(chunkList[index].file)
        reader.onload = e => {
            // 累加器 不能依赖index,
            count++;
            // 增量计算 md5
            spark.append(e.target.result);
            if (count === chunkList.length) {
                // 通知主线程,计算结束
                self.postMessage({
                    progress: 100,
                    hash: spark.end()
                });
            } else {
                // 每个区块计算结束,通知进度即可
                progress += 100 / chunkList.length;
                self.postMessage({ progress });
                // 计算下一个
                loadNext(count);
            }
        }
    }
    // 启动
    loadNext(0);
}

主线程调用

async calculateHash(chunkList) {
    return new Promise((resolve) => {
        this.container.workder = new Worker("/hash.js");
        this.container.workder.postMessage({ chunkList });
        this.container.workder.onmessage = (e) => {
        	// 监听工作者线程发来的消息
            const { progress, hash } = e.data;
            this.hashProgress = Number(progress.toFixed(2));
            if (hash) {
                resolve(hash);
            }
        };
    });
},

并发控制

  同一域名下的最大请求并发数是 6 个,超出的请求不会被发送,而是加入到请求队列中等待。大文件的分块是往往是较多的,如果不做并发控制,很容易会占满并发数,导致页面上的其他请求无法发送,数据无法刷新。

async sendRequest(urls, max = 4 /** 最大并发数 */) {
	// 续传时,所有的块都已经上传了
	if (urls.length === 0) return Promise.resolve();
	
    return new Promise((resolve, reject) => {
        const len = urls.length;
        let counter = 0;
        const start = async () => {
            // 有请求,有通道
            while (counter < len && max > 0) {
                max--; // 占用通道
                const req = urls.find((u) => u.status === Status.wait);
                if (req === undefined) break;

                req.status = Status.uploading;
                const { form, index } = req;
                request({
                    url: "/chunkUpload",
                    data: form,
                    onProgress: this.createProgresshandler(
                        this.chunkList[index]
                    ),
                    requestList: this.requestList,
                })
                    .then(() => {
                        req.status = Status.done;
                        max++; // 释放通道
                        counter++;
                        if (counter === len) {
                            console.log("resolve", "请求结束");
                            resolve();
                        } else {
                            start();
                        }
                    })
                    .catch(() => {
                        req.status = Status.error;
                        urls.forEach((item) => {
                            // 已经出错,未上传的就不要上传了
                            if (item.status === Status.wait) {
                                item.status = Status.error;
                            }
                        });
                        this.chunkList[index].progress = -1; // 报错的进度条
                        max++; // 释放当前占用的通道,但是counter不累加
                        // 还需终止其他的
                        return reject();
                    });
            }
        };
        start();
    });
},

文件上传

  整个上传流程如下:创建文件切片、计算文件 hash、获取已上传文件块列表、筛选出未上传的文件块并将其上传、请求合并文件。

// 判断文件是否存在,如果不存在,获取已经上传的切片
async verify(filename, hash) {
    const data = await post("/chunkVerify", { filename, hash });
    return data;
},

// 合并文件
async mergeRequest() {
    await post("/chunkMerge", {
        filename: this.container.file.name,
        size: SIZE,
        fileHash: this.container.hash,
    });
},

async uploadChunks(uploadedList = []) {
    const list = this.chunkList
        // 筛选出还没有上传的文件块
        .filter((chunk) => uploadedList.indexOf(chunk.hash) == -1)
        .map(({ chunk, hash, index }) => {
            const form = new FormData();
            form.append("chunk", chunk);
            form.append("hash", hash);
            form.append("filename", this.container.file.name);
            form.append("fileHash", this.container.hash);
            return { form, index, status: Status.wait };
        });
    this.sendRequest(list, 4)
        .then(async () => {
            if (
                uploadedList.length + list.length ===
                this.chunkList.length
            ) {
                // 上传和已经存在之和 等于全部的再合并
                await this.mergeRequest();
            }
        })
        .catch(() => {
            // 上传有被reject的
            console.log("亲 上传失败了,考虑重试下呦");
        });
},

async handleUpload() {
    if (!this.container.file) return;

    // 创建切片
    const chunkList = this.createFileChunk(this.container.file);

    // 计算哈希
    this.container.hash = await this.calculateHash(chunks);

    const { uploaded, uploadedList } = await this.verify(
        this.container.file.name,
        this.container.hash
    );

    if (uploaded) {
        return console.log("秒传:上传成功");
    }
    this.chunkList = chunkList.map((chunk, index) => {
        const chunkName = this.container.hash + "-" + index;
        return {
            fileHash: this.container.hash,
            chunk: chunk,
            index,
            hash: chunkName,
            progress: uploadedList.indexOf(chunkName) > -1 ? 100 : 0,
            size: chunk.size,
        };
    });
    // 传入已经存在的切片清单
    await this.uploadChunks(uploadedList);
},

终止和恢复上传

  通过取消请求就可以实现终止上传的功能,而恢复上传其实和续传一个道理,获取已上传的列表,然后将没有上传的文件块上传即可。

// 终止上传
handlePause() {
	this.requestList.forEach((item) => {
        item && item.abort();
    });
    this.requestList = [];
}

// 恢复上传
async handleResume() {
    const { uploadedList } = await this.verify(
        this.container.file.name,
        this.container.hash
    );
    await this.uploadChunks(uploadedList);
},

参考:https://juejin.cn/post/6844904055819468808
demo: https://gitee.com/liuyuechuliu/file-upload

你可能感兴趣的:(JavaScript,前端,javascript,node.js)