【JavaScript】文件分片上传

这篇具有很好参考价值的文章主要介绍了【JavaScript】文件分片上传。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

【JavaScript】文件分片上传

普通文件上传

一般我们是用 FormData 上传,比较简单,注意 headers 加上"Content-Type": "multipart/form-data"

const pictureUpload = (file) => {
  const formData = new FormData();
  formData.append("file", file);
  return request({
    url: "/upload",
    method: "post",
    headers: { "Content-Type": "multipart/form-data" },
    data: formData,
  });
};

分片上传

当遇到文件太大、网络不好等情况时,如果发生连接中断、挂掉,那整个文件就白传,需要重头再传,这是非常不人性化的用户体验。所以我们可以根据网络情况将文件分成小碎片,最后在服务端将碎片合并,以降低网络传输中断带来的风险。

整体流程

  1. 【前端】文件分片,以序号命名(序号在后端被用于保证合并时的顺序)
  2. 【前端】计算文件hash(hash是文件的摘要,用于唯一标识)
  3. 【前端】根据 fileName + hash 查询; 【后端】返回文件是否已经存在(秒传),或者返回文件已经上传了哪些片(断点续传)
  4. 【前端】上传分片,携带分片的命名和文件hash;【 后端】保存分片,建立一个以hash做名字的目录,将所有分片保存在该目录下
  5. 【前端】分片传递完毕,携带 fileName + hash发起合并请求;【后端】将hash目录下的所有分片合并成文件,并删除碎片

技术点分析

文件选择方式

@隐藏input框,自定义trigger

常规的 input 存在样式问题,大多数情况下会自定义一个 trigger

<template>
  <div class="container">
    <div class="trigger" @click="trigger">
      <div v-if="theFile" class="preview">
        {{ theFile.name }}
      </div>

      <div v-else class="cross">
        <div class="bar vertical"></div>
        <div class="bar horizontal"></div>
      </div>
    </div>

    <input
      ref="inputRef"
      type="file"
      @change="fileHandler"
      style="display: none"
    />
  </div>
</template>

<style scoped lang="scss">
.container {
  width: v-bind(sizePx);
  height: v-bind(sizePx);
  border: 1px dashed #ccc;

  .trigger {
    --bar-width: 20px;

    width: 100%;
    height: 100%;

    position: relative;
    cursor: pointer;
    .bar {
      height: var(--bar-width);
      width: 80%;
      background-color: #ccc;
      position: absolute;
      top: calc(50% - var(--bar-width) / 2);
      left: 10%;
    }
    .vertical {
      transform: rotate(90deg);
    }
  }
}
</style>

【JavaScript】文件分片上传

@拖拽上传

利用拖拽事件的dataTransfer来获取文件

      <div
        class="cross"
        @drop.prevent="fileDropHandler"
        @dragover.prevent="dragOverHandler"
        @dragleave.prevent="dragLeaveHandler"
      >
function fileDropHandler(e: DragEvent) {
  if (e.dataTransfer?.files) {
    console.log(e.dataTransfer.files);
    const file: File = e.dataTransfer.files[0];
  }
}
function dragLeaveHandler() {
  triggerRef.value.style.border = "1px dashed #ccc";
}
function dragOverHandler() {
  triggerRef.value.style.border = "1px solid #a00";
}

分片

固定分片大小

    const file = event.target.files[0];

    const chunk_size = 100 * 1024; // 100 KB
    const chunks = [];
    let curIndex = 0;
    let curSize = 0;
    while (curSize <= file.size) {
      chunks[curIndex] = {
        blob: file.slice(curSize, curSize + chunk_size),
        name: curIndex + "",
      };
      curIndex++;
      curSize = curIndex * chunk_size;
    }
    console.log(chunks);
@动态分片

根据网络状况来动态分片,切片大小随着网速适应变化。

计算哈希

直接在主线程计算哈希,有两个问题:1.影响主线程其他操作,导致卡死;2. 时间太久。
(计算哈希使用md5算法,由于该算法的实现方式,后分组依赖于前分组的计算结果,故无法并发地分片求hash)【3】

比较常用的两款哈希计算库有 spark-md5hash-wasm。 这里用后者。

针对第一个问题,可以考虑使用 DedicatedWorker 或者 requestIdleCallback

@worker

vite项目中,可以直接使用 import MyWorker from './worker?worker' 语法来引入一个worker

import { createMD5 } from "hash-wasm";

