在日常工作中,文件上传是一个很常见的功能。在项目开发过程中,我们通常都会使用一些成熟的上传组件来实现对应的功能。一般来说,成熟的上传组件不仅会提供漂亮 UI 或好的交互体验,而且还会提供多种不同的上传方式,以满足不同的场景需求。
一般在我们工作中,主要会涉及到 8 种文件上传的场景,每一种场景背后都使用不同的技术,其中也有很多细节需要我们额外注意。今天阿宝哥就来带大家总结一下这 8 种场景,让大家能更好地理解成熟上传组件所提供的功能。阅读本文后,你将会了解以下的内容:
单文件上传:利用 input 元素的 accept 属性限制上传文件的类型、利用 JS 检测文件的类型及使用 Koa 实现单文件上传的功能;
多文件上传:利用 input 元素的 multiple 属性支持选择多文件及使用 Koa 实现多文件上传的功能;
目录上传:利用 input 元素上的 webkitdirectory 属性支持目录上传的功能及使用 Koa 实现目录上传并按文件目录结构存放的功能;
压缩目录上传:在目录上传的基础上,利用 JSZip 实现压缩目录上传的功能;
拖拽上传:利用拖拽事件和 DataTransfer 对象实现拖拽上传的功能;
剪贴板上传:利用剪贴板事件和 Clipboard API 实现剪贴板上传的功能;
大文件分块上传:利用 Blob.slice、SparkMD5 和第三方库 async-pool 实现大文件并发上传的功能;
服务端上传:利用第三方库 form-data 实现服务端文件流式上传的功能。
一、单文件上传
对于单文件上传的场景来说,最常见的是图片上传的场景,所以我们就以图片上传为例,先来介绍单文件上传的基本流程。
1.1 前端代码
html
在以下代码中,我们通过 input 元素的 accept 属性限制了上传文件的类型。这里使用 image/* 限制只能选择图片文件,当然你也可以设置特定的类型,比如 image/png 或 image/png,image/jpeg。
<inputid=”uploadFile”type=”file”accept=”image/*”/><buttonid=”submit”onclick=”uploadFile()”>上传文件</button>
需要注意的是,虽然我们把 input 元素的 accept 属性设置为 image/png。但如果用户把 jpg/jpeg 格式的图片后缀名改为 .png,就可以成功绕过这个限制。要解决这个问题,我们可以通过读取文件中的二进制数据来识别正确的文件类型。
那么在前端能否不借助工具,读取文件的二进制数据呢?答案是可以的,这里阿宝哥就不展开介绍了。感兴趣的话,你可以阅读 JavaScript 如何检测文件的类型? 这篇文章。另外,需要注意的是 input 元素 accept 属性有存在兼容性问题。比如 IE 9 以下不支持,具体如下图所示:
js
constuploadFileEle=document.querySelector(“#uploadFile”);constrequest=axios.create({baseURL:”http://localhost:3000/upload”,timeout:60000,});asyncfunctionuploadFile(){if(!uploadFileEle.files.length)return;constfile=uploadFileEle.files[0];//获取单个文件//省略文件的校验过程,比如文件类型、大小校验upload({url:”/single”,file,});}functionupload({url,file,fieldName=”file”}){letformData=newFormData();formData.set(fieldName,file);request.post(url,formData,{//监听上传进度onUploadProgress:function(progressEvent){constpercentCompleted=Math.round((progressEvent.loaded*100)/progressEvent.total);console.log(percentCompleted);},});}
在以上代码中,我们先把读取的 File 对象封装成 FormData 对象,然后利用 Axios 实例的 post 方法实现文件上传的功能。在上传前,通过设置请求配置对象的 onUploadProgress 属性,就可以获取文件的上传进度。
1.2 服务端代码
Koa 是一个简单易用的 Web 框架,它的特点是优雅、简洁、轻量、自由度高。所以我们选择它来搭建文件服务,并使用以下中间件来实现相应的功能:
以上代码相对比较简单,我们就不展开介绍了。Koa 内核很简洁,扩展功能都是通过中间件来实现。比如示例中使用到的路由、CORS、静态资源处理等功能都是通过中间件实现。因此要想掌握 Koa 这个框架,核心是掌握它的中间件机制。如果你想深入了解的话,可以阅读 如何更好地理解中间件和洋葱模型 这篇文章。其实除了单文件上传外,在文件上传的场景中,我们也可以同时上传多个文件。
单文件上传示例:single-file-upload
https://github.com/semlinker/file-upload-demos/tree/master/single-file-upload
二、多文件上传
要上传多个文件,首先我们需要允许用户同时选择多个文件。要实现这个功能,我们可以利用 input 元素的 multiple 属性。跟前面介绍的 accept 属性一样,该属性也存在兼容性问题,具体如下图所示:
2.1 前端代码
html
相比单文件上传的代码,多文件上传场景下的 input 元素多了一个 multiple 属性:
<inputid=”uploadFile”type=”file”accept=”image/*”multiple/><buttonid=”submit”onclick=”uploadFile()”>上传文件</button>
js
在单文件上传的代码中,我们通过 uploadFileEle.files[0] 获取单个文件,而对于多文件上传来说,我们需要获取已选择的文件列表,即通过 uploadFileEle.files 来获取,它返回的是一个 FileList 对象。
asyncfunctionuploadFile(){if(!uploadFileEle.files.length)return;constfiles=Array.from(uploadFileEle.files);upload({url:”/multiple”,files,});}
因为要支持上传多个文件,所以我们需要同步更新一下 upload 函数。对应的处理逻辑就是遍历文件列表,然后使用 FormData 对象的 append 方法来添加多个文件,具体代码如下所示:
functionupload({url,files,fieldName=”file”}){letformData=newFormData();files.forEach((file)=>{formData.append(fieldName,file);});request.post(url,formData,{//监听上传进度onUploadProgress:function(progressEvent){constpercentCompleted=Math.round((progressEvent.loaded*100)/progressEvent.total);console.log(percentCompleted);},});}2.2 服务端代码
router.post(“/upload/multiple”,async(ctx,next)=>{try{awaitnext();urls=ctx.files.file.map(file=>`${RESOURCE_URL}/${file.originalname}`);ctx.body={code:1,msg:”文件上传成功”,urls};}catch(error){ctx.body={code:0,msg:”文件上传失败”,};}},multerUpload.fields([{name:”file”,//与FormData表单项的fieldName相对应},]));
介绍完单文件和多文件上传的功能,接下来我们来介绍目录上传的功能。
多文件上传示例:multiple-file-upload
https://github.com/semlinker/file-upload-demos/tree/master/multiple-file-upload
三、目录上传
可能你还不知道,input 元素上还有一个的 webkitdirectory 属性。当设置了 webkitdirectory 属性之后,我们就可以选择目录了。
<inputid=”uploadFile”type=”file”accept=”image/*”webkitdirectory/>
当我们选择了指定目录之后,比如阿宝哥桌面上的 images 目录,就会显示以下确认框:
虽然通过 webkitdirectory 属性可以很容易地实现选择目录的功能,但在实际项目中我们还需要考虑它的兼容性。比如在 IE 11 以下的版本就不支持该属性,其它浏览器的兼容性如下图所示:
了解完 webkitdirectory 属性的兼容性,我们先来介绍前端的实现代码。
3.1 前端代码
为了让服务端能按照实际的目录结构来存放对应的文件,在添加表单项时我们需要把当前文件的路径提交到服务端。此外,为了确保@koa/multer 能正确处理文件的路径,我们需要对路径进行特殊处理。即把 / 斜杠替换为 @ 符号。对应的处理方式如下所示:
functionupload({url,files,fieldName=”file”}){letformData=newFormData();files.forEach((file,i)=>{formData.append(fieldName,files[i],files[i].webkitRelativePath.replace(/\//g,”@”););});request.post(url,formData);//省略上传进度处理}3.2 服务端代码
目录上传与多文件上传,服务端代码的主要区别就是 @koa/multer 中间件的配置对象不一样。在 destination 属性对应的函数中,我们需要把文件名中 @ 还原成 /,然后根据文件的实际路径来生成目录。
constfse=require(“fs-extra”);conststorage=multer.diskStorage({destination:asyncfunction(req,file,cb){//images@image-1.jpeg=>images/image-1.jpegletrelativePath=file.originalname.replace(/@/g,path.sep);letindex=relativePath.lastIndexOf(path.sep);letfileDir=path.join(UPLOAD_DIR,relativePath.substr(0,index));//确保文件目录存在,若不存在的话,会自动创建awaitfse.ensureDir(fileDir);cb(null,fileDir);},filename:function(req,file,cb){letparts=file.originalname.split(“@”);cb(null,`${parts[parts.length-1]}`);},});
现在我们已经实现了目录上传的功能,那么能否把目录下的文件压缩成一个压缩包后再上传呢?答案是可以的,接下来我们来介绍如何实现压缩目录上传的功能。
目录上传示例:directory-upload
https://github.com/semlinker/file-upload-demos/tree/master/directory-upload
四、压缩目录上传
4.1 前端代码
JSZip 实例上的 file(name, data [,options]) 方法,可以把文件添加到 ZIP 文件中。基于该方法我们可以封装了一个 generateZipFile 函数,用于把目录下的文件列表压缩成一个 ZIP 文件。以下是 generateZipFile 函数的具体实现:
functiongenerateZipFile(zipName,files,options={type:”blob”,compression:”DEFLATE”}){returnnewPromise((resolve,reject)=>{constzip=newJSZip();for(leti=0;i<files.length;i ){zip.file(files[i].webkitRelativePath,files[i]);}zip.generateAsync(options).then(function(blob){zipName=zipName||Date.now() “.zip”;constzipFile=newFile([blob],zipName,{type:”application/zip”,});resolve(zipFile);});});}
在创建完 generateZipFile 函数之后,我们需要更新一下前面已经介绍过的 uploadFile 函数:
asyncfunctionuploadFile(){letfileList=uploadFileEle.files;if(!fileList.length)return;letwebkitRelativePath=fileList[0].webkitRelativePath;letzipFileName=webkitRelativePath.split(“/”)[0] “.zip”;letzipFile=awaitgenerateZipFile(zipFileName,fileList);upload({url:”/single”,file:zipFile,fileName:zipFileName});}
在以上的 uploadFile 函数中,我们会对返回的 FileList 对象进行处理,即调用 generateZipFile 函数来生成 ZIP 文件。此外,为了在服务端接收压缩文件时,能获取到文件名,我们为 upload 函数增加了一个 fileName 参数,该参数用于调用 formData.append 方法时,设置上传文件的文件名:
functionupload({url,file,fileName,fieldName=”file”}){if(!url||!file)return;letformData=newFormData();formData.append(fieldName,file,fileName);request.post(url,formData);//省略上传进度跟踪}
以上就是压缩目录上传,前端部分的 JS 代码,服务端的代码可以参考前面单文件上传的相关代码。
压缩目录上传示例:directory-compress-upload
https://github.com/semlinker/file-upload-demos/tree/master/directory-compress-upload
五、拖拽上传
要实现拖拽上传的功能,我们需要先了解与拖拽相关的事件。比如 drag、dragend、dragenter、dragover 或 drop 事件等。这里我们只介绍接下来要用到的拖拽事件:
dragenter:当拖拽元素或选中的文本到一个可释放目标时触发;dragover:当元素或选中的文本被拖到一个可释放目标上时触发(每100毫秒触发一次);dragleave:当拖拽元素或选中的文本离开一个可释放目标时触发;drop:当元素或选中的文本在可释放目标上被释放时触发。
基于上面的这些事件,我们就可以提高用户拖拽的体验。比如当用户拖拽的元素进入目标区域时,对目标区域进行高亮显示。当用户拖拽的元素离开目标区域时,移除高亮显示。很明显当 drop 事件触发后,拖拽的元素已经放入目标区域了,这时我们就需要获取对应的数据。
那么如何获取拖拽对应的数据呢?这时我们需要使用 DataTransfer 对象,该对象用于保存拖动并放下过程中的数据。它可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型。若拖动操作涉及拖动文件,则我们可以通过 DataTransfer 对象的 files 属性来获取文件列表。
介绍完拖拽上传相关的知识后,我们来看一下具体如何实现拖拽上传的功能。
5.1 前端代码
html
<divid=”dropArea”><p>拖拽上传文件</p><divid=”imagePreview”></div></div>
css
#dropArea{width:300px;height:300px;border:1pxdashedgray;margin-bottom:20px;}#dropAreap{text-align:center;color:#999;}#dropArea.highlighted{background-color:#ddd;}#imagePreview{max-height:250px;overflow-y:scroll;}#imagePreviewimg{width:100%;display:block;margin:auto;}
js
为了让大家能够更好地阅读拖拽上传的相关代码,我们把代码拆成 4 部分来讲解:
1、阻止默认拖拽行为
constdropAreaEle=document.querySelector(“#dropArea”);constimgPreviewEle=document.querySelector(“#imagePreview”);constIMAGE_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);});functionpreventDefaults(e){e.preventDefault();e.stopPropagation();}
2、切换目标区域的高亮状态
[“dragenter”,”dragover”].forEach((eventName)=>{dropAreaEle.addEventListener(eventName,highlight,false);});[“dragleave”,”drop”].forEach((eventName)=>{dropAreaEle.addEventListener(eventName,unhighlight,false);});//添加高亮样式functionhighlight(e){dropAreaEle.classList.add(“highlighted”);}//移除高亮样式functionunhighlight(e){dropAreaEle.classList.remove(“highlighted”);}
3、处理图片预览
dropAreaEle.addEventListener(“drop”,handleDrop,false);functionhandleDrop(e){constdt=e.dataTransfer;constfiles=[…dt.files];files.forEach((file)=>{previewImage(file,imgPreviewEle);});//省略文件上传代码}functionpreviewImage(file,container){if(IMAGE_MIME_REGEX.test(file.type)){constreader=newFileReader();reader.onload=function(e){letimg=document.createElement(“img”);img.src=e.target.result;container.append(img);};reader.readAsDataURL(file);}}
4、文件上传
functionhandleDrop(e){constdt=e.dataTransfer;constfiles=[…dt.files];//省略图片预览代码files.forEach((file)=>{upload({url:”/single”,file,});});}constrequest=axios.create({baseURL:”http://localhost:3000/upload”,timeout:60000,});functionupload({url,file,fieldName=”file”}){letformData=newFormData();formData.set(fieldName,file);request.post(url,formData,{//监听上传进度onUploadProgress:function(progressEvent){constpercentCompleted=Math.round((progressEvent.loaded*100)/progressEvent.total);console.log(percentCompleted);},});}
拖拽上传算是一个比较常见的场景,很多成熟的上传组件都支持该功能。其实除了拖拽上传外,还可以利用剪贴板实现复制上传的功能。
拖拽上传示例:drag-drop-upload
https://github.com/semlinker/file-upload-demos/tree/master/drag-drop-upload
六、剪贴板上传
在介绍如何实现剪贴板上传的功能前,我们需要了解一下 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 方法会返回一个空字符串。
利用 Clipboard API 我们可以很方便地操作剪贴板,但实际项目使用过程中也得考虑它的兼容性:
要实现剪贴板上传的功能,可以分为以下 3 个步骤:
监听容器的粘贴事件;读取并解析剪贴板中的内容;动态构建 FormData 对象并上传。
了解完上述步骤,接下来我们来分析一下具体实现的代码。
6.1 前端代码
html
<divid=”uploadArea”><p>请先复制图片后再执行粘贴操作</p></div>
css
#uploadArea{width:400px;height:400px;border:1pxdashedgray;display:table-cell;vertical-align:middle;}#uploadAreap{text-align:center;color:#999;}#uploadAreaimg{max-width:100%;max-height:100%;display:block;margin:auto;}
js
在以下代码中,我们使用 addEventListener 方法为 uploadArea 容器添加 paste 事件。在对应的事件处理函数中,我们会优先判断当前浏览器是否支持异步 Clipboard API。如果支持的话,就会通过 navigator.clipboard.read 方法来读取剪贴板中的内容。在读取内容之后,我们会通过正则判断剪贴板项中是否包含图片资源,如果有的话会调用 previewImage 方法执行图片预览操作并把返回的 blob 对象保存起来,用于后续的上传操作。
constIMAGE_MIME_REGEX=/^image\/(jpe?g|gif|png)$/i;constuploadAreaEle=document.querySelector(“#uploadArea”);uploadAreaEle.addEventListener(“paste”,async(e)=>{e.preventDefault();constfiles=[];if(navigator.clipboard){letclipboardItems=awaitnavigator.clipboard.read();for(constclipboardItemofclipboardItems){for(consttypeofclipboardItem.types){if(IMAGE_MIME_REGEX.test(type)){constblob=awaitclipboardItem.getType(type);insertImage(blob,uploadAreaEle);files.push(blob);}}}}else{constitems=e.clipboardData.items;for(leti=0;i<items.length;i ){if(IMAGE_MIME_REGEX.test(items[i].type)){letfile=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 来实现的,对应的代码如下所示:
functionpreviewImage(file,container){constreader=newFileReader();reader.onload=function(e){letimg=document.createElement(“img”);img.src=e.target.result;container.append(img);};reader.readAsDataURL(file);}
当用户预览完成后,如果确认上传我们就会执行文件的上传操作。因为文件是从剪贴板中读取的,所以在上传前我们会根据文件的类型,自动为它生成一个文件名,具体是采用时间戳加文件后缀的形式:
functionupload({url,files,fieldName=”file”}){letformData=newFormData();files.forEach((file)=>{letfileName= newDate() “.” IMAGE_MIME_REGEX.exec(file.type)[1];formData.append(fieldName,file,fileName);});request.post(url,formData);}
前面我们已经介绍了文件上传的多种不同场景,接下来我们来介绍一个 “特殊” 的场景 —— 大文件上传。
剪贴板上传示例:clipboard-upload
https://github.com/semlinker/file-upload-demos/tree/master/clipboard-upload
七、大文件分块上传
相信你可能已经了解大文件上传的解决方案,在上传大文件时,为了提高上传的效率,我们一般会使用 Blob.slice 方法对大文件按照指定的大小进行切割,然后通过多线程进行分块上传,等所有分块都成功上传后,再通知服务端进行分块合并。具体处理方案如下图所示:
因为在 JavaScript 中如何实现大文件并发上传? 这篇文章中,阿宝哥已经详细介绍了大文件并发上传的方案,所以这里就不展开介绍了。我们只回顾一下大文件并发上传的完整流程:
前面我们都是介绍客户端文件上传的场景,其实也有服务端文件上传的场景。比如在服务端动态生成海报后,上传到另外一台服务器或云厂商的 OSS(Object Storage Service)。下面我们就以 Node.js 为例来介绍在服务端如何上传文件。
大文件分块上传示例:big-file-upload
https://github.com/semlinker/file-upload-demos/tree/master/big-file-upload
八、服务端上传
服务器上传就是把文件从一台服务器上传到另外一台服务器。借助 Github 上 form-data 这个库提供的功能,我们可以很容易地实现服务器上传的功能。下面我们来简单介绍一下单文件和多文件上传的功能:
8.1 单文件上传constfs=require(“fs”);constpath=require(“path”);constFormData=require(“form-data”);constform1=newFormData();form1.append(“file”,fs.createReadStream(path.join(__dirname,”images/image-1.jpeg”)));form1.submit(“http://localhost:3000/upload/single”,(error,response)=>{if(error){console.log(“单图上传失败”);return;}console.log(“单图上传成功”);});8.2 多文件上传constform2=newFormData();form2.append(“file”,fs.createReadStream(path.join(__dirname,”images/image-2.jpeg”)));form2.append(“file”,fs.createReadStream(path.join(__dirname,”images/image-3.jpeg”)));form2.submit(“http://localhost:3000/upload/multiple”,(error,response)=>{if(error){console.log(“多图上传失败”);return;}console.log(“多图上传成功”);});
观察以上代码可知,创建完 FormData 对象之后,我们只需要通过 fs.createReadStream API 创建可读流,然后调用 FormData 对象的 append 方法添加表单项,最后再调用 submit 方法执行提交操作即可。
其实除了 ReadableStream 之外,FormData 对象的 append 方法还支持以下类型:
constFormData=require(‘form-data’);consthttp=require(‘http’);constform=newFormData();http.request(‘http://nodejs.org/images/logo.png’,function(response){form.append(‘my_field’,’myvalue’);form.append(‘my_buffer’,newBuffer(10));form.append(‘my_logo’,response);});
服务端文件上传的内容就介绍到这里,关于 form-data 这个库的其他用法,感兴趣的话,可以阅读对应的使用文档。其实除了以上介绍的八种场景外,在日常工作中,你也可能会使用一些同步工具,比如 Syncthing 文件同步工具实现文件传输。好的,本文的所有内容都已经介绍完了,最后我们来做一个总结。
服务端上传示例:server-upload
https://github.com/semlinker/file-upload-demos/tree/master/server-upload
九、总结
本文阿宝哥详细介绍了文件上传的八种场景,希望阅读完本文后,你对八种场景背后使用的技术有一定的了解。由于篇幅有限,阿宝哥就没有展开介绍与 multipart/form-data 类型相关的内容,感兴趣的小伙伴可以自行了解一下。
此外,在实际项目中,你可以考虑直接使用成熟的第三方组件,比如 Github 上的 Star 数 11K 的 filepond。该组件采用插件化的架构,以插件的方式,提供了非常多的功能,比如 File encode、File rename、File poster、Image preview 和 Image crop 等。总之,它是一个很不错的组件,以后有机会的话,大家可以尝试一下。