生产队的驴最近太忙了,所以一天一更或者一天两更基本上是遥遥无期的。 问我最近在忙什么?好说,不过是在自己设计的IM系统中各种各样的文件发送、消息同步、离线通知、内容安全等等倒灶的需求中反复挣扎。这一篇刚好是最近关于文件上传的一部分总结,有问题我们评论区见~
需求分析
在实现文件上传组件之前,我们需要明确几个基本需求:
- 文件选择:用户能够通过组件选择文件。
- 预览功能:在文件选择后,用户能看到上传的文件信息或缩略图。
- 上传功能:将选择的文件通过 HTTP 请求上传至服务器。
- 错误处理:处理上传过程中可能出现的错误。
- UI 友好性:确保组件在移动设备和桌面设备上的良好使用体验。
知识点介绍
在实现过程中,我们将用到以下一些 Vue 3 的核心概念:
- Composition API:Vue 3 引入的新的 API,可以更灵活地组织和复用逻辑。
- 响应式数据:通过
ref
和reactive
处理组件的状态。 - 生命周期钩子:使用
onMounted
和onUnmounted
等钩子处理组件的生命周期。 - 事件处理:使用事件处理方法来响应用户操作。
- 异步请求:利用
fetch
或者传统的XMLHttpRequest
进行文件上传。
客户端实现
在 src/components
或者components
目录下创建 FileUpload.vue
文件,并开始编写组件:
Vue3 是src/components
Nuxt3 是components
<template>
<div class="file-upload">
<!-- 文件输入框,当文件改变时触发 handleFileChange 方法 -->
<input type="file" @change="handleFileChange" />
<!-- 上传按钮,当没有选中文件时禁用 -->
<button @click="uploadFile" :disabled="!selectedFile">上传文件</button>
<!-- 显示文件信息 -->
<div v-if="fileInfo">
<p>文件名称: {{ fileInfo.name }}</p>
<p>文件大小: {{ fileInfo.size }} bytes</p>
</div>
<!-- 显示上传进度 -->
<div v-if="uploadProgress > 0">上传进度: {{ uploadProgress }}%</div>
<!-- 显示错误信息 -->
<div v-if="errorMessage" style="color: red">错误: {{ errorMessage }}</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
// 选中的文件
const selectedFile = ref(null);
// 文件信息
const fileInfo = ref(null);
// 上传进度
const uploadProgress = ref(0);
// 错误信息
const errorMessage = ref("");
// 处理文件改变事件
const handleFileChange = (event) => {
const file = event.target.files[0];
if (file) {
selectedFile.value = file;
fileInfo.value = { name: file.name, size: file.size };
errorMessage.value = "";
}
};
// 上传文件
const uploadFile = async () => {
if (!selectedFile.value) return;
const formData = new FormData();
formData.append("file", selectedFile.value);
try {
const response = await fetch("your-upload-endpoint-url", {
method: "POST",
body: formData,
onUploadProgress: (event) => {
if (event.lengthComputable) {
uploadProgress.value = Math.round((event.loaded * 100) / event.total);
}
},
});
if (!response.ok) {
throw new Error("上传失败");
}
const result = await response.json();
console.log("上传成功:", result);
resetForm();
} catch (error) {
errorMessage.value = error.message;
}
};
// 重置表单
const resetForm = () => {
selectedFile.value = null;
fileInfo.value = null;
uploadProgress.value = 0;
errorMessage.value = "";
};
</script>
<style scoped>
/* 添加样式 */
.file-upload {
display: flex;
flex-direction: column;
gap: 10px;
}
</style>
-
模板部分 (
<template>
):- 包含文件输入框、上传按钮、文件信息显示、上传进度显示和错误信息显示。
-
脚本部分 (
<script setup lang="ts">
):- 使用
ref
定义响应式变量:selectedFile
、fileInfo
、uploadProgress
和errorMessage
。 handleFileChange
方法处理文件输入改变事件,更新selectedFile
和fileInfo
。uploadFile
方法处理文件上传,使用fetch
发送 POST 请求,处理上传进度并捕获错误。resetForm
方法重置表单状态。
- 使用
打开 src/App.vue
文件,导入并注册我们的 FileUpload
组件:
Nuxt3是自动引入的并且App.vue在根目录中
<template>
<div id="app">
<h1>文件上传示例</h1>
<FileUpload />
</div>
</template>
<script>
import FileUpload from './components/FileUpload.vue';
export default {
components: {
FileUpload,
},
};
</script>
【运行效果】
服务端实现
在 Nuxt 3 中,你可以使用 server/api
目录来创建 API 路由。我们需要一个处理文件上传的 API 路由。
首先,安装必要的依赖项:
npm install formidable
然后,创建一个 API 路由来处理文件上传:
// server/api/upload.post.ts
import formidable, { File } from 'formidable';
import { IncomingMessage } from 'http';
import { defineEventHandler, H3Event } from 'h3';
import fs from 'fs';
import path from 'path';
export default defineEventHandler(async (event: H3Event) => {
const form = formidable({ multiples: true });
return new Promise((resolve, reject) => {
form.parse(event.node.req as IncomingMessage, (err, fields, files) => {
if (err) {
reject(err);
return;
}
const uploadDir = path.join(process.cwd(), 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir);
}
const processFile = (file: File) => {
const filePath = path.join(uploadDir, file.originalFilename as string);
fs.renameSync(file.filepath, filePath);
};
if (Array.isArray(files.file)) {
// 处理多个文件
files.file.forEach(file => processFile(file));
} else if (files.file) {
// 处理单个文件
processFile(files.file as File);
} else {
reject(new Error('No files were uploaded.'));
return;
}
resolve({ message: '文件上传成功', files });
});
});
});
- 启用多文件上传:将
formidable
的multiples
选项设置为true
,允许多文件上传。 - 创建上传目录:确保上传目录存在,如果不存在则创建。
- 处理文件:
- 定义
processFile
函数来处理单个文件的保存。 - 如果
files.file
是数组,则循环处理每个文件。 - 如果
files.file
不是数组,但存在,则处理单个文件。 - 如果
files.file
为空,则抛出错误。
- 定义
- 返回响应:上传成功后返回响应。
后续都用Nuxt3视角来写了,我本地没有vue项目QAQ
启动项目
npm run dev
访问地址http://localhost:3000/
就可以动手测试一下啦
别忘了前面按钮上传的地址改改成
/api/upload
选择文件
上传成功
优化组件
更抽象的需求
咱们先自己琢磨一下,产品老弟平时给你需求的时候,是不是这么简单?在他给你抽象需求之前,咱们自己先抽象一下~
-
颜值提升,变美变好看:
- 自定义样式:让用户可以自己改样式,传个样式类或者自定义样式啥的,组件外观随便搞。
- 主题支持:搞几个主题(比如浅色、深色),用户想换就换。
- 动画效果:文件上传、进度更新、成功失败啥的都加点动画,动起来更好看。
-
组件化,方便复用:
- 支持多种文件类型:配置一下支持的文件类型,图片、文档、音频、视频啥的都行。
- 多文件上传:一次选多个文件上传,省事儿。
- 拖放上传:拖文件到上传区域就能上传,用户体验更好。
- 可配置的上传 URL:上传的服务器地址可以配置,灵活点。
- 事件回调:上传成功、失败、进度更新这些都搞个回调,方便其他模块用。
-
上传进度条:
- 进度条样式:进度条搞几个样式(比如线形、圆形),还能自定义。
- 分段上传:大文件分段上传,进度条显示每个分段的进度。
- 取消上传:提供取消上传的功能,进度条显示取消状态。
-
错误处理和用户提示:
- 详细的错误信息:详细的错误信息要有,比如文件太大、不支持的文件类型、网络错误啥的。
- 重试机制:上传失败了,搞个重试按钮,用户可以重新上传。
- 用户提示:文件选择、上传成功、上传失败这些关键步骤,都要有友好的用户提示。
-
文件管理:
- 文件预览:上传前显示文件预览,比如图片缩略图、文档预览。
- 文件列表:显示已上传文件的列表,提供删除、重命名等管理功能。
- 文件大小限制:配置一下单个文件和总上传文件大小的限制。
-
安全性:
- 文件校验:客户端先校验一下文件(比如大小、类型),再上传。
- 身份验证:上传请求里带上用户身份验证信息,确保文件上传安全。
需求分析和功能设计
-
变美,变好看:
- 自定义样式:允许用户通过传递样式类或自定义样式来改变组件的外观。
- 前端:使用
class
或style
属性,并通过props
接收用户传递的样式类或样式对象。 - 核心技术:Vue 3 的
props
和class
绑定。 - 功能逻辑:在组件中定义
props
接收样式类或样式对象,并应用到组件的根元素上。
- 前端:使用
- 主题支持:提供多种主题(如浅色、深色),并允许用户切换。
- 前端:使用 CSS 变量或 SCSS 混合来定义主题,通过
props
传递主题名称并动态切换主题。 - 核心技术:CSS 变量、SCSS、Vue 3 的
props
。 - 功能逻辑:定义不同主题的 CSS 变量集,根据
props
中的主题名称动态切换变量集。
- 前端:使用 CSS 变量或 SCSS 混合来定义主题,通过
- 动画效果:在文件上传、进度更新、成功和失败时添加动画效果。
- 前端:使用 CSS 动画或 JavaScript 动画库(如 Animate.css 或 GSAP)在特定事件触发时应用动画。
- 核心技术:CSS 动画、JavaScript 动画库。
- 功能逻辑:在上传、进度更新、成功和失败等事件中添加动画类或调用动画库函数。
- 自定义样式:允许用户通过传递样式类或自定义样式来改变组件的外观。
-
组件化,以便其他模块有需求时快速使用:
- 支持多种文件类型:允许配置支持的文件类型,如图片、文档、音频、视频等。
- 前端:在文件选择时使用
accept
属性限制文件类型,通过props
传递允许的文件类型。 - 核心技术:HTML 文件输入的
accept
属性、Vue 3 的props
。 - 功能逻辑:在文件输入元素中使用
accept
属性,根据props
设置允许的文件类型。
- 前端:在文件选择时使用
- 多文件上传:支持一次选择并上传多个文件。
- 前端:在文件输入框中添加
multiple
属性,允许用户选择多个文件。 - 核心技术:HTML 文件输入的
multiple
属性。 - 功能逻辑:在文件输入元素中添加
multiple
属性,允许用户选择多个文件。
- 前端:在文件输入框中添加
- 拖放上传:支持拖放文件到上传区域进行上传。
- 前端:在组件中监听
dragover
和drop
事件,处理文件拖放。 - 核心技术:HTML5 拖放 API。
- 功能逻辑:在组件中监听
dragover
和drop
事件,获取拖放的文件并触发上传逻辑。
- 前端:在组件中监听
- 可配置的上传 URL:允许通过属性配置上传的服务器端 URL。
- 前端:通过
props
传递上传 URL,在上传时使用该 URL。 - 核心技术:Vue 3 的
props
。 - 功能逻辑:在组件中定义
props
接收上传 URL,在上传请求中使用该 URL。
- 前端:通过
- 事件回调:提供上传成功、失败、进度更新等事件回调,便于其他模块集成和响应。
- 前端:使用 Vue 的自定义事件机制 (
$emit
) 触发事件回调。 - 核心技术:Vue 3 的事件系统。
- 功能逻辑:在上传过程中触发相应的事件回调(如
uploadSuccess
、uploadError
、uploadProgress
)。
- 前端:使用 Vue 的自定义事件机制 (
- 支持多种文件类型:允许配置支持的文件类型,如图片、文档、音频、视频等。
-
上传进度条:
- 进度条样式:提供不同样式的进度条(如线形、圆形),并允许自定义。
- 前端:通过
props
传递进度条样式类型,并在组件中根据类型渲染不同样式的进度条。 - 核心技术:Vue 3 的
props
、CSS。 - 功能逻辑:在组件中定义
props
接收进度条样式类型,根据类型渲染不同的进度条样式。
- 前端:通过
- 取消上传:提供取消上传的功能,并在进度条中显示取消状态。
- 前端:在上传过程中提供取消按钮,使用
AbortController
取消上传请求。 - 核心技术:JavaScript 的
AbortController
。 - 功能逻辑:在上传过程中创建
AbortController
,在取消按钮点击时调用abort
方法。
- 前端:在上传过程中提供取消按钮,使用
- 进度条样式:提供不同样式的进度条(如线形、圆形),并允许自定义。
-
错误处理和用户提示:
- 详细的错误信息:提供详细的错误信息,如文件大小超限、不支持的文件类型、网络错误等。
- 前端:在上传过程中捕获错误并显示详细的错误信息。
- 核心技术:JavaScript 异常处理、Vue 3 的状态管理。
- 功能逻辑:在上传过程中捕获异常,设置错误状态并显示错误信息。
- 重试机制:在上传失败时,提供重试按钮以便用户重新上传文件。
- 前端:在上传失败时显示重试按钮,重新调用上传函数。
- 核心技术:Vue 3 的事件系统。
- 功能逻辑:在上传失败时显示重试按钮,用户点击重试按钮时重新调用上传函数。
- 用户提示:在文件选择、上传成功、上传失败等关键步骤中,提供友好的用户提示。
- 前端:使用弹窗、通知或提示框显示用户提示信息。
- 核心技术:Vue 3 的状态管理、UI 库(如 Element Plus)。
- 功能逻辑:在关键步骤中设置状态并触发 UI 库的提示组件显示提示信息。
- 详细的错误信息:提供详细的错误信息,如文件大小超限、不支持的文件类型、网络错误等。
-
文件管理:
- 文件预览:在上传前显示文件的预览(如图片缩略图、文档预览)。
- 前端:使用
FileReader
读取文件内容并生成预览。 - 核心技术:JavaScript 的
FileReader
API。 - 功能逻辑:在文件选择后使用
FileReader
读取文件内容并生成预览(如图片缩略图)。
- 前端:使用
- 文件列表:显示已上传文件的列表,并提供删除、重命名等管理功能。
- 前端:在组件中维护文件列表状态,提供删除和重命名功能。
- 核心技术:Vue 3 的状态管理。
- 功能逻辑:在组件中维护文件列表状态,提供删除和重命名按钮,触发相应的操作。
- 文件大小限制:允许配置单个文件和总上传文件大小的限制。
- 前端:在文件选择时检查文件大小,超过限制则提示用户。
- 核心技术:JavaScript 文件处理。
- 功能逻辑:在文件选择时检查文件大小,超过限制则设置错误状态并显示提示信息。
- 文件预览:在上传前显示文件的预览(如图片缩略图、文档预览)。
-
安全性:
- 文件校验:在客户端对文件进行基本校验(如文件大小、类型)后再上传。
- 前端:在文件选择时进行基本校验,符合条件的文件才能上传。
- 核心技术:JavaScript 文件处理。
- 功能逻辑:在文件选择时进行文件大小和类型校验,不符合条件的文件则阻止上传并提示用户。
- 文件校验:在客户端对文件进行基本校验(如文件大小、类型)后再上传。
后续迭代实现功能
- 分段上传:对于大文件,支持分段上传,并在进度条中显示每个分段的上传进度。
- 前端:将大文件分割成小块进行上传,使用 JavaScript 实现分段上传逻辑。
- 后端:实现分段上传接口,接收并合并文件块。
- 核心技术:JavaScript 文件分割、上传 API。
- 功能逻辑:将大文件分割成小块,逐块上传并跟踪进度,在后端合并文件块。
- 身份验证:在上传请求中包含用户身份验证信息,以确保文件上传的安全性。
- 前端:在上传请求中添加身份验证信息(如 JWT Token)。
- 后端:在文件上传接口中验证用户身份,确保只有合法用户才能上传文件。
- 核心技术:JWT、身份验证 API。
- 功能逻辑:在上传请求中附加身份验证信息,后端验证用户身份。
这些未实现的功能将在后续的文章中详细介绍。
前端优化
FileUpload.vue
<template>
<div
:class="['file-upload', customClass]"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
type="file"
:multiple="multiple"
@change="handleFileChange"
ref="fileInput"
hidden
:accept="acceptedFileTypes"
/>
<div class="upload-area" @click="triggerFileInput">
<slot name="upload-area">
<p>点击或拖放文件到此区域上传</p>
</slot>
</div>
<div v-if="files.length" class="file-list">
<div class="overall-progress-bar-container">
<div
class="overall-progress-bar"
:style="{ width: overallProgress + '%' }"
></div>
</div>
<div v-if="mode === 'list'" class="file-list-mode">
<div v-for="(file, index) in files" :key="index" class="file-item">
<p>{{ file.name }} ({{ formatSize(file.size) }})</p>
<div class="progress-bar-container">
<div
class="progress-bar"
:style="{ width: uploadProgress[index] + '%' }"
></div>
</div>
<button @click="removeFile(index)" class="remove-button">删除</button>
</div>
</div>
<div v-if="mode === 'preview'" class="file-preview-mode">
<div
v-for="(file, index) in files"
:key="index"
class="file-preview-item"
>
<div class="file-preview-cover">
<template v-if="isImage(file)">
<img :src="filePreview(file)" alt="Image preview" />
</template>
<template v-else-if="isVideo(file)">
<video :src="filePreview(file)" controls />
</template>
<template v-else>
<p class="file-name">{{ file.name }}</p>
</template>
<button @click="removeFile(index)" class="remove-icon">✖</button>
</div>
<div class="progress-bar-container-preview">
<div
class="progress-bar"
:style="{ width: uploadProgress[index] + '%' }"
></div>
</div>
</div>
</div>
</div>
<button @click="uploadFiles" :disabled="!files.length">上传文件</button>
<div v-if="errorMessage" class="error-message">{{ errorMessage }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
const mimeTypesDictionary: Record<string, string> = {
// 图片类型
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
svg: "image/svg+xml",
// 文档类型
pdf: "application/pdf",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
txt: "text/plain",
rtf: "application/rtf",
// 压缩文件
zip: "application/zip",
rar: "application/vnd.rar",
tar: "application/x-tar",
gz: "application/gzip",
"7z": "application/x-7z-compressed",
// 音频文件
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
m4a: "audio/mp4",
// 视频文件
mp4: "video/mp4",
avi: "video/x-msvideo",
mov: "video/quicktime",
mkv: "video/x-matroska",
webm: "video/webm",
// 其他常见文件类型
html: "text/html",
css: "text/css",
js: "application/javascript",
json: "application/json",
xml: "application/xml",
csv: "text/csv",
};
interface FileUploadProps {
uploadUrl: string;
multiple?: boolean;
customClass?: string;
maxFileSize?: number;
allowedFileExtensions?: (keyof typeof mimeTypesDictionary)[];
mode?: "list" | "preview";
}
const props = withDefaults(defineProps<FileUploadProps>(), {
multiple: false,
customClass: "",
maxFileSize: 5 * 1024 * 1024,
allowedFileExtensions: () => ["jpg", "jpeg", "png", "pdf", "mp4", "mp3"],
mode: "list",
});
const emit = defineEmits(["fileChange", "uploadSuccess", "uploadError"]);
const files = ref<File[]>([]);
const uploadProgress = ref<number[]>([]);
const overallProgress = ref<number>(0);
const errorMessage = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const acceptedFileTypes = computed(() => {
return props.allowedFileExtensions
.map((ext) => mimeTypesDictionary[ext.toLowerCase()] || "")
.filter((mimeType) => mimeType !== "")
.join(",");
});
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
errorMessage.value = null;
const selectedFiles = Array.from(target.files);
const validFiles = selectedFiles.filter((file) => validateFile(file));
files.value = validFiles;
emit("fileChange", validFiles);
}
};
const handleDrop = (event: DragEvent) => {
if (event.dataTransfer?.files) {
errorMessage.value = null;
const droppedFiles = Array.from(event.dataTransfer.files);
const validFiles = droppedFiles.filter((file) => validateFile(file));
files.value = validFiles;
emit("fileChange", validFiles);
}
};
const validateFile = (file: File): boolean => {
if (file.size > props.maxFileSize!) {
errorMessage.value = `文件 ${file.name} 超过最大限制 ${formatSize(
props.maxFileSize!
)}`;
return false;
}
const fileExtension = file.name.split(".").pop()?.toLowerCase();
if (!fileExtension || !props.allowedFileExtensions!.includes(fileExtension)) {
errorMessage.value = `文件 ${file.name} 类型不被允许`;
return false;
}
return true;
};
const triggerFileInput = () => {
if (fileInput.value) {
fileInput.value.click();
}
};
const uploadFiles = async () => {
if (!files.value.length) return;
uploadProgress.value = files.value.map(() => 0);
overallProgress.value = 0;
try {
for (let i = 0; i < files.value.length; i++) {
const formData = new FormData();
formData.append("file", files.value[i]);
const xhr = new XMLHttpRequest();
xhr.open("POST", props.uploadUrl, true);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
uploadProgress.value[i] = percentComplete;
updateOverallProgress();
}
};
xhr.onload = () => {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
uploadProgress.value[i] = 100;
updateOverallProgress();
emit("uploadSuccess", result);
} else {
throw new Error("上传失败");
}
};
xhr.onerror = () => {
throw new Error("上传失败");
};
xhr.send(formData);
}
} catch (error) {
errorMessage.value = error.message;
emit("uploadError", error);
}
};
const updateOverallProgress = () => {
const totalProgress = uploadProgress.value.reduce(
(sum, progress) => sum + progress,
0
);
overallProgress.value = totalProgress / files.value.length;
};
const removeFile = (index: number) => {
files.value.splice(index, 1);
uploadProgress.value.splice(index, 1);
updateOverallProgress();
emit("fileChange", files.value);
};
const formatSize = (size: number): string => {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`;
return `${(size / (1024 * 1024)).toFixed(2)} MB`;
};
const isImage = (file: File): boolean => {
return file.type.startsWith("image/");
};
const isVideo = (file: File): boolean => {
return file.type.startsWith("video/");
};
const filePreview = (file: File): string => {
return URL.createObjectURL(file);
};
</script>
<style scoped>
.file-upload {
display: flex;
flex-direction: column;
gap: 20px;
border: 2px dashed #ccc;
padding: 30px;
border-radius: 10px;
margin: 0 auto;
text-align: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.upload-area {
padding: 30px;
background-color: #e3f2fd;
border-radius: 10px;
cursor: pointer;
transition: background-color 0.3s;
}
.upload-area:hover {
background-color: #bbdefb;
}
.file-list {
margin-top: 5px;
}
.overall-progress-bar-container {
width: 100%;
background-color: #e0e0e0;
border-radius: 5px;
overflow: hidden; /* 确保发光效果不会溢出容器 */
margin-bottom: 10px;
padding: 2px; /* 适当增加内边距以显示发光效果 */
position: relative; /* 为了使用 ::before 伪元素 */
}
.overall-progress-bar {
height: 8px;
background-color: #4caf50;
width: 0%;
transition: width 0.3s;
border-radius: 5px; /* 使进度条的边缘圆润 */
position: relative; /* 为了使用 ::before 伪元素 */
box-shadow: 0 0 10px rgba(76, 175, 80, 0.7); /* 柔和的外发光效果 */
}
.file-list-mode .file-item {
display: flex;
flex-direction: column;
align-items: flex-start;
background-color: #fff;
padding: 10px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 10px;
width: 100%;
}
.file-item p {
margin: 0;
font-size: 14px;
}
.progress-bar-container {
width: 100%;
background-color: #e0e0e0;
border-radius: 5px;
overflow: hidden;
margin-top: 5px;
}
.progress-bar-container-preview {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background-color: rgba(224, 224, 224, 0.8);
border-radius: 0 0 5px 5px;
overflow: hidden;
}
.progress-bar {
height: 10px;
background-color: #76c7c0;
width: 0%;
transition: width 0.3s;
}
.remove-button {
margin-top: 10px;
padding: 5px 10px;
background-color: #ff5252;
color: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
}
.remove-button:hover {
background-color: #ff1744;
}
button {
padding: 10px 20px;
background-color: #42a5f5;
color: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
}
button:disabled {
background-color: #90caf9;
cursor: not-allowed;
}
button:hover:not(:disabled) {
background-color: #1e88e5;
}
.error-message {
color: red;
font-size: 14px;
}
.file-preview-mode {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.file-preview-item {
position: relative;
width: 130px;
height: 130px;
background-color: #f0f0f0;
border-radius: 5px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.file-preview-cover {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.file-preview-cover img,
.file-preview-cover video {
max-width: 100%;
max-height: 100%;
object-fit: cover;
}
.file-name {
font-size: 14px;
text-align: center;
padding: 5px;
}
.remove-icon {
position: absolute;
top: 5px;
right: 5px;
padding: 5px;
background-color: rgba(255, 82, 82, 0.8);
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
font-size: 12px;
transition: background-color 0.3s;
}
.remove-icon:hover {
background-color: rgba(255, 23, 68, 0.8);
}
</style>
使用示例
在您的主应用中使用该组件,并配置必要的属性和事件回调:
<template>
<div id="app">
<h1>文件上传示例</h1>
<FileUpload
mode="preview"
upload-url="/api/upload"
:multiple="true"
custom-class="custom-upload"
:max-file-size="1024 * 1024 * 1024"
:allowed-file-extensions="['jpeg', 'png', 'pdf', 'zip', 'jpg', 'mp4']"
@uploadSuccess="handleUploadSuccess"
@uploadError="handleUploadError"
@fileChange="handleFileChange"
/>
</div>
</template>
<script setup lang="ts">
const handleUploadSuccess = (result: any) => {
console.log("上传成功:", result);
};
const handleUploadError = (error: any) => {
console.error("上传失败:", error);
};
const handleFileChange = (files: File[]) => {
console.log("文件改变:", files);
};
</script>
<style>
.custom-upload {
border-color: #007bff;
max-width: 600px;
}
</style>
后端优化
后端部分的优化可以从以下几个方面入手:
- 抽象和模块化:将文件上传逻辑抽象成独立的类或函数,便于复用和维护。
- 配置管理:通过配置文件管理上传目录、文件大小限制等参数,增强灵活性。
我们可以通过以下步骤来实现这些优化:
1. 抽象化文件上传逻辑
首先,我们将文件上传逻辑抽象成一个服务类。
创建文件上传服务类
// server/services/fileUploadService.ts
import formidable, { File } from 'formidable';
import { IncomingMessage } from 'http';
import fs from 'fs';
import path from 'path';
interface FileUploadConfig {
uploadDir: string;
maxFileSize: number;
allowedFileTypes: string[];
}
class FileUploadService {
private config: FileUploadConfig;
constructor(config: FileUploadConfig) {
this.config = config;
}
async handleFileUpload(req: IncomingMessage): Promise<{ message: string; files: formidable.Files }> {
const form = formidable({
multiples: true,
maxFileSize: this.config.maxFileSize,
filter: ({ mimetype }) => this.config.allowedFileTypes.includes(mimetype || ''),
});
return new Promise((resolve, reject) => {
form.parse(req, (err, fields, files) => {
if (err) {
reject(err);
return;
}
if (!fs.existsSync(this.config.uploadDir)) {
fs.mkdirSync(this.config.uploadDir);
}
const processFile = (file: File) => {
const filePath = path.join(this.config.uploadDir, file.originalFilename as string);
try {
fs.copyFileSync(file.filepath, filePath);
fs.unlinkSync(file.filepath);
} catch (copyError) {
reject(copyError);
return;
}
};
if (Array.isArray(files.file)) {
// 处理多个文件
files.file.forEach(file => processFile(file));
} else if (files.file) {
// 处理单个文件
processFile(files.file as File);
} else {
reject(new Error('不允许上传的文件类型'));
return;
}
resolve({ message: '文件上传成功', files });
});
});
}
}
export default FileUploadService;
2. 配置管理
创建一个配置文件来管理上传相关的配置。
创建配置文件
// server/config/fileUploadConfig.ts
import path from 'path';
const fileUploadConfig = {
uploadDir: path.join(process.cwd(), 'uploads'),
maxFileSize: 10 * 1024 * 1024, // 10 MB
allowedFileTypes: [
'image/jpeg',
'image/png',
'application/pdf',
'video/mp4',
'audio/mp3',
// 其他允许的文件类型
],
};
export default fileUploadConfig;
3. 整合优化后的代码
整合上述优化后的代码到 API 路由中。
更新 API 路由
// server/api/upload.post.ts
import FileUploadService from '../services/fileUploadService';
import fileUploadConfig from '../config/fileUploadConfig';
const fileUploadService = new FileUploadService(fileUploadConfig);
export default defineEventHandler(async (event) => {
try {
const result = await fileUploadService.handleFileUpload(event.node.req);
return result;
} catch (err: any) {
throw createError({ statusCode: 500, message: err.message });
}
});
启动项目
确保所有依赖项已安装,启动项目:
npm run dev
访问地址 http://localhost:3000/
测试文件上传功能。
通过这些步骤,你可以将文件上传逻辑抽象成独立的类,并通过配置文件管理上传相关的参数。这不仅增强了代码的可维护性和复用性,还使得配置更加灵活。
总结
本文详细介绍了如何在 Vue 3 和 Nuxt 3 中实现一个文件上传组件,并进行了多方面的优化。
首先,我们明确了文件上传组件的基本需求,包括文件选择、预览功能、上传功能、错误处理和 UI 友好性。随后,通过引入 Vue 3 的 Composition API、响应式数据、生命周期钩子和事件处理等核心概念,逐步实现了一个基础的文件上传组件。
在客户端实现部分,我们展示了如何创建 FileUpload.vue
组件,处理文件选择和上传,并通过 fetch
进行异步请求来上传文件。同时,我们也讨论了如何在 Nuxt 3 中配置和使用该组件。
在服务端实现部分,我们使用 formidable
库处理文件上传,创建了一个 API 路由来接收和保存上传的文件。通过详细的代码示例,展示了如何处理多文件上传、创建上传目录以及处理文件保存逻辑。
为了提升组件的可用性和用户体验,我们进一步优化了组件和服务。优化内容包括:
- 前端优化:支持多种文件类型、多文件上传、拖放上传、进度条显示、取消上传、错误处理和用户提示、文件预览和文件管理等功能。
- 后端优化:将文件上传逻辑抽象为服务类,通过配置文件管理上传相关参数,增强代码的可维护性和复用性。
通过这些优化,文件上传组件变得更加灵活、易用,并且具备更好的用户体验和安全性。
本文不仅提供了详细的代码实现,还通过需求分析和功能设计,帮助读者理解各个功能点的实现逻辑和技术细节。希望通过本文的讲解,读者能够掌握在 Vue 3 和 Nuxt 3 中实现文件上传组件的基本方法,并能够根据实际需求进行定制和优化。