self.onmessage = async (e) => {
  const md5 = await createMD5();
  md5.init();
  console.log(e.data);
  md5.update(new Uint8Array(await e.data.arrayBuffer()));
  const hash = md5.digest();
  self.postMessage(hash);
};
import HashWorker from "./HashWorker?worker";
const w = new HashWorker();
w.onmessage = (e) => {
    console.log(e.data);
};
w.postMessage(file);

将文件内容从主线程传递到worker也会导致内存暴增,可以利用分片结果进行增量式postMessage减缓内存压力

import { createMD5 } from "hash-wasm";
import { IHasher } from "hash-wasm/dist/lib/WASMInterface";

let md5: IHasher | null = null;
self.onmessage = async (e) => {
  if (!md5) {
    md5 = await createMD5();
    md5.init();
  }

  if (e.data.done) {
    const hash = md5.digest();
    self.postMessage({ done: true, hash });
    self.close();
  } else {
    md5.update(new Uint8Array(await e.data.blob.arrayBuffer()));
    self.postMessage({ done: false, progress: e.data.name });
  }
};

    const worker = new HashWorker();
    worker.onmessage = (e) => {
      if (e.data.done) {
        console.log(e.data.hash);
        worker.terminate();
      } else {
        console.log(e.data.progress);
        loadChunk();
      }
    };
    curIndex = 0;
    function loadChunk() {
      if (curIndex < chunks.length) {
        worker.postMessage(chunks[curIndex]);
        curIndex++;
      } else {
        worker.postMessage({ done: true });
      }
    }
    loadChunk();
@requestIdleCallback

因为文件分片了,利用事件循环的空隙来计算哈希,是很精妙的一个思路,不会影响用户交互,但是实测计算速度还是很慢,跟worker没法比。

    let md5 = await createMD5();
    md5.init();
    curIndex = 0;
    async function loadChunk() {
      if (curIndex < chunks.length) {
        md5.update(new Uint8Array(await chunks[curIndex].blob.arrayBuffer()));
        curIndex++;
        percentage.value = Math.floor((curIndex / chunks.length) * 100);
        requestIdleCallback((deadline) => {
          if (deadline.timeRemaining() > 1) {
            loadChunk();
          }
        });
      } else {
        const hash = md5.digest();
        console.log(hash);
        console.timeEnd("hash");
        percentage.value = 100;
      }
    }
    console.time("hash");
    loadChunk();
@抽样

针对第二个问题,可以牺牲hash的准确性,减少工作量,从而缩短时间。网上比较多的策略可以是完整保留首尾两片,其余片都取一个bit。我这里直接简单修改一下,只取偶数片来哈希,哈希所花的时间理论上会少一半。

    function loadChunk() {
      if (curIndex < chunks.length) {
        worker.postMessage(chunks[curIndex++]);
        curIndex++;
      } else {
        worker.postMessage({ done: true });
      }
    }

请求

@并发控制

分片过多,使用串行上传速度肯定慢,但是使用 Promise.all 不限制并发数也会在建立TCP连接的时候浏览器会直接卡死。最好的办法是手动控制并发数。

async function upload(chunks: any[], hash: string, fileName: string) {
  const uploadedChunks = await queryChunks({ hash, fileName: fileName });
  if (!uploadedChunks) return; // 文件已经存在了,如果完全没传则返回[]

  const toUploadChunks = chunks.filter(
    (ck) => !uploadedChunks.includes(ck.name)
  );

  /* -- Promise.all -- */
  // await Promise.all(
  //   toUploadChunks.map((ck) => {
  //     const fd = new FormData();
  //     fd.append("file", ck.blob);
  //     fd.append("hash", hash);
  //     fd.append("chunkName", ck.name);
  //     return uploadChunks(fd);
  //   })
  // );
  
  await queueUpload(
    toUploadChunks.map((ck) => {
      const fd = new FormData();
      fd.append("file", ck.blob);
      fd.append("hash", hash);
      fd.append("chunkName", ck.name);
      return fd;
    })
  );

  await mergeChunks({ hash, fileName: fileName });
}
import { uploadChunks } from "@/api/backend";

