前端性能优化之大文件上传

3,263 阅读3分钟

如果不做处理面临的问题

  1. 文件过大,用intpu type='file'上传的时候,是一次性加载进入内存,会内存溢出;
  2. 如果文件体积比较大,或者网络条件不好时,上传的时间会比较长(要传输更多的报文,丢包重传的概率也更大),用户不能刷新页面,只能耐心等待请求完成,遇到断网等情况,用户只能从头再来,体验特别不好;

需要解决的几个问题

  1. 文件的唯一性判断,如果用户上传的文件在服务器存在,则不用上传,实现秒传功能;
  2. 将文件分片,按片来发请求;
  3. 断点续传;
  4. 显示上传进度;
  5. 暂停、恢复上传;

文件切片

编码方式上传中,在前端我们只要先获取文件的二进制内容,然后对其内容进行拆分,最后将每个切片上传到服务端即可。 在JavaScript中,文件FIle对象是Blob对象的子类,Blob对象包含一个重要的方法slice,通过这个方法,我们就可以对二进制文件进行拆分。

  1. 前端进行切片处理,然后将每个切片传到服务器
class Util {
    // 生成文件 hash(web-worker)   
    static calculateHash(fileChunkList) {
            return new Promise(resolve => {
                // 添加 worker 属性
                let worker = new Worker("./util/hash.js");
                worker.postMessage({ fileChunkList });
                worker.onmessage = e => {
                    const { percentage, hash } = e.data;
                    this.hashPercentage = percentage;
                    if (hash) {
                        resolve(hash);
                    }
                };
            });
        }
        /**
         * 生成文件切片
         */
    static createFileChunks(file) {
            const fileChunkList = [];
            let start = 0,
                end = size,
                index = 0;
            while (end < file.size) {
                let chunk;
                let _file = file.slice(start, end);
                start = end;
                end = end + size;
                if (end > file.size - 1 && start < file.size - 1) {
                    end = file.size - 1;
                }
                chunk = {
                    file: _file,
                    index: index
                }
                fileChunkList.push(chunk);
                index++;
            }
            return fileChunkList;
        }
        //验证是否已经上传
    static verifyUploaded(fileName, fileHash) {
            $.ajax({
                url: 'http://localhost:3300/upload/verify',
                method: 'GET',
                async: false,
                data: {
                    hash: fileHash,
                    fileName: file.name,
                },
                success: function(res) {
                    this.uploadedList = res.uploadedList;
                    this.isUploaded = res.isUploaded;
                }
            })
        }
        //上传切片
    static async uploadChunks(fileChunkList, file) {
        var fileHash = await Util.calculateHash(fileChunkList);
        Util.verifyUploaded(file.name, fileHash);
        if (isUploaded) {
            alert('[已经上传过]--上传成功');
            return;
        }
        const requestList = fileChunkList.filter((data) => {
            return !uploadedList.includes(fileHash + '_' + data.index);
        });
        Promise.all(
            requestList.map(function(fileChunk) {
                new Promise(resovle => {
                    const formData = new FormData();
                    formData.append("file", fileChunk.file);
                    formData.append("index", fileChunk.index);
                    formData.append('hash', fileHash);
                    //上传切片
                    $.ajax({
                        url: 'http://localhost:3300/upload/bigFile',
                        method: 'POST',
                        processData: false,
                        contentType: false,
                        data: formData,
                        xhr: function() {
                            var xhr = $.ajaxSettings.xhr();
                            fileChunk.xhr = xhr;
                            xhr.onprogress = function e() {
                                // For downloads
                                if (e.lengthComputable) {
                                    console.log(e.loaded / e.total);
                                }
                            };
                            xhr.upload.onprogress = function(e) {
                                // For uploads
                                if (e.lengthComputable) {
                                    loaded = loaded + e.loaded;
                                    progressbar.progressbar("value", (loaded / file.size) * 100);
                                    progressLabel.text(progressbar.progressbar("value") + "%");
                                }
                            };
                            xhr.onload = e => {
                                if (requestList) {
                                    const xhrIndex = requestList.findIndex(item => item.index === chunk.inddex);
                                    requestList.splice(xhrIndex, 1);
                                }
                            }
                            return xhr;
                        },
                        success: function() {
                            resovle();
                        },
                        error: function() {}
                    });
                });
            })
        ).then(() => {
            //所有切片生成完以后进行切片合并
            $.ajax({
                url: 'http://localhost:3300/upload/done',
                method: 'GET',
                data: {
                    hash: fileHash,
                    fileName: file.name,
                    size: size
                }
            })
        }).catch(function(reason) {
            throw new Error(reason);
        });
    }

}
  1. 服务器接收切片请求,并将文件写入磁盘
