大文件切片上传
前言
对文件进行切片,就是将一个请求拆分成多个请求,每个请求的时间会缩短,即使某个请求失败,重新请求即可;后端做合并切片的操作。
在写切片上传之前,归纳下 切片 File 相关的一系列 API。File、Blob、FileReader、ArrayBuffer 等等
切片之前
Blob
Blob( binary large object---二进制大对象
): 表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream
来用于数据操作。
new Blob(array, options);
:
array
: 由ArrayBuffer
、ArrayBufferView
、Blob
、DOMString
等对象构成的,将会被放进 Blob;options
: 对象,常规一个属性是type
, 默认值为 “”,表示将会被放入到 blob 中的数组内容的 MIME 类型;endings
—— 默认值为 “transparent”,用于指定包含行结束符 \n 的字符串如何被写入。
例子:
new Blob(["Hello World"], { type: "text/plain" });
由图片可以看到两个 size
与 type
(只读)
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 互转
- file 对象转 base64
const file = new File(["hello"], "demo.txt");
const reader = new FileReader();
reader.readAsDataURL(file);
console.log(reader);
base64
→blob
// 都是例子,简单写
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 });
}
blob
→ArrayBuffer
let blob = new Blob([1, 2, 3, 4]);
let reader = new FileReader();
reader.onload = function (result) {
console.log(result);
};
reader.readAsArrayBuffer(blob);
buffer
→blob
let blob = new Blob([buffer]);
blob
→base64
const reader = new FileReader();
reader.onloadend = () => {
console.log(reader.result);
};
reader.readAsDataURL(blob);
切片开始
步骤 1
先编写一个用户 excel 表 (其他文件也可以)
步骤 2
编写一个小小的简单的前端代码 (思路走完,不做任何优化)
思路:
- 上传文件 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>
- 需要把这个文件根据大小分成若干个
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,
});
}
- 循环开始上传
fileChunks.map((item) => {
uploadFc(item);
});
async function uploadFc(data: Object) {
if (!data) return;
// uploadUser 上传文件的 接口
await uploadUser(data);
}
- 点击合并进行完成 整个 文件的上传
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)
思路:
- 接受到每一块 切片 并进行存入到我们的 服务器对应位置
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();
- 需要获取 文件夹所有的文件并合并成一个文件
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 的做到功能完美、代码完美。
- 优化下 input 控制 文件个数( single )
- 优化下 input 控制的 传入文件格式
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
- 优化下 后端 删除 临时文件 合并后
fs.readdirSync(`${dir}`).map((hash, index) => {
const _url = path.resolve(dir, `./${index + "---" + fileName}`);
if (fs.existsSync(_url)) fs.unlinkSync(_url);
});
- 优化失败进行显示点击再次上传
- 优化前端点击上传 根据上传 自动识别是否 调用合并接口
完整前端代码
<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 上的优化很多都不想做了,了解其中 的 某一种方式即可,前端了解内部一种方式,和后端配合,不管达成了什么统一,基本不离其原理。
切片的目的是为了 超大文件 进行分割,然后逐个击破。网络故障、网页缓慢等也可以没上传完的单独上传。