export async function queueUpload(tasks = [], max = 5) {
  return new Promise((resolve, reject) => {
    const results = [];
    if (tasks.length === 0) {
      resolve(results);
      return;
    }

    let curTask = 0;
    let count = 0;

    function run() {
      while (count < max && curTask < tasks.length) {
        const task = tasks[curTask++];
        count++;
        uploadChunks(task)
          .then((res) => {
            count--;
            if (curTask === tasks.length) {
              resolve(results);
            } else {
              results.push(res);
              run();
            }
          })
          .catch((e) => reject(e));
      }
    }

    run();
  });
}

上面这段代码是有问题的。读者可以自己看一下,我在反思中会解释。

@进度展示

利用xhr的 progress 事件来获取进度。axiosonUploadProgress

import { uploadChunks } from "@/api/backend";

export async function queueUpload(tasks = [], max = 5, onProgress) {
  return new Promise((resolve, reject) => {
    const results = [];
    if (tasks.length === 0) {
      resolve(results);
      return;
    }

    let curTask = 0;
    let count = 0;
    const progress = [];

    function run() {
      while (count < max && curTask < tasks.length) {
        const task = tasks[curTask++];
        count++;
        uploadChunks(task, onUploadProgress(curTask))
          .then((res) => {
            count--;
            if (curTask === tasks.length && count === 0) {
              resolve(results);
            } else {
              results.push(res);
              run();
            }
          })
          .catch((e) => reject(e));
      }
    }

    function onUploadProgress(curTask) {
      return (pe) => {
        progress[curTask] = pe.loaded / pe.total;
        onProgress(progress.reduce((a, c) => a + c) / tasks.length);
      };
    }

    run();
  });
}

至于可视化展示上,自由发挥啦,完全可以做出很多令人拍案叫绝的用户体验
整体进度可以使用内外两个环来分表表示 hash 和 上传 的进度,外圈完成后开始内圈。
分片进度可以参考大圣的方案,对每一个分片用一个小方块来表示,用背景色的高度来表示当前分片的进度。
【JavaScript】文件分片上传

【JavaScript】文件分片上传

  <div class="progress">
    <div
      v-for="p in progress"
      class="progress_item"
      :style="{
        '--p': p + '%',
      }"
    ></div>
  </div>
.progress {
  display: flex;
  flex-wrap: wrap;
  border: 1px dashed #ccc;

  .progress_item {
    margin: 1px;
    background-image: linear-gradient(
      180deg,
      dodgerblue 0%,
      dodgerblue var(--p),
      white var(--p),
      white 100%
    );
    width: 24px;
    height: 24px;
    border: 1px dashed #ccc;
  }
}
@手动中止/暂停

如果在上传过程中关闭标签页,上传就会被终止,下次进来重新选择文件,拿到hash再查询已传碎片,达到断点续传。但有时有用户可能想优雅地主动点击暂停。这种情况就需要利用 AbortController 来实现取消请求

const controller = new AbortController();

axios.get('/foo/bar', {
   signal: controller.signal
})

controller.abort()

值得注意的是,如果增加了手动中止功能,基本上都要实现恢复/继续上传的功能,那此时就要注意进度展示时的进度显示问题。可能会出现进度条残存,断点续传时出现进度条倒退等问题。

合并

      const chunks = readdirSync(storageHashContainerPath);
      const numericAscend = (a, b) => +a - +b;
      chunks.sort(numericAscend).forEach((ck) => {
        const chunkPath = join(storageHashContainerPath, ck);
        appendFileSync(storageFilePath, readFileSync(chunkPath));
        unlinkSync(chunkPath);
      });
@流式并发合并

串行合并效率比较低,因为所有序号已知,且碎片间无依赖关系,完全可以采用并发合并。

