记录一次实现大文件上传(包含失败重传,失败删除为传完的切片,限制并发等功能)。
上传的几种方式
-
点击上传
- 通过file类型的
input标签的
<!-- 实例 --> const uploadInput = document.createElement("input"); uploadInput.type = "file"; uploadInput.click(); uploadInput.addEventListener("change", async (e) => {}); - 通过file类型的
-
拖拽上传
- 通过监听H5的拖拽属性
- 拖动事件:
- dragstart 在元素开始被拖动时触发
- dragend 在拖动操作完成时触发
- drag 在元素被拖动时触发
- 释放区事件:
- dragenter 被拖动元素进入到释放区所占据得屏幕空间时触发
- dragover 当被拖动元素在释放区内移动时触发
- dragleave 当被拖动元素没有放下就离开释放区时触发
- drop 当被拖动元素在释放区里放下时触发
- 拖动事件:
<!--实例--> <template> <div> <div class="drag-wrapper" @dragover="onDragOver" @drop="onDrop" @dragleave="onDragleave"> 移动到此处 </div> </div> </template> const onDragOver = (e) => { e.preventDefault(); // console.log(e, "onDragOver"); }; const onDragleave = (e) => { e.preventDefault(); } const onDrop = (e) => { // 阻止默认事件 e.preventDefault(); const { dataTransfer } = e; if (dataTransfer) { const { files } = dataTransfer; onUpload(files) console.log(files, "files"); } }; - 通过监听H5的拖拽属性
3.剪切板上传
- 通过监听paste事件,获取到clipboardData
document .getElementById("ClipboardUpload") .addEventListener("paste", function (e) { console.log(e); if (e.clipboardData && e.clipboardData.items) { Array.prototype.forEach.call(e.clipboardData.items, function (item) { console.log(item.getAsFile(), 'file') }) } });
大文件上传
分片上传
-
File 文件(File)接口提供有关文件的信息,并允许网页中的 JavaScript 访问其内容。 通常情况下, File 对象是来自用户在一个 元素上选择文件后返回的 FileList 对象,也可以是来自由拖放操作生成的 DataTransfer 对象,或者来自 HTMLCanvasElement 上的 mozGetAsFile() API. File 对象是特殊类型的 Blob,且可以用在任意的 Blob 类型的 context 中。比如说, FileReader, URL.createObjectURL(), createImageBitmap() (en-US), 及 XMLHttpRequest.send() 都能处理 Blob 和 File。MDN
-
Blob Blob 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作。 MDN
文件上传
大文件上传
大概思路步骤:
- 将文件切割成片
- 计算文件的md5值
- 发送文件的md5值请求给服务端 查询是否已经上传过,如果上传终止上传
- 查询是否有上传过的切片,有则只上传未上传的
- 将切割的切片并发上传
- 全部发送成功后 发送合并请求
文件切片
-
- 首先了解Blob
Blob 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作.
- File
文件(File)接口提供有关文件的信息,并允许网页中的 JavaScript 访问其内容。 通常情况下, File 对象是来自用户在一个 元素上选择文件后返回的 FileList 对象,也可以是来自由拖放操作生成的 DataTransfer 对象 File 接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。
- 文件切割 就是通过拖拽或者input拿到文件File,利用Blob的slice方法对文件进行切割。
// 切割文件 const createfileChunkList = (file, size = SIZE) => { const fileChunkList = []; let cur = 0; while (cur < file.size) { fileChunkList.push({ file: file.slice(cur, cur + size, file.type) }); cur += size; } return fileChunkList; };
计算文件hash值
我们通过启用web-work计算文件hash值
-
配置web-work(以vue为例)
- 安装 worker-loader
- 在vue.config.js 配置 worker-loader
chainWebpack: config => { config.module.rule('worker').test(/\.worker\.js$/).use('worker-loader').loader('worker-loader').options({ inline: 'fallback' }).end(); config.module.rule('js').exclude.add(/\.worker\.js$/); config.output.globalObject("this"); }, -
计算文件hash
import Worker from '@/worker/hash.worker.js' // 计算hash const calculateHash = (fileChunkList) => { return new Promise((resolve) => { state.worker = new Worker() state.worker.postMessage({ fileChunkList, }); state.worker.onmessage = (e) => { const { percentage, hash } = e.data; state.hashProgress = percentage; if (hash) { resolve(hash); } }; }); };// hash.worker.js import SparkMD5 from 'spark-md5'; // 生成文件 hash self.onmessage = (e) => { const { fileChunkList } = e.data; const spark = new SparkMD5.ArrayBuffer(); let percentage = 0; let count = 0; const loadNext = (index) => { const reader = new FileReader(); reader.readAsArrayBuffer(fileChunkList[index].file); reader.onload = (e) => { count++; spark.append(e.target.result); if (count === fileChunkList.length) { self.postMessage({ percentage: 100, hash: spark.end(), }); self.close(); } else { percentage += 100 / fileChunkList.length; self.postMessage({ percentage, }); // 递归计算下一个切片 loadNext(count); } }; }; loadNext(0); };
判断文件是否已上传(文件秒传)
- 将生成的文件hash发到服务查看是否存在该文件
// 点击上传
const onUpload = async () => {
const uploadInput = document.createElement("input");
uploadInput.type = "file";
uploadInput.click();
uploadInput.addEventListener("change", async (e) => {
const [file] = e.target.files;
const fileChunkList = createfileChunkList(file);
// 计算hash
const hash = await calculateHash(fileChunkList);
// 检查是否有该文件
const {
code,
data: { isExist, url },
} = await checkFile({
hash,
fileName: file.name,
});
if (code === 200 && isExist) {
state.url = url;
return;
}
...
};
```
```js
// 服务端接口(Egg.js)
// 查找是否存在该文件
async checkFile() {
const { hash, fileName } = this.ctx.request.body;
const filePath = path.join(UPLOADPATH, hash + path.extname(fileName));
// 判断该该路径文件是否存在
const isExist = fs.existsSync(filePath);
this.ctx.body = {
code: 200,
data: {
isExist,
url: `download?filename=${fileName}&${hash + path.extname(fileName)}`,
},
};
}
判断文件是否上传过切片
- 判断文件是否有上传过的切片,如果有则只需要上传没有上传过的
// 点击上传
const onUpload = async () => {
const uploadInput = document.createElement("input");
uploadInput.type = "file";
uploadInput.click();
uploadInput.addEventListener("change", async (e) => {
const [file] = e.target.files;
// console.log(file, 'fiel')
const fileChunkList = createfileChunkList(file);
// 计算 文件md5
const hash = await calculateHash(fileChunkList);
// 是否上传过文件
const {
code,
data: { isExist, url },
} = await checkFile({
hash,
fileName: file.name,
});
if (code === 200 && isExist) {
state.url = url;
return;
}
// 检查是否存在切片
const existChunkList = await getExistChunk(hash);
// 整理切片数据,加上进度、状态、失败重传次数等
// 已经上传过的切片 进度设置为100% 且状态为success
const chunkList = fileChunkList.map((fileChunk, index) => ({
chunk: fileChunk.file,
fileName: file.name,
index,
hash,
progress: existChunkList.includes(index) ? 100 : 0, // 传输文件的进度
status: existChunkList.includes(index) ? "success" : "ready", // 状态
retryNum: 0, // 失败重传次数
source: CancelToken.source(), // 上传取消
}));
state.chunkList = chunkList;
state.hash = hash;
state.fileName = file.name;
// 上传切片
uploadChunks(chunkList.filter((item) => item.status !== "success"));
});
};
```
```js
const getExistChunk = async (hash) => {
const {
code,
data: { existChunk },
} = await getExistFileChunk({
hash,
});
if (code === 200) {
return existChunk;
}
return [];
};
- 查找切片接口只需要要 通过查看是否存在该文件hash命名的文件夹,如果存在读取该文件夹。
async getExistFileChunk() {
const { hash } = this.ctx.request.query;
// 首先判断是否存在装切片的文件夹
const dirPath = path.join(UPLOADPATH, hash);
if (fs.existsSync(dirPath)) {
// 读取该文件夹
const files = fs.readdirSync(dirPath);
this.ctx.body = {
code: 200,
data: {
existChunk: files.map(item => +path.basename(item, path.extname(item))),
},
};
} else {
this.ctx.body = {
code: 200,
data: {
existChunk: [],
},
};
}
}
上传切片
- 上传未上传的切片
- 上传成功后 发送合并请求
// 上传切片
const uploadChunks = async (chunkList) => {
try {
// 限制并发上传切片请求
await limitRequest(chunkList);
const { hash, fileName } = state;
// 如果全部完成 则发起合并请求接口
const { code, data } = await mergeChunks({
hash,
fileName,
});
if (code === 200) {
state.url = data.url;
}
} catch (err) {
console.log(err)
}
};
- 限制每次并发3个请求
- 失败进行重传
const limitRequest = (chunklist, size = 3) => {
return new Promise(async (resolve, reject) => {
// 发送成功数量
let count = 0;
if (!chunklist.length) {
resolve();
}
const request = () => {
while (count < chunklist.length && size > 0) {
size--;
// 等待发送的切片
const fileChunkIndex = chunklist.findIndex((chunk) => {
return chunk.status === "error" || chunk.status === "ready";
});
// 如果没有找到 符合要求的切片则 退出循环
if (fileChunkIndex < 0) {
break
}
chunklist[fileChunkIndex].status = "pedding";
const { chunk, fileName, index, hash, source } =
chunklist[fileChunkIndex];
const chunkIndex = state.chunkList.findIndex(
(item) => item.index === index
);
const formData = new FormData();
formData.append("hash", hash);
formData.append("filename", fileName);
formData.append("index", index);
formData.append("file", chunk, fileName);
uploadChunk(formData, {
onUploadProgress: (e) => {
const { total, loaded } = e;
state.chunkList[chunkIndex].progress = (loaded / total) * 100;
},
cancelToken: source.token,
})
.then((res) => {
state.chunkList[chunkIndex].status = "success";
size++;
count++;
// 全部上传成功
if (count === chunklist.length) {
resolve({
hash,
fileName,
});
} else {
request();
}
})
.catch((e) => {
const source = CancelToken.source()
chunklist[fileChunkIndex].source = source
state.chunkList[chunkIndex].source = source
chunklist[fileChunkIndex].status = "error";
chunklist[fileChunkIndex].retryNum++;
state.chunkList[chunkIndex].status = "error";
// 切片重试上传超过十次则上传失败
if (chunklist[fileChunkIndex].retryNum > 10) {
reject(e);
}
size++;
});
}
};
request();
});
};
- 接收切片的接口 先判断是否存在该文件夹,不存在则创建文件夹再写入
async uploadChunk() {
const stream = await this.ctx.getFileStream();
const hash = stream.fields.hash;
// 利用hash创建文件
function mkdirsSync(hash) {
if (fs.existsSync(hash)) {
return true;
}
if (mkdirsSync(path.dirname(hash))) {
fs.mkdirSync(hash);
return true;
}
}
mkdirsSync(path.join(UPLOADPATH, hash));
// 生成写入路径
const target = path.join(UPLOADPATH, hash, stream.fields.index + path.extname(stream.filename));
const writeStream = fs.createWriteStream(target);
// 如果监听到取消 则删除文件
this.ctx.req.on('aborted', () => {
writeStream.close();
fs.unlinkSync(target);
});
try {
// 异步把文件流 写入
await awaitWriteStream(stream.pipe(writeStream));
} catch (err) {
// 如果出现错误,关闭管道
await sendToWormhole(stream);
writeStream.close();
this.ctx.body = {
code: 500,
};
}
this.ctx.body = {
code: 200,
data: {},
};
}
合并切片
- 合并切片的接口只需要将文件夹下的文件依次写入通道
async merge() {
const { hash, fileName } = this.ctx.request.body;
const dirPath = path.join(UPLOADPATH, hash);
const filePath = path.join(UPLOADPATH, hash + path.extname(fileName));
try {
const files = fs.readdirSync(dirPath).sort();
const targetStream = fs.createWriteStream(filePath);
const readStream = function(files) {
const name = files.shift();
const filePath = path.join(dirPath, name);
const originStream = fs.createReadStream(filePath);
// 依次将流写入通道
originStream.pipe(targetStream, { end: false });
originStream.on('end', function() {
fs.unlinkSync(filePath);
if (files.length > 0) {
readStream(files);
} else {
targetStream.close();
originStream.close();
fs.rmdirSync(dirPath);
}
});
};
readStream(files);
} catch (e) {
this.ctx.body = {
code: 500,
};
}
this.ctx.body = {
code: 200,
data: {
url: `/download?filename=${fileName}&${hash + path.extname(fileName)}`,
},
};
}