美文网首页
2022-07-12-🌦🌦前端大文件上传

2022-07-12-🌦🌦前端大文件上传

作者: 沐深 | 来源:发表于2022-07-12 10:20 被阅读0次

    背景:

    前端文件上传是非常普遍的功能,当需要上传大文件时会有以下问题。

    1.前后端上传时间限制,一次性传输大小限制。
    2.网络抖动等,失败后需要重新上传。
    3.http1.1版本, TCP只有传送一个请求
    4.无进度条,用户体验极差

    主要步骤:

    前端
    加载文件 ➡️ 分片 ➡️ 上传

    node.js
    解析文件 ➡️ 存放文件碎片 ➡️ 合并文件

    比如重庆市向上海市订购了一批高铁列车,如果一次性运过来不太现场,没有那么大的船,还有就是一次性运过来,如果路上出事故,需要重新发送一批了,损失严重。

    所以我们准备分批发送。

    1.浏览器加载文件

      <input id="file" type="file" onchange="uploadFile()"id="upload" />
      <input type="button" id="upload" value="文件上传" class="btn btn-warning"  onclick="handleUpload()" />
    

    这一步主要是把文件读取到内存里。

    document.getElementById('file').files 是 FileList类型。

    document.getElementById('file').files[0] 是File类型的包装器。

    File FileList FileReader关系:
    FileReader只能读取 File或者 blob对象,File对象是FileList的子集,constructor == Blob, 有slice方法。

    2.上传文件方式选择

    文件上传采用 formData形式,而不是json。原因json传参需要JSON. stringify序列化
    比如一下代码:

    var xhr = new XMLHttpRequest();
    xhr.open('post','http://localhost:3000/ajaxpost');
    xhr.setReuqestHeader('Content-Type','application/json');
    var params = JSON.stringify({
        city: '重庆',
        spcial: '山城'
    })
    xhr.send(params);
    xhr.onload = function () {
        console.log(xhr.responseText);    
    }
    

    在序列化过程中,会抹掉一些比如 function File blob的对象,所以采用formData形式进行文件上传。

    3.上传

    3.1直接上传
    • Promise 封装 ajax
     const postAjax = (url,fd) => {
          const xhr = new XMLHttpRequest();
          return new Promise((resolve, reject) => {
            xhr.open('POST', url, true);
            xhr.onreadystatechange = function() {
              if (xhr.readyState == 4 && xhr.status == 200) {
                console.log(xhr.responseText, "responseText" )
                resolve(xhr.responseText)
              }
            };
            xhr.send(fd);
          })
        }
    
     const url = "http://127.0.0.1:1000/file/uploading"
     function uploadFile() {
          const file = document.getElementById('file').files[0];
          blockUpload(file)
        }
     const blockUpload = (file) => {
          const fd = new FormData();
          fd.append("file", file);
          fd.append("fileName", file.name)
          postAjax(url, fd);
        }
    
    3.2 分片上传

    File对象可以使用,slice + File.size,对文件进行切割,切割后的chunk实际上是浏览器对象Blob。

     const url = "http://127.0.0.1:1000/file/uploading"
     const mergrUrl = "http://127.0.0.1:1000/file/mergrChunk"
     const handleUpload = () => {
            $("#file").click();
      }
    
      function uploadFile() {
          const file = document.getElementById('file').files[0];
          chunkedUpload(file)
      }
    
    
    • 分片
     const chunkedUpload = async (file) => {
          const chunkSize = 1024;
          for (let start = 0; start <= file.size; start += chunkSize) {
            const chunk = file.slice(start, start + chunkSize); // 分片 blob对象
            const fd = new FormData();
            fd.append("chunk", chunk);
            fd.append("hash", start);
            fd.append("fileName", file.name)
            // 上传 利用async实现,同步请求
            let per = Math.floor(100 * start / file.size );
    
            if ((file.size - start) < chunkSize) {
              per = 100;
            }
         
            await postAjax(url, fd).then(res => {
              $('#bar').css({'width': per + "%",});
              $('#bar').html(per + '%');
            })
          }
    

    此时我们会等待一条船到达重庆,再让下一条船出发,河里同时只有一条船通行,就是说分片请求会等待上一个完成。

    3.3 并发上传

    为了利用浏览器的并发能力,把请求分批发送,每次并发11个,node.js同一个IP最多可以异步处理11个请求。

    
    
    
    const chunkedUpload = async (file) => {
    ++ const chunkSize = 1024;
    ++ let postQueue = [];
    ++ const parallelNum = 11; //谷歌最大线程数量 大于11后提效不明显
            for (let start = 0; start <= file.size; start += chunkSize) {
              const chunk = file.slice(start, start + chunkSize); // 分片 blob对象
              const fd = new FormData();
              fd.append("chunk", chunk);
    +         fd.append("hash", start); //node.js 接受时做为文件名
              fd.append("fileName", file.name)
    
    +         let per = Math.floor(100 * start / file.size );
              
    +         if ((file.size - start) < chunkSize) {
    +            per = 100;
    +          }
            
    +          // 一个线程使用完,再发送另一个
    -        await postAjax(url, fd).then(res => {
    -        })
    +        if (postQueue.length < parallelNum) {
    +           postQueue.push(postAjax(url, fd))
    +        }
    
    +        if (postQueue.length >= parallelNum || per === 100) {
    +            // 11个请求并发
    +            await Promise.all(postQueue).then(res => {
                   $('#bar').css({'width': per + "%",});
                   $('#bar').html(per + '%');
    +              postQueue = [];
    +            }).catch(err => {
    +                console.error(err)
    +            })
    +          }
    +        }
    };
    

    此时,我们可以同时发出11条船,等这11条到达重庆,开始下一轮,重新发送11条船,这样就能缩短运输时间啦。

    3.4 any版
    ...
           if (postQueue.length < parallelNum) {
    -          postQueue.push(postAjax(url, fd))
    +          postQueue.push({post: (postAjax(url, fd)), hash: start} )
            }
           
            let per = Math.floor(100 * start / file.size );
    
            if ((file.size - start) < chunkSize) {
              per = 100;
            }
            if (postQueue.length >= parallelNum || per === 100) {
              // 维持一个请求队列,一个请求完成加入一个,不用等待上一轮完成
    +         const postApiQueues = postQueue.map(item => item.post)
              await Promise.any(postApiQueues).then(res => {
    +           let hash = res.hash
    +           const index = postQueue.find(item => item.hash = hash)
    +           postQueue.splice(index, 1)
    -           postQueue = [];
                $('#bar').css({'width': per + "%",});
                $('#bar').html(per + '%');
                if (per >= 100) {
                  postAjax(mergrUrl, fd).then(res => {
                  
                })
              }
              }).catch(err => {
                  console.error(err)
                })
            }
    ...
    

    把以上代码Promise.all 改成 Promise.any

    这样等任何一条船到达重启,我们就可以开始马上让一艘船发货。

    4.文件接收

    4.1 node.js 接收文件流
    • app.js 接收文件流
    const express = require("express");
    const app = express();
    app.use(express.static("public"));
    const multiparty = require("multiparty");
    const fs = require("fs-extra");
    
    const path = require("path");
    const UPLOAD_DIR = path.resolve(__dirname);
    
    
    app.get("/", (req, res) => {
      res.sendFile(`${__dirname}/index.html`);
    });
    let FILE_NAME = "";
    let chunkDir = "";
    
    app.post("/file/uploading", (req, res, next) => {
      /* 生成multiparty对象,并配置上传目标路径 */
      var form = new multiparty.Form();
      form.parse(req, async (err, fields, files) => {
        if(err) return;
        const [chunk] = files.chunk;
        const [hash] = fields.hash;
        const [fileName] = fields.fileName;
        FILE_NAME = fileName;
        chunkDir = path.resolve(UPLOAD_DIR, "fileSteam/fchunkDir" + fileName);
    
        if (!fs.existsSync(chunkDir)) {
          await fs.mkdirs(chunkDir);
        }
        // 文件暂时放入 chunkDir文件夹中
    
        await fs.move(chunk.path, `${chunkDir}/${hash}`);
    
        res.writeHead(200, { "content-type": "text/plain;charset=utf-8" });
        res.write("200");
        res.end();
      });
    });
    
    
    app.use(express.static("public")).listen(1000);
    
    

    上面的app.js 解析文件,然后临时存放在 chunkDir+文件名的文件夹下


    相当于把高铁车的所有零件放入一个独立的仓库,仓库的名字就是高铁的名字,比如复兴号。

    4.2 node.js 合并文件流 生成文件
    • app.js
    app.post("/file/uploading", (req, res, next) => {
       ......
    });
    
    // 合并chunk
    + const stream = require("./writeStream");
    
    + app.post("/file/mergrChunk", async (req, res, next) => {
    +  FILE_NAME = path.resolve(UPLOAD_DIR, "fileSteam/" + FILE_NAME);
    +  console.log(FILE_NAME, "========================");
    +  let dests = fs.readdirSync(chunkDir);
    +  dests = dests.sort((a, b) => a - b);
    +  await stream.WriteStreamsAsync(dests, FILE_NAME, chunkDir);
    +  await fs.removeSync(chunkDir);
    +  res.write("200");
    +  res.end();
    });
    
    app.use(express.static("public")).listen(1000);
    
    

    前端文件传送完成,向后端发送一个合并请求,合并前把文件排序一下,文件合并操作在writeStream.js中。

    • writeStream.js
    const fs = require("fs"); // 引入fs模块
    const path = require("path");
    /**
     * @params dests 文件流
     * @params FILE_NAME  生成的文件名
     * @params chunkDir 文件路径
     */
    const WriteStreamsAsync = async (dests, FILE_NAME, chunkDir) => {
      let writeable = fs.createWriteStream(FILE_NAME);
      for (let i = 0; i < dests.length; i++) {
        await write(dests[i], writeable, chunkDir);
      }
    };
    
    const write = (item, writeable, chunkDir) => {
      return new Promise((resolve, reject) => {
        let destPath = path.resolve(__dirname, chunkDir + '/' + item);
        let readable = fs.createReadStream(destPath);
        readable.pipe(writeable, { end: false });
        readable.on("end", () => {
          // 关闭流之前立即写入最后一个额外的数据块
          resolve();
        });
      });
    };
    
    module.exports = { WriteStreamsAsync };
    
    

    利用 fs. createReadStream fs. createWriteStream 文件流api合并文件切片,生成文件,大文件上传完成。
    这一步相当于把高铁组装起来,复原了。

    断点续传

    断点续传可以,在文件中断后继续上次的传输节点,继续上传。

    在网页刷新后,把上传的节点存储到localStorage中,下次上传从localStorage查找是否有这个文件的节点存在,如果有从这个节点上传,如果没有,重新上传。

      const postAjax = (url,fd) => {
          const xhr = new XMLHttpRequest();
          return new Promise((resolve, reject) => {
            xhr.open('POST', url, true);
            xhr.onreadystatechange = function() {
              if (xhr.readyState == 4 && xhr.status == 200) {
              const res = JSON.parse(xhr.responseText)
    +         if (res.hash) {
    +              window.localStorage.setItem(fileName, res.hash);
    +         }
                resolve(res)
              }
            };
            xhr.send(fd);
          })
        }
    
    function uploadFile() {
          const file = document.getElementById('file').files[0];
    +      let fileName = window.localStorage.getItem('fileName');
    +      const pointHash = window.localStorage.getItem(fileName) || 0;
           chunkedUpload(file, +pointHash)
    }
    
    const chunkedUpload = async (file, pointHash) => {
          const chunkSize = 1024 * 10;
          let postQueue = [];
          const parallelNum = 25; //谷歌最大线程数量 大于11后提效不明显,node.js在1s内最多异步处理11个请求
          for (let start = pointHash; start <= file.size; start += chunkSize) {
            const chunk = file.slice(start, start + chunkSize); // 分片 blob对象
            const fd = new FormData();
            fd.append("chunk", chunk);
            fd.append("hash", start);
            fd.append("fileName", file.name)
    +        window.localStorage.setItem('fileName', file.name);
            // 线程并发
            if (postQueue.length < parallelNum) {
              postQueue.push({post: (postAjax(url, fd)), hash: start} )
            }
           
            let per = Math.floor(100 * start / file.size );
    
            if ((file.size - start) < chunkSize) {
              per = 100;
            }
            if (postQueue.length >= parallelNum || per === 100) {
    
              const postApiQueues = postQueue.map(item => item.post)
              await Promise.any(postApiQueues).then(res => {
                let hash = res.hash
                const index = postQueue.find(item => item.hash = hash)
                postQueue.splice(index, 1)
                
                $('#bar').css({'width': per + "%",});
                $('#bar').html(per + '%');
                if (per >= 100) {
                  postAjax(mergrUrl, fd).then(res => {
    +                let fileName = window.localStorage.getItem('fileName');
    +                window.localStorage.removeItem(fileName);
    +                window.localStorage.removeItem('fileName');
                  })
                }
              }).catch(err => {
                  console.error(err)
                })
            }
          }
        };
    

    速度对比:

    为了便于观测我们先把网络设置成fast3G
    这样能保证带宽不会影响传输速度

    1.promise.all并行版


    并行上传时间: 32.95S

    2.any版上传


    any上传时间: 25.96S

    3.await排队版


    排队上传时间: 1.5min

    4.直传版


    上传时间: 18.88s

    可以看出,在传送速度上。

    直传版(18.88s) > any版(25.96s) > 并行版(32.95s) > 排队版(1.5min)

    结论

    1.TCP建立请求,关闭请求是非常费时间的。
    2.并行请求速度是排队上传快很多,这个方式是可行的。

    git代码地址

    相关文章

      网友评论

          本文标题:2022-07-12-🌦🌦前端大文件上传

          本文链接:https://www.haomeiwen.com/subject/vvqljrtx.html