文件上传 = 拖拽 + 多文件 + 文件夹

从前往后看都是努力,从后往前看都是命运

大家好,我是柒八九 。一个专注于前端开发技术/RustAI应用知识分享Coder

前言

今天呢,和大家聊点耳熟能详的东西。文件上传

讲到这里,大家不要嗤之以鼻,认为这不是分分钟就用组件库实现的吗?确实,现在很多成熟的组件库都提供了文件上传的功能,但是呢,它们只提供部分的功能。比方说,

  1. 执行{多}文件上传
  2. 拖拽上传
  3. 针对文件夹内容上传
  4. {多}文件上传 + 文件夹上传

但是呢,这些框架只是提供了上面的部分功能,而不是将上面的功能全部一网打尽。

我们来看一下Antd的文件上传的功能。

Antd_Upload能实现上述功能,但是不能将上面所有功能糅合到一起。因为多文件上传文件夹上传它们实现原理是不同的。(Arco_Upload也是如此)。

所以,今天我们就来自己手搓一个文件上传。它所拥有的能力如下

  1. 支持{多}文件上传
  2. 拖拽上传
  3. 文件内容上传
  4. {多}文件上传 + 文件夹上传

也就是说,我们的文件上传可以上传你本地的任何文件。(除了系统文件,这个我们会提到)。

实现效果如下所示:

有人会说,人家都给你提供了组件,你为啥不用,你这不是重复造轮子吗?其实还真不是,之所以会出现这个问题,是因为现有的解决方案不支持我们的需求,我们才会大费周折的去找解决方案。

最后但同样重要的是:本文会提供一种解决方案,并且也会实现上述的所有功能,但是到后面文件上传到服务器的部分,这里就不做介绍了。也就是说,我们最后,通过操作能获取到Files信息,就认为这个组件封装成功了。

好了,天不早了,干点正事哇。

我们能所学到的知识点

  1. 项目初始化
  2. 拖拽功能
  3. 处理input
  4. 处理文件&回调
  5. 唤起弹窗

1. 项目初始化

因为,我们在做项目展示的时候,需要用到一些组件库和工具库,所以我们就抛弃vite/cra了,我们这里就直接使用我们的f_cli直接构建一个前端项目。

shell 复制代码
f_cli create upload_demo

如果你是一个老粉,你就知道,我们的f_cli是支持组件库选择的。在项目初始化时,我们可以选择组件库。

针对此次的demo我们就选择antd。然后,其他配置都按照你的心意来就完事了。

一顿操作之后,我们就有了一个功能完备的前端项目。

随后,我们可以执行yarn dev进行前端项目开发了。

也就说,我们下面的代码讲解和项目组织都是基于f_cli生成项目的基础上。


2. 拖拽功能

其实,针对拖拽功能的处理,我们有很多解决方案。

  1. 利用原生特性- 在DOM原生上新增draggable属性,然后监听dragstart/dragend等。可行吗,必须可行。但是,你需要处理和监听的事情很多。

    • 如果对这块还有些陌生,可以参考MDN_drag对这块的解释
  2. 利用库,有很多业界比较出名的拖拽库能处理我们的问题,使用库的好处就是我们通过简单的API能够获取我们想要的数据和要实现的功能操作。(所以,我们就是用第三方库实现拖拽功能)下面就列举几个比较常见的拖拽库。

通过npm_trend得知,react-dropzone独占鳌头。所以,我们就选用react-dropzone作为我们的拖拽解决方案。

拖拽组件

既然,材料和食谱都已经确定,那我们就需要烹饪我们的膳食了。

现在,我们把我们的上传场景再做一次限定,我们可以将我们整个页面作为我们的拖拽区域,这样我们就不必拘泥于特定组件了。(当然,这个区域是可以变更的)。

那么,我们为这个组件起一个霸气侧漏的名字 -- FullScreenDropZone。看这名字多气派,FullScreen,它支持全屏范围内拖拽。也就是说,不管你把文件拖拽到页面的哪个位置,都可以触发文件上传功能。

"全屏"? 按照SPA的尿性,那岂不是需要在一个路由的组件的根部。没错,它就是这样的。

组件挂载位置

我们先把内部代码扔下,我们先来讲讲FullScreenDropZone是从哪里被调用的。

上面的代码中,只是展示了FullScreenDropZone在何处调用,不是最终的代码。我们可以看到,我们就是把它挂载到了某个路由下的根部。并且,该页面的子组件用children先展示替代。当然,我们也可以把children的逻辑留下来,然后将组件在Router中配置。

