如何使用 Vue.js 3 制作拖放文件上传器
我们在本文中构建的文件上传器与上一个文件上传器有何不同? 之前的拖放文件上传器是用 Vanilla JS 构建的,并且真正专注于如何使文件上传和拖放文件选择工作,因此其功能集有限。 它会在您选择文件后立即上传文件,并带有一个简单的进度条和图像缩略图预览。 您可以在此演示中看到所有这些。
除了使用 Vue,我们还将改变功能:添加图像后,它不会立即上传。相反,将显示缩略图预览。缩略图右上角将有一个按钮,如果您不想选择图像或改变主意不上传,该按钮将从列表中删除该文件。
然后,您将点击“上传”按钮将图像数据发送到服务器,每张图片都会显示其上传状态。最重要的是,我制作了一些时髦的样式(但我不是设计师,所以不要太苛刻地评判)。我们不会在本教程中深入研究这些样式,但您可以在GitHub 存储库中自行复制或筛选它们 - 但是,如果您要复制它们,请确保您将项目设置为能够使用 Stylus 样式(或者您可以将其设置为使用 Sass 并更改lang
为样式块,它就会以这种方式工作)。您还可以在演示页面scss
上看到我们今天正在构建的内容。
注意:我假设读者具有丰富的 JavaScript 知识,并且很好地掌握了 Vue 功能和 API,尤其是 Vue 3 的组合 API,但不一定掌握了使用它们的最佳方法。本文旨在学习如何在 Vue 应用程序上下文中创建拖放上传器,同时讨论良好的模式和实践,并且不会深入讨论如何使用 Vue 本身。
设置
设置 Vue 项目的方法有很多:Vue CLI、Vite、Nuxt和Quasar都有自己的项目脚手架工具,我相信还有很多。我对其中大多数工具都不太熟悉,而且我不会为这个项目推荐任何一种合适的工具,所以我建议你阅读你选择的工具的文档,以了解如何按照我们需要的方式为这个小项目进行设置。
我们需要使用脚本设置语法来设置 Vue 3 ,如果你从Github repo获取我的样式,你需要确保已设置为从 Stylus 编译你的 Vue 样式(或者你可以将其设置为使用 Sass 并将lang
样式块更改为“scss”,它就会以这种方式工作)。
放置区
现在我们已经设置好了项目,让我们深入研究代码。我们将从处理拖放功能的组件开始。这将是一个简单的包装div
元素,大部分带有一堆事件侦听器和发射器。这种元素非常适合可重用组件(尽管它只在这个特定项目中使用过一次):它有非常具体的工作要做,而且这项工作足够通用,可以在很多不同的方式/地方使用,而不需要大量的自定义选项或复杂性。
这是优秀开发人员始终关注的事情之一。将大量功能塞进一个组件对于这个项目或任何其他项目来说都不是一个好主意,因为这样 1) 如果以后发现类似情况,它就无法重用;2) 更难整理代码并弄清楚每个部分之间的关系。所以,我们将尽我们所能遵循这一原则,从组件开始DropZone
。我们将从组件的简单版本开始,然后对其进行一些修饰,以帮助您更轻松地理解正在发生的事情,所以让我们在文件夹DropZone.vue
中创建一个文件src/components
:
<template> <div @drop.prevent="onDrop"> <slot></slot> </div> </template> <script setup> import { onMounted, onUnmounted } from 'vue' const emit = defineEmits(['files-dropped']) function onDrop(e) { emit('files-dropped', [...e.dataTransfer.files]) } function preventDefaults(e) { e.preventDefault() } const events = ['dragenter', 'dragover', 'dragleave', 'drop'] onMounted(() => { events.forEach((eventName) => { document.body.addEventListener(eventName, preventDefaults) }) }) onUnmounted(() => { events.forEach((eventName) => { document.body.removeEventListener(eventName, preventDefaults) }) }) </script>
首先,查看模板,您将看到一个div
带有drop
事件处理程序(带有prevent
修饰符以防止默认操作)的 ,它正在调用我们稍后将要介绍的函数。 里面div
有一个slot
,因此我们可以重复使用这个带有自定义内容的组件。 然后我们得到 JavaScript 代码,它位于script
带有setup
属性的标签内。
在脚本中,我们定义了一个将要发出的事件,称为“files-dropped”,其他组件可以使用该事件对放置在此处的文件执行某些操作。然后我们定义onDrop
处理放置事件的函数。现在,它所做的就是发出我们刚刚定义的事件,并添加一个刚刚作为有效负载放置的文件数组。请注意,我们使用了扩展运算符的技巧,将文件列表从提供给我们的转换FileList
为e.dataTransfer.files
数组,File
以便系统中接收文件的部分可以对其调用所有数组方法。
最后,我们来到处理主体上发生的其他拖放事件的地方,防止拖放期间的默认行为(即它将在浏览器中打开其中一个文件)。我们创建一个简单地调用preventDefault
事件对象的函数。然后,在生命周期钩子中,onMounted
我们遍历事件列表并防止即使在文档主体上也出现默认行为。在onUnmounted
钩子中,我们删除了那些监听器。
活跃
那么,我们可以添加哪些额外的功能呢?我决定添加的一件事是一些状态,指示放置区是否“活动”,这意味着文件当前悬停在放置区上方。这很简单;创建一个ref
名为 的函数active
,在文件被拖到放置区上方时将其设置为 true,在文件离开放置区或被放置时将其设置为 false。
我们还希望使用 将此状态公开给组件DropZone
,因此我们将 变成slot
一个作用域插槽并在那里公开该状态。除了使用作用域插槽(或为了增加灵活性而在其基础上添加),我们可以发出一个事件来通知外部 的值的active
变化。这样做的好处是,正在使用的整个组件都DropZone
可以访问状态,而不限于模板中插槽内的组件/元素。不过,我们将在本文中坚持使用作用域插槽。
最后,为了更好起见,我们将添加一个data-active
反映 值的属性active
,以便我们可以将其作为样式的键。如果您愿意,也可以使用类,但我倾向于使用数据属性作为状态修饰符。
我们把它写出来吧:
<template> <!-- add `data-active` and the event listeners --> <div :data-active="active" @dragenter.prevent="setActive" @dragover.prevent="setActive" @dragleave.prevent="setInactive" @drop.prevent="onDrop"> <!-- share state with the scoped slot --> <slot :dropZoneActive="active"></slot> </div> </template> <script setup> // make sure to import `ref` from Vue import { ref, onMounted, onUnmounted } from 'vue' const emit = defineEmits(['files-dropped']) // Create `active` state and manage it with functions let active = ref(false) function setActive() { active.value = true } function setInactive() { active.value = false } function onDrop(e) { setInactive() // add this line too emit('files-dropped', [...e.dataTransfer.files]) } // ... nothing changed below this </script>
我在代码中添加了一些注释来记录更改的位置,因此我不会深入研究它,但我有一些注释。我们prevent
再次在所有事件侦听器上使用修饰符,以确保不会激活默认行为。此外,您会注意到setActive
和setInactive
函数似乎有点过头了,因为您可以active
直接设置,并且您当然可以提出这个论点,但请稍等片刻;将会有另一个更改真正证明创建函数的合理性。
您看,我们的做法存在问题。如您在下面的视频中看到的,将此代码用于放置区意味着当您在放置区内拖动某些东西时,它可以在活动状态和非活动状态之间闪烁。
为什么会这样?当您将某个元素拖到子元素上时,它将“进入”该元素并“离开”放置区,这会导致其变为非活动状态。事件dragenter
将冒泡到放置区,但它发生在dragleave
事件之前,因此这没有帮助。然后,dragover
事件将再次在放置区上触发,这将使其重新变为活动状态,但在此之前不会闪烁到非活动状态。
为了解决这个问题,我们将为该函数添加一个短暂的超时时间,setInactive
以防止它立即变为非活动状态。然后setActive
将清除该超时时间,这样如果在我们实际将其设置为非活动状态之前调用它,它实际上不会变为非活动状态。让我们进行这些更改:
// Nothing changed above let active = ref(false) let inActiveTimeout = null // add a variable to hold the timeout key function setActive() { active.value = true clearTimeout(inActiveTimeout) // clear the timeout } function setInactive() { // wrap it in a `setTimeout` inActiveTimeout = setTimeout(() => { active.value = false }, 50) } // Nothing below this changes
您会注意到超时时间为 50 毫秒。为什么是这个数字?因为我已经测试了几种不同的超时时间,感觉这个最好。
我知道这很主观,但请听我说完。我测试过更小的超时时间,15ms 是我从未看到过闪烁的最低值,但谁知道这在其他硬件上会如何工作?在我看来,它的误差幅度太小了。您可能也不想超过 100ms,因为当用户故意执行某些应该导致其处于非活动状态的操作时,这会导致感知到的延迟。最后,我选择了中间某个时间,这个时间足够长,几乎可以保证任何硬件都不会出现闪烁,也不会有感知到的延迟。
这就是我们所需要的DropZone
组件,所以让我们继续下一个难题:文件列表管理器。
文件列表管理器
我想首先需要解释一下我所说的文件列表管理器是什么意思。这将是一个组合函数,它返回几种方法来管理用户尝试上传的文件的状态。这也可以实现为Vuex / Pinia /替代存储,但为了保持简单并避免在不需要时安装依赖项,将其保留为组合函数非常有意义,特别是因为数据不太可能在整个应用程序中被广泛需要,而这正是存储最有用的地方。
您也可以直接将功能构建到将使用我们的DropZone
组件的组件中,但此功能似乎很容易重用;将其从组件中拉出使得组件更容易理解正在发生的事情的目的(假设良好的函数和变量名称)而无需涉足整个实现。
现在我们已经明确了这将是一个组合功能以及为什么,以下是文件列表管理器将执行的操作:
保存用户已选择的文件列表;
防止重复文件;
允许我们从列表中删除文件;
使用有用的元数据扩充文件:ID、可用于显示文件预览的 URL 以及文件的上传状态。
那么,让我们来构建它src/compositions/file-list.js
:
import { ref } from 'vue' export default function () { const files = ref([]) function addFiles(newFiles) { let newUploadableFiles = [...newFiles] .map((file) => new UploadableFile(file)) .filter((file) => !fileExists(file.id)) files.value = files.value.concat(newUploadableFiles) } function fileExists(otherId) { return files.value.some(({ id }) => id === otherId) } function removeFile(file) { const index = files.value.indexOf(file) if (index > -1) files.value.splice(index, 1) } return { files, addFiles, removeFile } } class UploadableFile { constructor(file) { this.file = file this.id = `${file.name}-${file.size}-${file.lastModified}-${file.type}` this.url = URL.createObjectURL(file) this.status = null } }
我们默认导出一个函数,该函数返回文件列表(作为ref
)和一些用于在列表中添加和删除文件的方法。最好将文件列表设置为只读,以强制您使用这些方法来操作列表,您可以使用readonly
从 Vue 导入的函数轻松完成此操作,但这会导致我们稍后构建的上传器出现问题。
请注意,范围files
限定在组合函数内并在其中设置,因此每次调用该函数时,您都会收到一个新的文件列表。如果您想在多个组件/调用之间共享状态,则需要将该声明从函数中拉出,以便它在模块中被限定范围并设置一次,但在我们的例子中,我们只使用它一次,所以这并不重要,我当时的想法是,文件列表的每个实例都将由单独的上传器使用,并且任何状态都可以传递给子组件,而不是通过组合函数共享。
此文件列表管理器最复杂的部分是将新文件添加到列表中。首先,我们要确保如果FileList
传递的是对象而不是对象数组File
,则将其转换为数组(就像我们在DropZone
发出文件时所做的那样。这意味着我们可能可以跳过该转换,但谨慎一点总比后悔好)。然后我们将文件转换为UploadableFile
,这是我们定义的一个类,它包装文件并为我们提供一些额外的属性。我们id
根据文件的几个方面生成一个,以便我们可以检测重复项,blob://
图像的 URL 以便我们可以显示预览缩略图和跟踪上传的状态。
现在我们有了文件的 ID,我们会过滤掉文件列表中已存在的任何文件,然后将它们连接到文件列表的末尾。
可能的改进
虽然此文件列表管理器已经可以很好地完成其功能,但还有许多升级空间。首先,.file
我们不必将文件包装在新类中,然后必须调用它才能访问原始文件对象,而是可以将文件包装在指定新属性的代理中,然后将任何其他属性请求转发给原始对象,这样就更加无缝了。
作为将每个文件包装在 中的替代方法UploadableFile
,我们可以提供实用函数来返回给定文件的 ID 或 URL,但这样做稍微不太方便,并且意味着您可能会多次计算这些属性(对于每次渲染等等),但这并不重要,除非您要处理人们一次删除数千张图像的情况,在这种情况下您可以尝试记住它。
至于状态,它不是直接从中提取的File
,因此无法像其他函数那样使用简单的实用函数,但您可以将每个文件的状态存储在上传器中(我们稍后会构建它),而不是直接存储在文件中。这可能是在大型应用程序中处理它的更好方法,这样我们就不会最终UploadableFile
用一堆属性填充类,这些属性只会促进应用程序的单个区域,而在其他地方毫无用处。
注意:就我们的目的而言,直接在文件对象上提供属性是迄今为止最方便的,但绝对可以说它不是最合适的。
另一个可能的改进是允许您指定过滤器,以便仅允许将某些文件类型添加到列表中。这还需要addFiles
在某些文件与过滤器不匹配时返回错误,以便让用户知道他们犯了错误。这绝对是应该在生产就绪的应用程序中完成的事情。
更好地团结起来
我们距离成品还很远,但让我们把我们拥有的部分放在一起,以验证到目前为止一切是否正常。我们将编辑文件/src/App.vue
,把这些部分放进去,但你可以将它们添加到你想要的任何页面/部分组件中。但是,如果你把它放在一个备用组件中,请忽略任何只会在主应用程序组件上看到的内容(例如“应用程序”的 ID)。
<template> <div id="app"> <DropZone class="drop-area" @files-dropped="addFiles" #default="{ dropZoneActive }"> <div v-if="dropZoneActive"> <div>Drop Them</div> </div> <div v-else> <div>Drag Your Files Here</div> </div> </DropZone> </div> </template> <script setup> import useFileList from './compositions/file-list' import DropZone from './components/DropZone.vue' const { files, addFiles, removeFile } = useFileList() </script>
如果你从该部分开始script
,你会发现我们并没有做很多事情。我们正在导入刚刚写完的两个文件,并初始化文件列表。请注意,我们还没有使用files
或removeFile
,但稍后会使用,所以我暂时将它们保留在那里。如果 ESLint 抱怨未使用的变量,我很抱歉。我们files
至少需要它,以便我们稍后查看它是否正常工作。
转到模板,您可以看到我们DropZone
立即使用了组件。我们给它一个类,以便我们可以设置它的样式,传递addFiles
“files-dropped”事件处理程序的函数,并获取作用域槽变量,以便我们的内容可以根据放置区是否处于活动状态而动态变化。然后,在放置区的槽内,我们创建一个div
显示消息,如果放置区处于非活动状态,则显示一条消息,提示将文件拖过,如果放置区处于活动状态,则显示一条消息,提示将文件放下。
现在,你可能需要一些样式,至少让放置区更大、更容易找到。我不会在这里粘贴任何样式,但你可以App.vue
在 repo 中找到我使用的样式。
现在,在测试应用程序的当前状态之前,我们需要在浏览器中安装 Vue DevTools 的测试版(稳定版尚不支持 Vue 3)。您可以从 Chrome 网上应用店为大多数基于 Chromium 的浏览器获取 Vue DevTools,或在此处为 Firefox 下载 Vue DevTools。
安装完成后,使用npm run serve
(Vue CLI)、npm run dev
(Vite) 或您在应用中使用的任何脚本运行您的应用,然后通过命令行中提供的 URL 在浏览器中打开它。打开 Vue DevTools,然后将一些图像拖放到放置区。如果成功,当您查看我们刚刚编写的组件时,您应该会看到一个包含您添加的文件的数组(见下面的屏幕截图)。
太棒了!现在让我们为无法(或不想)拖放的用户提供更多便利,方法是添加一个隐藏的文件输入(对于需要它的用户,当通过键盘聚焦时,它会变得可见,假设您使用我的样式)并用一个大标签包裹所有内容,以便我们尽管看不见它,也可以使用它。最后,我们需要向文件输入添加一个事件监听器,这样当用户选择一个文件时,我们就可以将其添加到我们的文件列表中。
让我们从对该部分的更改开始script
。我们只需在其末尾添加一个函数:
function onInputChange(e) { addFiles(e.target.files) e.target.value = null }
此函数处理从输入触发的“change”事件,并将输入中的文件添加到文件列表。请注意函数中的最后一行重置输入的值。如果用户通过输入添加文件,决定将其从我们的文件列表中删除,然后改变主意并决定再次使用输入添加该文件,则文件输入将不会触发“change”事件,因为文件输入没有更改。通过像这样重置值,我们确保事件将始终被触发。
现在,让我们对模板进行修改。将插槽内的所有代码更改DropZone
为以下内容:
<label for="file-input"> <span v-if="dropZoneActive"> <span>Drop Them Here</span> <span class="smaller">to add them</span> </span> <span v-else> <span>Drag Your Files Here</span> <span class="smaller"> or <strong><em>click here</em></strong> to select files </span> </span> <input type="file" id="file-input" multiple @change="onInputChange" /> </label>
我们将整个内容包装在一个链接到文件输入的标签中,然后我们重新添加动态消息,尽管我添加了更多的消息来通知用户他们可以单击以选择文件。我还为“放置它们”消息添加了一些内容,以便它们具有相同数量的文本行,这样放置区在活动时不会改变大小。最后,我们添加文件输入,设置multiple
属性以允许用户一次选择多个文件,然后将“更改”事件侦听器连接到我们刚刚编写的函数。
再次运行应用程序,如果您停止它,无论我们拖放文件还是单击框以使用文件选择器,我们都应该在 Vue DevTools 中看到相同的结果。
预览选定的图像
很好,但用户不会使用 Vue DevTools 来查看他们放置的文件是否真的被添加了,所以让我们开始向用户显示这些文件。我们将从编辑App.vue
(或您添加的任何组件文件DropZone
)开始,并显示一个包含文件名的简单文本列表。
label
让我们在上一步中添加的代码之后立即将以下代码添加到模板中:
<ul v-show="files.length"> <li v-for="file of files" :key="file.id">{{ file.file.name }}</li> </ul>
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。