热身

二进制数据和文件

在 Web 开发中,当我们处理文件时(创建,上传,下载),经常会遇到二进制数据。另一个典型的应用场景是图像处理。
这些都可以通过 JavaScript 进行处理,而且二进制操作性能更高。
不过,在 JavaScript 中有很多种二进制数据格式,会有点容易混淆。仅举几个例子:
ArrayBuffer,Uint8Array,DataView,Blob,File及其他。
image.png

ArrayBuffer

ArrayBuffer.xmind

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。 它是一个字节数组,通常在其他语言中称为“byte array”。 你不能直接操作ArrayBuffer的内容,而是要通过类型数组对象DataView对象来操作,它们会将缓冲区中的数据表示为特定的格式,并通过这些格式来读写缓冲区的内容。 ——————— From MDN

如何理解?

  1. ArrayBuffer是一个内存区域。它里面存储了什么?无从判断。只是一个原始的字节序列。
  2. 如要操作ArrayBuffer,我们需要使用“视图”对象。视图对象本身并不存储任何东西。它是一副“眼镜”,透过它来解释存储在ArrayBuffer中的字节。

    TypedArray

    类型化数组的行为类似于常规数组:具有索引,并且是可迭代的。TypedArray具有常规的Array方法,但有个明显的例外。我们可以遍历(iterate),map,slice,find和reduce等。
  • Uint8Array—— 将ArrayBuffer中的每个字节视为 0 到 255 之间的单个数字(每个字节是 8 位,因此只能容纳那么多)。这称为 “8 位无符号整数”。
  • Uint16Array—— 将每 2 个字节视为一个 0 到 65535 之间的整数。这称为 “16 位无符号整数”。
  • Uint32Array—— 将每 4 个字节视为一个 0 到 4294967295 之间的整数。这称为 “32 位无符号整数”。
  • Float64Array—— 将每 8 个字节视为一个5.0x10-324到1.8x10308之间的浮点数。
    1. newTypedArray(buffer,[byteOffset],[length]);
    2. newTypedArray(object);
    3. newTypedArray(typedArray);
    4. newTypedArray(length);newTypedArray();
    ``` let buffer = new ArrayBuffer(16); // 创建一个长度为 16 B 的 buffer

let view = new Uint32Array(buffer); // 将 buffer 视为一个 32 位整数的序列

alert(Uint32Array.BYTES_PER_ELEMENT); // 每个整数 4 个字节

console.log(view.length); // 4,它存储了 4 个整数 console.log(view.byteLength); // 16,字节中的大小

// 让我们写入一个值 view[0] = 123456;

// 遍历值 for(let num of view) { console.log(num); // 123456,然后 0,0,0(一共 4 个值) }

  1. <a name="Avoe1"></a>
  2. #### DataView
  3. > **DataView**视图是一个可以从 二进制[ArrayBuffer](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer)对象中读写多种数值类型的底层接口,使用它时,不用考虑不同平台的[字节序](https://developer.mozilla.org/zh-CN/docs/Glossary/Endianness)问题。

new DataView(buffer, [byteOffset], [byteLength])

  1. 当我们将混合格式的数据存储在同一缓冲区(buffer)中时,DataView非常有用。例如,当我们存储一个成对序列(16 位整数,32 位浮点数)时,用DataView可以轻松访问它们。

// 4 个字节的二进制数组,每个都是最大值 255 let buffer = new Uint8Array([255, 255, 255, 255]).buffer;

let dataView = new DataView(buffer);

// 在偏移量为 0 处获取 8 位数字 alert( dataView.getUint8(0) ); // 255

// 现在在偏移量为 0 处获取 16 位数字,它由 2 个字节组成,一起解析为 65535 alert( dataView.getUint16(0) ); // 65535(最大的 16 位无符号整数)

// 在偏移量为 0 处获取 32 位数字 alert( dataView.getUint32(0) ); // 4294967295(最大的 32 位无符号整数)

dataView.setUint32(0, 0); // 将 4 个字节的数字设为 0,即将所有字节都设为 0

  1. <a name="RZBk8"></a>
  2. ## ![image.png](https://cdn.nlark.com/yuque/0/2021/png/248010/1619151937709-3a170eae-aba5-4c81-a862-9275ed4b5740.png#clientId=uc0e57473-54b8-4&from=paste&height=411&id=u7c1c242c&margin=%5Bobject%20Object%5D&name=image.png&originHeight=822&originWidth=1236&originalType=binary&size=92317&status=done&style=none&taskId=u83b4ab58-65ca-4b9e-a439-6bc9ee34ae8&width=618)
  3. <a name="vp1vz"></a>
  4. #### 应用
  5. 通过ArrayBuffer的格式读取本地数据
  6. <a name="rApCW"></a>
  7. ## Blob
  8. [Blob.xmind](https://www.yuque.com/attachments/yuque/0/2021/xmind/248010/1619185180302-d3664d3b-48e4-43db-89e1-c0ed2fd19250.xmind)

new Blob(blobParts, options); blob.slice([byteStart], [byteEnd], [contentType]);

  1. - blobPartsBlob/BufferSource/String类型的值的数组。
  2. - options可选对象:
  3. - type——Blob类型,通常是 MIME 类型,例如image/png
  4. - endings—— 是否转换换行符,使Blob对应于当前操作系统的换行符(\r\n\n)。默认为"transparent"(啥也不做),不过也可以是"native"(转换)。
  5. <a name="S5Lk8"></a>
  6. ### 下载实现

let link = document.createElement(‘a’); link.download = ‘hello.txt’;

let blob = new Blob([‘Hello, world!’], {type: ‘text/plain’}); //URL.createObjectURL 取一个 Blob,并为其创建一个唯一的 URL,形式为 blob:/。 link.href = URL.createObjectURL(blob);

link.click();

URL.revokeObjectURL(link.href);

  1. ```
  2. let link = document.createElement('a');
  3. link.download = 'hello.txt';
  4. let blob = new Blob(['Hello, world!'], {type: 'text/plain'});
  5. let reader = new FileReader();
  6. reader.readAsDataURL(blob); // 将 Blob 转换为 base64 并调用 onload
  7. reader.onload = function() {
  8. link.href = reader.result; // data url
  9. link.click();
  10. };

