背景
前端上传文件时候,如果文件很大,上传时会出现各种问题,比如连接超时,网断了,都会导致上传失败。为了避免上传大文件时超时,就需要用到切片断点上传。
整体思路
-
客户端
- 使用 Blob.prototype.slice 方法对文件切片
- 使用 spark-md5 库计算文件 hash,并创建 web worker 开启新的线程执行
- 将各个分片文件上传到服务器,并携带 hash
- 上传进度计算
- 当所有文件上传完成后,发起合并请求,携带文件 hash、后缀名、分片大小
-
服务端
- 根据接收到的 hash 创建文件夹,将分片文件存储到文件夹中
- 收到合并请求后,读取各个分片文件。根据 hash 和后缀名,合并生成完整文件
- 删除存储分片文件的文件夹及其内容
架构图
- 客户端
- 服务端
前置知识点
大文件切片原理
File 是特殊的 Blob 对象,大文件上传切片的原理就是利用Blob.prototype.slice
方法,文件的slice方法可以返回原文件的某个切片
。和数组的 slice 方法相似。
ArrayBuffer
ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。ArrayBuffer 的内容不能直接操作,只能通过 DataView 对象或 TypedArray 对象来访问。这些对象用于读取和写入缓冲区内容。
FileReader
FileReader 是一个异步 API,用于读取文件并提取其内容以供进一步使用。它可以将 Blob 读取为不同的格式。
- 基本使用
可以使用 FileReader 构造函数来创建一个 FileReader 对象:
const reader = new FileReader();
这个对象常用属性如下:
error
:表示在读取文件时发生的错误;result
:文件内容。该属性仅在读取操作完成后才有效,数据的格式取决于使用哪种方法来启动读取文件readyState
:表示FileReader
状态的数字。取值如下:
常量名 | 值 | 描述 |
---|---|---|
EMPTY | 0 | 还没有加载任何数据 |
LOADING | 1 | 数据正在被加载 |
DONE | 2 | 已完成全部的读取要求 |
FileReader 对象提供了读取 ArrayBuffer
数据对象的方法:
FileReader.readAsArrayBuffer()
:该方法会按字节读取文件内容,转换为 ArrayBuffer 对象。readAsArrayBuffer() 方法读取文件后,会在内存中创建一个 ArrayBuffer 对象(二进制缓冲区),会将二进制数据存放在其中。语法结构:
FileReader.readAsArrayBuffer(Blob|File);
参数为 Blob 或 File 对象。
代码示例:
<input type="file" id="file" />
<script>
window.onload = function () {
const inputEl = document.getElementById("file");
inputEl.onchange = function () {
const file = inputEl.files[0];
if (file) {
let reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = function () {
console.log(this.result);
// ArrayBuffer(50122101)
console.log(new Blob([this.result]));
// Blob {size: 50122101, type: ''}
}
}
}
}
</script>
- 事件代理
FileReader 对象常用的事件如下:
FileReader.onabort
:该事件在读取操作被中断时触发FileReader.onerror
:该事件在读取操作发生错误时触发FileReader.onload
:该事件在读取操作完成时触发FileReader.onprogress
:该事件在读取 Blob 时触发
FormData
FormData 对象用以将数据编译成键值对,以便用XMLHttpRequest
来发送数据,其主要用于发送表单数据,但亦可用于发送带键数据(keyed data),而独立于表单使用。
创建FormData
对象,调用它的append()
方法来添加字段:
var formData = new FormData();
formData.append("username","张三");
使用 spark-md5 生成 md5 文件
简单demo:
<input type="file" id="file" />
<input type="button" id="submitBtn" onclick="submitFile()" value="提交" />
<script>
const btnEl = document.getElementById("submitBtn");
const fileEl = document.getElementById("file");
function submitFile() {
const file = fileEl.files[0];
if (file) {
var fileSize = file.size;
const chunkSize = 5 * 1024 * 1024;
var chunks = Math.floor(fileSize / chunkSize);
var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;;
var spark = new SparkMD5.ArrayBuffer();
var reader = new FileReader();
var currentChunk = 0;
reader.onload = function (e) {
const result = e.target.result;
spark.append(result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
console.log(`第${currentChunk}分片解析完成,开始解析${currentChunk + 1}分片`);
} else {
const md5 = spark.end();
console.log("解析完成");
console.log(md5);
}
};
function loadNext() {
var start = currentChunk * chunkSize;
var end = start + chunkSize > file.size ? file.size : (start + chunkSize);
reader.readAsArrayBuffer(blobSlice.call(file, start, end));
};
loadNext();
}
}
</script>
技术选型
客户端:html + 原生 JavaScript
服务端:Nodejs + multiparty
具体实现
客户端
- 上传页面
文件上传控件选用了最原始的input
上传,具体代码实现:
<input id="uploadFile" type="file" />
<button type="button" id="uploadBtn" onClick="startUpload()">开始上传</button>
<div class="progress">
上传进度:<span id="progressValue">0</span>
</div>
- 请求封装
考虑到通用性,使用原生 XMLHttpRequest
做一层简单的封装来请求
/**
* 封装请求
* @param {any} {
* url,
* method="post",
* data,
* headers={}
* }
* @returns
*/
function singleRequest({
url,
method = "post",
data,
headers = {}
}) {
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
Object.keys(headers).forEach(key => {
xhr.setRequestHeader(key, headers[key]);
});
xhr.send(data);
xhr.onload = e => {
resolve({
data: e.target.response
});
};
});
}
- 文件切片
点击上传按钮时,先调用handleFileChunk
方法将文件切片。具体实现过程是:设置默认单个切片文件的大小,通过while
循环和slice
方法将文件进行切割,并将生成的一个个 chunk 切片数据放到fileChunkList
数组中:
/**
* 文件分片
* @param {any} file 文件
* @param {any} chunkSize 分片大小
*/
const handleFileChunk = function (file, chunkSize) {
const fileChunkList = [];
// 索引值
let curIndex = 0;
while (curIndex < file.size) {
const endIndex = curIndex + chunkSize < file.size ? curIndex + chunkSize : file.size;
const curFileChunkFile = file.slice(curIndex, endIndex);
curIndex += chunkSize;
fileChunkList.push({ file: curFileChunkFile });
}
return fileChunkList;
}
- 生成 hash
无论是客户端还是服务端,都要用到文件和切片的 hash,生成 hash 最简单的方法是 文件名 + 切片下标,但是如果文件名一旦修改,生成的 hash 就会失效。事实上只要文件内容不变, hash 就不应该变化,所以我们根据文件内容生成 hash
这里我们选用 spark-md5
库,它可以根据文件内容计算出文件的hash
值。
如果上传的文件过大时,读取文件内容计算hash
非常耗时,并且会引起 UI 阻塞,导致页面假死,所以我们使用web-worker
在worker 线程计算 hash
,这样仍可以在主界面正常做交互。具体做法如下:
self.importScripts("./spark-md5.min.js");
//接受主进程发送过来的数据
self.onmessage = function (e) {
const { fileChunkList } = e.data;
getFileHash(fileChunkList).then((hash) => {
self.postMessage({
hash: hash
});
}).catch(() => {
self.postMessage({
error: "crate hash error"
});
})
};
/**
*
* 获取全部文件内容hash
* @param {any} fileList
*/
async function getFileHash(fileList) {
const spark = new SparkMD5.ArrayBuffer();
const result = fileList.map((item, key) => {
return getFileContent(item.file)
});
try {
const contentList = await Promise.all(result);
for (let i = 0; i < contentList.length; i++) {
spark.append(contentList[i]);
}
return spark.end();
} catch (e) {
console.log(e);
}
}
/**
*
* 获取全部文件内容
* @param {any} file
* @returns
*/
function getFileContent(file) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
//读取文件内容
fileReader.readAsArrayBuffer(file);
fileReader.onload = (e) => {
resolve(e.target.result);
};
fileReader.onerror = (e) => {
reject(fileReader.error);
fileReader.abort();
};
});
}
- 收集切片信息
生成文件切片后,将计算出来的hash、切片索引、文件个数、当前切片的hash、切片内容等信息都收集起来,发送给服务端。上传索引的目的是为了让后端可以知道当前切片是第几个切片,方便服务端合并切片:
const start = async function (bigFile) {
// 生成多个切片
const fileList = handleFileChunk(bigFile, DefaultChunkSize);
// 获取整个大文件的内容hash,方便实现秒传
const containerHash = await calculateHash(fileList);
// 给每个切片添加辅助内容信息
const chunksInfo = fileList.map(({ file }, index) => ({
// 整个文件hash
fileHash: containerHash,
// 当前是第几个切片
index,
// 文件个数
fileCount: fileList.length,
// 当前切片的hash
hash: containerHash + "-" + index,
// 切片内容
chunk: file,
// 文件总体大小
totalSize: bigFile.zise,
// 单个文件大小
size: file.size
}));
// 上传所有文件
uploadChunks(chunksInfo, bigFile.name);
}
- 上传切片
将切片相关的信息都放入formData
中,对每一个切片调用singleRequest
请求,生成Promise
,最后调用Promise.all
并发上传所有的切片:
/**
* 上传所有切片
* @param {any} chunks
* @param {any} fileName
*/
async function uploadChunks(chunks, fileName) {
const requestList = chunks
.map(({ chunk, hash, fileHash, index, fileCount, size, totalSize }) => {
//生成每个切片上传的信息
const formData = new FormData();
formData.append("hash", hash);
formData.append("index", index);
formData.append("fileCount", fileCount);
formData.append("size", size);
formData.append("splitSize", DefaultChunkSize);
formData.append("fileName", fileName);
formData.append("fileHash", fileHash);
formData.append("chunk", chunk);
formData.append("totalSize", totalSize);
return { formData, index };
})
.map(async ({ formData, index }) =>
singleRequest({
url: "http://127.0.0.1:3000/uploadBigFile",
data: formData,
})
);
//全部上传
await Promise.all(requestList);
}
- 暂停上传
原理是使用 XMLHttpRequest 的 abort 方法,取消一个xhr请求的发送。因此需要把上传每个切片的 xhr 对象保存起来:
/**
* 单个文件上传请求
* @param {any} {
url,
method = "post",
data,
headers = {},
xhrList
}
* @returns
*/
function singleRequest({
url,
method = "post",
data,
headers = {},
xhrList
}) {
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
Object.keys(headers).forEach(key => {
xhr.setRequestHeader(key, headers[key]);
});
xhr.send(data);
xhr.onload = e => {
// 将请求成功的xhr从列表删除
if (xhrList) {
const xhrIndex = xhrList.findIndex(item => item === xhr);
xhrList.splice(xhrIndex, 1);
}
resolve({
data: e.target.response
})
};
// 暴露xhr给外部
xhrList?.push(xhr);
})
}
- 恢复上传
切片文件上传后,服务端会创建一个文件夹存储所有上传的切片。每次前端上传前可以先验证下服务端是否已经保存了该切片,并把已经上传的切片列表返回,前端上传就跳过这些已经上传的切片
服务端
- express 搭建 node 服务
使用 express 搭建一个简单的 node 服务:
import http from "http";
import express from "express";
import createError from "http-errors";
const port = 3000;
const app = express();
const server = http.createServer(app);
// 设置跨域访问
app.use(function (req, res, next) {
// 设置允许跨域的域名,* 表示允许任意域名跨域
res.header("Access-Control-Allow-Origin", req.headers.origin);
// 允许携带cookie
res.header("Access-Control-Allow-Credentials", "true");
// 允许的header类型
res.header("Access-Control-Allow-Headers", [
"content-type",
"Origin"
]);
// 跨域允许的请求方式
res.header("Access-Controll-Allow-Methods", "DELETE,PUT,POST,GET,OPTIONS");
res.header("Access-Controll-Allow-Max-Age", `${20}`);
if (req.method.toLowerCase() === "options") res.send(200);
// 让options尝试请求快速结束
else next();
});
server.listen(port, () => {
console.log("监听端口:", port);
});
- 接收客户端传来的切片
服务端使用multiparty
处理客户端传来的formData
在 multipary.parse 的回调中,files 参数保存了 formData 中文件,fields 参数保存了 formData 中非文件的字段:
import http from "http";
import express from "express";
import path = require("path");
import createError from "http-errors";
const multiparty = require("multiparty");
const fse = require("fs-extra");
const port = 3000;
const app = express();
// 上传成功后返回URL地址
const resourceUrl = `http://127.0.0.1:${port}`;
// 存储文件目录
const uploadDir = path.join(__dirname, "/upload");
// 设置静态访问目录
app.use(express.static(uploadDir));
const server = http.createServer(app);
// 设置跨域访问
app.use(function (req, res, next) {
// 设置允许跨域的域名,* 表示允许任意域名跨域
res.header("Access-Control-Allow-Origin", req.headers.origin);
// 允许携带cookie
res.header("Access-Control-Allow-Credentials", "true");
// 允许的header类型
res.header("Access-Control-Allow-Headers", ["content-type", "Origin"]);
// 跨域允许的请求方式
res.header("Access-Controll-Allow-Methods", "DELETE,PUT,POST,GET,OPTIONS");
res.header("Access-Controll-Allow-Max-Age", `${20}`);
if (req.method.toLowerCase() === "options") res.send(200);
// 让options尝试请求快速结束
else next();
});
/**
* 提取文件后缀名
* @param filename
*/
const extractExt = (filename) => {
filename.slice(filename.lastIndexOf("."), filename.length);
};
app.post("/uploadBigFile", function (req, res, _next) {
const multipart = new multiparty.Form();
multipart.parse(req, async (err, fields, files) => {
if (err) {
console.error(err);
return res.json({
code: 5000,
data: null,
msg: "上传文件失败",
});
}
// 取出文件内容
const [chunk] = files.chunk;
// 当前chunk 文件 hash
const [hash] = fields.hash;
// 大文件的名称
const [fileName] = fields.fileName;
// 大文件的 hash
const [fileHash] = fields.fileHash;
// 切片索引
const [index] = fields.index;
// 总共切片个数
const [fileCount] = fields.fileCount;
// 当前 chunk 的大小
const [splitSize] = fields.splitSize;
// 整个文件大小
const [totalSize] = fields.totalSize;
// 切片目录不存在,创建切片目录
if (!fse.existsSync(chunkDir)) {
await fse.mkdirs(chunkDir);
}
const chunkFile = path.resolve(chunkDir, hash);
if (!fse.existsSync(chunkFile)) {
await fse.move(chunk.path, path.resolve(chunkDir, hash));
}
});
});
server.listen(port, () => {
console.log("监听端口:", port);
});
- 合并切片
接收到客户端发送的合并请求后,服务端将文件夹下的所有切片进行合并:
const fse = require("fs-extra");
const path = require("path");
/**
* 读流,写流
* @param path
* @param writeStream
* @returns
*/
const pipeStream = (path, writeStream) =>
new Promise((resolve) => {
const readStream = fse.createReadStream(path);
readStream.on("end", () => {
// fse.unlinkSync(path);
resolve(null);
});
readStream.pipe(writeStream);
});
/**
*
* 合并所有切片
* @export
* @param {any} {
* filePath:文件路径包含后缀名
* fileHash:文件hash
* chunkDir:切片存放的临时目录
* splitSize:每个切片的大小
* fileCount:文件总个数
* totalSize:文件总大小
* }
* @returns
*/
export async function chunkMerge({
filePath,
fileHash,
chunkDir,
splitSize,
fileCount,
totalSize,
}) {
const chunkPaths = await fse.readdir(chunkDir);
//帅选合适的切片
const filterPath = chunkPaths.filter((item) => {
return item.includes(fileHash);
});
//数量不对,抛出错误
if (filterPath.length !== fileCount) {
console.log("合并错误");
return;
}
// 根据切片下标进行排序,方便合并
filterPath.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
await Promise.all(
chunkPaths.map((chunkPath, index) => {
//并发写入,需要知道开始和结束位置
let end = (index + 1) * splitSize;
if (index === fileCount - 1) {
end = totalSize + 1;
}
return pipeStream(
path.resolve(chunkDir, chunkPath),
// 指定位置创建可写流
fse.createWriteStream(filePath, {
start: index * splitSize,
end: end,
})
);
})
);
//删除所有切片
// fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录
return filePath;
}
- 文件秒传
文件秒传,就是服务端已经存在了上传的资源,当用户再次上传时会直接提示上传成功。
文件秒传依赖上面生成的 hash,由于 hash 的唯一性,一旦服务端找到 hash 相同的文件,直接返回上传成功的消息
/**
* 提取文件后缀名
* @param filename
* @returns
*/
const extractExt = (filename) =>
filename.slice(filename.lastIndexOf("."), filename.length); // 提取后缀名
const saveFileName = `${fileHash}${extractExt(fileName)}`;
//获取整个文件存储路径
const filePath = path.resolve(uploadDIr, saveFileName);
// 大文件存在直接返回,根据内容hash存储,可以实现后续秒传
if (fse.existsSync(filePath)) {
return res.json({
code: 1000,
data: { url: `${resourceUrl}${saveFileName}` },
msg: "上传文件已存在",
});
}