为了大家做验证,把代码贴到了下面

jsx 复制代码
import React from "react";
import FullScreenDropZone from "@/components/FullScreenDropZone";
import { useDropzone } from "react-dropzone";

const Upload: React.FC<React.PropsWithChildren> = ({ children }) => {
    const {
        getRootProps: getDragAndDropRootProps,
        getInputProps: getDragAndDropInputProps,
        acceptedFiles: dragAndDropFiles,
    } = useDropzone({
        noClick: true,
        noKeyboard: true,
        disabled: false,
    });
    
  return (
      <FullScreenDropZone
            getDragAndDropRootProps={getDragAndDropRootProps}
        >   
          {children}
        </FullScreenDropZone>
  );
};

export default Upload;

上面代码中,还有一点我们需要提前说明下,我们不是选中了react-dropzone作为我们的拖拽方案了吗。

我们使用useDropzone来收集拖拽过程中所产生的数据信息。对于更具体的参数,可以参考react-dropzone_api

组件内部逻辑

从之前的代码中我们得知,FullScreenDropZone接收了一个从useDropzone中返回的属性getRootProps(我们为其取了一个别名getDragAndDropRootProps)

见名知意,该属性是为了获取在根元素上设置的属性和方法的。这是react-dropzone的语法,这里也不在过多解释。

jsx 复制代码
type Props = React.PropsWithChildren<{
    getDragAndDropRootProps: any;
}>;

export default function FullScreenDropZone(props: Props) {
  const [isDragActive, setIsDragActive] = useState(false);
  const onDragEnter = () => setIsDragActive(true);
  const onDragLeave = () => setIsDragActive(false);

  useEffect(() => {
      window.addEventListener("keydown", (event) => {
          if (event.code === "Escape") {
              onDragLeave();
          }
      });
  }, []);

  return (
      <DropDiv
          {...props.getDragAndDropRootProps({
              onDragEnter,
          })}
      >
          {isDragActive && (
              <Overlay 
                onDrop={onDragLeave} 
                onDragLeave={onDragLeave}
              >
                  <CloseButtonWrapper onClick={onDragLeave}>
                      x
                  </CloseButtonWrapper>
                  拖拽文件以上传
              </Overlay>
          )}
          {props.children}
      </DropDiv>
  );
}

上面的代码大致分为三部分

  1. 定义了状态(isDragActive)和方法
  2. useEffect中监听了keydownEscape
  3. return定义了布局展示逻辑

针对第一部分和第二部分,一目了然,这里就不再赘述。

我们,来简单解释一下第三部分。首先,映入眼帘的是一堆我们之前从没讲过的组件DropDiv/Overlay/CloseButtonWrapper

其实嘛,这就是一堆普通的div原生,只不过我们使用了styled-components为其添加了一些样式。(对于如何styled-components的使用,我们前几天在styled-components不完全手册有过解释)

这里我们就直接把它们的代码贴到下面。

jsx 复制代码
import { styled } from "styled-components";
const CloseButtonWrapper = styled("div")`
    position: absolute;
    top: 10px;
    right: 10px;
    cursor: pointer;
`;
const DropDiv = styled("div")`
    flex: 1;
    display: flex;
    flex-direction: column;
`;
const Overlay = styled("div")`
    border-width: 8px;
    left: 0;
    top: 0;
    outline: none;
    transition: border 0.24s ease-in-out;
    height: 100%;
    width: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    color: #fff;
    font-size: 24px;
    font-weight: 900;
    text-align: center;
    position: absolute;
    border-color: #51cd7c;
    border-style: solid;
    background: rgba(0, 0, 0, 0.9);
    z-index: 3000;
`;

然后,剩余的逻辑呢,就是在组件的顶部(DropDiv),接收调用处传来的getDragAndDropRootProps并且将内部方法onDragEnter配置到内部。

随后,就是基于isDragActive判断Overlay的显示和隐藏,从上面代码中我们可以得知,Overlay就是当页面处于拖拽状态时,出现的蒙层。其中还有一个小细节就是,当我们在拖拽过程中想终止上传,我们可以将文件拖拽到CloseButtonWrapper(页面右上角),然后就会触发类似关闭的效果。

当然,因为我们的FullScreenDropZone是在页面的顶层,为了体现更好的可移植性和封装性。当我们想为一个页面或者页面部分区域做拖拽处理时候,我们就可以将其用FullScreenDropZone包裹。所以,我们在FullScreenDropZone中有children的处理。

