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

大家好,我是柒八九。一个专注于前端开发技术/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直接构建一个前端项目。

f_cli create upload_demo

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

选择UI库

针对此次的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中配置。

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

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的语法,这里也不在过多解释。

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不完全手册有过解释)

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

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处理文件夹上传的配置信息
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来执行上述操作。

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

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中,随后我们就可以进行对应文件的处理了。

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

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)。

回调的处理

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

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

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的回调。

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

文件{夹}弹窗

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

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

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中,就是我们之前介绍过的。

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

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. 。。。。。

后记

分享是一种态度

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

本文由mdnice多平台发布


前端柒八九
18 声望3 粉丝