前言
文件上传在平时的开发过程中很经常会遇到,本文总结了如下常用的文件上传场景,包括前后端的代码实现,希望你下次遇到上传文件的场景可以直接秒杀。文章稍稍有点长,建议点赞收藏食用🐶。
- 上传方式
- 点击上传
- 拖拽上传
- 粘贴上传
- 上传限制
- 单、多文件上传
- 文件夹上传
- 上传进度
oss
上传- 大文件上传
- 切片
- 断点续传
- 秒传
上传方式
下面先来介绍三种常见的上传方式:
- 点击上传
- 拖拽上传
- 粘贴上传
点击上传
<div onClick={() => inputRef.current.click()} className={styles.uploadWrapper}> 点击、拖拽、粘贴文件到此处上传 <input onClick={(e) => e.stopPropagation()} onChange={hanldeChange} multiple type="file" ref={inputRef} /> </div>
点击上传代码十分简单,就是利用input[type="file"]
的能力唤起文件选择框,然后做一个自己喜欢的容器,把input
框藏起来,点击容器的时候模拟input
框点击即可,multiple
属性是用来做多文件上传的。
拖拽上传
const handleDrop = (event) => { event.preventDefault(); const files = event.dataTransfer.files; uploadFiles(files); }; const handleDragOver = (event) => { event.preventDefault(); }; return ( <div className={styles.container}> <div onDrop={handleDrop} onDragOver={handleDragOver} className={styles.uploadWrapper} ref={uploadRef} onClick={() => inputRef.current.click()} > 点击、拖拽、粘贴文件到此处上传 <input onChange={hanldeChange} multiple type="file" ref={inputRef} /> </div> </div> );
拖拽上传主要是实现了容器的drop
事件,当鼠标松开时从event.dataTransfer.files
获取到拖拽的文件
粘贴上传
useEffect(() => { const container = uploadRef.current; const pasteUpload = (event) => { event.preventDefault(); const items = (event.clipboardData || event.originalEvent.clipboardData) .items; let files = []; for (const item of items) { if (item.kind === "file") { files.push(item.getAsFile()); } } if (files.length > 0) { uploadFiles(files); } }; container.addEventListener("paste", pasteUpload); return () => { container.removeEventListener("paste", pasteUpload); }; }, []);
粘贴上传的方式就是在容器中监听paste
事件,把属于文件的粘贴内容过滤出来。
以上就是三种常见的上传方式,在这三种上传方式中,主要都是为了收集文件。最后上传的逻辑收口到一个uploadFiles
方法中,在这个方法中可以执行一些前置的校验,比如说文件大小、文件类型、文件个数等等,校验完之后再调用后端接口进行文件上传。
上传限制
上图是一个文件对象的一些相关属性,下面需要关注的属性有:
name
:文件名size
:文件大小,单位为字节,除以1024
等于KB
type
:文件类型
对于文件类型的限制,在点击上传的场景中,可以加上一个accept
的属性,比如说加上一个accept="image/*"
,这样弹出来的文件选择框中,就只能选择图片。但是对于其余两种方式,还是得需要在代码里面进行判断。
const uploadFiles = (files) => { if (files.length === 0) { return; } const list = Array.from(files); if (MAX_COUNT && list.length > MAX_COUNT) { message.error(`最多上传${MAX_COUNT}个文件`); return; } let isOverSize = false; if (MAX_SIZE) { isOverSize = list.filter((file) => { return file.size > MAX_SIZE; }).length > 0; } if (isOverSize) { message.error(`最多上传${MAX_SIZE / 1024 / 1024}M大的文件`); return; } let isNotMatchType = false; if (ACCEPS.length > 0) { isNotMatchType = list.filter((file) => { return ACCEPS.length > 0 && !ACCEPS.includes(file.type); }).length > 0; } if (isNotMatchType) { message.error("上传文件的类型不合法"); return; } };
开始上传
在介绍完上传文件的方式之后,就可以真正的把选中的文件发送给后端了。下面我将以Node
作为服务端语言,来介绍上传文件的前后端交互全流程。
在前端代码的uploadFiles
逻辑中加入以下逻辑,把我们上面收集到的文件填充到formData
的files
字段中,注意这个files
字段是跟后端约定好的字段,后端根据这个字段取到文件的信息:
setLoading(true); const formData = new FormData(); list.forEach((file) => { formData.append("files", file); }); const res = await uploadApi(formData); const data = res.data.data; const successCount = data.filter((item) => item.success).length; message.info( `上传完成,${successCount}个成功,${data.length - successCount}个失败` ); setLoading(false);
然后后端实现我们使用express
来搭建一个服务,这个服务目前需要做以下的事情:
- 创建一个静态目录,用于存储静态文件,可通过
URL
访问,使用的是express
自带的static
中间件 - 使用
multer
中间件,帮助我们在路由中获取文件参数 - 实现一个
writeFile
函数,将前端传过来的文件写入磁盘中
具体代码实现如下
const express = require("express"); const multer = require("multer"); const path = require("path"); const fs = require("fs"); const app = express(); const PORT = 3000; const STATIC_PATH = path.join(__dirname, "public"); const UPLOAD_PATH = path.join(__dirname, "public/upload"); app.use(express.static(STATIC_PATH)); const upload = multer(); const writeFile = async (file) => { const { originalname } = file; return new Promise((resolve) => { fs.writeFile(`${UPLOAD_PATH}/${originalname}`, file.buffer, (err) => { if (err) { resolve({ success: false, filePath: "", }); return; } resolve({ success: true, filePath: `http://localhost:3000/upload/${originalname}`, }); }); }); }; // 处理文件上传 app.post("/upload", upload.array("files"), async (req, res) => { // 'files'参数对应于表单中文件输入字段的名称 const files = req.files; const promises = files.map((file) => writeFile(file)); const result = await Promise.all(promises); // 返回上传成功的信息 res.json({ data:result }); }); app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`); });
上传进度
上传进度主要监听的是axios
暴露的onUploadProgress
事件,这个时候可以配合一个进度条使用
const res = await uploadApi(formData, { onUploadProgress: (progressEvent) => { const percentage = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); setProgress(percentage); }, });
这里我把网络调整成3G,可以更好的看到上传文件的进度过程: 链接
上传文件夹
拖拽/复制文件夹与点击文件夹上传稍有不同,前者需要我们自己去分析文件夹与文件的路径关系,而后者浏览器的标准接口已经帮我们处理好文件夹相关的路径信息,我们只需要稍作处理即可。下面来看具体的实现
拖拽/复制文件夹上传
先以拖拽为例,复制的逻辑与拖拽差不多。上面我们拖拽普通文件的时候是使用event.dataTransfer.files
,这个api
是拿不到文件夹的信息的。我们要换一个api
:event.dataTransfer.items
。在遍历这个数组时需要用到一个webkitGetAsEntry
方法,它可以获取到文件或者文件夹的相关信息。
比如上图是一个文件夹,具体看一下需要关注的属性:
createReader
:文件夹独有,可以递归获取文件夹下的文件夹或文件isDirectory
:是否为文件夹isFile
:是否为文件
上图是一个文件,需要关注的是
file
:异步方法,获取文件的内容信息isFile
:是否为文件
这样我们就可以递归的获取文件夹,以拖拽上传为例:
const processFiles = async (items) => { const folderFiles = []; const promises = Array.from(items).map((item) => { return new Promise(async (resolve) => { const entry = item.webkitGetAsEntry(); if (entry.isFile) { await getFileFromEntry(entry, folderFiles); } else if (entry.isDirectory) { await traverseDirectory(entry, folderFiles, entry.name); // 传递文件夹名称 } resolve(); }); }); await Promise.all(promises); return folderFiles; }; const getFileFromEntry = (entry, folderFiles, folderName) => { return new Promise((resolve) => { entry.file((file) => { if (folderName) { file.folder = folderName; } folderFiles.push(file); resolve(); }); }); }; const traverseDirectory = async (directory, folderFiles, folderName) => { return new Promise((resolve) => { const reader = directory.createReader(); reader.readEntries(async (entries) => { const entryPromises = entries.map((entry) => { return new Promise(async (entryResolve) => { if (entry.isFile) { await getFileFromEntry(entry, folderFiles, folderName); } else if (entry.isDirectory) { await traverseDirectory( entry, folderFiles, `${folderName}#${entry.name}` ); } entryResolve(); }); }); await Promise.all(entryPromises); resolve(); }); }); }; const handleDrop = async (event) => { event.preventDefault(); const items = event.dataTransfer.items; const files = await processFiles(items); uploadFiles(files); };
解释一下上面的流程:
- 首先判断是文件还是文件夹,是文件的话,则调用
file
方法拿到文件内容;是文件夹的话则调用createReader
来读取文件夹下面的信息 - 递归过程中需要把文件夹的名称手动拼成一个路径
- 这里注意我们使用
#
来作为文件路径之间的分割符,因为尝试了一下如果使用/
,后端会接收不到 - 并在读文件的时候,给文件对象赋予一个
folder
属性
然后来改造一下上传文件的逻辑
const buildFile = (file) => { if (file.folder) { const originalFile = file; const fileName = originalFile.name; const newFileName = `${file.folder}#${encodeURIComponent(fileName)}`; const newFile = new File([originalFile], newFileName, { type: originalFile.type, lastModified: originalFile.lastModified, }); return newFile; } return null; }; list.forEach((file) => { let newFile = buildFile(file); formData.append("files", newFile ? newFile : file); });
上传之前预处理文件,如果文件中存在folder
属性,则把文件夹的信息拼在文件名中,因为file.name
是一个只读属性,无法修改,所以这里需要拷贝一个文件,赋予新的文件名。这里注意文件名称中可以存在#
字符,所以需要使用encodeURIComponent
转一下。
复制的逻辑跟拖拽的处理逻辑大同小异,只有前面处理粘贴板的逻辑是不一样的:
const pasteUpload = async (event) => { event.preventDefault(); const items = (event.clipboardData || event.originalEvent.clipboardData) .items; const fileItems = Array.from(items).filter( (item) => item.kind === "file" ); const files = await processFiles(fileItems); if (files.length > 0) { uploadFiles(files); } };
点击文件夹上传
点击上传的时候,文件夹跟文件是不可以同时上传的,拖拽/复制的时候是可以的。所以点击文件夹上传的时候需要区分开来
<Button onClick={() => folderInputRef.current.click()} type="primary"> 上传文件夹 </Button> <input className={styles.hide} directory="" webkitdirectory="" onClick={(e) => e.stopPropagation()} onChange={handleFolderChange} multiple type="file" ref={folderInputRef} />
所以这里我另外做了一个按钮来实现点击文件夹的上传。
可以看到在点击上传文件夹的时候会有一个webkitRelativePath
属性,这个就是包含了文件的所有路径信息。所以我们只需要稍作处理,就可以直接调用uploadFiles
。
const handleFolderChange = (e) => { const list = Array.from(e.target.files); const files = list.map((file) => { if (file.webkitRelativePath) { const path = file.webkitRelativePath.split("/"); const folders = path.slice(0, -1); file.folder = folders.join("#"); } return file; }); if (files.length > 0) { uploadFiles(files); } folderInputRef.current.value = ""; };
后端实现
好的,上面就是前端部分的实现方式,下面我们来看后端的实现方式。后端要改造的点有如下几点:
- 给定一个
key
,表示这次的上传动作,给文件夹/文件起一个唯一名称 - 如果文件名中存在
#
,则认为该文件是处于某个文件夹下的,需要先创建好文件夹再写文件
具体代码如下:
const writeFile = async (file, key) => { const { originalname } = file; /**组装文件的唯一名称 */ const fileName = getFileName(originalname); /**组装文件夹的唯一名称 */ const folders = originalname .split("#") .slice(0, -1) .map((item) => `${item}-${key}`); let path = `${UPLOAD_PATH}/${fileName}`; /**前端读取的路径 */ let resPath = `${fileName}`; let folderFormat = []; for (let i = 0; i < folders.length; i++) { const folderName = folders.slice(0, i + 1).join("/"); folderFormat.push(folderName); } const folderName = folderFormat[folderFormat.length - 1]; /**如果存在文件夹信息 */ if (folderFormat.length > 0) { /**创建文件夹 */ if (!fs.existsSync(`${UPLOAD_PATH}/${folderName}`)) { fs.mkdirSync(`${UPLOAD_PATH}/${folderName}`); path = `${UPLOAD_PATH}/${folderName}/${fileName}`; resPath = `${folderName}/${fileName}`; } } return new Promise((resolve) => { fs.writeFile(path, file.buffer, (err) => { if (err) { resolve({ success: false, filePath: "", }); return; } resolve({ success: true, filePath: `http://localhost:3000/upload/${resPath}`, }); }); }); };
上传至OSS
在这个上云的时代,很少会直接把文件写在文件系统里面了,因为容器一重启文件就会丢,除非挂载了额外的磁盘路径。大多数还是把文件上传到对象存储服务里边,这里我以阿里云的oss
为例,把我们的文件从磁盘上传到对象存储。
const OSS = require("ali-oss"); const client = new OSS({ region: 'your-oss-region', accessKeyId: 'your-access-key-id', accessKeySecret: 'your-access-key-secret', bucket: 'your-bucket-name' }); fs.writeFile(path, file.buffer, async (err) => { const res = await client.put(resPath, path); if (err) { resolve({ success: false, filePath: "", }); return; } resolve({ success: true, filePath: res.url, }); });
写入文件后调用client.put
方法就可以把资源传输到oss
中,其中resPath
是阿里云oss
的存储地址,path
是文件的本地地址。
大文件上传
下面我们来讨论大文件上传,主要有分片上传,秒传,断点续传等。
- 分片上传:上传大文件时,如果整个文件一次性上传,网络故障或其他中断可能导致整个上传过程失败,用户需要重新上传整个文件。分片上传允许将文件拆分成小块,每个小块独立上传,如果其中一个小块上传失败,只需重新上传该小块,而不是整个文件。
- 秒传:如果该文件已经上传过,则直接返回成功
- 断点续传:只上传还没有上传过的文件片段
下面以单文件上传为例,讨论上面的三个功能
分片上传
先介绍一个分片上传的一整个流程:
- 前端将文件按照一定的大小规则进行切片
- 前端算出文件的
md5
,这个md5
会一直作为文件的唯一id标识,用这个md5向后端换一个uploadId - 前端拿到这个
uploadId
之后向后端传输所有分片 - 所有分片传输完之后发起合并分片请求
- 合并完成,上传结束
前端实现
这里我定义了1M
大小一个分片,通过SparkMD5
去计算文件的MD5
,然后通过file.slice
方法对文件进行切片,最后开始发起上传请求。
先用md5
换取一个uploadId
,随后把所有的分片发送过去,最后发送合并请求。
import SparkMD5 from "spark-md5"; //.... const calculateMD5 = (file) => { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => { const spark = new SparkMD5.ArrayBuffer(); spark.append(e.target.result); const md5 = spark.end(); resolve(md5); }; reader.onerror = (error) => { console.error(error); }; reader.readAsArrayBuffer(file); }); }; const getFileExtension = (file) => { const fileName = file.name; const dotIndex = fileName.lastIndexOf("."); if (dotIndex !== -1) { return fileName.substring(dotIndex + 1).toLowerCase(); } return null; // No file extension found }; const CHUNK_SIZE = 1 * 1024 * 1024; const uploadBigFile = async (file) => { const md5 = await calculateMD5(file); const totalChunks = Math.ceil(file.size / CHUNK_SIZE); const fileName = `${md5}.${getFileExtension(file)}`; const res = await initUpload({ fileName, fileMD5: md5, totalChunks, }); const uploadId = res.data.uploadId; const promises = []; for (let chunkNumber = 1; chunkNumber <= totalChunks; chunkNumber++) { const start = (chunkNumber - 1) * CHUNK_SIZE; const end = Math.min(chunkNumber * CHUNK_SIZE, file.size); const chunk = file.slice(start, end); const formData = new FormData(); formData.append("file", chunk); formData.append("fileName", fileName); formData.append("uploadId", uploadId); formData.append("partNumber", chunkNumber); formData.append("fileMD5", md5); promises.push(uploadPart(formData)); } await Promise.all(promises); await completeUpload({ uploadId, fileMD5: md5, fileName, });
对于大文件的md5
计算,可以有以下的拓展思考,本文就不再展开
- 把计算逻辑放到
web worker
,不要阻塞主线程 - 用
rust
等语言实现wasm
放到前端使用,可以加速md5
的计算过程
后端实现
后端需要实现三个接口:
- 初始化上传任务,返回
uploadId
- 接收各个分片,上传到
oss
- 所有分片上传完之后,向
oss
发起合并请求
const fileMap = {}; app.post("/initUpload", async (req, res) => { const { fileMD5, fileName, totalChunks } = req.body; const result = await client.initMultipartUpload(fileName); const uploadId = result.uploadId; fileMap[fileMD5] = { md5: fileMD5, uploadId, totalChunks, uploadedChunks: [], parts: [], url: "", }; res.json({ uploadId }); }); app.post("/uploadPart", upload.array("file"), async (req, res) => { const { fileName, uploadId, partNumber, fileMD5 } = req.body; if (fileMap[fileMD5].uploadedChunks.includes(partNumber)) { res.json({ success: true }); return; } try { const partResult = await client.uploadPart( fileName, uploadId, partNumber, req.files[0].buffer ); fileMap[fileMD5].uploadedChunks.push(partNumber); fileMap[fileMD5].parts.push({ number: partNumber, etag: partResult.etag, }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: "上传失败" }); } }); app.post("/completeUpload", async (req, res) => { const { fileName, uploadId, fileMD5 } = req.body; try { const parts = fileMap[fileMD5].parts.sort((a, b) => a.number - b.number); const completeResult = await client.completeMultipartUpload( fileName, uploadId, parts ); res.json({ completeResult }); } catch (error) { res.status(500).json({ error: "上传失败" }); } });
这里再介绍一下上面定义的fileMap
对象,这个对象主要用来记录一些大文件上传的相关信息,用于做后面的秒传和断点续传。
md5
:文件的md5
uploadId
:oss
上传的uploadId
totalChunks
:一共分多少片uploadedChunks
:目前已经上传的chunk
下标parts
:目前上传的文件部分url
:上传完成的URL
秒传
秒传就是对于已经上传过的文件立马返回上传成功以及上传后的链接,这样前端就不用再走分片上传合并的逻辑。
只需要改造一下initUpload
接口以及completeUpload
接口,在initUpload
的时候如果能在fileMap
中拿到url
就直接返回url
,前端拿到url
之后就不走分片上传逻辑;completeUpload
合并完成之后把url
填入fileMap
中。
app.post("/initUpload", async (req, res) => { const { fileMD5, fileName, totalChunks } = req.body; let uploadId; let url; if (!fileMap[fileMD5]) { const result = await client.initMultipartUpload(fileName); uploadId = result.uploadId; fileMap[fileMD5] = { md5: fileMD5, uploadId, totalChunks, uploadedChunks: [], parts: [], url: null, }; } else { uploadId = fileMap[fileMD5].uploadId; if (fileMap[fileMD5].url) { url = fileMap[fileMD5].url; } } res.json({ uploadId, url }); }); app.post("/completeUpload", async (req, res) => { const { fileName, uploadId, fileMD5 } = req.body; try { const parts = fileMap[fileMD5].parts.sort((a, b) => a.number - b.number); const completeResult = await client.completeMultipartUpload( fileName, uploadId, parts ); const url = completeResult.res.requestUrls[0]; fileMap[fileMD5].url = url; res.json({ url }); } catch (error) { res.status(500).json({ error: "上传失败" }); } });
断点续传
断点续传的逻辑就是不需要再次上传已经传过的片段,主要改造一下uploadPart
接口。如果当前分片可以在fileMap
中找到,则直接返回。
if (fileMap[fileMD5].uploadedChunks.includes(partNumber)) { res.json({ success: true }); return; }
最后
以上就是本文介绍的所有场景,如果你有一些不同的想法,欢迎评论区交流~如果你觉得有所收获的话,点点关注点点赞吧~