到这里,看起来我们拖拽功能已经完事了,其实这只是完成了一部分。

查看react-dropzone的使用方式,其实我们还缺少input的处理。用于接收getInputProps

但是,在上面代码中我们丝毫没看到关于inputgetInputProps的处理。只是用children将子组件进行了展示。

对咯,我们将input放置到了children中了。为什么会这么做呢,且看下面的分解。


3. 处理input

如果大家用原生写过上传,那势必就逃不过input的操作。

我们从MDN_Input_File可以窥探一二。

如上所示,我们可以

  • <input/>添加type="file"属性,就可以实现一个简单的文件上传的功能。
  • 如果要实现多文件上传,可以新增multiple属性。
  • 还可以设置accept来指定上传的文件格式

如果我们要实现文件夹上传,我们可以通过设置webkitdirectory

但是,使用webkitdirectory有兼容性问题。这块大家需要注意。

结合,在第二节中我们使用react-dropzone处理文件拖拽时,也需要一个<input/>接收返回的getInputProps属性。(<input {...getInputProps()} />)

UploadInputs

我们可以将上面三种<input/>做统一处理。也就是在这个组件中我们分别接收

  1. getDragAndDropInputProps 处理拖拽的配置信息
  2. getFileSelectorInputProps处理{多}文件上传的配置信息
  3. getFolderSelectorInputProps处理文件夹上传的配置信息
jsx 复制代码
export default function UploadSelectorInputs({
    getDragAndDropInputProps,
    getFileSelectorInputProps,
    getFolderSelectorInputProps,
}) {
  return (
      <>
        <input {...getDragAndDropInputProps()} />
        <input {...getFileSelectorInputProps()} />
        <input {...getFolderSelectorInputProps()} />
      </>
  );
}

然后我们在指定的组件中进行组件的调用,这个地方就是我们之前调用<FullScreenDropZone/>的地方,也就是页面根路径(pages/Upload)

从上面截图中我们看到,我们的UploadInputs消化了useDropzone返回的getInputProps属性了,但是其余的属性没有地方生成(用红框框起来的部分)

也就说,我们现在要在一个地方处理常规文件{夹}上传的属性定义。在这里我们定义一个Hook来执行这个操作。

useFileInput

我们在src/hook文件夹下新建一个useFileInput来执行上述操作。

jsx 复制代码
import { useCallback, useRef, useState } from "react";

export interface FileWithPath extends File {
    readonly path?: string;
}

export default function useFileInput({ directory }: { directory?: boolean }) {
    const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
    const inputRef = useRef<HTMLInputElement>();

    const openSelectorDialog = useCallback(() => {
        if (inputRef.current) {
            inputRef.current.value = null;
            inputRef.current.click();
        }
    }, []);

    const handleChange: React.ChangeEventHandler<HTMLInputElement> = async (
        event,
    ) => {
        if (!!event.target && !!event.target.files) {
            const files = [...event.target.files].map((file) =>
                toFileWithPath(file),
            );
            setSelectedFiles(files);
        }
    };

    const getInputProps = useCallback(
        () => ({
            type: "file",
            multiple: true,
            style: { display: "none" },
            ...(directory ? { directory: "", webkitdirectory: "" } : {}),
            ref: inputRef,
            onChange: handleChange,
        }),
        [],
    );

    return {
        getInputProps,
        open: openSelectorDialog,
        selectedFiles: selectedFiles,
    };
}

上面就是一个功能的封装,按照返回值来看,其中有三个重要的功能点

  1. getInputProps:返回input中的各个属性,其中根据directory属性值来区分是否是文件夹上传。
  2. open: 定义了一个方法,用于在外部执行文件获取的弹窗
  3. selectedFiles: 收集用户选择的文件信息

然后,我们就可以在指定地方(src/Upload)中执行了。

这样,我们就将三个input的属性都配置好了。它们分别能处理useDropzone/useFileInput(非文件夹)/useFileInput(文件夹)返回的inputPorps

也就是,此时我们实现了三种能力的文件收集功能。只不过,文件拖拽我们可以通过拖拽进行处理。而文件{夹}上传需要一些操作来触发其功能。

从上面截图中我们看到(绿色部分),有两类信息,我们还未处理

  1. xxxFiles:拖拽或者选中的文件信息
  2. open: 针对文件{夹}上传的触发回调

