如何使用 Vanilla JavaScript 制作拖放文件上传器
已发表: 2022-03-10众所周知,文件选择输入很难按照开发人员想要的方式设置样式,因此许多人只是将其隐藏并创建一个打开文件选择对话框的按钮。 不过,如今,我们有一种更奇特的方式来处理文件选择:拖放。
从技术上讲,这已经成为可能,因为文件选择输入的大多数(如果不是全部)实现允许您将文件拖到其上以选择它们,但这需要您实际显示file
元素。 那么,让我们实际使用浏览器给我们的API来实现一个拖放文件选择器和上传器。
在本文中,我们将使用“vanilla” ES2015+ JavaScript(无框架或库)来完成这个项目,并假设您对浏览器中的 JavaScript 有一定的了解。 这个例子——除了 ES2015+ 语法,它可以很容易地更改为 ES5 语法或由 Babel 转译——应该与每个常青浏览器以及 IE 10 和 11 兼容。
以下是您将要制作的内容的快速浏览:
拖放事件
我们需要讨论的第一件事是与拖放相关的事件,因为它们是此功能背后的驱动力。 总之,浏览器会触发八个与拖放相关的事件: drag
、 dragend
、 dragenter
、 dragexit
、 dragleave
、 dragover
、 dragstart
和drop
。 我们不会遍历所有这些,因为drag
、 dragend
、 dragexit
和dragstart
都在被拖动的元素上触发,在我们的例子中,我们将从文件系统中拖动文件而不是 DOM 元素,所以这些事件永远不会弹出。
如果你对它们感到好奇,你可以阅读 MDN 上关于这些事件的一些文档。
如您所料,您可以像为大多数浏览器事件注册事件处理程序一样为这些事件注册事件处理程序:通过addEventListener
。
let dropArea = document.getElementById('drop-area') dropArea.addEventListener('dragenter', handlerFunction, false) dropArea.addEventListener('dragleave', handlerFunction, false) dropArea.addEventListener('dragover', handlerFunction, false) dropArea.addEventListener('drop', handlerFunction, false)
这是一个小表格,描述了这些事件的作用,使用代码示例中的dropArea
以使语言更清晰:
事件 | 什么时候被解雇? |
---|---|
dragenter | 被拖动的项目被拖动到 dropArea 上,如果用户将其放在那里,则使其成为放置事件的目标。 |
dragleave | 被拖动的项目被拖出 dropArea 并拖到另一个元素上,使其成为放置事件的目标。 |
dragover | 每隔几百毫秒,当拖动的项目在 dropArea 上方并正在移动时。 |
drop | 用户松开鼠标按钮,将拖动的项目放到 dropArea 上。 |
请注意,拖动的项目被拖动到dropArea
的子元素上, dragleave
将在dropArea
上触发,而dragenter
将在该子元素上触发,因为它是新target
。 drop
事件将向上传播到dropArea
(除非传播在到达那里之前被不同的事件侦听器停止),因此尽管它不是事件的target
,它仍会在dropArea
上触发。
另请注意,为了创建自定义拖放交互,您需要在每个侦听器中为这些事件调用event.preventDefault()
。 如果您不这样做,浏览器将最终打开您放置的文件,而不是将其发送到drop
事件处理程序。
设置我们的表格
在我们开始添加拖放功能之前,我们需要一个带有标准file
输入的基本表单。 从技术上讲,这不是必需的,但如果用户的浏览器不支持拖放 API,最好提供它作为替代方案。
<div> <form class="my-form"> <p>Upload multiple files with the file dialog or by dragging and dropping images onto the dashed region</p> <input type="file" multiple accept="image/*" onchange="handleFiles(this.files)"> <label class="button" for="fileElem">Select some files</label> </form> </div>
很简单的结构。 您可能会注意到input
上有一个onchange
处理程序。 我们稍后再看。 在form
中添加一个action
和一个submit
按钮来帮助那些没有启用 JavaScript 的人也是一个好主意。 然后,您可以使用 JavaScript 摆脱它们以获得更清晰的表单。 在任何情况下,您都需要一个服务器端脚本来接受上传,无论它是内部开发的,还是您使用 Cloudinary 之类的服务来为您完成。 除了这些注释之外,这里没有什么特别的,所以让我们添加一些样式:
#drop-area { border: 2px dashed #ccc; border-radius: 20px; width: 480px; font-family: sans-serif; margin: 100px auto; padding: 20px; } #drop-area.highlight { border-color: purple; } p { margin-top: 0; } .my-form { margin-bottom: 10px; } #gallery { margin-top: 10px; } #gallery img { width: 150px; margin-bottom: 10px; margin-right: 10px; vertical-align: middle; } .button { display: inline-block; padding: 10px; background: #ccc; cursor: pointer; border-radius: 5px; border: 1px solid #ccc; } .button:hover { background: #ddd; } #fileElem { display: none; }
许多这些风格还没有发挥作用,但没关系。 现在的亮点是file
输入是隐藏的,但是它的label
被设计成一个按钮,所以人们会意识到他们可以点击它来调出文件选择对话框。 我们还遵循惯例,用虚线勾勒出放置区域。
添加拖放功能
现在我们进入情况的实质:拖放。 让我们在页面底部或单独的文件中添加一个脚本,但是您喜欢这样做。 我们在脚本中需要的第一件事是对放置区域的引用,以便我们可以将一些事件附加到它:
let dropArea = document.getElementById('drop-area')
现在让我们添加一些事件。 我们将从向所有事件添加处理程序开始,以防止默认行为并阻止事件冒泡超过必要的水平:
;['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, preventDefaults, false) }) function preventDefaults (e) { e.preventDefault() e.stopPropagation() }
现在让我们添加一个指示器,通过使用 CSS 更改放置区域的边框颜色的颜色,让用户知道他们确实将项目拖到了正确的区域。 样式应该已经在#drop-area.highlight
选择器下,所以让我们在必要时使用 JS 添加和删除该highlight
类。
;['dragenter', 'dragover'].forEach(eventName => { dropArea.addEventListener(eventName, highlight, false) }) ;['dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, unhighlight, false) }) function highlight(e) { dropArea.classList.add('highlight') } function unhighlight(e) { dropArea.classList.remove('highlight') }
由于我之前提到的,我们不得不同时使用dragenter
和dragover
来突出显示。 如果您开始直接将鼠标悬停在dropArea
,然后将鼠标悬停在其中一个子级上,则将dragleave
并删除突出显示。 dragover
事件在dragenter
和dragleave
事件之后触发,因此在我们看到它被移除之前,高亮将被添加回dropArea
。
当拖动的项目离开指定区域或放下项目时,我们也会删除突出显示。
现在我们需要做的就是弄清楚当一些文件被删除时该怎么做:
dropArea.addEventListener('drop', handleDrop, false) function handleDrop(e) { let dt = e.dataTransfer let files = dt.files handleFiles(files) }
这并没有使我们接近完成,但它做了两件重要的事情:
- 演示如何获取已删除文件的数据。
- 使用其
onchange
处理程序将我们带到file
input
所在的同一位置:等待handleFiles
。
请记住, files
不是数组,而是FileList
。 因此,当我们实现handleFiles
时,我们需要将其转换为数组以便更轻松地对其进行迭代:
function handleFiles(files) { ([...files]).forEach(uploadFile) }
那是虎头蛇尾的。 让我们进入uploadFile
来了解真正的内容。
function uploadFile(file) { let url = 'YOUR URL HERE' let formData = new FormData() formData.append('file', file) fetch(url, { method: 'POST', body: formData }) .then(() => { /* Done. Inform the user */ }) .catch(() => { /* Error. Inform the user */ }) }
这里我们使用FormData
,这是一个内置的浏览器 API,用于创建发送到服务器的表单数据。 然后,我们使用fetch
API 将图像实际发送到服务器。 确保更改 URL 以与后端或服务一起使用,并formData.append
您可能需要的任何其他表单数据,以便为服务器提供所需的所有信息。 或者,如果你想支持 Internet Explorer,你可能想使用XMLHttpRequest
,这意味着uploadFile
看起来像这样:
function uploadFile(file) { var url = 'YOUR URL HERE' var xhr = new XMLHttpRequest() var formData = new FormData() xhr.open('POST', url, true) xhr.addEventListener('readystatechange', function(e) { if (xhr.readyState == 4 && xhr.status == 200) { // Done. Inform the user } else if (xhr.readyState == 4 && xhr.status != 200) { // Error. Inform the user } }) formData.append('file', file) xhr.send(formData) }
根据您的服务器的设置方式,您可能希望检查不同范围的status
编号,而不仅仅是200
,但出于我们的目的,这将起作用。
附加的功能
这就是所有基本功能,但通常我们需要更多功能。 具体来说,在本教程中,我们将添加一个预览窗格,向用户显示所有选择的图像,然后我们将添加一个进度条,让用户查看上传进度。 所以,让我们开始预览图像。
图像预览
有几种方法可以做到这一点:你可以等到图片上传后,让服务器发送图片的 URL,但这意味着你需要等待,有时图片会很大。 另一种方法——我们今天将要探讨的——是对我们从drop
事件收到的文件数据使用 FileReader API。 这是异步的,您也可以使用 FileReaderSync,但我们可能会尝试连续读取多个大文件,因此这可能会阻塞线程很长一段时间并真正破坏体验。 所以让我们创建一个previewFile
函数,看看它是如何工作的:
function previewFile(file) { let reader = new FileReader() reader.readAsDataURL(file) reader.onloadend = function() { let img = document.createElement('img') img.src = reader.result document.getElementById('gallery').appendChild(img) } }
在这里,我们创建一个new FileReader
并使用File
对象在其上调用readAsDataURL
。 如前所述,这是异步的,因此我们需要添加一个onloadend
事件处理程序才能获得读取的结果。 然后,我们使用 base 64 数据 URL 作为新图像元素的src
,并将其添加到gallery
元素。 现在只需要做两件事就可以完成这项工作:添加gallery
元素,并确保实际调用previewFile
。
首先,在form
标签的末尾添加以下 HTML:
<div></div>
没什么特别的; 这只是一个div。 已经为它和其中的图像指定了样式,所以那里没有什么可做的。 现在让我们将handleFiles
函数更改为以下内容:
function handleFiles(files) { files = [...files] files.forEach(uploadFile) files.forEach(previewFile) }
有几种方法可以做到这一点,例如组合,或对forEach
的单个回调,在其中运行uploadFile
和previewFile
,但这也有效。 这样,当您删除或选择一些图像时,它们应该几乎立即显示在表单下方。 有趣的是,在某些应用程序中,您实际上可能不想上传图像,而是将它们的数据 URL 存储在localStorage
或其他一些客户端缓存中,以供应用程序稍后访问。 我个人想不出任何好的用例,但我敢打赌有一些。
跟踪进度
如果某件事可能需要一段时间,进度条可以帮助用户意识到实际正在取得进展,并指示完成需要多长时间。 借助 HTML5 progress
标签,添加进度指示器非常容易。 这次让我们先将其添加到 HTML 代码中。
<progress max=100 value=0></progress>
您可以将其放在label
之后或form
和画廊div
之间,无论您更喜欢哪个。 就此而言,您可以将其放置在body
标签中的任何位置。 此示例没有添加任何样式,因此它将显示浏览器的默认实现,这是可用的。 现在让我们开始添加 JavaScript。 我们将首先查看使用fetch
的实现,然后我们将展示XMLHttpRequest
的版本。 首先,我们需要在脚本顶部添加几个新变量:
let filesDone = 0 let filesToDo = 0 let progressBar = document.getElementById('progress-bar')
使用fetch
时,我们只能确定上传何时完成,因此我们跟踪的唯一信息是选择上传的文件数量(如filesToDo
)和已完成上传的文件数量(如filesDone
)。 我们还保留了对#progress-bar
元素的引用,以便我们可以快速更新它。 现在让我们创建几个函数来管理进度:
function initializeProgress(numfiles) { progressBar.value = 0 filesDone = 0 filesToDo = numfiles } function progressDone() { filesDone++ progressBar.value = filesDone / filesToDo * 100 }
当我们开始上传时,会调用initializeProgress
来重置进度条。 然后,对于每个完成的上传,我们将调用progressDone
来增加已完成上传的数量并更新进度条以显示当前进度。 所以让我们通过更新几个旧函数来调用这些函数:
function handleFiles(files) { files = [...files] initializeProgress(files.length) // <- Add this line files.forEach(uploadFile) files.forEach(previewFile) } function uploadFile(file) { let url = 'YOUR URL HERE' let formData = new FormData() formData.append('file', file) fetch(url, { method: 'POST', body: formData }) .then(progressDone) // <- Add `progressDone` call here .catch(() => { /* Error. Inform the user */ }) }
就是这样。 现在让我们看一下XMLHttpRequest
的实现。 我们可以对uploadFile
进行快速更新,但XMLHttpRequest
实际上为我们提供了比fetch
更多的功能,即我们能够为每个请求的上传进度添加一个事件侦听器,它会定期向我们提供有关请求的多少的信息完成的。 因此,我们需要跟踪每个请求的完成百分比,而不是只跟踪完成了多少。 因此,让我们开始用以下内容替换filesDone
和filesToDo
的声明:
let uploadProgress = []
然后我们还需要更新我们的函数。 我们将progressDone
重命名为updateProgress
并将它们更改为以下内容:
function initializeProgress(numFiles) { progressBar.value = 0 uploadProgress = [] for(let i = numFiles; i > 0; i--) { uploadProgress.push(0) } } function updateProgress(fileNumber, percent) { uploadProgress[fileNumber] = percent let total = uploadProgress.reduce((tot, curr) => tot + curr, 0) / uploadProgress.length progressBar.value = total }
现在initializeProgress
初始化一个长度等于numFiles
并用零填充的数组,表示每个文件都完成了 0%。 在updateProgress
中,我们找出哪个图像正在更新其进度,并将该索引处的值更改为提供的percent
。 然后,我们通过取所有百分比的平均值来计算总进度百分比,并更新进度条以反映计算的总数。 我们仍然像在fetch
示例中一样在handleFiles
中调用initializeProgress
,所以现在我们需要更新的是uploadFile
以调用updateProgress
。
function uploadFile(file, i) { // <- Add `i` parameter var url = 'YOUR URL HERE' var xhr = new XMLHttpRequest() var formData = new FormData() xhr.open('POST', url, true) // Add following event listener xhr.upload.addEventListener("progress", function(e) { updateProgress(i, (e.loaded * 100.0 / e.total) || 100) }) xhr.addEventListener('readystatechange', function(e) { if (xhr.readyState == 4 && xhr.status == 200) { // Done. Inform the user } else if (xhr.readyState == 4 && xhr.status != 200) { // Error. Inform the user } }) formData.append('file', file) xhr.send(formData) }
首先要注意的是我们添加了一个i
参数。 这是文件列表中文件的索引。 我们不需要更新handleFiles
来传递这个参数,因为它正在使用forEach
,它已经将元素的索引作为回调的第二个参数。 我们还将progress
事件侦听器添加到xhr.upload
,以便我们可以使用进度调用updateProgress
。 事件对象(在代码中称为e
)有两条相关信息: loaded
包含到目前为止已上传的字节数, total
包含文件的总字节数。
|| 100
|| 100
块在那里,因为有时如果有错误, e.loaded
和e.total
将为零,这意味着计算将作为NaN
出来,因此使用100
来报告文件已完成。 您也可以使用0
。 在任何一种情况下,错误都会显示在readystatechange
处理程序中,以便您可以通知用户它们。 这只是为了防止尝试使用NaN
进行数学运算而引发异常。
结论
那是最后一块。 您现在有一个网页,您可以在其中通过拖放上传图像,立即预览正在上传的图像,并在进度条中查看上传进度。 您可以在 CodePen 上看到最终版本(使用XMLHttpRequest
),但请注意,我将文件上传到的服务有限制,所以如果很多人测试它,它可能会中断一段时间。