Appearance
前置知识
1.FileReader
FileReader是一个读取文件的API,可以异步的读取并格式化文件。可以对读取出来的文件进行进一步操作。将文件真真的数据读取出来,计算出hash值。
ts
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(file); // 将文件转换成缓冲区数组
fileReader.onload=()=>{
fileReader.result // 读取的结果
const data = new Blob([fileReader.result]) // 转换成blob对象
}
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(file); // 将文件转换成缓冲区数组
fileReader.onload=()=>{
fileReader.result // 读取的结果
const data = new Blob([fileReader.result]) // 转换成blob对象
}
2.Blob、File
Blob和File并不是保存了文件的数据,而是仅仅保存了文件的基本信息。
Blob保存了文件的大小和类型
File继承与Blob,保存了文件的名字、修改时间等信息
这两个对象只是保存了文件的基本信息,若需要读取文件的内容还需要使用FileReader
这个API。
3.断点续传
断点续传就是客户端在还没有上传完所有分片时,取消了上传,想要恢复上一次的上传进度,通过与服务器交互得到当前已经上传的文件切片,客户端继续上传后续的切片,而不是上传所有切片。
客户端想要得知此文件的上传进度时,就需要使用hash,hash值在服务端中用来唯一标识一个文件,通过此hash值就能查找到对应文件。
hash就是一种算法,可以将任意数据转换成固定长度的字符串,且是不可逆的。当文件中某一个位发生了变化,整个hash的结果也不一样了,所以hash可以唯一标识一个文件,可以用简单的字符串,来描述整个文件的数据。秒传功能也是通过hash来实现。常用的hash算法就是md5。
4.hash算法
通过spark-md5可以使用hash算法来计算出文件的hash值。
计算hash的策略:
- 将文件的全部数据用来计算
- 第一个、最后一个切片全部参与计算,中间的切片只有前面两个字节、中间两个字节、最后两个字节用来计算hash。✅
- 增量计算,读取遍历所有切片来计算出整个文件的hash✅
5.文件切片
文件切片就是将大文件按照固定长度进行切片。
ts
/**
* 获取切片
* @param {File} file
* @param {number} chunkSize
* @returns
*/
function getChunk(file, chunkSize) {
const result = [];
for (let i = 0; i < file.size; i += chunkSize) {
result.push(file.slice(i, i+chunkSize));
}
return result;
}
/**
* 获取切片
* @param {File} file
* @param {number} chunkSize
* @returns
*/
function getChunk(file, chunkSize) {
const result = [];
for (let i = 0; i < file.size; i += chunkSize) {
result.push(file.slice(i, i+chunkSize));
}
return result;
}
6.计算整个文件的hash值
ts
/**
* 计算出整个文件的hash值
* @param {Blob[]} chunks
*/
async function getFileHush(chunks) {
// 整个文件的hash值
const result = new SparkMD5();
for (let i = 0; i < chunks.length; i++) {
await new Promise((r, j) => {
const fileReader = new FileReader();
fileReader.onload = function () {
result.append(this.result);
r();
};
fileReader.onerror = j;
fileReader.readAsArrayBuffer(chunks[i]);
});
}
return result.end();
}
/**
* 计算出整个文件的hash值
* @param {Blob[]} chunks
*/
async function getFileHush(chunks) {
// 整个文件的hash值
const result = new SparkMD5();
for (let i = 0; i < chunks.length; i++) {
await new Promise((r, j) => {
const fileReader = new FileReader();
fileReader.onload = function () {
result.append(this.result);
r();
};
fileReader.onerror = j;
fileReader.readAsArrayBuffer(chunks[i]);
});
}
return result.end();
}
hash计算过程监听
通过切片大小,知晓需要计算的总数量,计算完一个切片的hash就计数+1,然后计算出总比例就成。
ts
/**
* 计算出整个文件的hash值
* @param {Blob[]} chunks
*/
async function getFileHash(chunks) {
// 计算了多少个切片的hash了
let count = 0;
// 整个文件的hash值
const result = new SparkMD5();
for (let i = 0; i < chunks.length; i++) {
await new Promise((r, j) => {
const fileReader = new FileReader();
fileReader.onload = function () {
result.append(this.result);
count++;
console.log(`hash计算进度:${(count / chunks.length) * 100}%`);
r();
};
fileReader.onerror = j;
fileReader.readAsArrayBuffer(chunks[i]);
});
}
return result.end();
}
/**
* 计算出整个文件的hash值
* @param {Blob[]} chunks
*/
async function getFileHash(chunks) {
// 计算了多少个切片的hash了
let count = 0;
// 整个文件的hash值
const result = new SparkMD5();
for (let i = 0; i < chunks.length; i++) {
await new Promise((r, j) => {
const fileReader = new FileReader();
fileReader.onload = function () {
result.append(this.result);
count++;
console.log(`hash计算进度:${(count / chunks.length) * 100}%`);
r();
};
fileReader.onerror = j;
fileReader.readAsArrayBuffer(chunks[i]);
});
}
return result.end();
}
通过WebWorker计算出hash值
大文件需要让浏览器花费很多时间在JS线程上,导致渲染线程的卡顿,推荐使用WebWorker来计算出整个文件的hash值。
7.上传切片
上传切片通常是携带文件hash、切片hash、切片hash可以通过文件hash-切片索引
来构造出切片的hash。在上传切片时也要注意切片需要控制并发数量,不能同时将所有切片都上传了,可以考虑使用并发池的方式来上传切片。
1.并发上传所有分片❌
不推荐一次性上传所有分片,浏览器一次性只能同时进行六个HTTP请求,多余的请求都会被挂起,浪费资源。
2.逐片上传切片✅
每次上传一个切片,才可以上传下一个切片,同步的上传切片。效率低,但是简单。
ts
/**
* 逐片上传切片
* @param {Blob[]} chunks
* @param {string} hash
*/
async function chunksUpload01(chunks, hash) {
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
for (let i = 0; i < chunksData.length; i++) {
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://127.0.0.1:3000/upload");
xhr.send(chunksData[i]);
xhr.onload = resolve;
xhr.onerror = reject;
});
}
}
/**
* 逐片上传切片
* @param {Blob[]} chunks
* @param {string} hash
*/
async function chunksUpload01(chunks, hash) {
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
for (let i = 0; i < chunksData.length; i++) {
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://127.0.0.1:3000/upload");
xhr.send(chunksData[i]);
xhr.onload = resolve;
xhr.onerror = reject;
});
}
}
3.并发控制上传所有切片✅
ts
/**
* 并发上传切片
* @param {Blob[]} chunks
* @param {string} hash
*/
async function chunksUpload02(chunks, hash) {
// 构造每次请求的请求体
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
// 并发池
const pool = [];
// 最大并发数量
const max = 6;
for (let i = 0; i < chunksData.length; i++) {
// 发送请求
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://127.0.0.1:3000/upload");
xhr.send(chunksData[i]);
xhr.onload = resolve;
xhr.onerror = reject;
});
// 将请求保存并发池
pool.push(request);
// 请求成功,空闲出一个位置
request.then(() => {
const index = pool.findIndex((item) => item === request);
pool.splice(index, 1);
});
if (pool.length >= max) {
// 请求池满了
console.log("等待");
// 请求池达到最大,阻塞后续切片的上传直到有一个空闲位置
await Promise.race(pool);
console.log("等待结束");
}
}
// 等待所有请求结束
await Promise.all(pool);
console.log("切片上传完成!");
}
/**
* 并发上传切片
* @param {Blob[]} chunks
* @param {string} hash
*/
async function chunksUpload02(chunks, hash) {
// 构造每次请求的请求体
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
// 并发池
const pool = [];
// 最大并发数量
const max = 6;
for (let i = 0; i < chunksData.length; i++) {
// 发送请求
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://127.0.0.1:3000/upload");
xhr.send(chunksData[i]);
xhr.onload = resolve;
xhr.onerror = reject;
});
// 将请求保存并发池
pool.push(request);
// 请求成功,空闲出一个位置
request.then(() => {
const index = pool.findIndex((item) => item === request);
pool.splice(index, 1);
});
if (pool.length >= max) {
// 请求池满了
console.log("等待");
// 请求池达到最大,阻塞后续切片的上传直到有一个空闲位置
await Promise.race(pool);
console.log("等待结束");
}
}
// 等待所有请求结束
await Promise.all(pool);
console.log("切片上传完成!");
}
4.出错控制
切片上传失败的策略:全部重传?选择重传?GBN?
切片只要有一个上传失败,终止后续上传。恢复时,前端查询切片上传进度,后端返回上传的切片索引,前端就只需要上传未上传的切片即可。(选择重传)
8.文件上传错误控制
若服务器磁盘空间不足?(上传切片时,合并切片时需要考虑)
客户端上传有个切片上传失败?
实现
1.切片上传
后端实现
后端实现的步骤:
- 规定前端需要传递文件hash、切片hash、切片数据三个数据,并在处理函数中解析出来
- 通过文件hash创建切片文件夹,将所有相同文件hash的切片存储在此文件夹中
- 存储完成响应结果
ts
// 切片上传接口(不包含秒传的逻辑)
app.post("/upload", upload.single("file"), function (req, res, next) {
const file_hash = req.body["file-hash"]; // 约定前端传入file-hash代表文件的hash值
const chunk_hash = req.body["chunk-hash"]; // 约定传入chunk-hash代表切片的hash值
const file = req.file;
const [_, chunk_index] = chunk_hash.split("-"); // 约定切片的hash值的格式为:文件hash-切片索引
if (!chunk_hash || Number.isNaN(+chunk_index) || _ !== file_hash) {
res.status(400);
res.send("error");
}
console.log(chunk_hash, file_hash, chunk_index);
// 以文件hash为文件名,将此文件的所有切片存在此文件夹
const chunkDirPath = resolve(tempPath, `./${file_hash}`);
// 切片的路径
const chunkPath = resolve(chunkDirPath, `./${chunk_hash}`);
if (!existsSync(chunkDirPath)) {
// 不存在切片文件夹,说明首次上传,创建切片文件夹
mkdirSync(chunkDirPath);
}
// 将切片写入到对应的文件夹中
writeFile(chunkPath, file.buffer, (err) => {
if (err) {
res.send(err);
} else {
res.send("ok");
}
});
});
// 切片上传接口(不包含秒传的逻辑)
app.post("/upload", upload.single("file"), function (req, res, next) {
const file_hash = req.body["file-hash"]; // 约定前端传入file-hash代表文件的hash值
const chunk_hash = req.body["chunk-hash"]; // 约定传入chunk-hash代表切片的hash值
const file = req.file;
const [_, chunk_index] = chunk_hash.split("-"); // 约定切片的hash值的格式为:文件hash-切片索引
if (!chunk_hash || Number.isNaN(+chunk_index) || _ !== file_hash) {
res.status(400);
res.send("error");
}
console.log(chunk_hash, file_hash, chunk_index);
// 以文件hash为文件名,将此文件的所有切片存在此文件夹
const chunkDirPath = resolve(tempPath, `./${file_hash}`);
// 切片的路径
const chunkPath = resolve(chunkDirPath, `./${chunk_hash}`);
if (!existsSync(chunkDirPath)) {
// 不存在切片文件夹,说明首次上传,创建切片文件夹
mkdirSync(chunkDirPath);
}
// 将切片写入到对应的文件夹中
writeFile(chunkPath, file.buffer, (err) => {
if (err) {
res.send(err);
} else {
res.send("ok");
}
});
});
前端实现
实现步骤:
- 将文件按照后端规定的大小分片成固定大小的切片数组
- 通过第三方库使用hash算法增量计算出整个文件的hash值(计算hash的过程可以使用webworker)
- 将分片并发上传到后端,注意并发控制
ts
/**
* 并发上传切片(循环)
* @param {Blob[]} chunks
* @param {string} hash
*/
async function chunksUpload02(chunks, hash) {
// 上传成功的数量
let count = 0;
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
// 并发池
const pool = [];
// 最大并发数量
const max = 6;
for (let i = 0; i < chunksData.length; i++) {
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[i]);
xhr.onload = resolve;
xhr.onerror = reject;
});
// 将请求保存在并发池中
pool.push(request);
// 请求成功后,从并发池中移除该请求
request.then(() => {
// 计算上传进度
textProxy.uploadProgress = (++count / chunks.length) * 100;
const index = pool.findIndex((item) => item === request);
pool.splice(index, 1);
});
if (pool.length >= max) {
// 请求池满了
console.log("等待");
// 请求池达到最大,需要等待并发池空闲,才能上传后续分片
await Promise.race(pool);
console.log("等待结束");
}
}
// 等待所有请求结束
await Promise.all(pool);
console.log("切片上传完成!");
}
/**
* 并发上传切片(递归)
* @param {Blob[]} chunks
* @param {string} hash
*/
async function chunksUpload03(chunks, hash) {
// 上传成功的数量
let count = 0;
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
// 并发池
const pool = [];
// 最大并发数量
const max = 6;
return new Promise((r) => {
upload(0);
async function upload(index) {
if (index >= chunksData.length) {
// 等待所有请求结束
await Promise.all(pool);
console.log("切片上传完成!");
return r();
}
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[index]);
xhr.onload = resolve;
xhr.onerror = reject;
});
pool.push(request);
request.then(() => {
const requsetIndex = pool.findIndex((item) => item === request);
pool.splice(requsetIndex, 1);
textProxy.uploadProgress = (++count / chunksData.length) * 100;
});
if (pool.length >= max) {
await Promise.race(pool);
upload(index + 1);
} else {
upload(index + 1);
}
}
});
}
/**
* 并发上传切片(循环)
* @param {Blob[]} chunks
* @param {string} hash
*/
async function chunksUpload02(chunks, hash) {
// 上传成功的数量
let count = 0;
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
// 并发池
const pool = [];
// 最大并发数量
const max = 6;
for (let i = 0; i < chunksData.length; i++) {
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[i]);
xhr.onload = resolve;
xhr.onerror = reject;
});
// 将请求保存在并发池中
pool.push(request);
// 请求成功后,从并发池中移除该请求
request.then(() => {
// 计算上传进度
textProxy.uploadProgress = (++count / chunks.length) * 100;
const index = pool.findIndex((item) => item === request);
pool.splice(index, 1);
});
if (pool.length >= max) {
// 请求池满了
console.log("等待");
// 请求池达到最大,需要等待并发池空闲,才能上传后续分片
await Promise.race(pool);
console.log("等待结束");
}
}
// 等待所有请求结束
await Promise.all(pool);
console.log("切片上传完成!");
}
/**
* 并发上传切片(递归)
* @param {Blob[]} chunks
* @param {string} hash
*/
async function chunksUpload03(chunks, hash) {
// 上传成功的数量
let count = 0;
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
// 并发池
const pool = [];
// 最大并发数量
const max = 6;
return new Promise((r) => {
upload(0);
async function upload(index) {
if (index >= chunksData.length) {
// 等待所有请求结束
await Promise.all(pool);
console.log("切片上传完成!");
return r();
}
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[index]);
xhr.onload = resolve;
xhr.onerror = reject;
});
pool.push(request);
request.then(() => {
const requsetIndex = pool.findIndex((item) => item === request);
pool.splice(requsetIndex, 1);
textProxy.uploadProgress = (++count / chunksData.length) * 100;
});
if (pool.length >= max) {
await Promise.race(pool);
upload(index + 1);
} else {
upload(index + 1);
}
}
});
}
2.切片合并
切片合并包含了文件合并和文件删除,其中有些坑需要注意。createReadSteam
读取文件是异步的,所以需要等待所有切片都读取完成后,才能删除切片。
后端实现
实现步骤:
- 规定前端需要传递文件hash、文件名称,用来上传合并文件的名称,文件名格式为
hash-文件名
。同时需要传递切片数量来验证切片是否上传完成 - 根据文件hash,找到对应文件的切片文件夹,读取所有的分片名称
- 遍历所有切片文件,按照索引和切片大小规则,将切片数据存储在合并文件对应偏移量中
- 合并完成,删除切片文件夹及其所有文件
ts
// 合并切片文件
app.post("/merge", async (req, res) => {
const { file_name, file_hash } = req.body;
// 非法校验
if (!file_hash || !file_hash) return;
// 文件切片的路径
const chunkDirPath = resolve(tempPath, `./${file_hash}`);
if (!existsSync(chunkDirPath)) {
// 不存在此文件的切片
res.status(400);
res.send("error!");
return;
}
// 1.切片文件合并
// 读取切片文件夹中的所有文件
const chunksName = readdirSync(chunkDirPath);
// 合并文件的路径 文件名格式为:hash-原名称
const filePath = resolve(uploadPath, `./${file_hash}-${file_name}`);
await Promise.all(
chunksName.map((chunkName) => {
// 获取切片路径和切片索引
const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
const [_, index] = chunkName.split("-");
if (Number.isNaN(index)) {
// 合并失败
res.status(400);
res.send("error!");
}
// 创建合并文件
const ws = createWriteStream(filePath, {
start: +index * chunkSize,
});
// 将切片文件写入到合并文件中
return new Promise((r, j) => {
const rs = createReadStream(chunkPath);
rs.pipe(ws);
rs.on("end", r);
rs.on("error",j);
});
})
);
// 2.将切片文件删除
try {
// 删除所有切片文件
await Promise.all(
chunksName.map((chunkName) => {
return new Promise((r, j) => {
const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
unlink(chunkPath, (err) => {
if (err) {
j(err);
} else {
console.log(`删除文件${chunkName}成功!`);
r();
}
});
});
})
);
// 删除切片文件夹
rmdir(chunkDirPath, (err) => {
if (err) {
throw new Error(err);
} else {
res.send("ok!");
}
});
} catch (error) {
res.status(500);
res.send("error!");
}
});
// 合并切片文件
app.post("/merge", async (req, res) => {
const { file_name, file_hash } = req.body;
// 非法校验
if (!file_hash || !file_hash) return;
// 文件切片的路径
const chunkDirPath = resolve(tempPath, `./${file_hash}`);
if (!existsSync(chunkDirPath)) {
// 不存在此文件的切片
res.status(400);
res.send("error!");
return;
}
// 1.切片文件合并
// 读取切片文件夹中的所有文件
const chunksName = readdirSync(chunkDirPath);
// 合并文件的路径 文件名格式为:hash-原名称
const filePath = resolve(uploadPath, `./${file_hash}-${file_name}`);
await Promise.all(
chunksName.map((chunkName) => {
// 获取切片路径和切片索引
const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
const [_, index] = chunkName.split("-");
if (Number.isNaN(index)) {
// 合并失败
res.status(400);
res.send("error!");
}
// 创建合并文件
const ws = createWriteStream(filePath, {
start: +index * chunkSize,
});
// 将切片文件写入到合并文件中
return new Promise((r, j) => {
const rs = createReadStream(chunkPath);
rs.pipe(ws);
rs.on("end", r);
rs.on("error",j);
});
})
);
// 2.将切片文件删除
try {
// 删除所有切片文件
await Promise.all(
chunksName.map((chunkName) => {
return new Promise((r, j) => {
const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
unlink(chunkPath, (err) => {
if (err) {
j(err);
} else {
console.log(`删除文件${chunkName}成功!`);
r();
}
});
});
})
);
// 删除切片文件夹
rmdir(chunkDirPath, (err) => {
if (err) {
throw new Error(err);
} else {
res.send("ok!");
}
});
} catch (error) {
res.status(500);
res.send("error!");
}
});
3.文件秒传
通过检查合并文件夹中的文件hash是否存在,存在则响应文件存在,无需重复上传。
后端实现
实现步骤:
- 前端发送文件hash值
- 后端通过在合并文件夹中查询是否有此hash值的文件夹存在
- 返回是否存在的信息
ts
// 文件秒传(查询文件是否上传过了)
app.get("/check", (req, res) => {
const { file_hash } = req.query;
if (!file_hash) {
res.status(400);
return res.send("error");
}
const filesName = readdirSync(uploadPath);
const flag = filesName.some((filename) => {
return filename.startsWith(file_hash);
});
if (flag) {
res.send({
code: 200,
msg: "ok",
data: 1, // 存在
});
} else {
res.send({
code: 200,
msg: "ok",
data: 0, // 不存在
});
}
});
// 文件秒传(查询文件是否上传过了)
app.get("/check", (req, res) => {
const { file_hash } = req.query;
if (!file_hash) {
res.status(400);
return res.send("error");
}
const filesName = readdirSync(uploadPath);
const flag = filesName.some((filename) => {
return filename.startsWith(file_hash);
});
if (flag) {
res.send({
code: 200,
msg: "ok",
data: 1, // 存在
});
} else {
res.send({
code: 200,
msg: "ok",
data: 0, // 不存在
});
}
});
4.断点续传(最难)
前端实现
实现步骤:
- 点击暂停按钮,终止请求
- 点击恢复按钮,通过文件hash来查询切片上传进度
- 根据后端返回的切片索引,过滤出未上传的切片,重新上传切片
- 每次选择文件后,在查询秒传后,如果不存在文件,则需要查询此文件的上传进度,上传未上传的切片
前面三个步骤都是用于暂停和恢复,第四个是用于关闭网页后又恢复网页重新上传文件。
恢复重传
ts
const mitt = new EventEmitter();
// 暂停的数据
const recoverData = {
hash: "",
chunksData: null,
file: null,
};
/**
* 监听按钮的点击事件
*/
buttonDOM.addEventListener("click", () => {
textProxy.isUploading = !textProxy.isUploading;
if (textProxy.isUploading === false) {
// 取消上传
mitt.emit("cancel");
} else {
recoverUpload(recoverData.hash, recoverData.chunksData, recoverData.file);
}
});
/**
* 并发上传切片(循环)
* @param {FormData[]} chunksData - 切片数据
* @param {string} hash - hash值
* @param {number} needCount - 需要上传的切片数量(总的)
* @param {number} uploadCount - 已经上传的切片数量
*/
function chunksUpload02(
chunksData,
hash,
needCount = chunksData.length,
uploadCount = 0
) {
return new Promise(async (r, j) => {
// 上传成功的数量
let count = uploadCount;
// 并发池
const pool = [];
// 最大并发数量
const max = 6;
for (let i = 0; i < chunksData.length; i++) {
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[i]);
xhr.onload = () => {
resolve();
// 请求成功移除监听
mitt.removeListener("cancel", handleAbort);
};
xhr.onerror = () => {
reject();
// 请求失败移除监听
mitt.removeListener("cancel", handleAbort);
};
function handleAbort() {
// 取消请求
xhr.abort();
if (chunksData.length === needCount) {
// 第一次暂停保存全部的文件切片和hash值
recoverData.hash = hash;
recoverData.chunksData = chunksData;
}
}
// 监听暂停上传的事件
mitt.on("cancel", handleAbort);
});
// 将请求保存在并发池中
pool.push(request);
// 请求成功后,从并发池中移除该请求
request.then(
() => {
// 计算上传进度
textProxy.uploadProgress = (++count / needCount) * 100;
const index = pool.findIndex((item) => item === request);
pool.splice(index, 1);
},
// 有一个切片上传失败了
j
);
if (pool.length >= max) {
// 请求池达到最大,需要等待并发池空闲,才能上传后续分片
await Promise.race(pool);
}
}
// 等待所有请求结束
await Promise.all(pool);
r();
});
}
/**
* 恢复上传
* @param {string} hash
* @param {FormData[]} chunksData
* @param {File} file
*/
async function recoverUpload(hash, chunksData, file) {
// 查询文件是否存在
const uploadChunksData = await new Promise((r, j) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
xhr.onload = (e) => {
try {
const data = JSON.parse(e.target.response);
r(data.map((item) => +item).sort((a, b) => a - b));
} catch (error) {
r(false);
}
};
xhr.onerror = j;
xhr.send();
});
if (!uploadChunksData) {
// 不存在切片文件夹
return;
}
// 构造出未上传的切片数据
const needUploadChunksData = [];
for (let i = 0; i < chunksData.length; i++) {
const chunkHash = chunksData[i].get("chunk-hash");
const index = uploadChunksData.findIndex((item) => {
return `${hash}-${item}` === chunkHash;
});
if (index === -1) {
// 保存未上传的切片
needUploadChunksData.push(chunksData[i]);
}
}
await chunksUpload02(
needUploadChunksData,
hash,
chunksData.length,
uploadChunksData.length
);
// 合并切片
mergeChunk(file, hash, chunksData.length);
}
const mitt = new EventEmitter();
// 暂停的数据
const recoverData = {
hash: "",
chunksData: null,
file: null,
};
/**
* 监听按钮的点击事件
*/
buttonDOM.addEventListener("click", () => {
textProxy.isUploading = !textProxy.isUploading;
if (textProxy.isUploading === false) {
// 取消上传
mitt.emit("cancel");
} else {
recoverUpload(recoverData.hash, recoverData.chunksData, recoverData.file);
}
});
/**
* 并发上传切片(循环)
* @param {FormData[]} chunksData - 切片数据
* @param {string} hash - hash值
* @param {number} needCount - 需要上传的切片数量(总的)
* @param {number} uploadCount - 已经上传的切片数量
*/
function chunksUpload02(
chunksData,
hash,
needCount = chunksData.length,
uploadCount = 0
) {
return new Promise(async (r, j) => {
// 上传成功的数量
let count = uploadCount;
// 并发池
const pool = [];
// 最大并发数量
const max = 6;
for (let i = 0; i < chunksData.length; i++) {
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[i]);
xhr.onload = () => {
resolve();
// 请求成功移除监听
mitt.removeListener("cancel", handleAbort);
};
xhr.onerror = () => {
reject();
// 请求失败移除监听
mitt.removeListener("cancel", handleAbort);
};
function handleAbort() {
// 取消请求
xhr.abort();
if (chunksData.length === needCount) {
// 第一次暂停保存全部的文件切片和hash值
recoverData.hash = hash;
recoverData.chunksData = chunksData;
}
}
// 监听暂停上传的事件
mitt.on("cancel", handleAbort);
});
// 将请求保存在并发池中
pool.push(request);
// 请求成功后,从并发池中移除该请求
request.then(
() => {
// 计算上传进度
textProxy.uploadProgress = (++count / needCount) * 100;
const index = pool.findIndex((item) => item === request);
pool.splice(index, 1);
},
// 有一个切片上传失败了
j
);
if (pool.length >= max) {
// 请求池达到最大,需要等待并发池空闲,才能上传后续分片
await Promise.race(pool);
}
}
// 等待所有请求结束
await Promise.all(pool);
r();
});
}
/**
* 恢复上传
* @param {string} hash
* @param {FormData[]} chunksData
* @param {File} file
*/
async function recoverUpload(hash, chunksData, file) {
// 查询文件是否存在
const uploadChunksData = await new Promise((r, j) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
xhr.onload = (e) => {
try {
const data = JSON.parse(e.target.response);
r(data.map((item) => +item).sort((a, b) => a - b));
} catch (error) {
r(false);
}
};
xhr.onerror = j;
xhr.send();
});
if (!uploadChunksData) {
// 不存在切片文件夹
return;
}
// 构造出未上传的切片数据
const needUploadChunksData = [];
for (let i = 0; i < chunksData.length; i++) {
const chunkHash = chunksData[i].get("chunk-hash");
const index = uploadChunksData.findIndex((item) => {
return `${hash}-${item}` === chunkHash;
});
if (index === -1) {
// 保存未上传的切片
needUploadChunksData.push(chunksData[i]);
}
}
await chunksUpload02(
needUploadChunksData,
hash,
chunksData.length,
uploadChunksData.length
);
// 合并切片
mergeChunk(file, hash, chunksData.length);
}
重传
ts
/**
* 大文件上传的整个逻辑
* @param {File} file
*/
async function uploadBid(file) {
textProxy.hashProgress = 0;
textProxy.uploadProgress = 0;
// 将文件切片
const chunks = getChunk(file, chunkSize);
// 通过切片增量计算出整个文件的hash
const hash = await getFileHash(chunks);
const flag = await checkExists(hash);
// 监听暂停事件,保存文件file
mitt.on("cancel", () => {
recoverData.file = file;
});
if (flag) {
textProxy.uploadProgress = 100;
alert("秒传成功!");
} else {
// 检测是否上传过切片了
const res = await checkRecoverUpload(hash, chunks);
if (res) {
// 上传未上传的切片
await chunksUpload02(res, hash, chunks.length, (chunks.length-res.length));
} else {
// 上传所有切片
await chunksUpload02(createChunksData(chunks, hash), hash);
}
// 合并切片
await mergeChunk(file, hash, chunks.length);
alert("上传成功!");
}
}
/**
* 检查恢复重传
* @param {string} hash - hash值
* @param {Blob[]} chunks - 整个文件的所有切片
*/
async function checkRecoverUpload(hash, chunks) {
// 查询文件是否存在
const uploadChunksData = await new Promise((r, j) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
xhr.onload = (e) => {
try {
const data = JSON.parse(e.target.response);
r(data.map((item) => +item).sort((a, b) => a - b));
} catch (error) {
r(e.target.response);
}
};
xhr.onerror = j;
xhr.send();
});
if (uploadChunksData instanceof Array) {
// 有记录
// 过滤出需要重传的切片数据
const needUploadChunk = [];
for (let i = 0; i < chunks.length; i++) {
if (!uploadChunksData.includes(i)) {
// 未上传的文件切片
const chunkData = new FormData();
chunkData.append("file", chunks[i]);
chunkData.append("file-hash", hash);
chunkData.append("chunk-hash", `${hash}-${i}`);
needUploadChunk.push(chunkData);
}
}
return needUploadChunk;
} else {
// 无记录
return false;
}
}
/**
* 大文件上传的整个逻辑
* @param {File} file
*/
async function uploadBid(file) {
textProxy.hashProgress = 0;
textProxy.uploadProgress = 0;
// 将文件切片
const chunks = getChunk(file, chunkSize);
// 通过切片增量计算出整个文件的hash
const hash = await getFileHash(chunks);
const flag = await checkExists(hash);
// 监听暂停事件,保存文件file
mitt.on("cancel", () => {
recoverData.file = file;
});
if (flag) {
textProxy.uploadProgress = 100;
alert("秒传成功!");
} else {
// 检测是否上传过切片了
const res = await checkRecoverUpload(hash, chunks);
if (res) {
// 上传未上传的切片
await chunksUpload02(res, hash, chunks.length, (chunks.length-res.length));
} else {
// 上传所有切片
await chunksUpload02(createChunksData(chunks, hash), hash);
}
// 合并切片
await mergeChunk(file, hash, chunks.length);
alert("上传成功!");
}
}
/**
* 检查恢复重传
* @param {string} hash - hash值
* @param {Blob[]} chunks - 整个文件的所有切片
*/
async function checkRecoverUpload(hash, chunks) {
// 查询文件是否存在
const uploadChunksData = await new Promise((r, j) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
xhr.onload = (e) => {
try {
const data = JSON.parse(e.target.response);
r(data.map((item) => +item).sort((a, b) => a - b));
} catch (error) {
r(e.target.response);
}
};
xhr.onerror = j;
xhr.send();
});
if (uploadChunksData instanceof Array) {
// 有记录
// 过滤出需要重传的切片数据
const needUploadChunk = [];
for (let i = 0; i < chunks.length; i++) {
if (!uploadChunksData.includes(i)) {
// 未上传的文件切片
const chunkData = new FormData();
chunkData.append("file", chunks[i]);
chunkData.append("file-hash", hash);
chunkData.append("chunk-hash", `${hash}-${i}`);
needUploadChunk.push(chunkData);
}
}
return needUploadChunk;
} else {
// 无记录
return false;
}
}
当然也可以把这些步骤都当作一种情况就是重新上传的步骤(不过需要计算多次的hash):
- 计算文件hash
- 查询此文件是否存在
- 不存在,查询文件上传进度
- 无任何进度,上传所有切片;有进度,上传未上传的切片即可
后端实现
实现步骤:
- 接收前端发送的文件hash
- 查询文件是否已经存在了
- 若不存在,从切片文件夹中查询此文件的切片文件夹是否存在
- 若存在,则读取切片文件夹中的所有文件,返回已经存在的切片索引
ts
// 查询切片上传得进度
app.get("/check-chunk", (req, res) => {
const { file_hash } = req.query;
if (!file_hash) {
res.status(400);
return res.send("error");
}
// 查找此切片文件夹是否存在
const chunksDirPath = resolve(tempPath, `./${file_hash}`);
if (!existsSync(chunksDirPath)) {
// 未找到此切片文件夹,可能没有上传过,可能已经合并完成了
res.status(404);
return res.send("not found");
}
// 读出此切片文件夹中已经上传的切片索引
const chunksIndex = readdirSync(chunksDirPath).map((chunkname) => {
const [_, index] = chunkname.split("-");
return index;
});
res.send(chunksIndex);
});
// 查询切片上传得进度
app.get("/check-chunk", (req, res) => {
const { file_hash } = req.query;
if (!file_hash) {
res.status(400);
return res.send("error");
}
// 查找此切片文件夹是否存在
const chunksDirPath = resolve(tempPath, `./${file_hash}`);
if (!existsSync(chunksDirPath)) {
// 未找到此切片文件夹,可能没有上传过,可能已经合并完成了
res.status(404);
return res.send("not found");
}
// 读出此切片文件夹中已经上传的切片索引
const chunksIndex = readdirSync(chunksDirPath).map((chunkname) => {
const [_, index] = chunkname.split("-");
return index;
});
res.send(chunksIndex);
});
所有代码
主要难度在于切片上传和断点续传,还要考虑到各种健壮性的问题。此代码仅仅只包含了基本的文件分片上传、断点续传、秒传的功能。
前端
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="X-UA-Compatible"
content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
#hash-progress,
#upload-progress {
background-color: skyblue;
height: 20px;
width: 0%;
}
</style>
<!-- <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.js"></script> -->
<script src="/client/lib/spark-md5.js"></script>
<script src="/client/lib/mitt.js"></script>
</head>
<body>
<input type="file" />
<button>暂停</button>
<div>
<div>
<div>hash计算进度</div>
<div>
<div id="hash-progress"></div>
</div>
</div>
<div>
<div>上传进度</div>
<div>
<div id="upload-progress"></div>
</div>
</div>
</div>
<script src="/client/index.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="X-UA-Compatible"
content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
#hash-progress,
#upload-progress {
background-color: skyblue;
height: 20px;
width: 0%;
}
</style>
<!-- <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.js"></script> -->
<script src="/client/lib/spark-md5.js"></script>
<script src="/client/lib/mitt.js"></script>
</head>
<body>
<input type="file" />
<button>暂停</button>
<div>
<div>
<div>hash计算进度</div>
<div>
<div id="hash-progress"></div>
</div>
</div>
<div>
<div>上传进度</div>
<div>
<div id="upload-progress"></div>
</div>
</div>
</div>
<script src="/client/index.js"></script>
</body>
</html>
ts
const fileDOM = document.querySelector("input[type=file]");
const buttonDOM = document.querySelector("button");
const hashProgressDOM = document.querySelector("#hash-progress");
const uploadProgressDOM = document.querySelector("#upload-progress");
const chunkSize = 1 * 1024 * 1024;
const baseURL = "http://127.0.0.1:3000";
const text = {
// 当前是在上传还是暂停状态
isUploading: true,
// hash计算进度
hashProgress: 0,
// 上传进度
uploadProgress: 0,
};
const textProxy = new Proxy(text, {
get(target, key) {
return target[key];
},
set(target, key, value) {
target[key] = value;
if (key === "isUploading") {
buttonDOM.innerText = value ? "暂停" : "恢复";
} else if (key === "hashProgress") {
hashProgressDOM.style.width = value + "%";
} else if (key === "uploadProgress") {
uploadProgressDOM.style.width = value + "%";
}
},
});
const mitt = new EventEmitter();
// 暂停的数据
const recoverData = {
hash: "",
chunksData: null,
file: null,
};
/**
* 监听文件域的变化
*/
fileDOM.addEventListener("change", function (e) {
e.preventDefault();
const file = this.files[0];
file && uploadBid(file);
});
/**
* 监听按钮的点击事件
*/
buttonDOM.addEventListener("click", () => {
textProxy.isUploading = !textProxy.isUploading;
if (textProxy.isUploading === false) {
// 取消上传
mitt.emit("cancel");
} else {
recoverUpload(recoverData.hash, recoverData.chunksData, recoverData.file);
}
});
/**
* 大文件上传的整个逻辑
* @param {File} file
*/
async function uploadBid(file) {
textProxy.hashProgress = 0;
textProxy.uploadProgress = 0;
// 将文件切片
const chunks = getChunk(file, chunkSize);
// 通过切片增量计算出整个文件的hash
const hash = await getFileHash(chunks);
const flag = await checkExists(hash);
// 监听暂停事件,保存文件file
mitt.on("cancel", () => {
recoverData.file = file;
});
if (flag) {
textProxy.uploadProgress = 100;
alert("秒传成功!");
} else {
// 检测是否上传过切片了
const res = await checkRecoverUpload(hash, chunks);
if (res) {
// 上传未上传的切片
await chunksUpload02(
res,
hash,
chunks.length,
chunks.length - res.length
);
} else {
// 上传所有切片
await chunksUpload02(createChunksData(chunks, hash), hash);
}
// 合并切片
await mergeChunk(file, hash, chunks.length);
alert("上传成功!");
}
}
/**
* 获取切片
* @param {File} file
* @param {number} chunkSize
* @returns
*/
function getChunk(file, chunkSize) {
const result = [];
for (let i = 0; i < file.size; i += chunkSize) {
result.push(file.slice(i, i + chunkSize));
}
return result;
}
/**
* 计算出整个文件的hash值
* @param {Blob[]} chunks
*/
async function getFileHash(chunks) {
// 计算了多少个切片的hash了
let count = 0;
// 整个文件的hash值
const result = new SparkMD5();
for (let i = 0; i < chunks.length; i++) {
await new Promise((r, j) => {
const fileReader = new FileReader();
fileReader.onload = function () {
result.append(this.result);
// 计算hash计算进度
textProxy.hashProgress = (++count / chunks.length) * 100;
r();
};
fileReader.onerror = j;
fileReader.readAsArrayBuffer(chunks[i]);
});
}
return result.end();
}
/**
* 上传所有分片(每六个分片上传)
* @param {Blob[]} chunks -文件的所有切片
* @param {string} hash -文件的hash
*/
async function chunkUpload(chunks, hash) {
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
// 并发上传的数量
const max = 6;
for (let i = 0; i < chunksData.length; i += max) {
await Promise.all(
chunksData
.slice(i, Math.floor(i + max, chunksData.length))
.map((data) => {
return new Promise((r, j) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(data);
xhr.onload = r;
xhr.onerror = j;
});
})
);
}
}
/**
* 上传所有分片(并发控制六个请求)
* @param {*} chunks
* @param {*} hash
*/
async function chunksUpload(chunks, hash) {
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
const uploadPool = [];
const max = 6;
for (let i = 0; i < chunksData.length; i++) {
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[i]);
xhr.onload = resolve;
xhr.onerror = reject;
});
request.then(() => {
const index = uploadPool.findIndex((req) => req === request);
uploadPool.splice(index, 1);
});
uploadPool.push(request);
if (uploadPool.length >= max) {
// 当前任务已经开始,等待空闲位置
const readyPromise = new Promise((resolve) => {
const readyInterval = setInterval(() => {
if (uploadPool.length < max) {
clearInterval(readyInterval);
resolve();
}
}, 100);
});
await Promise.race([request, readyPromise]);
}
}
await Promise.all(uploadPool);
}
/**
* 逐片上传切片
* @param {Blob[]} chunks
* @param {string} hash
*/
async function chunksUpload01(chunks, hash) {
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
for (let i = 0; i < chunksData.length; i++) {
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[i]);
xhr.onload = resolve;
xhr.onerror = reject;
});
}
}
/**
* 并发上传切片(循环)
* @param {FormData[]} chunksData - 切片数据
* @param {string} hash - hash值
* @param {number} needCount - 需要上传的切片数量(总的)
* @param {number} uploadCount - 已经上传的切片数量
*/
function chunksUpload02(
chunksData,
hash,
needCount = chunksData.length,
uploadCount = 0
) {
return new Promise(async (r, j) => {
// 上传成功的数量
let count = uploadCount;
// 并发池
const pool = [];
// 最大并发数量
const max = 6;
for (let i = 0; i < chunksData.length; i++) {
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[i]);
xhr.onload = () => {
resolve();
// 请求成功移除监听
mitt.removeListener("cancel", handleAbort);
};
xhr.onerror = () => {
reject();
// 请求失败移除监听
mitt.removeListener("cancel", handleAbort);
};
function handleAbort() {
// 取消请求
xhr.abort();
if (chunksData.length === needCount) {
// 第一次暂停保存全部的文件切片和hash值
recoverData.hash = hash;
recoverData.chunksData = chunksData;
}
}
// 监听暂停上传的事件
mitt.on("cancel", handleAbort);
});
// 将请求保存在并发池中
pool.push(request);
// 请求成功后,从并发池中移除该请求
request.then(
() => {
// 计算上传进度
textProxy.uploadProgress = (++count / needCount) * 100;
const index = pool.findIndex((item) => item === request);
pool.splice(index, 1);
},
// 有一个切片上传失败了
j
);
if (pool.length >= max) {
// 请求池达到最大,需要等待并发池空闲,才能上传后续分片
await Promise.race(pool);
}
}
// 等待所有请求结束
await Promise.all(pool);
r();
});
}
/**
* 并发上传切片(递归)
* @param {FormData[]} chunksData - 切片数据
* @param {string} hash - hash值
* @param {number} needCount - 需要上传的切片数量(总的)
* @param {number} uploadCount - 已经上传的切片数量
*/
async function chunksUpload03(
chunksData,
hash,
needCount = chunksData.length,
uploadCount = 0
) {
return new Promise((done, fail) => {
// 上传成功的数量
let count = uploadCount;
// 并发池
const pool = [];
// 最大并发数量
const max = 6;
return new Promise((r) => {
upload(0);
async function upload(index) {
if (index >= chunksData.length) {
// 等待所有请求结束
await Promise.all(pool);
console.log("切片上传完成!");
return r();
}
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[index]);
xhr.onload = () => {
resolve();
// 移除暂停上传事件监听
mitt.removeListener("cancel", handleCancel);
};
xhr.onerror = () => {
reject();
// 移除暂停上传事件监听
mitt.removeListener("cancel", handleCancel);
};
// 监听暂停上传事件
mitt.on("cancel", handleCancel);
function handleCancel() {
// 取消请求
xhr.abort();
// 保存文件切片和hash值
recoverData.hash = hash;
recoverData.chunksData = chunksData;
fail();
}
});
pool.push(request);
request.then(() => {
const requsetIndex = pool.findIndex((item) => item === request);
pool.splice(requsetIndex, 1);
textProxy.uploadProgress = (++count / needCount) * 100;
if (count === chunksData.length) {
done();
}
});
if (pool.length >= max) {
await Promise.race(pool);
}
await upload(index + 1);
}
});
});
}
/**
* 合并切片
* @param {File} file
* @param {string} hash
* @param {number} count
*/
async function mergeChunk(file, hash, count) {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/merge`);
xhr.setRequestHeader("content-type", "application/json");
xhr.send(
JSON.stringify({
file_name: file.name,
file_hash: hash,
chunk_count: count,
})
);
return new Promise((r, j) => {
xhr.onload = r;
xhr.onerror = j;
});
}
/**
* 文件秒传
* @param {string} hash
* @returns
*/
async function checkExists(hash) {
const xhr = new XMLHttpRequest();
xhr.open("GET", `${baseURL}/check?file_hash=${hash}`);
xhr.send();
return new Promise((r, j) => {
xhr.onload = (e) => {
const res = JSON.parse(e.target.response);
r(!!res.data);
};
xhr.onerror = j;
});
}
/**
* 恢复上传
* @param {string} hash
* @param {FormData[]} chunksData
* @param {File} file
*/
async function recoverUpload(hash, chunksData, file) {
// 查询文件是否存在
const uploadChunksData = await new Promise((r, j) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
xhr.onload = (e) => {
try {
const data = JSON.parse(e.target.response);
r(data.map((item) => +item).sort((a, b) => a - b));
} catch (error) {
r(false);
}
};
xhr.onerror = j;
xhr.send();
});
if (!uploadChunksData) {
// 不存在切片文件夹
return;
}
// 构造出未上传的切片数据
const needUploadChunksData = [];
for (let i = 0; i < chunksData.length; i++) {
const chunkHash = chunksData[i].get("chunk-hash");
const index = uploadChunksData.findIndex((item) => {
return `${hash}-${item}` === chunkHash;
});
if (index === -1) {
// 保存未上传的切片
needUploadChunksData.push(chunksData[i]);
}
}
await chunksUpload02(
needUploadChunksData,
hash,
chunksData.length,
uploadChunksData.length
);
// 合并切片
mergeChunk(file, hash, chunksData.length);
}
/**
* 检查恢复重传
* @param {string} hash - hash值
* @param {Blob[]} chunks - 整个文件的所有切片
*/
async function checkRecoverUpload(hash, chunks) {
// 查询文件是否存在
const uploadChunksData = await new Promise((r, j) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
xhr.onload = (e) => {
try {
const data = JSON.parse(e.target.response);
r(data.map((item) => +item).sort((a, b) => a - b));
} catch (error) {
r(e.target.response);
}
};
xhr.onerror = j;
xhr.send();
});
if (uploadChunksData instanceof Array) {
// 有记录
// 过滤出需要重传的切片数据
const needUploadChunk = [];
for (let i = 0; i < chunks.length; i++) {
if (!uploadChunksData.includes(i)) {
// 未上传的文件切片
const chunkData = new FormData();
chunkData.append("file", chunks[i]);
chunkData.append("file-hash", hash);
chunkData.append("chunk-hash", `${hash}-${i}`);
needUploadChunk.push(chunkData);
}
}
return needUploadChunk;
} else {
// 无记录
return false;
}
}
/**
* 构造切片数据
* @param {Blob[]} chunks
* @param {string} hash
*/
function createChunksData(chunks, hash) {
return chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
}
const fileDOM = document.querySelector("input[type=file]");
const buttonDOM = document.querySelector("button");
const hashProgressDOM = document.querySelector("#hash-progress");
const uploadProgressDOM = document.querySelector("#upload-progress");
const chunkSize = 1 * 1024 * 1024;
const baseURL = "http://127.0.0.1:3000";
const text = {
// 当前是在上传还是暂停状态
isUploading: true,
// hash计算进度
hashProgress: 0,
// 上传进度
uploadProgress: 0,
};
const textProxy = new Proxy(text, {
get(target, key) {
return target[key];
},
set(target, key, value) {
target[key] = value;
if (key === "isUploading") {
buttonDOM.innerText = value ? "暂停" : "恢复";
} else if (key === "hashProgress") {
hashProgressDOM.style.width = value + "%";
} else if (key === "uploadProgress") {
uploadProgressDOM.style.width = value + "%";
}
},
});
const mitt = new EventEmitter();
// 暂停的数据
const recoverData = {
hash: "",
chunksData: null,
file: null,
};
/**
* 监听文件域的变化
*/
fileDOM.addEventListener("change", function (e) {
e.preventDefault();
const file = this.files[0];
file && uploadBid(file);
});
/**
* 监听按钮的点击事件
*/
buttonDOM.addEventListener("click", () => {
textProxy.isUploading = !textProxy.isUploading;
if (textProxy.isUploading === false) {
// 取消上传
mitt.emit("cancel");
} else {
recoverUpload(recoverData.hash, recoverData.chunksData, recoverData.file);
}
});
/**
* 大文件上传的整个逻辑
* @param {File} file
*/
async function uploadBid(file) {
textProxy.hashProgress = 0;
textProxy.uploadProgress = 0;
// 将文件切片
const chunks = getChunk(file, chunkSize);
// 通过切片增量计算出整个文件的hash
const hash = await getFileHash(chunks);
const flag = await checkExists(hash);
// 监听暂停事件,保存文件file
mitt.on("cancel", () => {
recoverData.file = file;
});
if (flag) {
textProxy.uploadProgress = 100;
alert("秒传成功!");
} else {
// 检测是否上传过切片了
const res = await checkRecoverUpload(hash, chunks);
if (res) {
// 上传未上传的切片
await chunksUpload02(
res,
hash,
chunks.length,
chunks.length - res.length
);
} else {
// 上传所有切片
await chunksUpload02(createChunksData(chunks, hash), hash);
}
// 合并切片
await mergeChunk(file, hash, chunks.length);
alert("上传成功!");
}
}
/**
* 获取切片
* @param {File} file
* @param {number} chunkSize
* @returns
*/
function getChunk(file, chunkSize) {
const result = [];
for (let i = 0; i < file.size; i += chunkSize) {
result.push(file.slice(i, i + chunkSize));
}
return result;
}
/**
* 计算出整个文件的hash值
* @param {Blob[]} chunks
*/
async function getFileHash(chunks) {
// 计算了多少个切片的hash了
let count = 0;
// 整个文件的hash值
const result = new SparkMD5();
for (let i = 0; i < chunks.length; i++) {
await new Promise((r, j) => {
const fileReader = new FileReader();
fileReader.onload = function () {
result.append(this.result);
// 计算hash计算进度
textProxy.hashProgress = (++count / chunks.length) * 100;
r();
};
fileReader.onerror = j;
fileReader.readAsArrayBuffer(chunks[i]);
});
}
return result.end();
}
/**
* 上传所有分片(每六个分片上传)
* @param {Blob[]} chunks -文件的所有切片
* @param {string} hash -文件的hash
*/
async function chunkUpload(chunks, hash) {
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
// 并发上传的数量
const max = 6;
for (let i = 0; i < chunksData.length; i += max) {
await Promise.all(
chunksData
.slice(i, Math.floor(i + max, chunksData.length))
.map((data) => {
return new Promise((r, j) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(data);
xhr.onload = r;
xhr.onerror = j;
});
})
);
}
}
/**
* 上传所有分片(并发控制六个请求)
* @param {*} chunks
* @param {*} hash
*/
async function chunksUpload(chunks, hash) {
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
const uploadPool = [];
const max = 6;
for (let i = 0; i < chunksData.length; i++) {
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[i]);
xhr.onload = resolve;
xhr.onerror = reject;
});
request.then(() => {
const index = uploadPool.findIndex((req) => req === request);
uploadPool.splice(index, 1);
});
uploadPool.push(request);
if (uploadPool.length >= max) {
// 当前任务已经开始,等待空闲位置
const readyPromise = new Promise((resolve) => {
const readyInterval = setInterval(() => {
if (uploadPool.length < max) {
clearInterval(readyInterval);
resolve();
}
}, 100);
});
await Promise.race([request, readyPromise]);
}
}
await Promise.all(uploadPool);
}
/**
* 逐片上传切片
* @param {Blob[]} chunks
* @param {string} hash
*/
async function chunksUpload01(chunks, hash) {
const chunksData = chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
for (let i = 0; i < chunksData.length; i++) {
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[i]);
xhr.onload = resolve;
xhr.onerror = reject;
});
}
}
/**
* 并发上传切片(循环)
* @param {FormData[]} chunksData - 切片数据
* @param {string} hash - hash值
* @param {number} needCount - 需要上传的切片数量(总的)
* @param {number} uploadCount - 已经上传的切片数量
*/
function chunksUpload02(
chunksData,
hash,
needCount = chunksData.length,
uploadCount = 0
) {
return new Promise(async (r, j) => {
// 上传成功的数量
let count = uploadCount;
// 并发池
const pool = [];
// 最大并发数量
const max = 6;
for (let i = 0; i < chunksData.length; i++) {
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[i]);
xhr.onload = () => {
resolve();
// 请求成功移除监听
mitt.removeListener("cancel", handleAbort);
};
xhr.onerror = () => {
reject();
// 请求失败移除监听
mitt.removeListener("cancel", handleAbort);
};
function handleAbort() {
// 取消请求
xhr.abort();
if (chunksData.length === needCount) {
// 第一次暂停保存全部的文件切片和hash值
recoverData.hash = hash;
recoverData.chunksData = chunksData;
}
}
// 监听暂停上传的事件
mitt.on("cancel", handleAbort);
});
// 将请求保存在并发池中
pool.push(request);
// 请求成功后,从并发池中移除该请求
request.then(
() => {
// 计算上传进度
textProxy.uploadProgress = (++count / needCount) * 100;
const index = pool.findIndex((item) => item === request);
pool.splice(index, 1);
},
// 有一个切片上传失败了
j
);
if (pool.length >= max) {
// 请求池达到最大,需要等待并发池空闲,才能上传后续分片
await Promise.race(pool);
}
}
// 等待所有请求结束
await Promise.all(pool);
r();
});
}
/**
* 并发上传切片(递归)
* @param {FormData[]} chunksData - 切片数据
* @param {string} hash - hash值
* @param {number} needCount - 需要上传的切片数量(总的)
* @param {number} uploadCount - 已经上传的切片数量
*/
async function chunksUpload03(
chunksData,
hash,
needCount = chunksData.length,
uploadCount = 0
) {
return new Promise((done, fail) => {
// 上传成功的数量
let count = uploadCount;
// 并发池
const pool = [];
// 最大并发数量
const max = 6;
return new Promise((r) => {
upload(0);
async function upload(index) {
if (index >= chunksData.length) {
// 等待所有请求结束
await Promise.all(pool);
console.log("切片上传完成!");
return r();
}
const request = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/upload`);
xhr.send(chunksData[index]);
xhr.onload = () => {
resolve();
// 移除暂停上传事件监听
mitt.removeListener("cancel", handleCancel);
};
xhr.onerror = () => {
reject();
// 移除暂停上传事件监听
mitt.removeListener("cancel", handleCancel);
};
// 监听暂停上传事件
mitt.on("cancel", handleCancel);
function handleCancel() {
// 取消请求
xhr.abort();
// 保存文件切片和hash值
recoverData.hash = hash;
recoverData.chunksData = chunksData;
fail();
}
});
pool.push(request);
request.then(() => {
const requsetIndex = pool.findIndex((item) => item === request);
pool.splice(requsetIndex, 1);
textProxy.uploadProgress = (++count / needCount) * 100;
if (count === chunksData.length) {
done();
}
});
if (pool.length >= max) {
await Promise.race(pool);
}
await upload(index + 1);
}
});
});
}
/**
* 合并切片
* @param {File} file
* @param {string} hash
* @param {number} count
*/
async function mergeChunk(file, hash, count) {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${baseURL}/merge`);
xhr.setRequestHeader("content-type", "application/json");
xhr.send(
JSON.stringify({
file_name: file.name,
file_hash: hash,
chunk_count: count,
})
);
return new Promise((r, j) => {
xhr.onload = r;
xhr.onerror = j;
});
}
/**
* 文件秒传
* @param {string} hash
* @returns
*/
async function checkExists(hash) {
const xhr = new XMLHttpRequest();
xhr.open("GET", `${baseURL}/check?file_hash=${hash}`);
xhr.send();
return new Promise((r, j) => {
xhr.onload = (e) => {
const res = JSON.parse(e.target.response);
r(!!res.data);
};
xhr.onerror = j;
});
}
/**
* 恢复上传
* @param {string} hash
* @param {FormData[]} chunksData
* @param {File} file
*/
async function recoverUpload(hash, chunksData, file) {
// 查询文件是否存在
const uploadChunksData = await new Promise((r, j) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
xhr.onload = (e) => {
try {
const data = JSON.parse(e.target.response);
r(data.map((item) => +item).sort((a, b) => a - b));
} catch (error) {
r(false);
}
};
xhr.onerror = j;
xhr.send();
});
if (!uploadChunksData) {
// 不存在切片文件夹
return;
}
// 构造出未上传的切片数据
const needUploadChunksData = [];
for (let i = 0; i < chunksData.length; i++) {
const chunkHash = chunksData[i].get("chunk-hash");
const index = uploadChunksData.findIndex((item) => {
return `${hash}-${item}` === chunkHash;
});
if (index === -1) {
// 保存未上传的切片
needUploadChunksData.push(chunksData[i]);
}
}
await chunksUpload02(
needUploadChunksData,
hash,
chunksData.length,
uploadChunksData.length
);
// 合并切片
mergeChunk(file, hash, chunksData.length);
}
/**
* 检查恢复重传
* @param {string} hash - hash值
* @param {Blob[]} chunks - 整个文件的所有切片
*/
async function checkRecoverUpload(hash, chunks) {
// 查询文件是否存在
const uploadChunksData = await new Promise((r, j) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
xhr.onload = (e) => {
try {
const data = JSON.parse(e.target.response);
r(data.map((item) => +item).sort((a, b) => a - b));
} catch (error) {
r(e.target.response);
}
};
xhr.onerror = j;
xhr.send();
});
if (uploadChunksData instanceof Array) {
// 有记录
// 过滤出需要重传的切片数据
const needUploadChunk = [];
for (let i = 0; i < chunks.length; i++) {
if (!uploadChunksData.includes(i)) {
// 未上传的文件切片
const chunkData = new FormData();
chunkData.append("file", chunks[i]);
chunkData.append("file-hash", hash);
chunkData.append("chunk-hash", `${hash}-${i}`);
needUploadChunk.push(chunkData);
}
}
return needUploadChunk;
} else {
// 无记录
return false;
}
}
/**
* 构造切片数据
* @param {Blob[]} chunks
* @param {string} hash
*/
function createChunksData(chunks, hash) {
return chunks.map((item, index) => {
const data = new FormData();
data.append("file", item);
data.append("file-hash", hash);
data.append("chunk-hash", `${hash}-${index}`);
return data;
});
}
后端
ts
const express = require("express");
const multer = require("multer");
const {
existsSync,
createWriteStream,
createReadStream,
readdirSync,
writeFile,
mkdirSync,
rmdir,
unlink,
rmdirSync,
} = require("fs");
const { resolve } = require("path");
const upload = multer({ storage: multer.memoryStorage() });
// 切片文件夹
const tempPath = resolve(__dirname, "./static/temp");
// 合并切片的文件夹
const uploadPath = resolve(__dirname, "./static/upload");
// 客户端文件夹
const clientPath = resolve(__dirname, "./client");
// 切片固定大小
const chunkSize = 1 * 1024 * 1024;
const app = express();
// 解析json格式数据
app.use((req, res, next) => {
if (
req.headers["content-type"] &&
req.headers["content-type"].includes("application/json")
) {
let json = "";
req.on("data", (chunk) => {
json += chunk;
});
req.on("error", () => {
res.status(400);
res.send("json error!");
});
req.on("end", () => {
try {
const data = JSON.parse(json);
req.body = data;
next();
} catch (error) {
res.status(400);
res.send("json error!");
}
});
} else {
next();
}
});
// 请求文档资源
app.use((req, res, next) => {
if (req.path.startsWith("/client")) {
const filePath = resolve(__dirname, `./${req.path}`);
if (!existsSync(filePath)) {
res.status(404);
res.send("not found");
return;
}
res.setHeader("content-type", "text/javascript");
createReadStream(filePath).pipe(res);
} else {
next();
}
});
// 请求文档
app.get("/", (_, res) => {
createReadStream(resolve(clientPath, "./index.html")).pipe(res);
});
// 切片上传接口(不包含秒传的逻辑)
app.post("/upload", upload.single("file"), function (req, res, next) {
const file_hash = req.body["file-hash"]; // 约定前端传入file-hash代表文件的hash值
const chunk_hash = req.body["chunk-hash"]; // 约定传入chunk-hash代表切片的hash值
const file = req.file;
const [_, chunk_index] = chunk_hash.split("-"); // 约定切片的hash值的格式为:文件hash-切片索引
if (!chunk_hash || Number.isNaN(+chunk_index) || _ !== file_hash) {
res.status(400);
res.send("error");
}
// 以文件hash为文件名,将此文件的所有切片存在此文件夹
const chunkDirPath = resolve(tempPath, `./${file_hash}`);
// 切片的路径
const chunkPath = resolve(chunkDirPath, `./${chunk_hash}`);
if (!existsSync(chunkDirPath)) {
// 不存在切片文件夹,说明首次上传,创建切片文件夹
mkdirSync(chunkDirPath);
}
// 将切片写入到对应的文件夹中
writeFile(chunkPath, file.buffer, (err) => {
if (err) {
res.send(err);
} else {
res.send("ok");
}
});
});
// 合并切片文件
app.post("/merge", async (req, res) => {
const { file_name, file_hash, chunk_count } = req.body;
// 非法校验
if (!file_hash || !file_hash || !chunk_count || Number.isNaN(+chunk_count))
return;
const chunkCount = +chunk_count;
// 文件切片的路径
const chunkDirPath = resolve(tempPath, `./${file_hash}`);
if (!existsSync(chunkDirPath)) {
// 不存在此文件的切片
res.status(400);
res.send("error!");
return;
}
// 1.切片文件合并
// 读取切片文件夹中的所有文件
const chunksName = readdirSync(chunkDirPath);
// 1.5 校验切片文件是否完整
console.log(chunksName.length, chunkCount);
const indexs = chunksName
.map((item) => {
const [_, index] = item.split("-");
return +index;
})
.sort((a, b) => a - b);
// 检测切片长度是否相同 || 检测切片文件名称是否有误
if (
chunksName.length !== chunkCount ||
indexs.some((index) => Number.isNaN(index))
) {
// 删除所有切片文件
await Promise.all(
chunksName.map((chunkName) => {
return new Promise((r, j) => {
const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
unlink(chunkPath, (err) => {
if (err) {
j(err);
} else {
r();
}
});
});
})
);
// 删除切片文件夹
rmdirSync(chunkDirPath);
res.status(500);
res.send("合并失败");
return;
}
// 检测切片文件是否完整
for (let i = 0; i < indexs.length; i++) {
if (i !== indexs[i]) {
// 删除所有切片文件
// 删除所有切片文件
await Promise.all(
chunksName.map((chunkName) => {
return new Promise((r, j) => {
const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
unlink(chunkPath, (err) => {
if (err) {
j(err);
} else {
r();
}
});
});
})
);
// 删除切片文件夹
rmdirSync(chunkDirPath);
res.status(500);
res.send("合并失败");
return;
}
}
// 合并文件的路径 文件名格式为:hash-原名称
const filePath = resolve(uploadPath, `./${file_hash}-${file_name}`);
await Promise.all(
chunksName.map((chunkName) => {
// 获取切片路径和切片索引
const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
const [_, index] = chunkName.split("-");
if (Number.isNaN(index)) {
// 合并失败
res.status(400);
res.send("error!");
}
// 创建合并文件
const ws = createWriteStream(filePath, {
start: +index * chunkSize,
});
// 将切片文件写入到合并文件中
return new Promise((r, j) => {
const rs = createReadStream(chunkPath);
rs.pipe(ws);
rs.on("end", r);
});
})
);
// 2.将切片文件删除
try {
// 删除所有切片文件
await Promise.all(
chunksName.map((chunkName) => {
return new Promise((r, j) => {
const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
unlink(chunkPath, (err) => {
if (err) {
j(err);
} else {
r();
}
});
});
})
);
// 删除切片文件夹
rmdir(chunkDirPath, (err) => {
if (err) {
throw new Error(err);
} else {
res.send("ok!");
}
});
} catch (error) {
res.status(500);
res.send("error!");
}
});
// 文件秒传(查询文件是否上传过了)
app.get("/check", (req, res) => {
const { file_hash } = req.query;
if (!file_hash) {
res.status(400);
return res.send("error");
}
const filesName = readdirSync(uploadPath);
const flag = filesName.some((filename) => {
return filename.startsWith(file_hash);
});
if (flag) {
res.send({
code: 200,
msg: "ok",
data: 1, // 存在
});
} else {
res.send({
code: 200,
msg: "ok",
data: 0, // 不存在
});
}
});
// 查询切片上传得进度
app.get("/check-chunk", (req, res) => {
const { file_hash } = req.query;
if (!file_hash) {
res.status(400);
return res.send("error");
}
// 查找此切片文件夹是否存在
const chunksDirPath = resolve(tempPath, `./${file_hash}`);
if (!existsSync(chunksDirPath)) {
// 未找到此切片文件夹,可能没有上传过,可能已经合并完成了
res.status(404);
return res.send("not found");
}
// 读出此切片文件夹中已经上传的切片索引
const chunksIndex = readdirSync(chunksDirPath).map((chunkname) => {
const [_, index] = chunkname.split("-");
return index;
});
res.send(chunksIndex);
});
app.listen(3000);
const express = require("express");
const multer = require("multer");
const {
existsSync,
createWriteStream,
createReadStream,
readdirSync,
writeFile,
mkdirSync,
rmdir,
unlink,
rmdirSync,
} = require("fs");
const { resolve } = require("path");
const upload = multer({ storage: multer.memoryStorage() });
// 切片文件夹
const tempPath = resolve(__dirname, "./static/temp");
// 合并切片的文件夹
const uploadPath = resolve(__dirname, "./static/upload");
// 客户端文件夹
const clientPath = resolve(__dirname, "./client");
// 切片固定大小
const chunkSize = 1 * 1024 * 1024;
const app = express();
// 解析json格式数据
app.use((req, res, next) => {
if (
req.headers["content-type"] &&
req.headers["content-type"].includes("application/json")
) {
let json = "";
req.on("data", (chunk) => {
json += chunk;
});
req.on("error", () => {
res.status(400);
res.send("json error!");
});
req.on("end", () => {
try {
const data = JSON.parse(json);
req.body = data;
next();
} catch (error) {
res.status(400);
res.send("json error!");
}
});
} else {
next();
}
});
// 请求文档资源
app.use((req, res, next) => {
if (req.path.startsWith("/client")) {
const filePath = resolve(__dirname, `./${req.path}`);
if (!existsSync(filePath)) {
res.status(404);
res.send("not found");
return;
}
res.setHeader("content-type", "text/javascript");
createReadStream(filePath).pipe(res);
} else {
next();
}
});
// 请求文档
app.get("/", (_, res) => {
createReadStream(resolve(clientPath, "./index.html")).pipe(res);
});
// 切片上传接口(不包含秒传的逻辑)
app.post("/upload", upload.single("file"), function (req, res, next) {
const file_hash = req.body["file-hash"]; // 约定前端传入file-hash代表文件的hash值
const chunk_hash = req.body["chunk-hash"]; // 约定传入chunk-hash代表切片的hash值
const file = req.file;
const [_, chunk_index] = chunk_hash.split("-"); // 约定切片的hash值的格式为:文件hash-切片索引
if (!chunk_hash || Number.isNaN(+chunk_index) || _ !== file_hash) {
res.status(400);
res.send("error");
}
// 以文件hash为文件名,将此文件的所有切片存在此文件夹
const chunkDirPath = resolve(tempPath, `./${file_hash}`);
// 切片的路径
const chunkPath = resolve(chunkDirPath, `./${chunk_hash}`);
if (!existsSync(chunkDirPath)) {
// 不存在切片文件夹,说明首次上传,创建切片文件夹
mkdirSync(chunkDirPath);
}
// 将切片写入到对应的文件夹中
writeFile(chunkPath, file.buffer, (err) => {
if (err) {
res.send(err);
} else {
res.send("ok");
}
});
});
// 合并切片文件
app.post("/merge", async (req, res) => {
const { file_name, file_hash, chunk_count } = req.body;
// 非法校验
if (!file_hash || !file_hash || !chunk_count || Number.isNaN(+chunk_count))
return;
const chunkCount = +chunk_count;
// 文件切片的路径
const chunkDirPath = resolve(tempPath, `./${file_hash}`);
if (!existsSync(chunkDirPath)) {
// 不存在此文件的切片
res.status(400);
res.send("error!");
return;
}
// 1.切片文件合并
// 读取切片文件夹中的所有文件
const chunksName = readdirSync(chunkDirPath);
// 1.5 校验切片文件是否完整
console.log(chunksName.length, chunkCount);
const indexs = chunksName
.map((item) => {
const [_, index] = item.split("-");
return +index;
})
.sort((a, b) => a - b);
// 检测切片长度是否相同 || 检测切片文件名称是否有误
if (
chunksName.length !== chunkCount ||
indexs.some((index) => Number.isNaN(index))
) {
// 删除所有切片文件
await Promise.all(
chunksName.map((chunkName) => {
return new Promise((r, j) => {
const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
unlink(chunkPath, (err) => {
if (err) {
j(err);
} else {
r();
}
});
});
})
);
// 删除切片文件夹
rmdirSync(chunkDirPath);
res.status(500);
res.send("合并失败");
return;
}
// 检测切片文件是否完整
for (let i = 0; i < indexs.length; i++) {
if (i !== indexs[i]) {
// 删除所有切片文件
// 删除所有切片文件
await Promise.all(
chunksName.map((chunkName) => {
return new Promise((r, j) => {
const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
unlink(chunkPath, (err) => {
if (err) {
j(err);
} else {
r();
}
});
});
})
);
// 删除切片文件夹
rmdirSync(chunkDirPath);
res.status(500);
res.send("合并失败");
return;
}
}
// 合并文件的路径 文件名格式为:hash-原名称
const filePath = resolve(uploadPath, `./${file_hash}-${file_name}`);
await Promise.all(
chunksName.map((chunkName) => {
// 获取切片路径和切片索引
const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
const [_, index] = chunkName.split("-");
if (Number.isNaN(index)) {
// 合并失败
res.status(400);
res.send("error!");
}
// 创建合并文件
const ws = createWriteStream(filePath, {
start: +index * chunkSize,
});
// 将切片文件写入到合并文件中
return new Promise((r, j) => {
const rs = createReadStream(chunkPath);
rs.pipe(ws);
rs.on("end", r);
});
})
);
// 2.将切片文件删除
try {
// 删除所有切片文件
await Promise.all(
chunksName.map((chunkName) => {
return new Promise((r, j) => {
const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
unlink(chunkPath, (err) => {
if (err) {
j(err);
} else {
r();
}
});
});
})
);
// 删除切片文件夹
rmdir(chunkDirPath, (err) => {
if (err) {
throw new Error(err);
} else {
res.send("ok!");
}
});
} catch (error) {
res.status(500);
res.send("error!");
}
});
// 文件秒传(查询文件是否上传过了)
app.get("/check", (req, res) => {
const { file_hash } = req.query;
if (!file_hash) {
res.status(400);
return res.send("error");
}
const filesName = readdirSync(uploadPath);
const flag = filesName.some((filename) => {
return filename.startsWith(file_hash);
});
if (flag) {
res.send({
code: 200,
msg: "ok",
data: 1, // 存在
});
} else {
res.send({
code: 200,
msg: "ok",
data: 0, // 不存在
});
}
});
// 查询切片上传得进度
app.get("/check-chunk", (req, res) => {
const { file_hash } = req.query;
if (!file_hash) {
res.status(400);
return res.send("error");
}
// 查找此切片文件夹是否存在
const chunksDirPath = resolve(tempPath, `./${file_hash}`);
if (!existsSync(chunksDirPath)) {
// 未找到此切片文件夹,可能没有上传过,可能已经合并完成了
res.status(404);
return res.send("not found");
}
// 读出此切片文件夹中已经上传的切片索引
const chunksIndex = readdirSync(chunksDirPath).map((chunkname) => {
const [_, index] = chunkname.split("-");
return index;
});
res.send(chunksIndex);
});
app.listen(3000);