我们还需要一个组件用于接收刚才选择的文件信息和触发文件{夹}上传的操作。


4. 处理文件&回调

我们先来看看该组件是如何调用的。

如图所示,我们在Uploader中消费了webFileSelectorFiles/webFolderSelectorFiles/dragAndDropFiles/openFileSelector/openFolderSelector

其中uploadTypeSelectorView用于控制弹窗的显隐,这个我们稍后会有介绍。

先来看Uploader

Uploader

如图所示,上面有几部分重要的函数。

  1. 定义了一个state/ref
  2. 监听props.xxFiles
  3. 处理上传的逻辑
  4. 监听webFiles
  5. 文件{夹}弹窗

我们就挑几个比较重要的部分来解析

监听props.xxFiles

jsx 复制代码
enum PICKED_UPLOAD_TYPE {
    FILES = "files",
    FOLDERS = "folders",
}

export default function Uploader(props: Props) {
  const [webFiles, setWebFiles] = useState([]);
  const pickedUploadType = useRef<PICKED_UPLOAD_TYPE>(null);
  const isDragAndDrop = useRef(false);
  
  useEffect(() => {
      if (
          pickedUploadType.current === PICKED_UPLOAD_TYPE.FOLDERS &&
          props.webFolderSelectorFiles?.length > 0
      ) {
          setWebFiles(props.webFolderSelectorFiles);
      } else if (
          pickedUploadType.current === PICKED_UPLOAD_TYPE.FILES &&
          props.webFileSelectorFiles?.length > 0
      ) {
          setWebFiles(props.webFileSelectorFiles);
      } else if (props.dragAndDropFiles?.length > 0) {
          isDragAndDrop.current = true;
          setWebFiles(props.dragAndDropFiles);
      }
  },  
  [
      props.dragAndDropFiles,
      props.webFileSelectorFiles,
      props.webFolderSelectorFiles,
  ]);

}

在这个effect中我们监听dragAndDropFiles/webFileSelectorFiles/webFolderSelectorFiles来根据pickedUploadType的类型来获取指定上传文件的类型,并且通过setWebFiles来更新webFiles的值。

上面的代码就是,不论是你拖拽还是文件{夹}上传,都会被存放到webFilesstate变量中。

监听webFiles

上面的操作,我们把文件放置到了webFiles中,随后我们就可以进行对应文件的处理了。

ini 复制代码
useEffect(() => {
  if (
      webFiles?.length > 0
  ) {
      if (webFiles?.length > 0) {
          toUploadFiles.current = webFiles;
          setWebFiles([]);
      } 

      toUploadFiles.current = filterOutSystemFiles(toUploadFiles.current);
      if (toUploadFiles.current.length === 0) {
          return;
      }
      let files = toUploadFiles.current;
      // if (files.length > 2) { 
      //   files = files.slice(0, 2);
      //     message.info("超出指定的数量,已经智能截取到x条")
      // }
      console.log(files);
  }
}, [webFiles]);

由于,我们在进行文件夹上传和拖拽过程中,会将整个文件进行收集,此时会有一些系统文件(以.开头),这些文件并不是我们想要的,所以我们需要将其剔除。

这里我们使用了一个工具方法。filterOutSystemFiles

jsx 复制代码
export function filterOutSystemFiles(files: File[]) {
    if (files[0] instanceof File) {
        const browserFiles = files as File[];
        return browserFiles.filter((file) => {
            return !isSystemFile(file);
        });
    } 
}

export function isSystemFile(file: File) {
    return file.name.startsWith(".");
}

在进行文件处理后,我们就可以基于需求进行文件的操作。(这里我们只是做了简单的log)。

回调的处理

前面讲过,文件拖拽就是文件收集的过程,这里不需要额外的操作,但是对于文件{夹}来将我们需要唤起对应的弹窗。

所以,我们还需要处理对应弹窗的处理。

jsx 复制代码
const handleWebUpload = async (type: PICKED_UPLOAD_TYPE) => {
    pickedUploadType.current = type;
    if (type === PICKED_UPLOAD_TYPE.FILES) {
        props.showUploadFilesDialog();
    } else if (type === PICKED_UPLOAD_TYPE.FOLDERS) {
        props.showUploadDirsDialog();
    }
};


const handleUpload = (type) => () => {
    handleWebUpload(type);
};

const handleFileUpload = handleUpload(PICKED_UPLOAD_TYPE.FILES);
const handleFolderUpload = handleUpload(PICKED_UPLOAD_TYPE.FOLDERS);

