多文件上传的基础实现原理
多文件上传的核心依赖浏览器提供的两个原生能力,一个是<input type="file">标签的multiple属性,另一个是File API。当用户点击文件选择框时,multiple属性允许用户在系统的文件选择器中按住Ctrl或者Shift键选中多个文件,选中的文件会存储在input元素的files属性中,这个属性是一个FileList对象,本质上是一个类数组结构,包含了所有选中文件的基本信息,比如文件名、大小、类型、最后修改时间等。
拿到FileList之后,我们不能直接把它发送到后端,因为普通的表单提交或者AJAX请求默认只能处理字符串类型的数据,二进制文件需要被包装成合适的数据格式。这里就需要用到FormData对象,它可以模拟表单的提交行为,支持同时添加多个文件字段和普通字段,浏览器会自动把FormData的内容编码成multipart/form-data格式,这是后端接收文件时最常用的数据格式。整个流程可以概括为:用户选择多文件 → 前端获取文件列表 → 校验文件合法性 → 组装FormData → 发送AJAX请求 → 后端接收处理。
需要注意的是,File API是HTML5新增的特性,IE9及以下版本的浏览器不支持,如果需要兼容旧浏览器,只能让用户逐个选择文件,或者引导用户升级浏览器。另外,multiple属性只影响用户的文件选择行为,并不会自动把文件打包发送,所有的处理逻辑都需要开发者手动编写,这也是我们需要用JS实现多文件上传的核心原因。
原生JS实现多文件上传的完整示例
下面我们来实现一个完整的多文件上传功能,包含前端选择和上传、后端接收的基础逻辑。首先是前端的HTML结构,需要有一个文件选择框、一个上传按钮,以及用来展示上传状态和结果的区域:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>多文件上传示例</title>
</head>
<body>
<input type="file" id="fileInput" multiple accept="image/*,.pdf">
<button id="uploadBtn">开始上传</button>
<div id="progressContainer"></div>
<div id="resultContainer"></div>
<script src="upload.js"></script>
</body>
</html>

