多文件上传,首先大家需要明确一个关键点,之前单文件上传的话,当然,一次请求我们就发送一个文件即可。但是多文件上传,我们就需要多思考一下,我们究竟是一次性的把多个文件通过一起请求进行上传?还是把多个文件,通过多次请求进行上传。这就是我们开始讲到的前端上传文件需要注意的两个点之一:
1、显示样式 2、请求方式
因此,多文件上传根据具体请求方式的不同,一般就分为两种上传方式:
1、一次请求直接上传多个文件
2、一次请求上传一个文件,分多次上传
两种上传方式用哪一种,要根据具体的业务场景来选择。
比如,一开始就不允许用户上传大文件,而且对单个上传文件大小,个数以及所有文件的总体大小,都有严格限制,也不考虑上传失败的情况下要再次进行续传的情况,就可以用第一种。这种方式,一般上传文件都只是一种附加操作,并不会对用户的实际使用造成任何的影响。
如果对文件大小没有太大限制,而且传输出现问题的话,还希望继续传输,那么第二种就比较合适。
两种前端不同的处理方式,对应后端来说,也应该是不同的处理接口。
第一种处理办法,应该后端有一个多文件处理的接口,一次性的接收多个文件
第二种处理办法,后端不需要有其他接口,一个文件就是一次单文件上传,对于我们写的代码来说,就是多次调用/upload/single
接口
后端接收多文件上传的接口
在之前后端upload.js的路由文件中,加入多文件上传的路由
router.post('/multi', async (req, res, next) => {
multi(
req,
res,
async (err) => {
if (err instanceof multer.MulterError) {
const { SizeLimitError, CountLimitError } = require('../utils/UploadErrorTypes.js');
if (err.message === 'File too large') {
err = new SizeLimitError();
} else {
err = new CountLimitError();
}
}
if (err) {
next(err);
return;
}
const urls = await Promise.all(req.files.map((f) => handleFile(f, req)));
res.send({
code:0,
data: urls,
msg:'上传成功'
});
}
);
});
async function handleFile(file, req) {
const { uploadSuffixName,generateUrl } = require('../utils/fileUtils');
const filename = uploadSuffixName(file.originalname, file.filename);
const fs = require('fs');
//为保存文件重命名
await fs.promises.rename(file.path, `${file.destination}/${filename}`);
//生成后端访问路径
const url = generateUrl(req, filename);
return url;
}
前端一次性上传多个文件
multiple
对于前端一次性上传多个文件,基本的处理就是需要将上传文件标签加上multiple
属性
<form action="http://127.0.0.1:3002/upload/multi" method="post" enctype="multipart/form-data">
<input type="file" name="images" id="images" multiple>
<button type="submit">提交</button>
</form>
同样,我们的重点依然是在ajax上,而现在的Ajax代码和单文件相比,几乎没有任何变化,无非就是FormData
上传的参数,从之前的一个fils[0]
变成了需要遍历files
加入append到FormData对象中
<!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>
</head>
<body>
<form action="http://127.0.0.1:3002/upload/multi" method="post" enctype="multipart/form-data">
<input type="file" name="images" id="images" multiple>
<button type="submit">提交</button>
</form>
<hr>
<button id="ajaxUpload" type="button">提交</button>
</body>
<script>
const ajaxUpload = document.getElementById('ajaxUpload');
const images = document.getElementById('images');
ajaxUpload.addEventListener('click', function (e) {
upload(
images.files,
'http://localhost:3002/upload/multi',
v => console.log(v),
data => console.log(data));
})
const upload = (files,url,onProgress,onFinish,onloadend)=>{
let xhr = new XMLHttpRequest();
xhr.open('POST', url);
let formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('images', files[i]);
}
xhr.onload = function () {
const resp = JSON.parse(xhr.responseText);
onFinish(resp);
}
xhr.upload.onprogress = (e) => {
// 上传进度变化时更新 UI
const percent = Math.floor((e.loaded / e.total) * 100);
onProgress(percent);
};
xhr.onloadend = onloadend;
xhr.send(formData);
return () => {
xhr.abort();
}
}
</script>
</html>
webkitdirectory,mozdirectory,odirectory
上传文件还支持整个文件夹上传,不过需要在标签中加入对应的属性webkitdirectory mozdirectory odirectory
<body>
<form action="http://127.0.0.1:3002/upload/multi" method="post" enctype="multipart/form-data">
<input type="file" name="images" id="images" webkitdirectory mozdirectory odirectory />
<button type="submit">提交</button>
</form>
<hr>
<button id="ajaxUpload" type="button">提交</button>
</body>
<script>
const ajaxUpload = document.getElementById('ajaxUpload');
const images = document.getElementById('images');
ajaxUpload.addEventListener('click', function (e) {
console.log(images.files);
})
</script>
把这三个属性直接加在input标签上就非常简单,反正选择一个文件夹之后,自然就获取了这个文件下面的所有文件,包括子文件夹中的文件
拖拽 DragEvent
与 文件 File and Directory Entries API
当然了,其实不仅仅是file
框可以接收文件,其实,其他的标签也是能接收文件的。比如div
标签。不过这些标签要接收文件,需要配合HTML5
的新拖拽事件API,以及文件新接口
拖拽API - DragEvent
dragstart:当用户开始拖拽一个元素时触发。可以在事件处理程序中设置数据传输操作的数据类型和数据。
drag:当用户拖拽一个元素时持续触发。可以在事件处理程序中更新用户拖拽时的 UI 状态。
dragenter:当用户将一个元素拖拽到一个有效的放置目标上时触发。可以在事件处理程序中更新放置目标的 UI 状态。
dragover:当用户将一个元素拖拽到一个有效的放置目标上并在其上方悬停时持续触发。可以在事件处理程序中更新放置目标的 UI 状态。
dragleave:当用户将一个元素从一个有效的放置目标上拖离时触发。可以在事件处理程序中更新放置目标的 UI 状态。
drop:当用户将一个元素放置到一个有效的放置目标上时触发。可以在事件处理程序中处理放置操作的逻辑,并访问拖拽操作设置的数据。
dragend:当用户完成拖拽操作并释放元素时触发。可以在事件处理程序中进行清理操作,如重置 UI 状态或删除元素。
文件API - File and Directory Entries API
dataTransfer
dataTransfer
是一个 HTML5 拖放事件中用于访问拖放操作的数据的对象。它包含了一些与拖放操作相关的属性和方法,可以在拖放事件处理程序中使用它来操作拖放数据。
以下是一些常见的 dataTransfer 属性和方法:
- dataTransfer.effectAllowed:表示拖放操作的允许效果,可以是 none、copy、link、move 或它们的组合。
- dataTransfer.dropEffect:表示当前拖放操作的效果,可以是 none、copy、link 或 move。
- dataTransfer.files:表示拖放操作中的文件列表,是一个
FileList
对象。 - dataTransfer.items:表示拖放操作中的数据列表,是一个
DataTransferItemList
对象。 - dataTransfer.types:表示拖放操作中的数据类型列表,是一个数组。
- dataTransfer.getData(format):获取指定格式的拖放数据,返回一个字符串。
- dataTransfer.setData(format, data):设置指定格式的拖放数据,传入一个格式字符串和要设置的数据字符串。
DataTransferItemList
dataTransfer.items
获取的类型就是DataTransferItemList
。它表示了拖放操作中的数据项列表。它是一个伪数组对象,可以通过下标访问具体的数据项。
webkitGetAsEntry()
webkitGetAsEntry()
方法返回一个 FileSystemEntry
对象,表示一个文件或文件夹的条目。对于文件夹条目,可以进一步使用 createReader()
方法来访问它的子文件夹和文件。对于文件条目,则可以通过 file()
方法访问它的文件内容。
webkitGetAsEntry()
有WebKit
内核的浏览器标识,所以肯定是有兼容性问题的。
createReader()
webkitGetAsEntry()
方法获取到一个文件夹条目时,使用createReader()
创建一个 DirectoryReader
对象,用于访问该文件夹中的子文件夹和文件。
DirectoryReader
对象提供了以下一些常见的方法:
- readEntries(callback):读取文件夹中的子条目,返回一个 FileSystemEntry 对象的数组,并调用传入的回调函数。
- hasPending():检查是否有未读取的子条目,如果有返回 true,否则返回 false。
- cancel():取消读取操作。
readEntries(callback)
readEntries(callback)
是 DirectoryReader
接口的一个方法,用于读取目录中的条目(包括子目录和文件)。
readEntries(callback)
方法返回一个 void 类型的值,它会调用指定的回调函数,并将读取到的目录条目作为 DirectoryEntry
对象的数组传递给回调函数。如果目录中还有更多的条目需要读取,则 DirectoryReader
对象会保持打开状态,直到读取完所有的条目。
readEntries(callback)
方法是一个异步方法,因此在处理目录条目时应该使用回调函数或 Promise 等异步机制。
<!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>
.container {
border: 1px solid;
width: 100%;
height: 100px;
}
</style>
</head>
<body>
<div class="container"></div>
<script>
const div = document.querySelector('.container');
div.ondragenter = (e) => {
e.preventDefault();
};
div.ondragover = (e) => {
e.preventDefault();
};
div.ondrop = (e) => {
e.preventDefault();
for (const item of e.dataTransfer.items) {
const entry = item.webkitGetAsEntry();
if (entry.isDirectory) {
// 目录
const reader = entry.createReader();
reader.readEntries((entries) => {
console.log(entries);
});
} else {
// 文件
entry.file((f) => {
console.log(f);
});
}
}
console.log(results);
};
</script>
</body>
</html>
上面的代码要注意两点:
1、只能运行在服务器中,本地打开无效,可以使用VSCODE
的插件Live Server
打开
2、要实现文件投放效果,需要禁用浏览器默认事件e.preventDefault()
3、entry.createReader()
与entry.file()
读取文件时实际上是异步操作,也就是意味着,如果读取文件之后,你需要返回的话,最好放在Promise
中进行处理
将上面的代码再进行以下封装:
//只截取了文件获取相关代码片段,其他代码和上面一致
div.ondrop = async (e) => {
e.preventDefault();
let files = await dropFiles(e);
console.log(files);
};
async function dropFiles(event) {
if (
event.dataTransfer.types[0] !== "Files" ||
!event.dataTransfer.items
) {
return;
}
const entries = [...event.dataTransfer.items].map((item) =>
item.webkitGetAsEntry()
);
const allEntries = await deepTraverseFileTree(entries);
let files = await Promise.all(
allEntries.map(
(entry) =>
new Promise((resolve, reject) => entry.file(resolve, reject))
)
);
return files;
}
async function deepTraverseFileTree(entries) {
const dirs = entries.filter((entry) => !!entry && entry.isDirectory);
const files = entries.filter((entry) => !!entry && entry.isFile);
if (dirs.length) {
const childEntries = (
await Promise.all(
dirs.map(
(dir) =>
new Promise((resolve, reject) =>
dir.createReader().readEntries(resolve, reject)
)
)
)
).flat();
return deepTraverseFileTree(childEntries);
}
return [...files];
}
上面的代码仅仅只能获取最底层文件夹下的文件,如果我们需要获取整个文件夹下的文件内容,那么代码再进行修改
<!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>
.container {
border: 1px solid;
width: 100%;
height: 100px;
}
</style>
</head>
<body>
<div class="container"></div>
<script>
const div = document.querySelector(".container");
let exts = [".jpg", ".jpeg", ".bmp", ".webp", ".gif", ".png"];
div.ondragenter = (e) => {
e.preventDefault();
};
div.ondragover = (e) => {
e.preventDefault();
};
div.ondrop = async (e) => {
e.preventDefault();
let files = await dropFiles(e);
files = filterFiles(files);
console.log(files);
};
async function dropFiles(event) {
if (
event.dataTransfer.types[0] !== "Files" ||
!event.dataTransfer.items
) {
return;
}
const entries = [...event.dataTransfer.items].map((item) =>
item.webkitGetAsEntry()
);
const allEntries = await traverseFileTree(entries);
let files = await Promise.all(
allEntries.map(
(entry) =>
new Promise((resolve, reject) => entry.file(resolve, reject))
)
);
return files;
}
async function traverseFileTree(entries) {
let files = [];
async function traverse(entries) {
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.isFile) {
files.push(entry);
} else if (entry.isDirectory) {
const childEntries = await new Promise((resolve, reject) =>
entry.createReader().readEntries(resolve, reject)
);
await traverse(childEntries);
}
}
}
return traverse(entries).then(() => files);
}
function filterFiles(files) {
return files.filter((file) => verifiedExt(file.name));
}
let getExtName = (filename) => {
let index = filename.lastIndexOf(".");
return index > 0 ? filename.substring(index).toLowerCase() : "";
};
let verifiedExt = (filename) => {
let ext = getExtName(filename);
return exts.includes(ext);
};
</script>
</body>
</html>
无论怎么样,获取了所有文件之后,要一个一个的请求去进行上传就比较简单了。无非就是迭代数组,调用ajax进行上传即可。
//html
<button class="btnUpload">开始上传</button>
// js 其他代码省略...
const btnUpload = document.querySelector(".btnUpload");
btnUpload.onclick = () => {
if(files.length === 0){
alert('请先选择文件')
return
}
files.forEach(file=>{
upload(file,'http://localhost:3002/upload/single');
})
};
const upload = (file,url,onFinish,onProgress,onloadend)=>{
let xhr = new XMLHttpRequest();
xhr.open('POST', url);
let formData = new FormData();
formData.append('avatar', file);
xhr.onload = function () {
const resp = JSON.parse(xhr.responseText);
console.log(resp);
onFinish && onFinish(resp);
}
xhr.upload.onprogress = (e) => {
// 上传进度变化时更新 UI
const percent = Math.floor((e.loaded / e.total) * 100);
onProgress && onProgress(percent);
};
xhr.onloadend = onloadend;
xhr.send(formData);
return () => {
xhr.abort();
}
}
FileReader
在上传的时候,还想实现及时预览的效果,前面我们看到的预览效果,都是后端接收到图片后,响应回图片在后端的地址,实际上,我们后在前端看到的预览效果,是一个远程访问后端图片的效果。
但是,如果我们想在还没有上传图片之前进行预览图片,或者说,后端由于某些限制,不允许我直接访问后端文件,以及可能出现的跨域问题等等情况。
如果出现这些情况,我们只有把预览的操作全部交给前端完成。这个完全可以,不过需要用到另外一个新的API:FileReader
FileReader
是一种异步读取文件机制。
方法
FileReader 的实例拥有 4 个方法,其中 3 个用以读取文件,另一个用来中断读取
需要注意的是 ,无论读取成功或失败,方法并不会返回读取结果,结果存储在 FileReader对象的result属性中
方法名 | 描述 |
---|---|
FileReader.abort() | 中止读取操作。在返回时,readyState 属性为 DONE。 |
FileReader.readAsArrayBuffer() | 开始读取指定的 Blob 中的内容。 一旦完成,result 属性中保存的将是被读取文件的 ArrayBuffer 数据对象。 |
FileReader.readAsDataURL() | 开始读取指定的 Blob 中的内容。一旦完成,result 属性中将包含一个 data: URL 格式的 Base64 字符串以表示所读取文件的内容。 |
FileReader.readAsText() | 开始读取指定的Blob中的内容。一旦完成,result 属性中将包含一个字符串以表示所读取的文件内容。 |
事件处理
FileReader
包含了一套完整的事件模型,用于捕获读取文件时的状态。
事件 | 描述 |
---|---|
FileReader.onabort | 处理 abort 事件。该事件在读取操作被中断时触发。 |
FileReader.onerror | 处理 error 事件。该事件在读取操作发生错误时触发。 |
FileReader.onload | 处理 load 事件。该事件在读取操作完成时触发。 |
FileReader.onloadstart | 处理 loadstart 事件。该事件在读取操作开始时触发。 |
FileReader.onloadend | 处理 loadend 事件。该事件在读取操作结束时(要么成功,要么失败)触发。 |
FileReader.onprogress | 处理 progress 事件。该事件在读取Blob时触发。 |
因为 FileReader
继承自 EventTarget
,所以所有这些事件也可以通过 addEventListener
方法使用。
完整案例
<!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>
.container {
border: 1px solid;
width: 100%;
height: 100px;
padding: 10px;
display: flex;
justify-content: start;
}
.container img {
max-width: 100px;
max-height: 100px;
border: 1px solid transparent;
transition: all 0.5s ease;
margin: 0px 15px;
}
.container img:hover {
border: 1px solid #efefef;
box-shadow: 0 0 8px #fff, 0 5px 13px rgba(0, 0, 0, 0.07);
}
.uploadContent {
border: 1px solid #666;
width: 100%;
display: flex;
justify-self: start;
align-items: center;
transition: all 1s ease-in-out;
}
.content {
margin: 10px;
width: 200px;
padding: 15px;
border: 1px dashed #ccc;
}
.content img {
max-width: 200px;
max-height: 200px;
}
</style>
</head>
<body>
<div class="container"></div>
<br />
<button class="btnUpload">开始上传</button>
<br /><br />
<div class="uploadContent"></div>
<script>
const div = document.querySelector(".container");
const btnUpload = document.querySelector(".btnUpload");
const uploadContent = document.querySelector(".uploadContent");
let exts = [".jpg", ".jpeg", ".bmp", ".webp", ".gif", ".png"];
let files = [];
div.ondragenter = (e) => {
e.preventDefault();
div.style.backgroundColor = "#ADD8E6";
};
div.ondragover = (e) => {
e.preventDefault();
};
div.ondragleave = (e) => {
e.preventDefault();
div.style.backgroundColor = "";
};
div.ondrop = async (e) => {
e.preventDefault();
div.style.backgroundColor = "";
files = await dropFiles(e);
files = filterFiles(files);
console.log(files);
files.forEach((file) => {
const reader = new FileReader();
reader.addEventListener("load", (event) => {
const imageUrl = event.target.result;
const img = document.createElement("img");
img.src = imageUrl;
div.appendChild(img);
});
reader.readAsDataURL(file);
});
};
function createPreview() {
let content = document.createElement("div");
content.classList.add("content");
let h4 = document.createElement("h4");
let progressDiv = document.createElement("div");
let previewImage = document.createElement("img");
content.appendChild(h4);
content.appendChild(progressDiv);
content.appendChild(previewImage);
uploadContent.appendChild(content);
}
btnUpload.onclick = () => {
if (files.length === 0) {
alert("请先选择文件");
return;
}
files.forEach((file) => {
let content = document.createElement("div");
content.classList.add("content");
let h4 = document.createElement("h4");
let progressDiv = document.createElement("div");
let previewImage = document.createElement("img");
content.appendChild(h4);
content.appendChild(progressDiv);
content.appendChild(previewImage);
uploadContent.appendChild(content);
h4.innerHTML = file.name;
upload(
file,
"http://localhost:3002/upload/single",
(resp) => {
previewImage.src = resp.data;
},
(percent) => {
progressDiv.innerHTML = percent + "%";
}
);
});
};
const upload = (file, url, onFinish, onProgress, onloadend) => {
let xhr = new XMLHttpRequest();
xhr.open("POST", url);
let formData = new FormData();
formData.append("avatar", file);
xhr.onload = function () {
const resp = JSON.parse(xhr.responseText);
console.log(resp);
onFinish && onFinish(resp);
};
xhr.upload.onprogress = (e) => {
// 上传进度变化时更新 UI
const percent = Math.floor((e.loaded / e.total) * 100);
onProgress && onProgress(percent);
};
xhr.onloadend = onloadend;
xhr.send(formData);
return () => {
xhr.abort();
};
};
async function dropFiles(event) {
if (
event.dataTransfer.types[0] !== "Files" ||
!event.dataTransfer.items
) {
return;
}
const entries = [...event.dataTransfer.items].map((item) =>
item.webkitGetAsEntry()
);
const allEntries = await traverseFileTree(entries);
let files = await Promise.all(
allEntries.map(
(entry) =>
new Promise((resolve, reject) => entry.file(resolve, reject))
)
);
return files;
}
async function traverseFileTree(entries) {
let files = [];
async function traverse(entries) {
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.isFile) {
files.push(entry);
} else if (entry.isDirectory) {
const childEntries = await new Promise((resolve, reject) =>
entry.createReader().readEntries(resolve, reject)
);
await traverse(childEntries);
}
}
}
return traverse(entries).then(() => files);
}
function filterFiles(files) {
return files.filter((file) => verifiedExt(file.name));
}
let getExtName = (filename) => {
let index = filename.lastIndexOf(".");
return index > 0 ? filename.substring(index).toLowerCase() : "";
};
let verifiedExt = (filename) => {
let ext = getExtName(filename);
return exts.includes(ext);
};
</script>
</body>
</html>
Comments