这里多出了两个handleFileUpload/handleFolderUpload的回调。

这是我们要传人对应组件执行相关的操作。

文件{夹}弹窗

在这里就是我们消费刚才定义的几个回调的。

jsx 复制代码
import {Button, Modal } from 'antd'
interface Iprops {
    onClose: () => void;
    show: boolean;
    uploadFiles: () => void;
    uploadFolders: () => void;
}
export default function UploadTypeSelector({
    onClose,
    show,
    uploadFiles,
    uploadFolders,
}: Iprops) {

    
return (
    <Modal open={show} onCancel={onClose}>
        <Button onClick={uploadFiles}>文件上传</Button>
        <Button onClick={uploadFolders}>文件夹上传</Button>
    </Modal> 

);
}

5. 唤起弹窗

上面不是说过吗,针对文件{夹}上传,我们需要指派一个操作来唤起对应的文件上传弹窗。

这里,我们选择在页面中新增一个button来唤起一个弹窗,并且根据在弹窗中选择对应的上传类型来进行文件处理。

从上面的我们得知几件事情

  1. Uploader中接收了uploadTypeSelectorView相关属性
  2. UploadButton接收一个回调用于触发uploadTypeSelectorViewtrue

UploadButton

其实,这个组件很简单,就是用于更新uploadTypeSelectorViewtrue

jsx 复制代码
import { Button, ButtonProps } from "antd";
import { styled } from "styled-components";

interface Iprops {
    openUploader: () => void;
    text?: string;
    color?: ButtonProps["color"];
}
function UploadButton({
    openUploader,
    text,
    color,
}: Iprops) {
    const onClickHandler = () => openUploader();

    return (
        <Wrapper
        >
            <Button
                onClick={onClickHandler}
                className="desktop-button"
                color={color ?? "secondary"}
            >
                {text ?? "上传"}
            </Button>

        </Wrapper>
    );
}

export default UploadButton;

弹窗的处理

UploadButton中我们就有一个核心逻辑就是将uploadTypeSelectorView变为true。而uploadTypeSelectorView就是表示弹窗是否显隐的标志。

对应的页面如下:(请忽略丑陋的样式)

处理这块的逻辑是在Uploader中,就是我们之前介绍过的。

这里我们直接来看弹窗中的代码。

jsx 复制代码
import {Button, Modal } from 'antd'
interface Iprops {
    onClose: () => void;
    show: boolean;
    uploadFiles: () => void;
    uploadFolders: () => void;
}
export default function UploadTypeSelector({
    onClose,
    show,
    uploadFiles,
    uploadFolders,
}: Iprops) {

    
  return (
      <Modal open={show} onCancel={onClose}>
          <Button onClick={uploadFiles}>文件上传</Button>
          <Button onClick={uploadFolders}>文件夹上传</Button>
      </Modal> 

  );
}

其实就是渲染了两个button,然后点击不同的button来显示不同的文件上传组件。


6. TODO

其实上面的代码都是提供了一个最基本的上传操作。有些功能还是可以完善的。例如

  1. 约定文件类型
  2. 配置上传文件的大小
  3. 异步处理
  4. 在文件上传过程中,再次上传的逻辑(是失效还是进队列)
  5. 。。。。。

后记

分享是一种态度

全文完,既然看到这里了,如果觉得不错,随手点个赞和"在看"吧。

相关推荐
Wiktok1 小时前
pureadmin的动态路由和静态路由
前端·vue3·pureadmin
devii661 小时前
html.
前端
掘金安东尼1 小时前
为什么浏览器要限制 JavaScript 定时器?
前端·javascript·github
学前端搞口饭吃1 小时前
react context如何使用
前端·javascript·react.js
GDAL1 小时前
为什么Cesium不使用vue或者react,而是 保留 Knockout
前端·vue.js·react.js
IT_陈寒1 小时前
《Java 21新特性实战:5个必学的性能优化技巧让你的应用快30%》
前端·人工智能·后端
小谭鸡米花2 小时前
uni小程序中使用Echarts图表
前端·小程序·echarts
芜青2 小时前
【Vue2手录11】Vue脚手架(@vue_cli)详解(环境搭建+项目开发示例)
前端·javascript·vue.js
a别念m2 小时前
前端架构-CSR、SSR 和 SSG
前端·架构·前端框架
BillKu7 小时前
Vue3 + Element-Plus 抽屉关闭按钮居中
前端·javascript·vue.js