File File Reader

File File Reader.xmind

File对象继承自Blob,并扩展了与文件系统相关的功能。
获取File:

  1. 构造函数
    1. new File(fileParts, fileName, [options])
  • fileParts—— Blob/BufferSource/String 类型值的数组。
  • fileName—— 文件名字符串。
  • options—— 可选对象:
    • lastModified—— 最后一次修改的时间戳(整数日期)。
  1. 更常见的是,我们从或拖放或其他浏览器接口来获取文件。在这种情况下,file 将从操作系统(OS)获得 this 信息。

由于File是继承自Blob的,所以File对象具有相同的属性,附加:

  • name—— 文件名,
  • lastModified—— 最后一次修改的时间戳。

这就是我们从中获取File对象的方式:

  1. <input type="file" onChange={(e) => handleChange(e)} />
  2. const handleChange = (e) => {
  3. let file = e.target.files[0];
  4. let reader = new FileReader();
  5. reader.readAsDataURL(file);
  6. reader.onload = function () {
  7. console.log(reader.result);
  8. };
  9. reader.onerror = function () {
  10. console.log(reader.error);
  11. };
  12. };

image.png

FileReader是一个对象,其唯一目的是从Blob(因此也从File)对象中读取数据。
它使用事件来传递数据,因为从磁盘读取数据可能比较费时间。

  1. let reader = new FileReader(); // 没有参数

主要方法:

  • readAsArrayBuffer(blob)—— 将数据读取为二进制格式的ArrayBuffer
  • readAsText(blob, [encoding])—— 将数据读取为给定编码(默认为utf-8编码)的文本字符串。
  • readAsDataURL(blob)—— 读取二进制数据,并将其编码为 base64 的 data url。
  • readAsBinaryString 函数会按字节读取文件内容。然而诸如0101的二进制数据只能被机器识别,若想对外可见,还是需要进行一次编码,而readAsBinaryString的结果就是读取二进制并编码后的内容。尽管readAsBinaryString方法可以按字节读取文件,但由于读取后的内容被编码为字符,大小会受到影响,故不适合直接传输,也不推荐使用。如:测试的图片文件原大小为6764 字节,而通过readAsBinaryString读取后,内容被扩充到10092个字节
  • abort()—— 取消操作。

