🔥前端文件上传常见场景 -- 目录上传

291 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 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对它的介绍

image.png

通过input标签上的该属性,我们就能够支持目录上传了,那么我们就开始给我们的组件进行扩展吧

扩展组件支持目录上传

体验webkitdirectory的特性

首先给组件加上一个webkitdirectoryprop,然后套用在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"
/>

点击上传后,这次会发现只允许选择目录了,并且选好后会弹出一个确认框

image.png

并且打印一下input标签的files属性内容如下

image.png

可以看到,每个文件都有自己的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修改

接下来我们应当修改后端的multerstorage,让其读取路径并根据目录结构去创建目录和存放文件

但是这里会有一个大坑,我们先打印一下后端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))
  },
})

image.png

后端接收到的文件名居然把路径去掉了,只保留了文件名,可是前端我们命名已经传递的是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)
}

image.png

可以看到这样就能够读取了,那么接下来就好办了,把@还原回/然后保存即可

// 处理目录上传
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' }]),
  )
}

接下来前端上传一个目录试试

image.png

然后就能看到服务端出现了对应目录及其文件

image.png

至此,目录上传的功能也就实现完毕了

前端接口封装重构

目录上传的逻辑也是和单文件、多文件的处理差不多的,只是在于对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')
}