携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第24天,点击查看活动详情
前端开发中,常见的文件上传场景有:
- 单个文件上传
- 多个文件上传
- 目录上传
- 拖拽上传
- 剪贴板上传
- 大文件分块上传
本篇我们来讲一下目录上传文件的场景
往期回顾:
目录上传
对前文代码的重构
在实现之前,我们先对上节我们的代码进行一个重构,能够明显发现单文件上传和多文件上传组件的差别仅仅在于input标签的multiple属性和对input事件的处理而已,那么我们可以把它们抽离成一个组件,在组件内部去处理单文件和多文件上传的逻辑
<script setup lang="ts">
import { multipleUploadFile, singleUploadFile } from '~/api'
interface Props {
// 是否多文件上传
multiple?: boolean
// 上传文件的 MIME type
mime?: string
}
// withDefaults 可以解决类型声明 props 类型的时候无法声明默认值的问题
withDefaults(defineProps<Props>(), {
// 默认单文件上传
multiple: false,
// 默认只支持上传图片
mime: 'image/*',
})
// 文件上传表单元素
const uploadFileRef = ref<HTMLInputElement>()
const handleInputFileConfirm = () => {
// 没选择文件则不调用接口
if (!uploadFileRef.value?.files?.length) return
// 根据是否有 multiple 属性来决定调用哪个接口
if (uploadFileRef.value.multiple) {
const files = uploadFileRef.value.files
multipleUploadFile('file', files)
} else {
// 获取选择的第一个文件并调用接口上传
const file = uploadFileRef.value.files[0]
singleUploadFile('file', file)
}
}
</script>
<template>
<!-- 文件上传容器 -->
<div>
<input
display-none
:multiple="multiple"
:accept="mime"
type="file"
ref="uploadFileRef"
@input="handleInputFileConfirm"
/>
<button btn @click="uploadFileRef!.click()">上传</button>
</div>
</template>
现在我们的组件默认就是单文件,只支持图片文件上传了,那么接下来要做的就是扩展这个组件,让其支持目录上传的功能
了解 webkitdirectory
首先看看mdn对它的介绍
通过input标签上的该属性,我们就能够支持目录上传了,那么我们就开始给我们的组件进行扩展吧
扩展组件支持目录上传
体验webkitdirectory的特性
首先给组件加上一个webkitdirectory的prop,然后套用在input标签上
interface Props {
// 是否要开启目录上传 -- 非标准特性 有兼容性问题
webkitdirectory?: boolean
}
// withDefaults 可以解决类型声明 props 类型的时候无法声明默认值的问题
withDefaults(defineProps<Props>(), {
// 默认不开启目录上传
webkitdirectory: false,
})
<input
display-none
:multiple="multiple"
:accept="mime"
:webkitdirectory="webkitdirectory"
type="file"
ref="uploadFileRef"
@input="handleInputFileConfirm"
/>
点击上传后,这次会发现只允许选择目录了,并且选好后会弹出一个确认框
并且打印一下input标签的files属性内容如下
可以看到,每个文件都有自己的webkitRelativePath属性,其值为文件相对于上传的目录的相对路径
前端上传接口formData修改
那么现在我们要做的就是修改前端上传接口的formData,让每个文件的文件名是这里的webkitRelativePath,这样才能方便服务器根据目录结构进行保存
/**
* @description 目录上传
*/
export const uploadDirectory = (fieldName: string, files: FileList) => {
const formData = new FormData()
const fileArr = Array.from(files)
fileArr.forEach((file, idx) => {
formData.append(
fieldName,
fileArr[idx],
fileArr[idx].webkitRelativePath.replace(/\//g, '@'),
)
})
request.post(FileUploadAPI.MULTIPLE_UPLOAD, formData)
}
后端multer修改
接下来我们应当修改后端的multer的storage,让其读取路径并根据目录结构去创建目录和存放文件
但是这里会有一个大坑,我们先打印一下后端storage接收到的文件名看看
export const storage = multer.diskStorage({
destination: (req, file, cb) => {
console.log(file.originalname)
if (!existsSync(UPLOADED_DIR)) {
// 确保保存目录存在
mkdirSync(UPLOADED_DIR)
}
// 指定上传的文件的保存路径
cb(null, UPLOADED_DIR)
},
filename: (req, file, cb) => {
// 设置保存的文件名命名格式 -- 时间-文件名
cb(null, getUploadedFileName(file))
},
})
后端接收到的文件名居然把路径去掉了,只保留了文件名,可是前端我们命名已经传递的是webkitRelativePath这个相对路径作为文件名了呀,那看来是后端会对/分隔符做过滤,只保留最终文件名
所以我们可以在前端修改一下,将路径分隔符/改成一个别的字符,比如@,然后后端再尝试读取文件名看看
export const uploadDirectory = (fieldName: string, files: FileList) => {
const formData = new FormData()
const fileArr = Array.from(files)
fileArr.forEach((file, idx) => {
formData.append(
fieldName,
fileArr[idx],
fileArr[idx].webkitRelativePath.replace(/\//g, '@'),
)
})
request.post(FileUploadAPI.MULTIPLE_UPLOAD, formData)
}
可以看到这样就能够读取了,那么接下来就好办了,把@还原回/然后保存即可
// 处理目录上传
export const directoryStorage = multer.diskStorage({
destination: async (req, file, cb) => {
// 将 @ 分隔的相对路径转为操作系统可识别的路径
const relativePath = file.originalname.replace(/@/g, path.sep)
// 拼接出目录的保存路径
const directoryPath = path.join(
UPLOADED_DIR,
relativePath.substring(0, relativePath.lastIndexOf(path.sep)),
)
// 确保保存目录存在
await ensureDir(directoryPath)
// 指定上传的文件的保存路径
cb(null, directoryPath)
},
filename: (req, file, cb) => {
const items = file.originalname.split('@')
// 设置保存的文件名命名格式 -- 时间-文件名
cb(null, `${Date.now()}-${items[items.length - 1]}`)
},
})
再利用这个storage创建一个处理目录上传的multer对象
// 创建负责处理目录上传 FormData 的 multer 对象
export const uploadDirectoryMulter = multer({ storage: directoryStorage })
然后创建一个接口,使用这个multer中间件去处理文件上传
export const directoryUpload: RegisterRoute = router => {
router.post(
API_ROUTES.DIRECTORY_UPLOAD,
async (ctx, next) => {
try {
// 交给多文件上传 multer 去处理
await next()
ctx.body = generateUniversalResponseData(
0,
'upload successfully!',
null,
)
} catch (e) {
ctx.body = generateUniversalResponseData(
500,
`[UPLOAD_FILE_ERROR]: ${e}`,
null,
)
}
},
// 使用 fields 对文件处理 可以支持多文件
// name 要和 formData 的 fieldName 对应
uploadDirectoryMulter.fields([{ name: 'file' }]),
)
}
接下来前端上传一个目录试试
然后就能看到服务端出现了对应目录及其文件
至此,目录上传的功能也就实现完毕了
前端接口封装重构
目录上传的逻辑也是和单文件、多文件的处理差不多的,只是在于对formData的处理不同而已,可以给baseUploadFile新增一个uploadType参数,用于标识应调用哪个文件上传处理逻辑
import { FileUploadAPI } from '~/constants'
import { request } from '~/utils'
type UploadType = 'single' | 'multiple' | 'directory'
const baseUploadFile = (
fieldName: string,
fileOrFiles: File | FileList,
uploadType: UploadType,
) => {
const formData = new FormData()
let uploadUrl = ''
switch (uploadType) {
case 'single':
// 单文件上传
formData.set(fieldName, fileOrFiles as File)
uploadUrl = FileUploadAPI.SINGLE_UPLOAD
break
case 'multiple':
// 多文件上传
// 将 files 类数组转成数组方便遍历
Array.from(fileOrFiles as FileList).forEach(file => {
formData.append(fieldName, file)
})
uploadUrl = FileUploadAPI.MULTIPLE_UPLOAD
break
case 'directory':
Array.from(fileOrFiles as FileList).forEach(file => {
formData.append(
fieldName,
file,
file.webkitRelativePath.replace(/\//g, '@'),
)
})
uploadUrl = FileUploadAPI.DIRECTORY_UPLOAD
break
}
uploadUrl !== '' &&
request.post(uploadUrl, formData, {
// 计算上传进度
onUploadProgress: (progressEvent: ProgressEvent) => {
const uploadedPercent = Math.round(
(progressEvent.loaded / progressEvent.total) * 100,
)
console.log(uploadedPercent)
},
})
}
/**
* @description 上传文件 -- 会转成 formData 上传
* @param fieldName formData 的 字段名 不指定则默认为 file
* @param file input 中选择的文件
*/
export const singleUploadFile = (fieldName: string, file: File) => {
baseUploadFile(fieldName, file, 'single')
console.log('调用单文件上传API')
}
/**
* @description 多文件上传
* @param fieldName formData 的字段名 不指定则默认为 file
* @param files input 中选择的多个文件
*/
export const multipleUploadFile = (fieldName: string, files: FileList) => {
baseUploadFile(fieldName, files, 'multiple')
console.log('调用多文件上传API')
}
/**
* @description 目录上传
*/
export const uploadDirectory = (fieldName: string, files: FileList) => {
baseUploadFile(fieldName, files, 'directory')
console.log('调用目录上传API')
}