一直想做下图片上传的功能,今天终于把这个心愿了了,在制作的过程中也顺便把HTML5的File,Drop,Drag,URL,FileReader复习了下,多赢
我们先来回顾下文件上传有几种方式
form表单
这个是HTML5出来之前普遍的文件上传方式,它是通过页面的form表单进行上传的,代码如下
复制代码
注意下当中的几个属性:
- action:接受请求的URL
- enctype:请求的编码类型,默认是application/x-www-form-urlencoded,文件上传时设置为multipart/form-data
- method:请求的方法,文件上传时设置为POST
- multiple:可以让我们一次选择多个文件
- accept:设置上传的文件类型
另外将我们的input标签的type设置为file,点击之后就可以打开系统的文件管理器,单击上传按钮就可以把我们选择的文件发送到服务器了
FormData & XHR2
除了使用form表单来提交数据外,我们还可以自己构建表单数据进行提交,其中FormData用来创建表单数据,是属于HTML5的东西,XHR2用来发送请求到服务器
FormData对象API:
- append
- delete
- set
- get
- getAll
- has
- keys
- entries
- values
- forEach
我在demo中是这样用的
const formData = new FormData()files.forEach((file, index) => { formData.append(`img${index+1}`, file)})复制代码
XHR2是用来发送请求的,ajax的实现就是靠它,正是因为XHR2的出现才使得通过ajax上传文件变成可能,XHR2相对于XHR有以下特点:
- 可以设置timeout
- 可以使用FormData对象管理数据
- 可以上传二进制文件
- 可以跨域
- 可以获取数据传输的进度信息
关于XHR2的使用,下面给出一个demo,更详细的用法大家可以去MDN,
const formData = new FormData()const xhr = new XMLHttpRequest()xhr.timeout = 3000xhr.open('POST', 'upload')xhr.upload.onprogress = event => { if (event.lengthComputable) { const percent = event.loaded / event.total console.log(percent) }}xhr.onload = () => { if (xhr.status === 200 && xhr.readyState === 4) { alert('文件上传成功') } else { alert('文件上传失败') }}xhr.send(formData)复制代码
Fetch
终于说到Fetch了,Fetch是一种新的HTTP请求方式,替代了之前的XHR2,也是我个人比较喜欢的一种,因为它配合Promise,Async/Await写起代码来简直不要太爽了,关于它的使用大家可MDN
var form = new FormData(), url = 'http://.......', //服务器上传地址 file = files[0];form.append('file', file); fetch(url, { method: 'POST', body: form}).then(function(response) { if (response.status >= 200 && response.status < 300) { return response; } else { var error = new Error(response.statusText); error.response = response; throw error; }}).then(function(resp) { return resp.json();}).then(function(respData) { console.log('文件上传成功', respData);}).catch(function(e) { console.log('文件上传失败', e);});复制代码
回到正题,我们先来分析下我所做的DEMO有几个核心功能,然后针对每个功能去具体讲解如何实现的,整个demo是基于react写的
我把功能划分了一下:
- 选择文件上传
- 图片缩略图
- 图片删除
- 拖拽文件上传
- 拖拽文件删除
- 图片预览
选择文件上传
我的思路是这样子的:点击一个input,弹出一个文件选择器,我们可以选取多张图片,选择完成后,会触发input标签的change事件,我们可以从input的元素files属性里拿到我们选择的图片数据,然后把它添加到一个全局的数组里面
这里注意一点就是:选择的图片数据保存在input的files属性里面,files属性的值是一个类似数组的FileList对象,因此我们不能直接使用Array的实例方法
核心代码如下:
handleChange = event => { event.preventDefault() const { files } = this.state Array.prototype.forEach.call(this.fileInput.files, file => { const src = URL.createObjectURL(file) file.src = src this.setState({ files: [...files, file] }) this.fileInput.value = '' })}复制代码
其中我通过Array.prototype.forEach.call来调用数组的forEach方法,代码最后有一行this.fileInput.value = '' 是为了解决不能上传同一张图片,因为input内部会去检查我们上传的文件是否和上一次一样,如果一样是不会触发onchange事件的
图片缩略图
如何实现上传一张图片之后把它显示出来呢?这里我查阅了相关资料,一种是通过FileReader,另外一种是通过URL,咱们分别来讲解下
FileReader
什么是FileReader,我这里引用MDN中的一段话
The FileReader object lets web applications asynchronously read the contents of files (or raw data buffers) stored on the user's computer, using File or Blob objects to specify the file or data to read.
File objects may be obtained from a FileList object returned as a result of a user selecting files using the element, from a drag and drop operation's DataTransfer object, or from the mozGetAsFile() API on an HTMLCanvasElement.
大致意思就是FileReader可以异步读取电脑上的文件内容,我们可以使用File或者Blob对象来指定读取的文件
File对象可以来自input元素选择文件后返回的FileList对象,也可以来自使用Drag和Drop操作后的DataTransfer对象,或者是使用HTMLCanvasElement调用mozGetAsFile()返回的结果
关于FileReader的属性,函数,事件这里列举下
- 属性
- error: 表示读取文件时发生的错误,只读
- readState: 0-表示还没有加载数据,1-正在加载数据,2-已完成全部的读取请求,只读
- result: 文件的内容,只有在文件读取完成之后才有值,数据的格式取决于调用的方法
- 函数
- abort: 中断读取操作,返回时readyState变为2
- readAsArrayBuffer: 读取文件内容,返回格式ArrayBuffer
- readAsBinaryString: 读取文件内容,返回格式二进制
- readAsDataURL: 读取文件内容,返回格式data:URL
- readAsText: 读取文件内容,返回格式字符串
- 事件
- onabort: 读取被中断时触发
- onerror: 读取发生错误时触发
- onload: 读取完成时触发
- onloadstart: 读取开始时触发
- onloadend: 读取结束时触发,成功或者失败
- onprogress: 读取进度改变时触发
介绍完了FileReader的API之后,我们想一下如何实现文件上传后显示图片的缩略图
思路其实也特别简单,就是文件上传之后,我们获取到上传的文件对象,然后创建一个FileReader去读取我们上传的文件,读取成功之后我们的文件内容会保存在FileReader中的result中,然后我们创建一个img元素去显示我们读取的文件内容就可以了
核心代码如下:
handleChange = event => { event.preventDefault() const { files } = this.state Array.prototype.forEach.call(this.fileInput.files, file => { const reader = new FileReader() reader.readAsDataURL(file) reader.onload = event => { file.src = reader.result this.setState({ files: [...files, file] }) this.fileInput.value = '' } }) }复制代码
其中因为img元素是可以直接显示base64编码的图片的,所以我们在读取文件的时候调用的是readAsDataURL,文件读取成功后,fileReader中的result的值就是data:URL格式的文件内容,我们可以直接将它赋值给img元素的src属性,文件读取成功会触发onload事件,所以我们的操作都必须写在其回调函数里面
URL
使用了FileReader来读取文件不知道你有没疑问,我的文件就在我本地,我什么还要读取它,转成一个base64那么长的字符串,不能直接提供一个地址给img元素的src属性吗?答案是可以的,借住URL对象我们可以实现,这也是我推荐用的方式
先来了解下URL对象,MDN上是这样介绍它的
The URL interface represents an object providing static methods used for creating object URLs.
When using a user agent where no constructor has been implemented yet, it is possible to access such an object using the Window.URL properties (prefixed with Webkit-based browser as Window.webkitURL).
URL接口用来创建URL对象,该接口提供了一些静态方法
当我们的环境没有实现URL的构造函数时,我们可以通过Window.URL(Window.webkitURL)来返回一个URL实例
说白了这个URL其实就是一个工具类,用来处理我们的url的,如获取host,pathname,hash等参数,我们用到的倒不是这个,我们这里用到的是URL中的两个静态方法createObjectURL和revokeObjectURL
createObjectURL传入一个File或者Blob对象,返回一个DOMString,这个字符串可以用来展示我们的内容
revokeObjectURL用来销毁通过createObjectURL创建的DOMString
具体该怎么做呢?直接看代码
handleChange = event => { event.preventDefault() const { files } = this.state Array.prototype.forEach.call(this.fileInput.files, file => { const src = URL.createObjectURL(file) file.src = src this.setState({ files: [...files, file] }) this.fileInput.value = '' }) }复制代码
代码就一句话:const src = URL.createObjectURL(file),返回的src直接可以赋值给img的src属性,给FileReader不知道方便了多少
createObjectURL返回的字符串长这个样子的:blob:http://localhost:3000/81e8eaa9-3041-4c93-bd16-913f578ece42
关于URL其它的一些属性和方法在此次demo中暂时用不到就不列举出来了,感兴趣的同学可以取MDN了解下,
图片删除
图片删除这个功能就很简单了,点击图片上方的删除按钮,传入对应的index到删除方法,然后根据index在全局的files对象中找到对应的file过滤掉,返回一个新的数组
核心代码如下:
handleDelete = event => { event.preventDefault() event.stopPropagation() const { target: { dataset: { index } } } = event const { files } = this.state const newFiles = files.filter((file, index2) => { if (index2 === +index) { URL.revokeObjectURL(file.src) return false } return true }) this.setState({ files: newFiles })}复制代码
这里记住以下删除图片的同时,调用URL.revokeObjectURL方法删除对应的URL实例,节省内存,当然你不这样做也没什么问题
拖拽文件上传 & 拖拽文件删除
把拖拽文件上传和拖拽文件删除放在一起说是因为它们两个功能都需要用到HTML5提供的Drag和Drop API,咱们先来学习下这两个API
拖放事件
关于拖放事件有些是在被拖动元素上触发的,而有些则是在放置目标上触发的
当我们拖动某个元素时,会依次触发:
- dragstart
- drag
- dragend
这三个事件都是在被拖动元素上触发的。当拖动开始时会先触发dragstart事件,然后在拖动的过程中会持续触发drag事件,当拖动停止时(无论被拖动元素是否放到了有效的放置目标)都会触发dragend事件,这三个事件类似鼠标的移动事件mousestart,mousemove,mouseend
当某个元素被拖动到放置目标上,会依次触发:
- dragenter
- dragover
- dragleave 或 drop
这三个事件都是在放置目标上触发的。当元素进入放置目标时会触发dragenter事件,当元素在放置目标上移动时会持续触发dragover事件,当元素移出放置目标时会触发dragleave事件,当元素被放到了放置目标中会触发drop事件而不是dragleave事件,这几个事件(除drop)也类似鼠标的移动事件mouseenter,mouseover,mouseleave
阻止默认行为。虽然所有的元素都支持drop事件,但是这些元素默认是不允许放置的,这个时候当我们在放置目标上松开鼠标是不会触发drop事件的,我们可以通过event.preventDefault()来阻止默认的行为,如下:
droptarget.ondragenter = event => { event.preventDefault()}droptarget.ondragover = event => { event.preventDefault()}复制代码
另外在一些浏览器中,当我们移动图片到放置目标上,松开的时候会打开这张图片,如果移动的是超链接,则会打开这个页面。我们有时候需要阻止这种默认的行为,可以这样做
droptarget.ondrop = event => { event.preventDefault()}复制代码
dataTransfer对象
dataTransfer对象用来在拖动的过程中从被拖动元素向放置目标传递数据,这个对象有两个方法setData和getData
setData有两个参数,第一个是MIME类型,第二个则是我们要保存的值
event.dataTransfer.setData('text/plain', 'msg')event.dataTransfer.setData('text/uri-list', 'http://baidu.com')复制代码
getData只有一个参数,就是setData中我们传的第一个参数
event.dataTransfer.getData('text/plain')event.dataTransfer.setData('text/uri-list')复制代码
setData我们一般在dragstart中去使用,而getData只能在drop事件中去使用,这个务必记住
dropEffect & effectAllowed
dropEffect和effectAllowed是dataTransfer的两个属性,用来确定个被拖动的元素以及作为放置目标的元素能够接收什么操作
dropEffect必须搭配effectAllowed才有效果,我们必须在dragstart中设置这两个属性的值
effectAllowed的取值如下:
- uninitialized
- none
- copy
- link
- move
- copyLink
- copyMove
- linkMove
- all
dropEffect的取值如下:
- none
- move
- copy
- link
draggable
除了图片,链接,文本之外的元素默认是不可以拖动的,我们需要添加draggable属性就可以让这个元素变得可以拖动
其它成员
dataTransfer除了上述的方法和属性,还有以下方法和属性:
- addElement(element)
- clearData(format)
- setDragImage(element, x, y)
- types
拖拽文件上传的实现思路:我们在ondrop中拿到dataTransfer,文件就存放到其files属性中,然后使用FormData对象和XHR2把数据传递给服务器,核心代码如下:
handleDrop = event => { event.preventDefault() event.stopPropagation() const { files } = this.state Array.prototype.forEach.call(event.dataTransfer.files, file => { const src = URL.createObjectURL(file) file.src = src this.setState({ files: [...files, file] }) this.fileInput.value = '' })}handleUpload = event => { event.preventDefault() const { files } = this.state if (files.length === 0) { this.fileInput.click() return } const formData = new FormData() files.forEach((file, index) => { formData.append(`img${index+1}`, file) }) // xhr2上传文件 或者 fetch const xhr = new XMLHttpRequest() xhr.timeout = 3000 xhr.open('POST', 'upload') xhr.upload.onprogress = event => { if (event.lengthComputable) { const percent = event.loaded / event.total console.log(percent) } } xhr.onload = () => { if (xhr.status === 200 && xhr.readyState === 4) { alert('文件上传成功') } else { alert('文件上传失败') } } // xhr.send(formData) alert('文件上传成功') this.setState({ files: [] }) this.fileInput.value = ''}复制代码
拖拽文件删除的实现思路:在ondragstart中把拖动的文件的索引放到dataTransfer中,然后在ondrop中取出索引,根据索引值在全局的文件列表中进行删除,核心代码如下:
handleDustDrop = event => { event.preventDefault() const { dataTransfer } = event const index = dataTransfer.getData('text/plain') const { files } = this.state let deleteFile const newFiles = files.filter((file, index2) => { if (index2 === +index) { deleteFile = file.name URL.revokeObjectURL(file.src) return false } return true }) this.setState({ files: newFiles, deleteFile }) event.currentTarget.style.borderColor = '#cccccc'}复制代码
图片预览
图片预览的功能也非常简单,跟删除差不多,点击对应的图片传入index,然后从全局的files中找到对应的file,将其src属性的值赋值给一个预览的img元素的src属性即可
核心代码如下:
showPreview = event => { const { currentTarget: { dataset: { index } } } = event const { files } = this.state this.setState({ preview: true, previewImg: { name: files[+index].name, src: files[+index].src } })}hidePreview = event => { this.setState({ preview: false, previewImg: null })}复制代码
图片显示之后,给外层容器绑定一个点击事件,单击让预览图片隐藏
最后
你们的打赏是我写作的动力