read*方法的选择,取决于我们喜欢哪种格式,以及如何使用数据。

  • readAsArrayBuffer—— 用于二进制文件,执行低级别的二进制操作。对于诸如切片(slicing)之类的高级别的操作,File是继承自Blob的,所以我们可以直接调用它们,而无需读取。
  • readAsText—— 用于文本文件,当我们想要获取字符串时。
  • readAsDataURL—— 当我们想在src中使用此数据,并将其用于img或其他标签时。正如我们在Blob一章中所讲的,还有一种用于此的读取文件的替代方案:URL.createObjectURL(file)。

读取过程中,有以下事件:

  • loadstart—— 开始加载。
  • progress—— 在读取过程中出现。
  • load—— 读取完成,没有 error。
  • abort—— 调用了abort()。
  • error—— 出现 error。
  • loadend—— 读取完成,无论成功还是失败。

读取完成后,我们可以通过以下方式访问读取结果:

  • reader.result是结果(如果成功)
  • reader.error是 error(如果失败)。

使用最广泛的事件无疑是load和error。

FileReader用于 blob
正如我们在Blob一章中所提到的,FileReader不仅可读取文件,还可读取任何 blob。
我们可以使用它将 blob 转换为其他格式:

  • readAsArrayBuffer(blob)—— 转换为ArrayBuffer,
  • readAsText(blob, [encoding])—— 转换为字符串(TextDecoder的一个替代方案),
  • readAsDataURL(blob)—— 转换为 base64 的 data url。

文件读取

  1. const handleChange = (e) => {
  2. let file = e.target.files[0];
  3. let reader = new FileReader();
  4. reader.readAsDataURL(file);
  5. reader.onload = function () {
  6. console.log("result", reader.result);
  7. console.log("blob", new Blob([reader.result]));
  8. };
  9. reader.onerror = function () {
  10. console.log(reader.error);
  11. };
  12. };
  13. <input type="file" onChange={(e) => handleChange(e)} />

readAsDataURL

image.png

image.png

readAsText

image.png

image.png

readAsBinaryString

image.png

readAsArrayBuffer

image.png

本身ArrayBuffer中的内容对外是不可见的,若要查看其中的内容,就要引入另一个概念:类型化数组或者DataView

与readAsBinaryString类似,readAsArrayBuffer方法会按字节读取文件内容,并转换为ArrayBuffer对象。我们可以关注下文件读取后大小,与原文件大小一致。
readAsArrayBuffer与readAsBinaryString方法的区别,readAsArrayBuffer读取文件后,会在内存中创建一个ArrayBuffer对象(二进制缓冲区),将二进制数据存放在其中。通过此方式,我们可以直接在网络中传输二进制内容

  1. reader.onload = function () {
  2. console.log("result", reader.result);
  3. const view = new Unit8Array(reader.result)
  4. console.log('view',view)
  5. console.log("blob", new Blob([reader.result]));
  6. };

image.png

应用

在线预览文件

我们知道,img的src属性或background的url属性,可以通过被赋值为图片网络地址或base64的方式显示图片。
在文件上传中,我们一般会先将本地文件上传到服务器,上传成功后,由后台返回图片的网络地址再在前端显示。
通过FileReader的readAsDataURL方法,我们可以不经过后台,直接将本地图片显示在页面上。这样做可以减少前后端频繁的交互过程,减少服务器端无用的图片资源

  1. const handleChange = (e) => {
  2. let file = e.target.files[0];
  3. let reader = new FileReader();
  4. reader.readAsDataURL(file);
  5. reader.onload = function () {
  6. const img = document.createElement("img");
  7. img.src = reader.result;
  8. const APP = document.querySelector(".App");
  9. APP.append(img);
  10. };
  11. reader.onerror = function () {
  12. console.log(reader.error);
  13. };
  14. };
  15. <div className="App">
  16. <input type="file" onChange={(e) => handleChange(e)} />
  17. <hr />
  18. <div>预览区域</div>
  19. <hr />
  20. </div>

