文件上传
1. 文件上传场景
2.大文件分块上传
在上传大文件时,为了提高上传的效率,我们一般会使用 Blob.slice 方法对大文件按照指定的大小进行切割,然后通过多线程进行分块上传,等所有分块都成功上传后,再通知服务端进行分块合并
需要用到FormData、FileReader、File、Blob对象
如何实现大文件分块上传?
获取上传文件–>计算文件的md5–>判断文件是否存在–>如果不存在则实行并发分片上传–>上传全部分块执行合并操作
文件md5
使用spark-md5),分片上传需要一个唯一标识,而md5非常合适,使用的是
ArrayBuffer
方法,另一种是用SparkMD5.hashBinary( ) 直接将整个文件的二进制码传入直接返回文件的md5、这种方法对于小文件会比较有优势——简单并且速度快const calcFileMD5 = (file) => { return new Promise((resolve, reject) => { let chunkSize = 2097152, // 2M chunks = Math.ceil(file.size / chunkSize), currentChunk = 0, spark = new SparkMD5.ArrayBuffer(), fileReader = new FileReader(); fileReader.onload = (e) => { spark.append(e.target.result); currentChunk++; if (currentChunk < chunks) { loadNext(); } else { resolve(spark.end()); // 输出结果 } }; fileReader.onerror = (e) => { reject(fileReader.error); }; function loadNext() { let start = currentChunk * chunkSize, end = start + chunkSize >= file.size ? file.size : start + chunkSize; fileReader.readAsArrayBuffer(file.slice(start, end)); } loadNext(); }); }
判断文件是否上传
function checkFileExist(url, name, md5) { return request.get(url, { params: { name, md5, }, }).then((response) => response.data); }
// node逻辑 const fse = require('fs-extra'); router.get("/upload/exists", async (ctx) => { const { name: fileName, md5: fileMd5 } = ctx.query; const filePath = path.join(UPLOAD_DIR, fileName); const isExists = await fse.pathExists(filePath); // 判断哪是否已经存在文件 if (isExists) { ctx.body = { status: "success", data: { isExists: true, url: `${RESOURCE_URL}/${fileName}`, }, }; } else { let chunkIds = []; const chunksPath = path.join(TMP_DIR, fileMd5); const hasChunksPath = await fse.pathExists(chunksPath); // 判断分块文件夹是否相同 if (hasChunksPath) { let files = await readdir(chunksPath); chunkIds = files.filter((file) => { return IGNORES.indexOf(file) === -1; }); } ctx.body = { status: "success", data: { isExists: false, chunkIds, }, }; } });
并发控制,参考async-pool
asyncPool
函数,它用于实现异步任务的并发控制。该函数接收 3 个参数:poolLimit
(数字类型):表示限制的并发数;array
(数组类型):表示任务数组;iteratorFn
(函数类型):表示迭代函数,用于实现对每个任务项进行处理,该函数会返回一个 Promise 对象或异步函数。
async function asyncPool(poolLimit, array, iteratorFn) { const ret = []; // 存储所有的异步任务 const executing = []; // 存储正在执行的异步任务 for (const item of array) { // 调用iteratorFn函数创建异步任务 const p = Promise.resolve().then(() => iteratorFn(item, array)); ret.push(p); // 保存新的异步任务 // 当poolLimit值小于或等于总任务个数时,进行并发控制 if (poolLimit <= array.length) { // 当任务完成后,从正在执行的任务数组中移除已完成的任务 const e = p.then(() => executing.splice(executing.indexOf(e), 1)); executing.push(e); // 保存正在执行的异步任务 if (executing.length >= poolLimit) { await Promise.race(executing); // 等待较快的任务执行完成 } } } return Promise.all(ret); }
upload函数
如果文件没有上传则执行此操作
function upload({ url, file, fileMd5, fileSize, chunkSize, chunkIds, poolLimit = 1, }) { const chunks = typeof chunkSize === "number" ? Math.ceil(fileSize / chunkSize) : 1; return asyncPool(poolLimit, [...new Array(chunks).keys()], (i) => { if (chunkIds.indexOf(i + "") !== -1) { // 已上传的分块直接跳过 return Promise.resolve(); } let start = i * chunkSize; let end = i + 1 == chunks ? fileSize : (i + 1) * chunkSize; const chunk = file.slice(start, end); // 对文件进行切割 return uploadChunk({ url, chunk, chunkIndex: i, fileMd5, fileName: file.name, }); }); }
对于切割完的文件块,会通过
uploadChunk
函数,来执行实际的上传操作:function uploadChunk({ url, chunk, chunkIndex, fileMd5, fileName }) { let formData = new FormData(); formData.set("file", chunk, fileMd5 + "-" + chunkIndex); formData.set("name", fileName); formData.set("timestamp", Date.now()); return request.post(url, formData); }
合并操作,node中使用的是
createReadStream
、createWriteStream
以流的方式来读写文件function concatFiles(url, name, md5) { return request.get(url, { params: { name, md5, }, }); }
// node逻辑 const util = require("util"); const fs = require("fs"); const readdir = util.promisify(fs.readdir); router.get("/upload/concatFiles", async (ctx) => { const { name: fileName, md5: fileMd5 } = ctx.query; await concatFiles( path.join(TMP_DIR, fileMd5), path.join(UPLOAD_DIR, fileName) ); ctx.body = { status: "success", data: { url: `${RESOURCE_URL}/${fileName}`, }, }; }); // 合并文件使用流的方式 async function concatFiles(sourceDir, targetPath) { const readFile = (file, ws) => // 写入目标文件 new Promise((resolve, reject) => { fs.createReadStream(file) .on("data", (data) =>ws.write(data)) .on("end", resolve) .on("error", reject); }); const files = await readdir(sourceDir); // 读取目录下的所有的分块文件 const sortedFiles = files .filter((file) => { return IGNORES.indexOf(file) === -1; }) .sort((a, b) => a - b); const writeStream = fs.createWriteStream(targetPath); // 循环进行写入 for (const file of sortedFiles) { let filePath = path.join(sourceDir, file); // console.log(filePath, 'filePath'); await readFile(filePath, writeStream); await unlink(filePath); // 删除已合并的分块 } writeStream.end(); }
3.ArrayBuffer
ArrayBuffer又称类型化数组,是一个二进制数据的原始缓存区(分配一段可以存放数据的连续内存区域),无法直接读取或者写入只能通过视图(
TypedArray
视图和DataView
视图)来读写,视图的作用是以指定格式解读二进制数据。数组是放在堆中,ArrayBuffer数组则把数据放在栈中(所以取数据时后者快)
可以根据需要传递类型化数组或者DataView对象来解释原始缓存区
ArrayBuffer初始化后固定大小,数组则可以自由增减。
类型化数组有以下几种
名称 占用字节 描述
Int8Array 1 8位二补码有符号整数
Uint8Array 1 8位无符号整数
Uint8ClampedArray 1 8位无符号整型固定数组(数值在0~255之间)
Int16Array 2 16位二补码有符号整数
Uint16Array 2 16位无符号整数
Int32Array 4 32 位二补码有符号整数
Uint32Array 4 32 位无符号整数
Float32Array 4 32 位 IEEE 浮点数
Float64Array 8 64 位 IEEE 浮点数
这个接口的原始设计的目的,与WebGL项目有关。所谓WebGL,就是指浏览器与显卡之间的通信接口,为了满足JavaScript与显卡之间大量的、实时的数据交换,他们之间的数据通信必须是二进制的,而不能是传统的文本格式,文本格式传递一个32位整数,两端的JavaScript脚本和显卡都要进行格式转化,将非常耗时。这时要是存在一种机制,可以像C语言一样,可以直接操作字节,将4个字节的32位整数,以二进制形式原封不动的送入显卡,脚本的性能就会大幅提升。直接操作内存,大大增强了JavaScript处理二进制数据的能力,使得开发者有可能通过JavaScript与操作系统的原生接口进行二进制通信
4、项目地址
https://github.com/rookiewxy/electron-react-server
https://github.com/rookiewxy/electron-react-demo