处理多文件上传:拖拽上传/上传进度/并发控制
参考文章:https://www.cnblogs.com/goloving/p/15271258.html
Web源码:https://gitee.com/chenxiangzhi/node_projects/tree/master/file-uploader/public
完整的文件上传Node服务:https://gitee.com/chenxiangzhi/node_projects/tree/master/file-uploader
效果
一次性上传全部
逐个上传,且同一时间最多上传3个
Html结构
<!-- 拖拽区域 -->
<div class="drop-area">
<!-- 1. 图片预览区域 -->
<div class="image-preview-container"></div>
<!-- 2. 初始的提示元素 -->
<div class="drop-area-text">
<img src="./assets/icons/empty.svg" />
<span>拖拽图片到这里</span>
</div>
<!-- 3. 进度条遮罩 -->
<div class="mask">
<div class="mask-close">x</div>
<div class="progress-bar">
<div class="progress-bar-value"></div>
<span class="progress-bar-text"></span>
</div>
</div>
</div>
<!-- 按钮 -->
<div class="btn-group">
<div style="display: inline-block">
<label for="file" class="btn btn-upload">上传文件</label>
<input type="file" id="file" multiple accept="image/*" />
</div>
<button class="btn btn-abort disabled" disabled>停止上传</button>
<button class="btn btn-clear">清空记录</button>
</div>
<!-- 提示显示 -->
<span class="drop-area-helper"></span>
拖拽文件
阻止默认行为
将文件拖拽到浏览器的默认行为是打开一个新窗口打开文件,需要阻止该行为
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
dropAreaEle.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
高亮显示
.highlight {
border-style: dashed;
background-color: #e5eff8;
}
const dropAreaEle = document.querySelector('.drop-area'); // 拖拽区域
function highlight(e) {
if (!dropAreaEle.classList.contains('highlight')) {
dropAreaEle.classList.add('highlight');
}
}
function unhighlight(e) {
if (dropAreaEle.classList.contains('highlight')) {
dropAreaEle.classList.remove('highlight');
}
}
['dragenter', 'dragover'].forEach((eventName) => {
dropAreaEle.addEventListener(eventName, highlight, false);
});
['dragleave', 'mouseleave'].forEach((eventName) => {
dropAreaEle.addEventListener(eventName, unhighlight, false);
});
读取文件
const dropAreaEle = document.querySelector('.drop-area'); // 拖拽区域
dropAreaEle.addEventListener(
'drop',
(e) => {
const dt = e.dataTransfer;
const files = [...dt.files]; // 将文件转为数组,方便操作
// ...后续操作
handleMutiUpload(files )
},
false
);
图片预览
const dropTextEle = document.querySelector('.drop-area-text'); // 拖拽区域提示
const imgsContainer = document.querySelector('.image-preview-container'); // 预览容器
// 方式1:一次性上传全部
const handleMutiUpload = (files) => {
// 1. 文件验证(大小/类型/数量等)
// ...
// 2. 显示切换:把最开始的“拖拽图片到这里” 元素隐藏;
dropTextEle.style.display = 'none';
// 3. 创建图片预览元素,并将其插入图片预览容器
files.forEach(createPreviewItem);
// ... 后续上传
};
// 方式2: 逐个上传
const handleUploadOneByOne = async (files) => {
// 1. 文件验证(大小/类型/数量等)
// ...
// 2. 创建图片预览元素:要确保文件跟元素绑定,这样才能逐个显示上传进度
let fileList = [];
for (let i = 0; i < files.length; i++) {
fileList.push({
file: files[i],
ele: createPreviewItem(files[i]),
index: String(i),
});
}
// ... 后续上传
};
// 创建预览元素
function createPreviewItem(file) {
// 预览box
const div = document.createElement('div');
div.classList.add('preview-item', 'shadow');
// box内部的图片
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
div.append(img);
// box内部的图片名称
const span = document.createElement('span');
span.innerText = file.name;
span.title = file.name;
div.append(span);
// 将box插入容器
imgsContainer.append(div);
return div;
}
/* 拖拽区域提示 */
.drop-area-text {
position: absolute;
top: 0;
display: flex;
height: 100%;
width: 100%;
justify-content: center;
flex-direction: column;
align-items: center;
color: var(--secondary-color);
gap: 0.5rem;
}
/* 图片预览 */
/* 图片预览容器:三行四列的表格布局 */
.image-preview-container {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: 23% 23% 24% 24%;
grid-template-rows: 32% 32% 32%;
gap: 2%;
padding: 4px;
box-sizing: border-box;
}
/* 图片预览元素 */
.image-preview-container .preview-item {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
border-radius: 4px;
background: #fdfdfd;
padding: 4px 2px;
box-sizing: border-box;
}
.image-preview-container .preview-item img {
height: 75%;
object-fit: contain;
}
.image-preview-container .preview-item span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
padding-bottom: 2px;
}
图片上传
XMLHttpRequest
在开始图片上传之前,先引入一下XMLHttpRequest
:
const xhr = new XMLHttpRequest();
xhr.timeout = 60 * 1000;
xhr.open('POST', '/upload', true);
// 上传文件到服务器的过程
xhr.upload.addEventListener('abort', () => {
console.log('上传中止');
});
xhr.upload.addEventListener('error', () => {
console.log('上传失败');
});
xhr.upload.addEventListener('loadstart', (event) => {
console.log('开始上传');
});
xhr.upload.addEventListener('load', (event) => {
console.log('上传成功');
});
xhr.upload.addEventListener('loadend', (event) => {
console.log('上传结束:中止、失败、成功后,都会执行这个');
});
xhr.upload.addEventListener('progress', (event) => {
const percentage = (event.loaded / event.total) * 100;
console.log('上传进度: ${percentage }');
});
// 服务器响应的过程(服务器接收到文件后保存&校验等也是一个过程)
xhr.onreadystatechange = (ev) => {
if (xhr.readyState === 4) {
if (xhr.status !== 200) {
console.log('服务器响应错误');
} else {
console.log('服务器响应成功');
}
}
};
// 请求失败
xhr.onerror = (ev) => {
console.log('监听->请求错误', ev);
};
// 请求超时
xhr.ontimeout = (ev) => {
console.log('监听->请求超时');
};
// 发送请求
const formData = new FormData();
formData.append('image', <file>); // file是文件
xhr.send(formData);
封装上传函数
为了控制并发,需要让其返回一个promise
const uploadFile = (files, options) => {
const {
onSuccess = undefined, // 服务器响应成功
onUploadStart = undefined, // 文件开始上传
onUploadProgress = undefined, // 文件正在上传
onUploadSuccess = undefined, // 文件上传完毕
onUploadEnd = undefined, // 上传操作结束
onTimeout = undefined, // 请求超时
onError = undefined, // 请求错误
onAbort = undefined, // 上传被打断
abortHandler = undefined, // 打断的触发按钮
index = '1', // 标识上传请求
} = options;
// 通过resolve和reject传出index,
// 以便知道上传队列中那个上传结束了,将它从队列中移出
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.timeout = 60 * 1000;
xhr.open('POST', '/upload', true);
xhr.onerror = (ev) => {
console.log('请求错误', ev);
reject(index)
};
xhr.ontimeout = (ev) => {
console.log('请求超时');
onTimeout && onTimeout();
reject(index)
};
// 监听终止上传事件
const handleAbort = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) {
xhr.abort();
onAbort && onAbort();
}
};
abortHandler && abortHandler.addEventListener('click', handleAbort);
// 上传到服务器的过程
xhr.upload.addEventListener('abort', () => {
console.log('上传中止');
});
xhr.upload.addEventListener('error', () => {
console.log('上传失败');
onError && onError('上传过程中出现错误');
});
xhr.upload.addEventListener('loadstart', (event) => {
console.log('开始上传');
onUploadStart && onUploadStart();
});
xhr.upload.addEventListener('load', (event) => {
console.log('上传成功');
onUploadSuccess && onUploadSuccess();
});
xhr.upload.addEventListener('loadend', (event) => {
console.log('彻底结束: 终止、失败、成功都会调用这个');
onUploadEnd && onUploadEnd();
abortHandler && abortHandler.removeEventListener('click', handleAbort);
});
xhr.upload.addEventListener('progress', (event) => {
const percentage = (event.loaded / event.total) * 100;
onUploadProgress && onUploadProgress(`${percentage.toFixed(2)}%`);
});
// 服务器响应的过程(服务器接收到文件后保存&校验等也是一个过程)
xhr.onreadystatechange = (ev) => {
if (xhr.readyState === 4) {
if (xhr.status !== 200) {
onError && onError(xhr.responseText);
reject(index)
} else {
console.log('上传成功');
onSuccess && onSuccess();
resolve(index)
}
}
};
// 创建formdata
const formData = new FormData();
if (Array.isArray(files)) {
files.forEach((file) => {
formData.append('image', file);
});
} else {
formData.append('image', files);
}
xhr.send(formData);
});
};
上传进度
一次性上传全部
const maskEle = document.querySelector('.mask'); // 遮罩
const progressTxtEle = document.querySelector('.progress-bar-text'); // 进度条文字
const progressValueEle = document.querySelector('.progress-bar-value'); // 进度条进度
const FAILED_COLOR = '#ff5858';
const abortBtn = document.querySelector('.btn-abort'); //Abort按钮
const handleMutiUpload =()=>{
//...
uploadFile(files, {
onUploadStart: () => {
handleResetProgress(); // 重置进度条
maskEle.style.visibility = 'visible'; // 显示遮罩
maskCloseEle.style.visibility = 'hidden'; // 隐藏遮罩关闭按钮(只在上传结束时显示)
setDisabled(abortBtn, false); // 允许中止按钮 -> 允许在上传过程中中止
setDisabled(clearBtn, true); // 禁止清空按钮 -> 只允许在上传结束后清空记录
},
onUploadProgress: (value) => {
progressTxtEle.innerText = `正在上传:${value}`;
progressValueEle.style.width = value;
},
onUploadSuccess: () => {
progressTxtEle.innerHTML = '上传成功,请等待服务器处理...';
},
onUploadEnd: () => {
setDisabled(abortBtn, true);
},
onSuccess: () => {
progressTxtEle.innerHTML = '√ 成功上传至服务器。可继续拖拽进行上传';
maskCloseEle.style.visibility = 'visible';
setDisabled(clearBtn, false);
},
onError: (err) => {
progressValueEle.style.background = FAILED_COLOR;
progressTxtEle.style.color = FAILED_COLOR;
progressTxtEle.innerHTML = `✘ 上传失败 ${err}`;
maskCloseEle.style.visibility = 'visible';
setDisabled(clearBtn, false);
},
abortHandler: abortBtn,
onAbort: () => {
progressValueEle.style.background = FAILED_COLOR;
progressTxtEle.style.color = FAILED_COLOR;
progressTxtEle.innerHTML = `✘ 上传被强制中止`;
maskCloseEle.style.visibility = 'visible';
setDisabled(abortBtn, true);
setDisabled(clearBtn, false);
},
});
}
// 重置进度条
const handleResetProgress = () => {
progressValueEle.style.width = 0;
progressValueEle.style.background = 'rgb(70, 184, 204)';
progressTxtEle.style.color = 'rgb(70, 184, 204)';
progressTxtEle.innerText = '开始上传...';
};
// 切换按钮禁用与允许
const setDisabled = (ele, value) => {
ele.disabled = value;
if (value) {
ele.classList.add('disabled');
} else {
ele.classList.remove('disabled');
}
};
.mask {
top: 0;
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
visibility: hidden;
}
/* 进度条 */
.progress-bar {
height: 3px;
background-color: #f0f8ff;
border-radius: 2px;
width: 80%;
position: relative;
}
.progress-bar-value {
position: absolute;
left: 0;
height: 100%;
background-color: var(--primary-color);
width: 0;
transition: width 0.5s ease;
}
.progress-bar-text {
position: absolute;
top: 6px;
color: var(--primary-color);
font-size: 12px;
}
逐个上传
控制同一时间最多上传3个,并且给每一个上传附上进度条
const handleUploadOneByOne = async (files) => {
// ...
// 创建图片预览元素
let fileList = [];
for (let i = 0; i < files.length; i++) {
fileList.push({
file: files[i],
ele: createPreviewItem(files[i]),
index: String(i),
});
}
// 上传队列
const uploadQueue = [];
while (fileList.length > 0) {
while (uploadQueue.length < 3 && fileList.length > 0) {
// 取出文件
const item = fileList.shift();
// 创建promise
const promise = createUploadPromise(item.file, item.ele, item.index);
uploadQueue.push({
promise,
index: item.index,
});
}
// Promise.race:只关注谁被第一个resolve或reject
await Promise.race(uploadQueue.map((item) => item.promise)).then(
(index) => {
// 将这个被resolve或reject的promise踢出队列
const idx = uploadQueue.findIndex((item) => item.index === index);
uploadQueue.splice(idx, 1);
}
);
}
};
// 创建上传promise
const createUploadPromise = (image, ele, index) => {
// 上传进度显示
let progressBlcok = null; // div的高度从底部向上升起
let progressText = null; // 显示进度具体数值
return uploadFile(image, {
index,
onUploadStart: () => {
const [blockEle, textEle] = createProgressEles(ele);
progressBlcok = blockEle;
progressText = textEle;
},
onUploadProgress: (value) => {
progressBlcok.style.height = value;
progressText.innerText = value;
},
onUploadSuccess: () => {
progressText.innerText = '请稍后';
},
onUploadEnd: () => {},
onSuccess: () => {
progressText.innerText = '√';
},
onError: (err) => {
progressText.innerText = '✘';
progressText.style.color = FAILED_COLOR;
progressBlcok.style.background = FAILED_COLOR;
},
abortHandler: abortBtn,
onAbort: () => {
progressText.innerText = '';
progressText.style.color = FAILED_COLOR;
progressBlcok.style.background = FAILED_COLOR;
},
});
};
// 为单个文件创建进度元素
const createProgressEles = (parent) => {
const div = document.createElement('div');
div.classList.add('preview-item-mask');
const blockEle = document.createElement('div');
blockEle.classList.add('progress-block');
div.append(blockEle);
const textEle = document.createElement('div');
textEle.classList.add('progress-text');
div.append(textEle);
parent.append(div);
return [blockEle, textEle];
};
.preview-item-mask{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
}
.preview-item-mask .progress-block{
position: absolute;
bottom: 0;
left: 0;
height: 0;
width: 100%;
background-color: rgba(70, 184, 204, 0.6);
transition: height 0.5s ease;
}
.preview-item-mask .progress-text{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
font-size: large;
color: var(--primary-color);
}