// hash + fileName
app.get("/upload/merge", function (req, res) {
  console.log(req.query);
  const storageHashContainerPath = join(storageDir, req.query.hash);
  const storageFilePath = join(storageHashContainerPath, req.query.fileName);
  try {
    accessSync(storageFilePath);
    const msg = "文件已存在";
    res.json({ code: 0, msg, result: storageFilePath });
  } catch (e) {
    try {
      accessSync(storageHashContainerPath);
      // 合并目录下所有的片,并清理碎片
      const chunks = readdirSync(storageHashContainerPath);
      console.log(chunks);
      // 筛选出分片(约定的分片命名为 `fileName_startBit`)
      const getKey = (ck) => +ck.split("_").slice(-1)[0]; // 拿到 startBit
      const numericAscend = (a, b) => +a - +b;
      const comparator = (a, b) => numericAscend(...[a, b].map(getKey));

      /* - sync - */
      // chunks
      //   .filter((ck) => ck.startsWith(req.query.fileName))
      //   .sort(comparator)
      //   .forEach((ck) => {
      //     console.log(ck);
      //     const chunkPath = join(storageHashContainerPath, ck);
      //     appendFileSync(storageFilePath, readFileSync(chunkPath));
      //     unlinkSync(chunkPath);
      //   });

      /* -stream- */
      const _chunks = chunks
        .filter((ck) => ck.startsWith(req.query.fileName))
        .sort(comparator);
      Promise.all(
        _chunks.map((ck) => {
          const chunkPath = join(storageHashContainerPath, ck);
          return pipeline(
            createReadStream(chunkPath),
            createWriteStream(storageFilePath, {
              start: getKey(ck),
            })
          );
        })
      ).then(() => {
        Promise.all(
          _chunks.map((ck) => {
            const chunkPath = join(storageHashContainerPath, ck);
            return unlink(chunkPath);
          })
        ).then(() => {
          res.json({ code: 0, msg: "文件上传成功", result: storageFilePath });
        });
      });
    } catch (err) {
      console.log(err);
      res.json({ code: 1, msg: "合并文件出错" });
    }
  }
});

反思

分片命名问题

对于文件分片,我们传递的时候使用了 序号 来做标识,合并时也按照序号来合并。 其实还可以改用分片在源文件中的起始bit位置,这个位置是升序的,不仅表明了顺序,还可以得出分片在源文件中的位置。唯一的不足可能就是大文件的这个值会比较大,要考虑溢出的风险(考虑JavaScript最大的安全整数是9007199254740991,即意味着允许最大8192TB的文件,这基本是足够了的)。

然后,考虑一个文件,修改命名后,上传两次,会出现什么结果。首先,他们会有相同的hash,也就会出现在同一个hash目录下,那么合并时的逻辑就得考虑,是两个都保留呢,还是后来的覆盖先来的。正常我们应该将两个都保留,因为用户可能只在意文件名,而不会关心hash一不一样。为了将两个文件都保留下来,以及考虑到将来磁盘空间有限,我们肯定要做碎片定期清理,那我们就得做到能够区分文件和分片,因此我们可以对分片命名做一些标记,以此来从目录下的筛选出分片。(这里我遇到一个有意思的BUG,就是你去打开看这个文件没有问题,但是每传一次这个文件,最后合并出来的体积就会增大一点。这个bug就是因为没有区分文件和碎片,每次都合并hash目录下的所有文件,导致合并结果其实包含了已经存在的文件和其他所有分片。O(∩_∩)O哈哈~)

如何作标记呢?给分片加一个统一的前缀,比如 __ 不就可以了?

请考虑一下断点续传。如果第一次只传递了一些分片,没有完成合并操作,然后开始传第二个异名同hash的文件,如何区分两者各自的分片呢?
所以我们还应该为分片命名加上文件名来区分不同文件名对应的分片,以避免断点续传时查到错误的分片列表。

综上,我设计了分片命名规则为 fileName_startBit,后端在合并时根据 fileName 筛选过滤出正确的分片列表,然后选择 startBit 进行合并操作。

    const chunks = [];
    let curIndex = 0;
    let curBit = 0;
    while (curBit < file.size) {
      const endBit = Math.min(curBit + CHUNK_SIZE, file.size);
      chunks[curIndex] = {
        blob: file.slice(curBit, endBit),
        name: [file.name, curBit].join("_"),
      };
      curIndex++;
      curBit = endBit;
    }

我们要获取分片顺序时,取分片名最后一个 _ 后的数字即可,文件名本身中有 _ 也不会有任何影响。
【JavaScript】文件分片上传

并发控制代码实现的问题

在上面提到的并发控制的代码中,最后 resolve 的判断条件是有问题的。我在实测中发现概率性的出现了,文件合成了,最后还有多余的分片的情况。
【JavaScript】文件分片上传
然后看请求,发现merge请求在uplaod未全部完成的时候就已经发送了。

【JavaScript】文件分片上传
问题很好定位,因为我们最后resolve的时机不对,不能只判断任务数量达到最大,还得判断占用count 为0,才能确定是最后一个上传请求完成了。修改如下:文章来源地址https://www.toymoban.com/news/detail-462413.html

