小试牛刀之大文件切片上传

大文件切片上传

前言

对文件进行切片,就是将一个请求拆分成多个请求,每个请求的时间会缩短,即使某个请求失败,重新请求即可;后端做合并切片的操作。

在写切片上传之前,归纳下 切片 File 相关的一系列 API。File、Blob、FileReader、ArrayBuffer 等等

切片之前

Blob

Blobbinary large object---二进制大对象): 表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作。

new Blob(array, options);:

  1. array: 由 ArrayBufferArrayBufferViewBlobDOMString 等对象构成的,将会被放进 Blob;
  2. options: 对象,常规一个属性是type, 默认值为 “”,表示将会被放入到 blob 中的数组内容的 MIME 类型;endings —— 默认值为 “transparent”,用于指定包含行结束符 \n 的字符串如何被写入。

例子:

new Blob(["Hello World"], { type: "text/plain" });

由图片可以看到两个 sizetype (只读)

Blob 或者 File 文件只是一个类似仓库的东西,我们无法通过它取出来原始的二进制数据,而完成这些操作的是readAsArrayBuffer()方法,它是 FileReader对象的实例方法,作用就是将其中的二进制数据取出来

方法

  • slice([start[, end[, contentType]]]):返回一个新的 Blob 对象,包含了源 Blob 对象中指定范围内的数据
  • stream():返回一个能读取 blob 内容的 ReadableStream。
  • text():返回一个 Promise 对象且包含 blob 所有内容的 UTF-8 格式的 USVString。
  • arrayBuffer():返回一个 Promise 对象且包含 blob 所有内容的二进制格式的 ArrayBuffer。

文件(File): File 对象是特殊类型的 Blob,且可以用在任意的 Blob 类型的 context 中。Blob 的属性和方法都可以用于 File 对象。

例子:

const file = new File(["hello".repeat(1000000)], "demo.txt"); // 设置一个超级大的文件
const cutSize = 50000; // 设置一个每次切片的最大的size
for (let start = 0; start < file.size; start += cutSize) {
  const cutFile = file.slice(start, start + cutSize + 1); // 切好的 file
  const formData = new FormData();
  formData.append("file", cutFile); // formData 开始上传文件

  // **** axios   等  接口触发
}

FileReader

FileReader 是一个异步 API,用于读取文件并提取其内容以供进一步使用。FileReader 可以将 Blob 读取为不同的格式。

在这里就简单是用一个例子介绍下,具体还是网络搜索了解其深层意义。

FileReader 对象继承自 EventTarget: EventTarget是一个 DOM 接口,由可以接收事件并且可以创建侦听器的对象实现

<input type="file" id="fileUpload" />
const fileInput = document.getElementById("fileUpload");

const reader = new FileReader(); // 创建了一个 FileReader 对象
/**
 *
 * abort:该事件在读取操作被中断时触发;
 * error:该事件在读取操作发生错误时触发;
 * load:该事件在读取操作完成时触发;
 * progress:该事件在读取 Blob 时触发。
 *
 */
fileInput.onchange = (e) => {
  // readAsText : 读取指定 Blob 中的内容,完成之后,result 属性中将包含一个字符串以表示所读取的文件内容。
  // readAsDataURL: 读取指定 Blob 中的内容,完成之后,result 属性中将包含一个data: URL 格式的 Base64 字符串以表示所读取文件的内容。
  // readAsBinaryString: 读取指定 Blob 中的内容,完成之后,result 属性中将包含所读取文件的原始二进制数据;
  // readAsArrayBuffer: 读取指定 Blob 中的内容,完成之后,result 属性中保存的将是被读取文件的 ArrayBuffer 数据对象;
  reader.readAsText(e.target.files[0]); // 使用 readAsText() 方法读取 File 对象
};

reader.onload = (e) => {
  console.log(e.target.result); // 打印读取结果
};

Base64

是一种基于 64 个可打印字符来表示二进制数据的表示方法

atob():解码,解码一个 Base64 字符串;
btoa():编码,从一个字符串或者二进制数据编码一个 Base64 字符串。

btoa("file"); //  'ZmlsZQ=='
atob("ZmlsZQ=="); // 'file'

base64、File、Blob、ArrayBuffer 互转

  1. file 对象转 base64
