文件上传,8种场景

------------恢复内容开始------------

 

 

 

  • 单文件上传:利用 input 元素的 accept 属性限制上传文件的类型、利用 JS 检测文件的类型及使用 Koa 实现单文件上传的功能;

  • 多文件上传:利用 input 元素的 multiple 属性支持选择多文件及使用 Koa 实现多文件上传的功能;

  • 目录上传:利用 input 元素上的 webkitdirectory 属性支持目录上传的功能及使用 Koa 实现目录上传并按文件目录结构存放的功能;

  • 压缩目录上传:在目录上传的基础上,利用 JSZip 实现压缩目录上传的功能;

  • 拖拽上传:利用拖拽事件和 DataTransfer 对象实现拖拽上传的功能;

  • 剪贴板上传:利用剪贴板事件和 Clipboard API 实现剪贴板上传的功能;

  • 大文件分块上传:利用 Blob.sliceSparkMD5 和第三方库 async-pool 实现大文件并发上传的功能;

  • 服务端上传:利用第三方库 form-data 实现服务端文件流式上传的功能。


一、单文件上传

对于单文件上传的场景来说,最常见的是图片上传的场景,所以我们就以图片上传为例,先来介绍单文件上传的基本流程。

1.1 前端代码

html

在以下代码中,我们通过 input 元素的 accept 属性限制了上传文件的类型。这里使用 image/* 限制只能选择图片文件,当然你也可以设置特定的类型,比如 image/pngimage/png,image/jpeg

<input id="uploadFile" type="file" accept="image/*" />
<button id="submit" onclick="uploadFile()">上传文件</button>

 

需要注意的是,虽然我们把 input 元素的 accept 属性设置为 image/png。但如果用户把 jpg/jpeg 格式的图片后缀名改为 .png,就可以成功绕过这个限制。要解决这个问题,我们可以通过读取文件中的二进制数据来识别正确的文件类型。

要查看图片对应的二进制数据,我们可以借助一些现成的编辑器,比如 Windows 平台下的 WinHex 或 macOS 平台下的 Synalyze It! Pro 十六进制编辑器。这里我们使用 Synalyze It! Pro 这个编辑器,来查看阿宝哥头像对应的二进制数据。

 

 

复制代码
const uploadFileEle = document.querySelector("#uploadFile");

const request = axios.create({
  baseURL: "http://localhost:3000/upload",
  timeout: 60000, 
});

async function uploadFile() {
  if (!uploadFileEle.files.length) return;
  const file = uploadFileEle.files[0]; // 获取单个文件
  // 省略文件的校验过程,比如文件类型、大小校验
  upload({
    url: "/single",
    file,
  });
}

function upload({ url, file, fieldName = "file" }) {
  let formData = new FormData();
  formData.set(fieldName, file);
  request.post(url, formData, {
    // 监听上传进度
    onUploadProgress: function (progressEvent) {
      const percentCompleted = Math.round(
        (progressEvent.loaded * 100) / progressEvent.total
      );
      console.log(percentCompleted);
     },
  });
}
复制代码

在以上代码中,我们先把读取的 File 对象封装成 FormData 对象,然后利用 Axios 实例的 post 方法实现文件上传的功能。 在上传前,通过设置请求配置对象的 onUploadProgress 属性,就可以获取文件的上传进度。

 

二、多文件上传

要上传多个文件,首先我们需要允许用户同时选择多个文件。要实现这个功能,我们可以利用 input 元素的 multiple 属性。跟前面介绍的 accept 属性一样,该属性也存在兼容性问题,具体如下图所示:

2.1 前端代码

html

相比单文件上传的代码,多文件上传场景下的 input 元素多了一个 multiple 属性:

 

<input id="uploadFile" type="file" accept="image/*" multiple />
<button id="submit" onclick="uploadFile()">上传文件</button>

 

js

在单文件上传的代码中,我们通过 uploadFileEle.files[0] 获取单个文件,而对于多文件上传来说,我们需要获取已选择的文件列表,即通过 uploadFileEle.files 来获取,它返回的是一个 FileList 对象。

async function uploadFile() {
  if (!uploadFileEle.files.length) return;
  const files = Array.from(uploadFileEle.files);
  upload({
    url: "/multiple",
    files,
  });
}

因为要支持上传多个文件,所以我们需要同步更新一下 upload 函数。对应的处理逻辑就是遍历文件列表,然后使用 FormData 对象的 append 方法来添加多个文件,具体代码如下所示:

复制代码
function upload({ url, files, fieldName = "file" }) {
  let formData = new FormData();
  files.forEach((file) => {
    formData.append(fieldName, file);
  });
  request.post(url, formData, {
    // 监听上传进度
    onUploadProgress: function (progressEvent) {
      const percentCompleted = Math.round(
        (progressEvent.loaded * 100) / progressEvent.total
      );
      console.log(percentCompleted);
    },
  });
}
复制代码

 

三、目录上传

可能你还不知道,input 元素上还有一个的 webkitdirectory 属性。当设置了 webkitdirectory 属性之后,我们就可以选择目录了。

 

<input id="uploadFile" type="file" accept="image/*" webkitdirectory />

 

当我们选择了指定目录之后,比如阿宝哥桌面上的 images 目录,就会显示以下确认框:

 

 

 

点击上传按钮之后,我们就可以获取文件列表。列表中的文件对象上含有一个 webkitRelativePath 属性,用于表示当前文件的相对路径。

 

 

 

3.1 前端代码

为了让服务端能按照实际的目录结构来存放对应的文件,在添加表单项时我们需要把当前文件的路径提交到服务端。此外,为了确保@koa/multer 能正确处理文件的路径,我们需要对路径进行特殊处理。即把 / 斜杠替换为 @ 符号。对应的处理方式如下所示:

复制代码
function upload({ url, files, fieldName = "file" }) {
  let formData = new FormData();
  files.forEach((file, i) => {
    formData.append(
      fieldName, 
      files[i],
      files[i].webkitRelativePath.replace(/\//g, "@");
    );
  });
  request.post(url, formData); // 省略上传进度处理
}
复制代码

四、压缩目录上传

JavaScript 如何在线解压 ZIP 文件? 这篇文章中,介绍了在浏览器端如何使用 JSZip 这个库实现在线解压 ZIP 文件的功能。 JSZip 这个库除了可以解析 ZIP 文件之外,它还可以用来 创建和编辑 ZIP 文件。利用 JSZip 这个库提供的 API,我们就可以把目录下的所有文件压缩成 ZIP 文件,然后再把生成的 ZIP 文件上传到服务器。

4.1 前端代码

JSZip 实例上的 file(name, data [,options]) 方法,可以把文件添加到 ZIP 文件中。基于该方法我们可以封装了一个 generateZipFile 函数,用于把目录下的文件列表压缩成一个 ZIP 文件。以下是 generateZipFile 函数的具体实现:

复制代码
function generateZipFile(
  zipName, files,
  options = { type: "blob", compression: "DEFLATE" }
) {
  return new Promise((resolve, reject) => {
    const zip = new JSZip();
    for (let i = 0; i < files.length; i++) {
      zip.file(files[i].webkitRelativePath, files[i]);
    }
    zip.generateAsync(options).then(function (blob) {
      zipName = zipName || Date.now() + ".zip";
      const zipFile = new File([blob], zipName, {
        type: "application/zip",
      });
      resolve(zipFile);
    });
  });
}
复制代码

在创建完 generateZipFile 函数之后,我们需要更新一下前面已经介绍过的 uploadFile 函数:

复制代码
async function uploadFile() {
  let fileList = uploadFileEle.files;
  if (!fileList.length) return;
  let webkitRelativePath = fileList[0].webkitRelativePath;
  let zipFileName = webkitRelativePath.split("/")[0] + ".zip";
  let zipFile = await generateZipFile(zipFileName, fileList);
  upload({
    url: "/single",
    file: zipFile,
    fileName: zipFileName
  });
}
复制代码
在以上的 uploadFile 函数中,我们会对返回的 FileList 对象进行处理,即调用 generateZipFile 函数来生成 ZIP 文件。此外,为了在服务端接收压缩文件时,能获取到文件名,我们为 upload 函数增加了一个 fileName 参数,该参数用于调用 formData.append 方法时,设置上传文件的文件名:
function upload({ url, file, fileName, fieldName = "file" }) {
  if (!url || !file) return;
  let formData = new FormData();
  formData.append(
    fieldName, file, fileName
  );
  request.post(url, formData); // 省略上传进度跟踪
}

五、拖拽上传

要实现拖拽上传的功能,我们需要先了解与拖拽相关的事件。比如 dragdragenddragenterdragoverdrop 事件等。这里我们只介绍接下来要用到的拖拽事件:

  • dragenter:当拖拽元素或选中的文本到一个可释放目标时触发;
  • dragover:当元素或选中的文本被拖到一个可释放目标上时触发(每100毫秒触发一次);
  • dragleave:当拖拽元素或选中的文本离开一个可释放目标时触发;
  • drop:当元素或选中的文本在可释放目标上被释放时触发。

基于上面的这些事件,我们就可以提高用户拖拽的体验。比如当用户拖拽的元素进入目标区域时,对目标区域进行高亮显示。当用户拖拽的元素离开目标区域时,移除高亮显示。很明显当 drop 事件触发后,拖拽的元素已经放入目标区域了,这时我们就需要获取对应的数据。

那么如何获取拖拽对应的数据呢?这时我们需要使用 DataTransfer 对象,该对象用于保存拖动并放下过程中的数据。它可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型。若拖动操作涉及拖动文件,则我们可以通过 DataTransfer 对象的 files 属性来获取文件列表。

介绍完拖拽上传相关的知识后,我们来看一下具体如何实现拖拽上传的功能。

5.1 前端代码

html

<div id="dropArea">
   <p>拖拽上传文件</p>
   <div id="imagePreview"></div>
</div>

css

复制代码
#dropArea {
  width: 300px;
  height: 300px;
  border: 1px dashed gray;
  margin-bottom: 20px;
}
#dropArea p {
  text-align: center;
  color: #999;
}
#dropArea.highlighted {
  background-color: #ddd;
}
#imagePreview {
  max-height: 250px;
  overflow-y: scroll;
}
#imagePreview img {
  width: 100%;
  display: block;
  margin: auto;
}
复制代码

js

为了让大家能够更好地阅读拖拽上传的相关代码,我们把代码拆成 4 部分来讲解:

1、阻止默认拖拽行为

复制代码
const dropAreaEle = document.querySelector("#dropArea");
const imgPreviewEle = document.querySelector("#imagePreview");
const IMAGE_MIME_REGEX = /^image\/(jpe?g|gif|png)$/i;

["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
   dropAreaEle.addEventListener(eventName, preventDefaults, false);
   document.body.addEventListener(eventName, preventDefaults, false);
});

function preventDefaults(e) {
  e.preventDefault();
  e.stopPropagation();
}
复制代码

2、切换目标区域的高亮状态

复制代码
["dragenter", "dragover"].forEach((eventName) => {
    dropAreaEle.addEventListener(eventName, highlight, false);
});
["dragleave", "drop"].forEach((eventName) => {
    dropAreaEle.addEventListener(eventName, unhighlight, false);
});

// 添加高亮样式
function highlight(e) {
  dropAreaEle.classList.add("highlighted");
}

// 移除高亮样式
function unhighlight(e) {
  dropAreaEle.classList.remove("highlighted");
}
复制代码

3、处理图片预览

复制代码
dropAreaEle.addEventListener("drop", handleDrop, false);

function handleDrop(e) {
  const dt = e.dataTransfer;
  const files = [...dt.files];
  files.forEach((file) => {
    previewImage(file, imgPreviewEle);
  });
  // 省略文件上传代码
}

function previewImage(file, container) {
  if (IMAGE_MIME_REGEX.test(file.type)) {
    const reader = new FileReader();
    reader.onload = function (e) {
      let img = document.createElement("img");
      img.src = e.target.result;
      container.append(img);
    };
    reader.readAsDataURL(file);
  }
}
复制代码

4、文件上传

复制代码
function handleDrop(e) {
  const dt = e.dataTransfer;
  const files = [...dt.files];
  // 省略图片预览代码
  files.forEach((file) => {
    upload({
      url: "/single",
      file,
    });
  });
}

const request = axios.create({
  baseURL: "http://localhost:3000/upload",
  timeout: 60000,
});

function upload({ url, file, fieldName = "file" }) {
  let formData = new FormData();
  formData.set(fieldName, file);
  request.post(url, formData, {
    // 监听上传进度
    onUploadProgress: function (progressEvent) {
      const percentCompleted = Math.round(
        (progressEvent.loaded * 100) / progressEvent.total
      );
      console.log(percentCompleted);
    },
  });
}
复制代码

拖拽上传算是一个比较常见的场景,很多成熟的上传组件都支持该功能。其实除了拖拽上传外,还可以利用剪贴板实现复制上传的功能。

六、剪贴板上传

在介绍如何实现剪贴板上传的功能前,我们需要了解一下 Clipboard API。Clipboard 接口实现了 Clipboard API,如果用户授予了相应的权限,就能提供系统剪贴板的读写访问。在 Web 应用程序中,Clipboard API 可用于实现剪切、复制和粘贴功能。该 API 用于取代通过 document.execCommand API 来实现剪贴板的操作。

在实际项目中,我们不需要手动创建 Clipboard 对象,而是通过 navigator.clipboard 来获取 Clipboard 对象:

在获取 Clipboard 对象之后,我们就可以利用该对象提供的 API 来访问剪贴板,比如:

navigator.clipboard.readText().then(
  clipText => document.querySelector(".editor").innerText = clipText
);

以上代码将 HTML 中含有 .editor 类的第一个元素的内容替换为剪贴板的内容。如果剪贴板为空,或者不包含任何文本,则元素的内容将被清空。这是因为在剪贴板为空或者不包含文本时,readText 方法会返回一个空字符串。

要实现剪贴板上传的功能,可以分为以下 3 个步骤:

  • 监听容器的粘贴事件;
  • 读取并解析剪贴板中的内容;
  • 动态构建 FormData 对象并上传。

了解完上述步骤,接下来我们来分析一下具体实现的代码。

6.1 前端代码

html

<div id="uploadArea">
   <p>请先复制图片后再执行粘贴操作</p>
</div>

css

复制代码
#uploadArea {
   width: 400px;
   height: 400px;
   border: 1px dashed gray;
   display: table-cell;
   vertical-align: middle;
}
#uploadArea p {
   text-align: center;
   color: #999;
}
#uploadArea img {
   max-width: 100%;
   max-height: 100%;
   display: block;
   margin: auto;
}
复制代码

js

在以下代码中,我们使用 addEventListener 方法为 uploadArea 容器添加 paste 事件。在对应的事件处理函数中,我们会优先判断当前浏览器是否支持异步 Clipboard API。如果支持的话,就会通过 navigator.clipboard.read 方法来读取剪贴板中的内容。在读取内容之后,我们会通过正则判断剪贴板项中是否包含图片资源,如果有的话会调用 previewImage 方法执行图片预览操作并把返回的 blob 对象保存起来,用于后续的上传操作。

复制代码
const IMAGE_MIME_REGEX = /^image\/(jpe?g|gif|png)$/i;
const uploadAreaEle = document.querySelector("#uploadArea");

uploadAreaEle.addEventListener("paste", async (e) => {
  e.preventDefault();
  const files = [];
  if (navigator.clipboard) {
    let clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
      for (const type of clipboardItem.types) {
        if (IMAGE_MIME_REGEX.test(type)) {
           const blob = await clipboardItem.getType(type);
           insertImage(blob, uploadAreaEle);
           files.push(blob);
         }
       }
     }
  } else {
      const items = e.clipboardData.items;
      for (let i = 0; i < items.length; i++) {
        if (IMAGE_MIME_REGEX.test(items[i].type)) {
          let file = items[i].getAsFile();
          insertImage(file, uploadAreaEle);
          files.push(file);
        }
      }
  }
  if (files.length > 0) {
    confirm("剪贴板检测到图片文件,是否执行上传操作?") 
      && upload({
           url: "/multiple",
           files,
         });
   }
});
复制代码

若当前浏览器不支持异步 Clipboard API,则我们会尝试通过 e.clipboardData.items 来访问剪贴板中的内容。需要注意的是,在遍历剪贴板内容项的时候,我们是通过 getAsFile 方法来获取剪贴板的内容。

前面已经提到,当从剪贴板解析到图片资源时,会让用户进行预览,该功能是基于 FileReader API 来实现的,对应的代码如下所示:

复制代码
function previewImage(file, container) {
  const reader = new FileReader();
  reader.onload = function (e) {
    let img = document.createElement("img");
    img.src = e.target.result;
    container.append(img);
  };
  reader.readAsDataURL(file);
}
复制代码

当用户预览完成后,如果确认上传我们就会执行文件的上传操作。因为文件是从剪贴板中读取的,所以在上传前我们会根据文件的类型,自动为它生成一个文件名,具体是采用时间戳加文件后缀的形式:

function upload({ url, files, fieldName = "file" }) {
  let formData = new FormData();
  files.forEach((file) => {
    let fileName = +new Date() + "." + IMAGE_MIME_REGEX.exec(file.type)[1];
    formData.append(fieldName, file, fileName);
  });
  request.post(url, formData);
}

前面我们已经介绍了文件上传的多种不同场景,接下来我们来介绍一个 “特殊” 的场景 —— 大文件上传。

七、大文件分块上传

相信你可能已经了解大文件上传的解决方案,在上传大文件时,为了提高上传的效率,我们一般会使用 Blob.slice 方法对大文件按照指定的大小进行切割,然后通过多线程进行分块上传,等所有分块都成功上传后,再通知服务端进行分块合并。具体处理方案如下图所示:

因为在 JavaScript 中如何实现大文件并发上传? 这篇文章中,阿宝哥已经详细介绍了大文件并发上传的方案,所以这里就不展开介绍了。我们只回顾一下大文件并发上传的完整流程:

posted @   Magi黄元  阅读(274)  评论(0编辑  收藏  举报
编辑推荐:
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
阅读排行:
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
点击右上角即可分享
微信分享提示