这里的accept属性用来限制用户可以选择的文件类型,image/*表示所有图片类型,.pdf表示只允许PDF文件,用户选择不符合类型的文件时会直接被系统过滤器过滤,无法选中。接下来是核心的JS逻辑,写在upload.js中:
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const progressContainer = document.getElementById('progressContainer');
const resultContainer = document.getElementById('resultContainer');
// 点击上传按钮触发逻辑
uploadBtn.addEventListener('click', async () => {
const files = fileInput.files;
// 没有选择文件时直接返回
if (files.length === 0) {
alert('请先选择要上传的文件');
return;
}
// 遍历文件列表做基础校验
for (let i = 0; i < files.length; i++) {
const file = files[i];
// 限制单个文件大小不超过2MB
if (file.size > 2 * 1024 * 1024) {
alert(`文件${file.name}大小超过2MB,无法上传`);
return;
}
}
// 创建FormData对象,添加所有文件
const formData = new FormData();
// 给每个文件添加相同的字段名,后端可以用数组接收
for (let i = 0; i < files.length; i++) {
formData.append('uploadFiles', files[i]);
}
// 也可以添加其他的表单字段,比如用户ID
formData.append('userId', '10001');
// 创建XMLHttpRequest对象,监听上传进度
const xhr = new XMLHttpRequest();
// 监听上传进度事件
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressContainer.innerHTML = `上传进度:${percent}%`;
}
});
// 监听请求完成事件
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const res = JSON.parse(xhr.responseText);
resultContainer.innerHTML = `上传成功,共上传${res.fileCount}个文件`;
} else {
resultContainer.innerHTML = `上传失败,错误码:${xhr.status}`;
}
});
// 监听错误事件
xhr.addEventListener('error', () => {
resultContainer.innerHTML = '网络错误,上传失败';
});
// 发送请求,后端接口地址假设为/upload
xhr.open('POST', 'https://ipipp.com/upload');
xhr.send(formData);
});
上面的代码使用了XMLHttpRequest来发送请求,主要是因为它原生支持upload对象的progress事件,可以精准获取上传的进度,这是Fetch API目前还不具备的能力。如果你不需要进度展示,也可以用Fetch API来发送FormData,代码会更简洁一些。比如把发送部分替换为:
fetch('https://ipipp.com/upload', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(data => {
resultContainer.innerHTML = `上传成功,共上传${data.fileCount}个文件`;
})
.catch(err => {
resultContainer.innerHTML = '上传失败,请重试';
});
后端接收的部分以Node.js的Express框架为例,需要使用multer中间件来处理multipart/form-data格式的数据,具体代码如下:
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();
// 配置multer存储,文件保存到uploads目录
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
// 给文件加上时间戳避免重名
const uniqueName = Date.now() + '-' + file.originalname;
cb(null, uniqueName);
}
});
const upload = multer({ storage: storage });
// 处理多文件上传的接口,字段名和前端FormData的append第一个参数一致
app.post('/upload', upload.array('uploadFiles', 10), (req, res) => {
// req.files就是所有上传的文件信息数组
console.log('上传的文件:', req.files);
console.log('其他字段:', req.body.userId);
res.json({
code: 0,
msg: '上传成功',
fileCount: req.files.length
});
});
app.listen(3000, () => {
console.log('服务启动在3000端口');
});
多文件上传的常见优化与问题处理
基础的上传功能虽然能跑通,但在实际生产环境中还需要处理很多细节问题。首先是文件校验的完善,除了大小限制,还可以校验文件类型,因为accept属性只是前端的限制,用户可以通过修改请求绕过,所以后端也需要做类型校验,前端也可以在拿到File对象后通过file.type属性判断,不过这个属性也不是完全可信,更稳妥的方式是校验文件的魔术数字,也就是文件开头的二进制标识,但前端处理二进制相对麻烦,一般前端做基础校验,后端做严格校验即可。
其次是上传的并发控制,如果用户一次选择几十个甚至上百个文件,全部同时发送会给服务器造成很大压力,也可能导致浏览器同时发起的请求数量超过限制,部分请求会被阻塞。这时候可以实现一个简单的并发控制逻辑,比如最多同时上传3个文件,一个上传完成后再启动下一个。我们可以把文件列表改造成队列,用计数器记录当前正在上传的数量,当计数器小于并发数且有未上传的文件时,就取出下一个文件发起上传请求。
// 并发控制上传函数,max是最大并发数
function uploadWithConcurrency(files, max = 3) {
return new Promise((resolve) => {
let index = 0; // 当前待上传的文件索引
let finished = 0; // 已完成的上传数量
const total = files.length;
const result = []; // 存储每个文件的上传结果
// 启动上传的函数
function startUpload() {
// 如果所有文件都已经处理完,就结束
if (index >= total && finished >= total) {
resolve(result);
return;
}
// 如果当前并发数没满,且还有未上传的文件,就启动新的上传
while (index < total && finished + (index - finished) < max) {
const currentIndex = index;
const file = files[currentIndex];
index++;
// 单个文件的上传逻辑
const formData = new FormData();
formData.append('uploadFiles', file);
fetch('https://ipipp.com/upload', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(data => {
result[currentIndex] = { success: true, data };
finished++;
startUpload(); // 当前文件上传完成,启动下一个
})
.catch(err => {
result[currentIndex] = { success: false, error: err };
finished++;
startUpload();
});
}
}
startUpload();
});
}
// 使用方式
uploadBtn.addEventListener('click', async () => {
const files = fileInput.files;
if (files.length === 0) return;
const results = await uploadWithConcurrency(files, 3);
console.log('所有文件上传完成:', results);
});
另外还需要处理上传失败的重试逻辑,比如某个文件因为网络波动上传失败了,可以自动重试2-3次,再失败才提示用户。还有大文件的分片上传,如果单个文件超过几百MB,直接上传很容易失败,这时候需要把文件切成多个小片段,分别上传,后端再合并所有片段,这部分逻辑相对复杂,需要前后端配合实现,但核心还是基于FormData和File API的slice方法,把大文件切割成小的Blob对象再逐个发送。
最后要注意跨域问题,如果前端和后端不在同一个域下,后端需要设置CORS相关的响应头,允许前端域名的请求,否则浏览器会拦截响应。另外,FormData在发送的时候不需要手动设置Content-Type请求头,浏览器会自动设置正确的multipart/form-data类型和对应的边界标识,如果手动设置了反而可能导致后端无法正确解析数据。