const file = new File(["hello"], "demo.txt");
const reader = new FileReader();
reader.readAsDataURL(file);
console.log(reader);
  1. base64blob
// 都是例子,简单写
function demo() {
  const dataURI = "data:image/png;base64,iVBORw0KGgo";
  const byteStr = atob(dataURI.split(",")[1]);
  const mimeStr = dataURI.split(",")[0].split(":")[1].split(";")[0];
  const ab = new ArrayBuffer(byteStr.length); // 每个ArrayBuffer对象表示的只是内存中指定的字节数
  // 创建一个ab的引用,类型是Uint8
  let ia = new Uint8Array(ab);
  for (let i = 0; i < byteStr.length; i++) {
    ia[i] = byteStr.charCodeAt(i);
  }
  new Blob([ab], { type: mimeStr });
}
  1. blobArrayBuffer
let blob = new Blob([1, 2, 3, 4]);
let reader = new FileReader();
reader.onload = function (result) {
  console.log(result);
};
reader.readAsArrayBuffer(blob);
  1. bufferblob
let blob = new Blob([buffer]);
  1. blobbase64
const reader = new FileReader();
reader.onloadend = () => {
  console.log(reader.result);
};
reader.readAsDataURL(blob);

切片开始

步骤 1

先编写一个用户 excel 表 (其他文件也可以)

步骤 2

编写一个小小的简单的前端代码 (思路走完,不做任何优化)

思路:

  1. 上传文件 input
<input type="file" @change="onChange" />
<script>
  let file: any = null;
  let fileName = "";
  function onChange(e: any) {
    file = e.target.files[0];
    fileName = new Date().getTime() + file.name; // 暂时 定义成这种 文件名
  }
</script>
  1. 需要把这个文件根据大小分成若干个
let index = 0; // 这个用来定义 文件的顺序和个数的
//  定义一个每一次上传的大小
// 我们的user 文件大小是 10697 个字节
// 向上取整:Math.ceil(x)   Math.ceil(10697 / (1024 * 4)) = 3
const size = 1024 * 4;
// 定义一个文件块数组来装 n 个 size 大小的文件
const fileChunks = [];
// 分块
for (let i = 0; i < file.size; i += size) {
  const _file = file.slice(i, i + size);
  fileChunks.push({
    index: index++,
    file: _file,
    fileName,
  });
}
  1. 循环开始上传
fileChunks.map((item) => {
  uploadFc(item);
});

async function uploadFc(data: Object) {
  if (!data) return;
  // uploadUser  上传文件的 接口
  await uploadUser(data);
}
  1. 点击合并进行完成 整个 文件的上传
async function merge() {
  // mergeUser 合并的接口
  await mergeUser({
    fileName,
  });
}

完整代码

<template>
  <div class="file_main">
    <input type="file" @change="onChange" />
    <div>
      <a-space>
        <a-button type="primary" block @click="uploadClick">上传</a-button>
        <a-button block @click="merge">合并</a-button>
      </a-space>
    </div>
  </div>
</template>
<script lang="ts" setup>
  import { notification } from "ant-design-vue";
  import { ref } from "vue";
  import { uploadUser, mergeUser } from "@/http/api/index";
  const dataValue = ref<string>("");
  let file: any = null;
  let fileName = "";
  function onChange(e: any) {
    file = e.target.files[0];
    fileName = new Date().getTime() + file.name; // 暂时demo就这样
  }

  function uploadClick() {
    if (!file) return;
    // 1. 定义一个每一次上传的大小
    // const size = 1024 * 50
    const size = 1024 * 4;
    // 2. 定义一个 个数
    let index = 0;
    // 3. 定义一个文件块数组来装 n 个 size 大小的文件
    const fileChunks = [];
    // 赋值给fileChunks
    for (let i = 0; i < file.size; i += size) {
      const _file = file.slice(i, i + size);
      fileChunks.push({
        index: index++,
        file: _file,
        fileName,
      });
    }
    // 开始上传
    fileChunks.map((item) => {
      uploadFc(item);
    });
  }

  async function uploadFc(data: Object) {
    if (!data) return;
    const { code, msg } = await uploadUser(data);
    code === 200 &&
      notification.success({
        message: "提示",
        description: msg,
      });
  }

  async function merge() {
    const { code, msg } = await mergeUser({
      fileName,
    });
    code === 200 &&
      notification.success({
        message: "提示",
        description: msg,
      });
  }
