处理多文件上传:拖拽上传/上传进度/并发控制

参考文章: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);
}
posted @ 2024-05-17 09:47  sanhuamao  阅读(112)  评论(0编辑  收藏  举报