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

背景:

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

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代码地址

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

推荐阅读更多精彩内容