</script>
<style lang="less" scoped>
  .file_main {
    padding: 48px;
    input {
      margin: 54px 0;
    }
  }
</style>

步骤 3

后端简单处理代码 (不做任何优化,我的主业还是 没怎么去玩 nodejs)

思路:

  1. 接受到每一块 切片 并进行存入到我们的 服务器对应位置
const { index, fileName } = ctx.request.body; // 获取 入参 index
const file = ctx.request.files.file; // 获取传的文件 file
// 需要 存的 文件夹 地址
const dir = `${path.join(__dirname, "../file")}/${fileName}`;
//  创建文件夹
if (!fs.existsSync(dir)) fs.mkdirSync(dir);
// 读取文件 前端传的 文件切片
const buffer = fs.readFileSync(file.filepath);
// 创建并写入
const ws = fs.createWriteStream(`${dir}/${index + "---" + fileName}`);
ws.write(buffer);
ws.close();
  1. 需要获取 文件夹所有的文件并合并成一个文件
const { fileName } = ctx.request.body; // 获取 入参 index
// 获取文件夹名称
const dir = `${path.join(__dirname, "../file")}/${fileName}`;

// 循环 并 组合成 缓存到 二进制数据  文件流
const bufferList = fs.readdirSync(`${dir}`).map((hash, index) => {
  const buffer = fs.readFileSync(
    path.resolve(dir, `./${index + "---" + fileName}`)
  );
  len += buffer.length;
  return buffer;
});
//合并文件
const buffer = Buffer.concat(bufferList, len);

// 创建并写入
const ws = fs.createWriteStream(`${dir}/${fileName}`);
ws.write(buffer);
ws.close();

完整代码

const Koa = require("koa");
const body = require("koa-body");
const router = require("koa-router")();
const fs = require("fs");
const path = require("path");

const app = new Koa();

//用于解析 formData
router.use(body({ multipart: true }));

router.post("/api/mergeUser", (ctx, next) => {
  const { fileName } = ctx.request.body; // 获取 入参 index
  const dir = `${path.join(__dirname, "../file")}/${fileName}`; // 获取文件夹名称
  try {
    if (!fs.existsSync(dir)) throw "失败";
    // 定义一个 合并的 buffer长度
    let len = 0;
    // 读取当前文件夹下面的所有文件 循环 并 组合
    const bufferList = fs.readdirSync(`${dir}`).map((hash, index) => {
      const buffer = fs.readFileSync(
        path.resolve(dir, `./${index + "---" + fileName}`)
      );
      len += buffer.length;
      return buffer;
    });
    //合并文件
    const buffer = Buffer.concat(bufferList, len);
    // 创建并写入
    const ws = fs.createWriteStream(`${dir}/${fileName}`);
    ws.write(buffer);
    ws.close();
    ctx.body = JSON.stringify({
      msg: `合并完成`,
      code: 200,
    });
  } catch (e) {
    console.log(e);
    ctx.body = JSON.stringify({
      msg: `合并失败`,
      code: 502,
    });
  }
});

router.post("/api/uploadUser", (ctx, next) => {
  ctx.set("content-type", "multipart/form-data;charset=utf-8");
  const { index, fileName } = ctx.request.body; // 获取 入参 index
  const file = ctx.request.files.file; // 获取传的文件 file
  const dir = `${path.join(__dirname, "../file")}/${fileName}`; // 全名
  // console.log(file);
  try {
    // 判断是否存在这个 文件, 不存在就创建这个文件夹
    if (!fs.existsSync(dir)) fs.mkdirSync(dir);
    // 读取文件 对应的
    const buffer = fs.readFileSync(file.filepath);
    // 创建并写入
    const ws = fs.createWriteStream(`${dir}/${index + "---" + fileName}`);
    ws.write(buffer);
    ws.close();
    ctx.body = JSON.stringify({
      msg: `${index}: ${fileName}上传成功`,
      code: 200,
    });
  } catch (e) {
    console.log(e);
    ctx.body = JSON.stringify({
      msg: `${index}: ${fileName}上传失败`,
      code: 502,
      data: index,
    });
  }
});

app.use(router.routes());
app.use(router.allowedMethods());

app.listen(3000, () => {
  console.log("server run: http://127.0.0.1:3000");
});