image.png

二进制数据上传

  1. const handleChange = (e) => {
  2. let file = e.target.files[0];
  3. let reader = new FileReader();
  4. reader.readAsDataURL(file);
  5. reader.onload = function () {
  6. upload(reader.result)
  7. };
  8. reader.onerror = function () {
  9. console.log(reader.error);
  10. };
  11. };
  12. const upload = (binary)=> {
  13. let response = await fetch('/article/fetch/post/user', {
  14. method: 'POST',
  15. headers: {
  16. // 'Content-Type': 'application/json;charset=utf-8'
  17. },
  18. body: binary
  19. });
  20. }
  21. <div className="App">
  22. <input type="file" onChange={(e) => handleChange(e)} />
  23. <hr />
  24. <div>预览区域</div>
  25. <hr />
  26. </div>

网络上传 FormData

fetch

典型的 fetch 请求由两个await调用组成:

  1. let response = await fetch(url, options); // 解析 response header
  2. let result = await response.json(); // 将 body 读取为 json

或者以 promise 形式:

  1. fetch(url, options)
  2. .then(response => response.json())
  3. .then(result => /* process result */)

响应的属性:

  • response.status—— response 的 HTTP 状态码,
  • response.ok—— HTTP 状态码为 200-299,则为true。
  • response.headers—— 类似于 Map 的带有 HTTP header 的对象。

获取 response body 的方法:

  • response.text()—— 读取 response,并以文本形式返回 response,
  • response.json()—— 将 response 解析为 JSON 对象形式,
  • response.formData()—— 以FormData对象(form/multipart 编码,参见下一章)的形式返回 response,
  • response.blob()—— 以Blob(具有类型的二进制数据)形式返回 response,
  • response.arrayBuffer()—— 以ArrayBuffer(低级别的二进制数据)形式返回 response。

到目前为止我们了解到的 fetch 选项:

  • method—— HTTP 方法,
  • headers—— 具有 request header 的对象(不是所有 header 都是被允许的)
  • body—— 要以string,FormData,BufferSource,Blob或UrlSearchParams对象的形式发送的数据(request body)。

POST 请求
要创建一个POST请求,或者其他方法的请求,我们需要使用fetch选项:

  • method—— HTTP 方法,例如POST,
  • body—— request body,其中之一:
    • 字符串(例如 JSON 编码的),
    • FormData对象,以form/multipart形式发送数据,
    • Blob/BufferSource发送二进制数据,
    • URLSearchParams,以x-www-form-urlencoded编码形式发送数据,很少使用。body:’age=30$name=guanqingchao’

      FormData

      Form对象可以将数据编译成键值对的格式,以便于发送数据,主要用于:
      (1) 发送表单数据(通过表单元素的name和value组成querystring,实现表单数据的序列化),也可以用来发送键值对格式的数据(非表单)
      (2)异步上传二进制文件。

FormData的特殊之处在于网络方法(network methods),例如fetch可以接受一个FormData对象作为 body。它会被编码并发送出去,带有Content-Type: multipart/form-data。

  1. <form className="formElem">
  2. <input type="text" name="name" value="John" />
  3. <input type="text" name="surname" value="Smith" />
  4. Picture: <input type="file" name="picture" accept="image/*" />
  5. <input type="submit" />
  6. </form>
  7. useEffect(() => {
  8. const form = document.querySelector(".formElem");
  9. const formData = new FormData(form);
  10. const name = formData.get("name");
  11. const surname = formData.get("surname");
  12. const picture = formData.get("picture");
  13. formData.append("token", "kshdfiwi3rh");
  14. console.log(name, surname, formData.get("token"), picture);
  15. form.onsubmit = async () => {
  16. let response = await fetch("/article/formdata/post/user", {
  17. method: "POST",
  18. body: new FormData(form)
  19. });
  20. let result = await response.json();
  21. };
  22. });

