文件上传
单文件上传
之所以说是普通文件上传,主要是区别于之后的图片上传,大文件上传,多文件上传。
文件上传本质上还是向后端发起一个请求,和普通的请求不同之处只在于:
1.请求头设置的不同
2.传输内容的不同
设置Content-type
对于文件上传来说,Content-type就应该设置为multipart/form-data
如果前端发起请求,手动设置了multipart/form-data,反而会上传失败,后端报错content-type missing boundary,原因是post 请求上传文件的时候是不需要自己设置 Content-Type,并且会自动给你添加一个 boundary ,用来分割消息主体中的每个字段,这时候自己设置Content-type服务器反而不知道怎么分割每个字段了
FormData
下面是mdn对FormData的描述
developer.mozilla.org/zh-CN/docs/…
FormData 接口提供了一种表示表单数据的键值对 key/value 的构造方式,并且可以轻松的将数据通过XMLHttpRequest.send() 方法发送出去,本接口和此方法都相当简单直接。如果送出时的编码类型被设为 "multipart/form-data",它会使用和表单一样的格式。
我们创建一个FormData实例并且将文件,文件的其他信息(文件名,文件后缀)等通过formData.append(key,value)的方式,添加进此实例中,最后再将实例放入请求里
multiparty插件
npm i multiparty
我们后端使用express,multiparty是一个express的插件,用于分析接收到文件上传的请求后进行解析
选择/获取文件
在上传文件之前,我们首先要做的是让用户选择文件,也就是我们需要获取到File对象,这里提供两种方案
input框选择
//tsx
<input
ref={fileInputField}
onChange={handleFileSelect}
style={{
visibility: "hidden",
}}
type="file" //必须设置为file类型
title=""//如果设置为空,鼠标悬浮之后不显示title
multiple//可以选择多个文件
accept=".png,.jpg,.jpeg" //设置可以选择文件的后缀名
></input>
//input的onChange方法
const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;//获取选择到的文件
if (files) {
setFiles([...files]);
}
};
文件拖拽上传
当我们把一个文件拖到浏览器中的时候(比如pdf文档),浏览器会默认帮我们打开这个文件,也就是说,当我们把文件拖入浏览器的时候,浏览器是可以检测到这个文件对象的。
//tsx
<div
ref={fileInputContent}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
</div>
//拖拽获取 dragenter dragleave dragover drop
//拖拽上传
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); //阻止浏览器默认事件,浏览器默认行为是打开文件
const files = e.dataTransfer.files;//注意
};
//拖拽进入阻止默认函数
const handleDragOver = (e: DragEvent<HTMLDivElement>) => e.preventDefault();
代码
let formData = new FormData();
formData.append("file", _file);//此处_file是一个File对象,需要从input框获取
formData.append("filename", _file.name);
fetch("http://127.0.0.1:8888/upload_single", {
method: "POST",
body: formData,
}).then((res) => {
res.json().then(() => {
console.log("文件上传成功");
});
});
app.post("/upload_single", async (req, res) => {
try {
// console.log("req", req);
let { files } = await multiparty_upload(req, true);
let file = (files.file && files.file[0]) || {};
res.send({
code: 0,
codeText: "upload success",
originalFilename: file.originalFilename,
servicePath: file.path.replace(__dirname, HOSTNAME),
});
} catch (err) {
res.send({
code: 1,
codeText: err,
});
}
});
const uploadDir = `${__dirname}/upload`;
const multiparty_upload = function multiparty_upload(req, auto) {
typeof auto !== "boolean" ? (auto = false) : null;
let config = {
maxFieldsSize: 200 * 1024 * 1024,
};
if (auto) config.uploadDir = uploadDir;
return new Promise(async (resolve, reject) => {
await delay();
new multiparty.Form(config).parse(req, (err, fields, files) => {
if (err) {
reject(err);
return;
}
resolve({
fields,
files,
});
});
});
};
进度管控
其实进度管控只需要获取到:上传文件的总大小total,已经上传文件的大小loaded,我们就可以计算出总数了
xhr
xhr其实是比较简单的,有一个onprogress事件,如果使用axios也是同理,也有相应的事件。
/*
XMLHttpRequest.upload下的onprogress方法,得到目前上传的文件大小和总文件大小
*/
// @/server/index.js
export function upload({ methods, url, form }, cb, index) {
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
xhr.open(methods, url);
xhr.onload = function () {
resolve(xhr.responseText);
}
//onprogress方法可以获取到loaded,total,再计算其进度即可
xhr.upload.onprogress = function (e) {
//传递进度条需要的数据,并执行进度条函数
cb(index, e.loaded, e.total)
}
xhr.send(form);
})
}
fetch
很抱歉,fetch不能实现上传进度的功能。
但是我们就想用fetch发送请求,就是想知道上传进度,我们也可以使用切片上传。
我们知道,无论是请求或者响应,它的 body 属性的类型都是一个叫做 ReadableStream 的可读流。
这种可读流都有一个特点,就是在同一时间只能被一个人读取,那么你想想,请求里的流是不是被浏览器读取了?浏览器把这个流读出来,然后发送到了服务器,所以说我们就读不了了,就是这个问题。
而且浏览器在读的过程中又不告诉我们它读了多少,但是目前 W3C 正在讨论一种方案,这种方案是附带在 ServiceWorker 里边的,它里边有一套 API 叫做,BackgroundFetchManager目前这套 API 里可以实现请求进度的监听,但是这套 API 还在试验中,不能用于生产环境。
切片上传
如果我们有一个比较大的文件,用户上传的时候需要花费较多时间,如果在此期间,网络发生波动,或者用户关闭网页,上传的接口停止执行,那么用户之前传的都白传了,这用户不得气死?
切片上传就是解决这个问题的,使用切片上传的好处:
1.传输大文件的时候用户可以保留之前的进度(断点续传)
2.fetch也能做到进度感知
切片上传示意图
文件切片
第一件事就是把一个完整的文件切成一片一片
核心的方法 file.slice(start, end)
/**
* 文件切片处理
* @param file 文件
* @param maxSliceSize 切片大小
* @param maxSliceCount 最大切片数量
* @returns 切片后数据块数组
*/
function fileSclieHandle(
file: File,
maxSliceSize = 1024 * 100,
maxSliceCount = 100
) {
let sliceSize = maxSliceSize; //切片大小
let sliceCount = Math.ceil(file.size / sliceSize); //切片数量
let index = 0;
let chunks = [];
//设置最大切片数量
if (sliceCount > maxSliceCount) {
sliceSize = file.size / maxSliceCount;
sliceCount = maxSliceCount;
}
while (index < sliceCount) {
chunks.push({
//file.sclice作为切片的核心方法
file: file.slice(index * sliceSize, (index + 1) * sliceSize),
filename: `${HASH}_${index + 1}.${suffix}`,
});
index++;
}
return {
chunks,
sliceCount,
sliceSize,
};
}
文件切片的三个关键接口
/upload_already获取已经上传的切片信息
/upload_chunk上传每一个切片,和文件上传一样
/upload_merge所有切片上传完成通知后端进行切片合并
export const fileSliceUpload = async (file: File, userID: string) => {
if (!file) {
alert("未选择文件");
return;
}
const { HASH, suffix } = (await changeBuffer(file)) as changeBufferType;
let already: string | any[] = [];
// 获取已经上传的切片信息
try {
const res = await fetch(
`${BASE_URL}/upload_already?HASH=${HASH}?userID=${userID}`,
{
method: "GET",
}
);
let data: { code: number; fileList: any[] } = await res.json();
if (+data.code === 0) {
already = data.fileList;
console.log("已经上传的切片信息", already);
}
} catch (err) {}
// 已经上传的切片(数据块)数量
let chunksUploadIndex = 0;
const { chunks, sliceCount } = fileSclieHandle(file);
chunksUpload();
// 每次上传成功后执行
async function uploadComplate() {
// 管控进度条
chunksUploadIndex++;
console.log(`上传进度:${(chunksUploadIndex / sliceCount) * 100}%`);
// 当所有切片都上传成功,我们合并切片
if (chunksUploadIndex < sliceCount) return;
try {
let res = await fetch(`${BASE_URL}/upload_merge`, {
method: "POST",
body: JSON.stringify({
count: sliceCount,
HASH: HASH,
userID,
suffix,
}),
headers: {
"content-type": "application/json",
},
});
const data = await res.json();
if (+data.code === 0) {
console.log("成功!!!!!!!!!!!");
return;
}
throw data.codeText;
} catch (err) {
console.log("err", err);
}
}
// 把每一个切片都上传到服务器上
function chunksUpload() {
chunks.forEach((chunk) => {
// 已经上传的无需在上传
if (already.length > 0 && already.includes(chunk.filename)) {
uploadComplate();
return;
}
let fm = new FormData();
fm.append("file", chunk.file);
fm.append("filename", chunk.filename);
fm.append("userID", userID);
fm.append("suffix", suffix as string);
fetch(`${BASE_URL}/upload_chunk`, {
method: "POST",
body: fm,
})
.then((res) => res.json())
.then((data) => {
if (+data.code === 0) {
uploadComplate();
return;
}
return Promise.reject(data.codeText);
})
.catch(() => {
console.log("err");
// alert("当前切片上传失败,请您稍后再试~~");
});
});
}
/**
* 文件切片处理
* @param file 文件
* @param maxSliceSize 切片大小
* @param maxSliceCount 最大切片数量
* @returns 切片后数据块数组
*/
function fileSclieHandle(
file: File,
maxSliceSize = 1024 * 100,
maxSliceCount = 100
) {
let sliceSize = maxSliceSize; //切片大小
let sliceCount = Math.ceil(file.size / sliceSize); //切片数量
let index = 0;
let chunks = [];
//设置最大切片数量
if (sliceCount > maxSliceCount) {
sliceSize = file.size / maxSliceCount;
sliceCount = maxSliceCount;
}
while (index < sliceCount) {
chunks.push({
//file.sclice作为切片的核心方法
file: file.slice(index * sliceSize, (index + 1) * sliceSize),
filename: `${HASH}_${index + 1}.${suffix}`,
});
index++;
}
return {
chunks,
sliceCount,
sliceSize,
};
}
};
//文件自动上传插件
const multiparty_upload = (req) => {
const config = {};
return new Promise((resolve, reject) => {
//multiparty.Form(config).parse方法来将文件(req)写入文件夹(config.uploadDir)
new multiparty.Form(config).parse(req, (err, fields, files) => {
if (err) {
reject(err);
return;
} else {
resolve({ fields, files });
}
});
});
};
// 创建文件并写入到指定的目录 & 返回客户端结果
const writeFile = function writeFile(res, path, file, filename, stream) {
return new Promise((resolve, reject) => {
if (stream) {
try {
let readStream = fs.createReadStream(file.path),
writeStream = fs.createWriteStream(path);
readStream.pipe(writeStream);
readStream.on("end", () => {
resolve();
fs.unlinkSync(file.path);
res.send({
code: 0,
codeText: "upload success",
originalFilename: filename,
servicePath: path.replace(__dirname, HOSTNAME),
});
});
} catch (err) {
reject(err);
res.send({
code: 1,
codeText: err,
});
}
return;
}
fs.writeFile(path, file, (err) => {
if (err) {
reject(err);
res.send({
code: 1,
codeText: err,
});
return;
}
resolve();
res.send({
code: 0,
codeText: "upload success",
originalFilename: filename,
servicePath: path.replace(__dirname, HOSTNAME),
});
});
});
};
// 检测文件是否存在
const exists = function exists(path) {
return new Promise((resolve) => {
fs.access(path, fs.constants.F_OK, (err) => {
if (err) {
resolve(false);
return;
}
resolve(true);
});
});
};
/**
* 文件切片写入
* TODO:设置过期时间,过期后删除临时文件
*/
router.post("/upload_chunk", async (req, res) => {
try {
let { fields, files } = await multiparty_upload(req);
let file = (files.file && files.file[0]) || {};
let filename = (fields.filename && fields.filename[0]) || "";
let userID = (fields.userID && fields.userID[0]) || "";
let suffix = (fields.suffix && fields.suffix[0]) || "";
let path = "";
let isExists = false;
let fileIsExists = false;
// 创建存放切片的临时目录
let [, HASH] = /^([^_]+)_(\d+)/.exec(filename);
//上传切片之前判断文件是否已经存在,如果已经存在就不进行上传
fileIsExists = await exists(
`${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}.${suffix}`
);
if (fileIsExists) {
res.send({
code: 0,
msg: "文件已经上传,无需合并切片",
});
return;
}
path = `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}`;
!fs.existsSync(path) ? fs.mkdirSync(path) : null;
// 把切片存储到临时目录中
path = `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}/${filename}`;
isExists = await exists(path);
if (isExists) {
res.send({
code: 0,
codeText: "file is exists",
originalFilename: filename,
servicePath: `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}/${filename}`,
});
return;
}
writeFile(res, path, file, filename, true);
} catch (err) {
console.log(err);
res.send({
code: 1,
codeText: err,
});
}
});
/**
* 从临时文件中获取已经上传的切片
*/
router.get("/upload_already", async (req, res) => {
let { HASH, userID } = req.query;
let path = `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}`,
fileList = [];
try {
fileList = fs.readdirSync(path);
fileList = fileList.sort((a, b) => {
let reg = /_(\d+)/;
return reg.exec(a)[1] - reg.exec(b)[1];
});
res.send({
code: 0,
codeText: "",
fileList: fileList,
});
} catch (err) {
res.send({
code: 0,
codeText: "",
fileList: fileList,
});
}
});
// 大文件切片上传 & 合并切片
const merge = async function merge(HASH, count, suffix, userID) {
return new Promise(async (resolve, reject) => {
let path = `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}`;
let fileList = [];
let isChunksExists;
let fileIsExists;
isChunksExists = await exists(path);
fileIsExists = await exists(
`${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}.${suffix}`
);
if (fileIsExists) {
if (isChunksExists) {
fs.rmdir(path, (err) => {
if (err) {
console.log(err);
} else {
console.log("文件已存在,文件夹删除成功");
}
});
}
resolve({
path: `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}.${suffix}`,
filename: `${HASH}.${suffix}`,
});
return;
}
if (!isChunksExists) {
reject("HASH path is not found!");
return;
}
fileList = fs.readdirSync(path);
if (fileList.length < count) {
reject("the slice has not been uploaded!");
return;
}
fileList
.sort((a, b) => {
let reg = /_(\d+)/;
return reg.exec(a)[1] - reg.exec(b)[1];
})
.forEach((item) => {
fs.appendFileSync(
`${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}.${suffix}`,
fs.readFileSync(`${path}/${item}`)
);
fs.unlinkSync(`${path}/${item}`);
});
fs.rmdirSync(path);
resolve({
path: `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}.${suffix}`,
filename: `${HASH}.${suffix}`,
});
});
};
/**
* 切片合并
*/
router.post("/upload_merge", async (req, res) => {
let { HASH, count, userID, suffix } = req.body;
try {
let { filename, path } = await merge(HASH, count, suffix, userID);
res.send({
code:0,
msg:'切片合并成功!'
})
} catch (err) {
res.send({
code: 1,
codeText: err,
});
}
});