if (curTask === tasks.length && count === 0) {

参考文献

  1. 字节跳动面试官:请你实现一个大文件上传和断点续传 - 掘金
  2. 字节跳动面试官,我也实现了大文件上传和断点续传 - 掘金
  3. MD5加密概述,原理及实现_md5加密原理_Oliver_xpl的博客-CSDN博客
  4. Node.js 多文件 Stream 合并,串行和并发两种模式实现_nodejs打包后怎么合并_高先生的猫的博客-CSDN博客

到了这里,关于【JavaScript】文件分片上传的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 前端大文件分片上传

    开始上传:前端启动文件分片上传。后端返回唯一标识。 分片上传:获取到上传的文件,然后设置一个固定的分片大小,将文件切成多个小片,计算出每一个分片的MD5值(32位)。将每个分片的内容和MD5标识符一同上传至服务器。服务端接收每个分片及相关信息后,通过对每

    2024年04月15日
    浏览(33)
  • Minio文件分片上传实现

    资源准备 MacM1Pro 安装Parallels19.1.0请参考 https://blog.csdn.net/qq_41594280/article/details/135420241 MacM1Pro Parallels安装CentOS7.9请参考 https://blog.csdn.net/qq_41594280/article/details/135420461 部署Minio和整合SpringBoot请参考 https://blog.csdn.net/qq_41594280/article/details/135613722 Minio Paralles虚拟机文件百度网盘获取

    2024年01月21日
    浏览(41)
  • Java实现文件分片上传

    Java实现文件分片上传 为什么要使用分片上传 在需要上传文件时,不可避免地会遇到上传文件内容过大,上传时间太长地问题,采用文件分片上传就可以解决这个问题。 什么是分片上传? 简单的说就是本来是需要一次搬一个很大的东西,比如是一大桶水,一次搬起来比较费

    2024年02月08日
    浏览(53)
  • SpringBoot+前端文件分片上传

    在日常生活中,文件上传相关的操作随处可见,大到处理大数据量的文件,小到头像上传,都离不开文件上传操作,但是当一个文件的大小超过了某个阈值时,这个文件的上传过程就会变得及其的慢,且会消耗大量网络资源,这是我们不愿意看到的,所以,文件分片上传孕育而生。 文件分

    2024年02月19日
    浏览(36)
  • Node.js 操作百度网盘实现文件上传(小文件上传,大文件分片上传)

    Node.js 操作百度网盘实现文件上传(小文件上传,大文件分片上传) 前提准备:获取百度网盘的授权码 https://pan.baidu.com/union/doc/al0rwqzzl

    2024年02月09日
    浏览(47)
  • 前端大文件分片上传 进度条展示 上传暂停、开始、取消

    实现的效果: 1、多个大文件(支持10个G以上)分片上传 2、进度条展示进度 3、控制文件上传暂停和取消 实现关键点: 1、文件预处理(md5计算、请求和进度处理等) 2、分片上传的流程(查询已上传分片、文件合并等) 3、文件的暂停、开始、取消 首先使用file类型的input框

    2024年04月26日
    浏览(36)
  • springboot实现minio文件分片上传

    在Spring Boot中实现MinIO的文件分片上传涉及到几个关键步骤。MinIO是一个高性能的分布式对象存储服务,它兼容Amazon S3的API。分片上传主要用于上传大文件,将大文件分成多个部分,分别上传,最后再将这些部分合并成一个完整的文件。这样做的好处是提高了上传的可靠性,并

    2024年01月17日
    浏览(60)
  • React 实现文件分片上传和下载

    文件分片上传是一种将大文件分割成多个小片段进行上传的技术。它的原理是将大文件切割成固定大小的小块,然后逐个上传这些小块,最后在服务器端将这些小块合并成完整的文件。 文件分片上传的机制可以提高上传速度和稳定性。由于大文件的上传可能会受到网络不稳定

    2024年02月13日
    浏览(35)
  • uniapp微信小程序实现大文件上传 分片上传 进度条

    一、安装   二、页面引入  三、实现功能(重要)         1.获取图片的路径         2.设置分片的大小         3.将数据放入 四、实现上传进度条 全部函数js 五、修改npm的源码,处理请求源码中请求所携带的参数问题,以及报错处理(重要) 修改npm后的源码。

    2024年02月03日
    浏览(49)
  • 基于element UI 实现大文件分片上传

    将文件进行切片,上传至服务器,上传完成后通知服务器进行合并 屏幕录制2022-11-11 16.40.06 测试用例 s-upload s-upload-img

    2024年02月01日
    浏览(36)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包