image.png
image.png

Buffer

Buffer

大文件上传


思路总结

大文件切片

  • 问题点:前端将文件做成切片进行传递,那么后端怎么知道已经全部接收到所有的文件切片

    1. 前端主动通知,当所有的切片传递完成后(Promise.all),再发送一个请求通知后端已经完成切片传递,后端进行切片合并

服务端合并切片

  • 问题点:文件切片传递到后端,后端怎么将文件进行还原

文件编号,前端通过Blob.slice()进行文件切片,给每一个切片按顺序进行编号(index),将编号信息一并传递给后端(通过异步Promise发送请求)后端接收切片,在本地或者静态资源服务器新建切片文件夹,将切片保存到文件夹中(fs.createReadStream/fs.createWriteStream/pipe),得到合并通知读取文件按前端传递的文件顺序进行切片合并

文件秒传

所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户再次上传时会直接提示上传成功

文件秒传需要依赖上一步生成的 hash,即在上传前,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可

断点续传

  • 问题点:断点续传如何实现,暂停上传之后,怎么继续上传

    1. <br /> 服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片<br /> <br />无论是前端还是服务端,都必须要生成文件和切片的 hash,使用文件名 + 切片下标作为切片 hash,文件名一旦修改就失去了效果,而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是根据文件内容生成 hash

这里用到另一个库 spark-md5,它可以根据文件内容计算出文件的 hash 值【抽样hash】

考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash【也可以时间分片requestIdleCallback】,这样用户仍可以在主界面正常的交互
由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了importScripts 函数用于导入外部脚本,通过它导入 spark-md5

spark-md5 需要根据所有切片才能算出一个 hash 值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的 hash,具体可以看官方文档 spark-md5

将所有请求放到uploadFileQuene队列中,每当一个切片上传成功时,将对应的请求从队列中删除,uploadFileQuene只保存正在上传切片的请求

文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端跳过这些已经上传切片,这样就实现了“续传”的效果

  • 服务端已存在该文件,不需要再次上传 实现文件秒传
  • 服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把已上传的文件切片返回给前端

暂停上传 : 点击暂停,调用保存在 uploadFileQuene 中的 abort 方法,取消并清空所有正在上传的切片
恢复上传 : 恢复上传的时候,获取已经上传过的分片文件,前端再次上传的时候,过滤掉已经上传的文件

axios的取消 参见 axios取消

前端

大文件上传
  • 将大文件转换成二进制流的格式
  • 利用流可以切割的属性,将二进制流切割成多份
  • 组装和分割块同等数量的请求块,并行或串行的形式发出请求
  • 待我们监听到所有请求都成功发出去以后,再给服务端发出一个合并的信号

    断点续传
  • 为每一个文件切割块添加不同的标识hash

  • 当上传成功的之后,记录上传成功的标识
  • 当我们暂停或者发送失败后,可以重新发送没有上传成功的切割文件

进度条显示

  • ajax可以获取到请求进度 onUploadProgress

    后端
  • 接收每一个切割文件,并在接收成功后,存到指定位置,并告诉前端接收成功

  • 收到合并信号,将所有的切割文件排序,合并,生成最终的大文件,然后删除切割小文件,并告知前端大文件的地址

实现

前端

  1. 文件上传的时候,对文件进行切片,通过blob.slice()方法对文件进行切割,通过formData封装form信息,示例中按照SIZE固定大小进行分割,返回对应的文件切块、文件名称和hash(hash使用文件名 + 下标,以便后端知道当前切片是第几个切片,用于之后的合并切片)

    【hash优化】

  2. 点击上传,全部上传完毕,发送合并请求