效果图

选择文件并点击确定后

点击合并后

当然 切片的几个文件是打不开的

冲刺完善

上面的大家伙可以也看到了很多的不足,什么切片部分失败了怎么办,切片完成了后是不是应该自动触发合并,合并后是否应该把切片的存到文件夹里面的删除掉。

现在呢,这里就小小的小小的优化下

我们的目的其实是书写切片上传文件的一些步骤思路,而不在 demo 的做到功能完美、代码完美。

  1. 优化下 input 控制 文件个数( single )
  2. 优化下 input 控制的 传入文件格式
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
  1. 优化下 后端 删除 临时文件 合并后
fs.readdirSync(`${dir}`).map((hash, index) => {
  const _url = path.resolve(dir, `./${index + "---" + fileName}`);
  if (fs.existsSync(_url)) fs.unlinkSync(_url);
});
  1. 优化失败进行显示点击再次上传
  2. 优化前端点击上传 根据上传 自动识别是否 调用合并接口

完整前端代码

<template>
  <input
    type="file"
    @change="onChange"
    single
    accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
  />
  <div>
    <p
      v-for="item in fileChunks"
      :key="item.index"
      :class="item.code && item.code !== 200 ? 'error' : ''"
    >
      {{ item.index }}: {{ item.fileName }}
      <span class="icon_status">
        <check-circle-two-tone
          two-tone-color="#52c41a"
          v-if="item.code === 200"
        />
        <close-circle-outlined v-else-if="item.code && item.code !== 200" />
        <sync-outlined spin v-else />
      </span>
      <a-button
        type="link"
        v-if="item.code && item.code !== 200"
        @click="reUpload(item)"
        >重新上传</a-button
      >
    </p>
  </div>
  <div>
    <a-space>
      <a-button type="primary" block @click="uploadClick">上传</a-button>
    </a-space>
  </div>
</template>
<script lang="ts" setup>
  import {
    CheckCircleTwoTone,
    SyncOutlined,
    CloseCircleOutlined,
  } from "@ant-design/icons-vue";
  import { notification } from "ant-design-vue";
  import { ref } from "vue";
  import { uploadUser, mergeUser } from "@/http/api/index";

  interface Chunks {
    index: number;
    file: Blob;
    fileName: string;
    code?: number;
  }

  const dataValue = ref<string>("");
  let file: File;
  let fileName = "";
  let successNum = 0;
  const fileChunks = ref<Chunks[]>([]);
  function onChange(e: any) {
    successNum = 0;
    file = e.target.files[0];
    fileName = new Date().getTime() + "-" + file.name; // 暂时demo就这样
  }

  function uploadClick() {
    if (!file) {
      notification.error({
        message: "提示",
        description: "请选择一个文件",
      });
      return;
    }
    // 1. 定义一个每一次上传的大小
    // const size = 1024 * 50
    const size = 1024 * 4;
    // 2. 定义一个 个数
    let index = 0;
    // 赋值给fileChunks
    for (let i = 0; i < file.size; i += size) {
      const _file = file.slice(i, i + size);
      fileChunks.value.push({
        index: index++,
        file: _file,
        fileName,
      });
    }
    // 开始上传
    fileChunks.value.map((item, ind) => {
      setTimeout(() => {
        uploadFc(item);
      }, 5000 * ind);
    });
  }

  function reUpload(data: Chunks) {
    data.code = undefined;
    uploadFc(data);
  }

  async function uploadFc(data: Chunks) {
    if (!data) return;
    const { code, msg } = await uploadUser(data);
    if (code === 200) {
      successNum++;
      if (successNum === fileChunks.value.length) {
        merge();
      }
    }

    data.code = code;
  }

  async function merge() {
    const { code, msg } = await mergeUser({
      fileName,
    });
    code === 200 &&
      notification.success({
        message: "提示",
        description: "上传成功",
      });
  }
</script>

效果图:

总结

假吧意思的优化哈,很多也没有优化(什么 hash 更严谨什么的), nodejs 上的优化很多都不想做了,了解其中 的 某一种方式即可,前端了解内部一种方式,和后端配合,不管达成了什么统一,基本不离其原理。

切片的目的是为了 超大文件 进行分割,然后逐个击破。网络故障、网页缓慢等也可以没上传完的单独上传。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值