const multiparty = require("multiparty");
const fse = require("fs-extra");
const path = require("path");
const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存储目录
function index(req, res) {
    const multipart = new multiparty.Form();
    multipart.parse(req, async(err, fields, files) => {
        if (err) {
            return;
        }

        const [file] = files.file;
        const [context] = fields.context;
        const [index] = fields.index;
        const filename = context + '_' + index;
        const fileDir = path.resolve(UPLOAD_DIR, filename);

        // 切片目录不存在,创建切片目录
        if (!fse.existsSync(UPLOAD_DIR)) {
            await fse.mkdirs(UPLOAD_DIR);
        }

        await fse.move(file.path, fileDir);
        res.end("received file chunk");
    });
}
module.exports = index;

结果分析:

  1. 上传一个72286KB的文件;
  2. 将切片大小定义为10M;
  3. 所以发送了8个切片请求;
  4. 服务器将这个8个切片写到磁盘;

合并切片

在接收到前端发送的合并请求后,服务端将文件夹下的所有切片进行合并

  1. 前端发送合并请求
 //所有切片生成完以后进行切片合并
            $.ajax({
                url: 'http://localhost:3300/upload/done',
                method: 'GET',
                data:{
                    hash:fileHash,
                    fileName:file.name,
                    size:size
                }
            })
  1. 服务器端合并切片
const pipeStream = (path,writeStream)=>{
    new Promise(resolve=>{
        const readStream = fse.createReadStream(path);
        readStream.on('end',()=>{
            fse.unlinkSync(path);
            resolve();
        });
        readStream.pipe(writeStream);
    })
}
async function merge(req,res){
    const hash = req.query.hash;
    const size = req.query.size;
    const fileName = req.query.fileName;
    const fileDir = path.resolve(UPLOAD_DIR,hash);
    const filePaths = await fse.readdir(fileDir);
    //根据切片的index进行排序
    filePaths.sort((value,value2)=>{
        return value.split('-')[1]-value2.split('-')[1]
    });
    Promise.all(
        filePaths.map((filePath,index)=>{
            pipeStream(path.resolve(fileDir,filePath),fse.createWriteStream(`${fileDir}/${fileName}`,{start:index*size,end:(index+1)*size}));
            
        })
    ).then(()=>{
        //合并完以后删除切片目录   
        fse.rmdir(fileDir);
        res.end(
            JSON.stringify({
                code:200,
                message:'file merged success'
            })
        );
    })
    
}

如上图可以看到切片合并后生成了一个上传的大文件

显示上传进度

 xhr.upload.onprogress = function(e) {
                                // For uploads
                                if (e.lengthComputable) {
                                    loaded = loaded + e.loaded;
                                    progressbar.progressbar("value", (loaded / file.size) * 100);
                                    progressLabel.text(progressbar.progressbar("value") + "%");
                                }
                            };

累加每个切片的上传进度,然后除以整个文件的大小,即为整个文件的上传进度。

暂停、恢复上传、断点续传

断点续传就是将暂停的上传切片的请求恢复上传。

  1. 每次发送文件上传请求前发送一个verify请求,判断该文件是否已经上传过,并且返回已经上传过的切片请求uploadedList;
  2. 创建一个数组requestList用来存放所有的切片请求;
  3. 从requestList中上传掉所有的uploadedList;
  4. 根据xhr.onload中删除每一个上传成功的请求;
  5. 暂停上传使用 XMLHttpRequest 的 abort 方法实现;
  6. 恢复上传的时候就只发送requestList中剩下的切片请求;

项目代码路径

github.com/fionaVg1/no…

  • 运行步骤
  1. npm install
  2. node server.js
  3. node app.js
  4. 浏览器:http://localhost:3300/