create-react-app

  1. import logo from './logo.svg';
  2. import { useState } from "react";
  3. import './App.css';
  4. const axios = require('axios').default;
  5. const SIZE = 1024 * 1024; //切片大小 1KB
  6. function App() {
  7. const [file, setFile] = useState(null);
  8. const [chunkFileList, setChunkFileList] = useState([]); //切片信息
  9. //上传文件
  10. const handleInputFile = (e) => {
  11. const files = e.target.files;
  12. setFile(files[0]);
  13. const chunkFileList = createChunkFile(files);
  14. setChunkFileList(chunkFileList);
  15. };
  16. // 生成切片文件 返回文件切片大小 文件名称
  17. const createChunkFile = (files = []) => {
  18. if (!files.length) return;
  19. const fileChunks = [];
  20. for (let file of files) {
  21. const fileSize = file.size;
  22. const fileName = file.name;
  23. let curSize = 0;
  24. let curIndex = 0;
  25. while (curSize <= fileSize) {
  26. let end = curSize + SIZE <= fileSize ? curSize + SIZE : fileSize;
  27. curIndex++;
  28. fileChunks.push({
  29. chunk: file.slice(curSize, end),
  30. filename: fileName,
  31. hash: fileName + '_' + curIndex,
  32. });
  33. curSize += SIZE;
  34. }
  35. }
  36. return fileChunks;
  37. };
  38. const handleUpload = () => {
  39. if (!chunkFileList.length) return;
  40. const uploadFileQuene = [];
  41. console.log('切片信息', chunkFileList)
  42. chunkFileList.forEach((file, index) => {
  43. const { chunk, filename, hash } = file;
  44. const form = new FormData();
  45. form.append(`chunks`, chunk);
  46. form.append("hash", hash);
  47. form.append("filename", filename);
  48. uploadFileQuene.push(
  49. uploadApi({
  50. url: "http://localhost:3000/api/upload", //上传切片
  51. data: form
  52. })
  53. );
  54. });
  55. Promise.all(uploadFileQuene).then(async () => {
  56. const res = await uploadApi({
  57. url: "http://localhost:3000/api/merge", //合并切片
  58. data: {
  59. filename: file.name,
  60. size: SIZE,//切片大小
  61. },
  62. });
  63. console.log('全部上传完毕', res);
  64. });
  65. };
  66. const uploadApi = async ({ url, data }) => {
  67. return axios({
  68. method: 'post',
  69. url: url,
  70. data: data,
  71. });
  72. //用fetch 无法获取koa返回的response
  73. // fetch(url, {
  74. // method: "POST",
  75. // mode: 'no-cors',
  76. // headers: {
  77. // 'Accept': 'application/json',
  78. // 'Content-Type': 'application/json',
  79. // // "Content-Type": "application/json;charset=utf-8"
  80. // },
  81. // body: data
  82. // }).then(res=>console.log(999999,res));
  83. };
  84. return (
  85. <div className="App">
  86. <header className="App-header">
  87. <input type="file" multiple="multiple" onChange={handleInputFile} />
  88. <button onClick={handleUpload}>点击我上传大文件呀</button>
  89. <hr />
  90. <p>显示进度条</p>
  91. </header>
  92. </div>
  93. );
  94. }
  95. export default App;

