之所以说是普通文件上传,主要是区别于之后的图片上传,大文件上传,多文件上传。
文件上传本质上还是向后端发起一个请求,和普通的请求不同之处只在于:
1.请求头设置的不同
2.传输内容的不同
对于文件上传来说,Content-type就应该设置为multipart/form-data
如果前端发起请求,手动设置了multipart/form-data,反而会上传失败,后端报错content-type missing boundary
,原因是post 请求上传文件的时候是不需要自己设置 Content-Type,并且会自动给你添加一个 boundary ,用来分割消息主体中的每个字段,这时候自己设置Content-type服务器反而不知道怎么分割每个字段了
下面是mdn对FormData的描述
https://developer.mozilla.org/zh-CN/docs/Web/API/FormData
FormData 接口提供了一种表示表单数据的键值对 key/value 的构造方式,并且可以轻松的将数据通过XMLHttpRequest.send() 方法发送出去,本接口和此方法都相当简单直接。如果送出时的编码类型被设为 "multipart/form-data",它会使用和表单一样的格式。
我们创建一个FormData实例并且将文件,文件的其他信息(文件名,文件后缀)等通过formData.append(key,value)
的方式,添加进此实例中,最后再将实例放入请求里
npm i multiparty
我们后端使用express,multiparty是一个express的插件,用于分析接收到文件上传的请求后进行解析
在上传文件之前,我们首先要做的是让用户选择文件,也就是我们需要获取到File对象,这里提供两种方案
//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其实是比较简单的,有一个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发送请求,就是想知道上传进度,我们也可以使用切片上传。
我们知道,无论是请求或者响应,它的 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, }); } });
文章
849人气
0粉丝
0关注
©Copyrights 2016-2022 杭州易知微科技有限公司 浙ICP备2021017017号-3 浙公网安备33011002011932号
互联网信息服务业务 合字B2-20220090