后端:

  1. 接收前端上传的文件信息,filename、hash等,将切片文件保存到临时文件夹中
  2. 接收到合并文件请求,读取切片,按照序号顺序将切换合并,将切片文件作为可独流,通过pipe,写入到文件存放路径下,转成文件
  3. 切片合并之后,删除临时文件夹 ``` const Koa = require(‘koa’); const Router = require(‘koa-router’); const koabody = require(‘koa-body’); const fs = require(‘fs’); const path = require(‘path’); const cors = require(‘@koa/cors’); const multiparty = require(“multiparty”); const fse = require(“fs-extra”);

const app = new Koa(); const router = new Router();

const UPLOAD_DIR_TMP = path.resolve(dirname, “file-tmp”); // 大文件临时存储目录 const UPLOAD_DIR_REAL = path.resolve(dirname, “file”); // 大文件存储目录 if (!fs.existsSync(UPLOAD_DIR_TMP)) { fs.mkdirSync(UPLOAD_DIR_TMP); }

if (!fs.existsSync(UPLOAD_DIR_REAL)) { fs.mkdirSync(UPLOAD_DIR_REAL); }

app.use(cors()); app.use(koabody({ multipart: true }))

router.post(‘/api/upload’, ctx => { //切片保存接口 const chunks = { …ctx.request.files, …ctx.request.body } if (!Object.keys(chunks).length) { ctx.response.body = JSON.stringify({ message: ‘没有上传文件呢’, status: 0 }); return; }

  1. const { filename, hash, chunks: chunk } = chunks;
  2. const fileName = path.basename(filename, path.extname(filename)); //去除文件扩展名
  3. const chunkDir = path.resolve(UPLOAD_DIR_TMP, fileName);
  4. const chunksSavePath = path.resolve(UPLOAD_DIR_TMP, fileName, hash);
  5. //创建保存切片的文件夹
  6. !fs.existsSync(chunkDir) && fs.mkdirSync(chunkDir);
  7. //写入
  8. const readStream = fs.createReadStream(chunk.path);//???Why path
  9. const writeStream = fs.createWriteStream(chunksSavePath);//写入流是文件路径 非目录
  10. readStream.pipe(writeStream);
  11. ctx.response.body = JSON.stringify({
  12. message: '本片段上传成功啦',
  13. status: 1
  14. });

})

router.post(‘/api/merge’, async (ctx, next) => { //切片合并接口

  1. const mergeInfo = ctx.request.body;
  2. const { filename, size } = mergeInfo;
  3. const bigFilePath = path.resolve(UPLOAD_DIR_REAL, filename);//文件最后存放路径
  4. await mergeChunks(filename, size);
  5. ctx.response.body = JSON.stringify({
  6. message: '恭喜你全部文件上传成功啦!!!',
  7. url: bigFilePath,
  8. status: 1
  9. });

})

app.use(router.routes()) app.listen(3000);

/**

  • @description: 合并切片
  • @param {*} fileName
  • @param {*} chunksNameList
  • @return {} / async function mergeChunks(filename, size) { const chunkDir = genDir(filename, UPLOAD_DIR_TMP); const chunkPaths = await fs.readdirSync(chunkDir); //获取chunk路径 const bigFilePath = path.resolve(UPLOAD_DIR_REAL, filename);//文件最后存放路径 // 根据切片下标进行排序 // 否则直接读取目录的获得的顺序可能会错乱 chunkPaths.sort((a, b) => a.split(“-“)[1] - b.split(“-“)[1]); await Promise.all(
    1. chunkPaths.map((chunkPath, index) => {
    2. //写入新的文件中
    3. const writeStream = fs.createWriteStream(bigFilePath, {
    4. start: index * size,
    5. end: (index + 1) * size
    6. });
    7. return pipeStream(path.resolve(chunkDir, chunkPath), writeStream)
    8. })
    ) delDir(chunkDir);// 合并后删除保存切片的目录 // fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录

}

function genDir(filename, dirPath) { const fileName = path.basename(filename, path.extname(filename)); //去除文件扩展名 const chunkDir = path.resolve(dirPath, fileName); return chunkDir; }

const pipeStream = (path, writeStream) => new Promise(resolve => { const readStream = fs.createReadStream(path); readStream.on(“end”, () => { fs.unlinkSync(path); resolve(); }); readStream.pipe(writeStream); });

/**

  • @description: 删除文件夹
  • @param {String} path
  • @return {} / function delDir(path) { const dirs = fs.readdirSync(path);//读取当前路径下的文件及文件夹 dirs.forEach(dir => {
    1. let curPath = path + '/' + dir//获得当前路径
    2. console.log('临时文件下文件', dirs)
    3. if (fs.statSync(curPath).isDirectory()) {//是否为文件夹
    4. delDir(curPath);//遍历
    5. } else if (fs.statSync(curPath).isFile()) {//是否为文件
    6. fs.unlinkSync(curPath)
    7. }
    }) fs.rmdirSync(path)//删除空文件夹 } ```

优化【TODO】

  1. 抽样hash
  2. 时间分片
  3. 文件碎片清理
  4. 并发请求数优化

【TODO 时间对比】

image.png

References

https://juejin.cn/post/6844904046436843527#heading-2
https://juejin.cn/post/6902304890081509383
https://juejin.cn/post/6844903534689796110
https://zhuanlan.zhihu.com/p/104826733