从前往后看都是努力,从后往前看都是命运
大家好,我是柒八九 。一个专注于前端开发技术/Rust
及AI
应用知识分享 的Coder
前言
今天呢,和大家聊点耳熟能详的东西。文件上传。
讲到这里,大家不要嗤之以鼻,认为这不是分分钟就用组件库实现的吗?确实,现在很多成熟的组件库都提供了文件上传的功能,但是呢,它们只提供部分的功能。比方说,
- 执行{多}文件上传
- 拖拽上传
- 针对文件夹内容上传
- {多}文件上传 + 文件夹上传
但是呢,这些框架只是提供了上面的部分功能,而不是将上面的功能全部一网打尽。
我们来看一下Antd
的文件上传的功能。
Antd_Upload能实现上述功能,但是不能将上面所有功能糅合到一起。因为多文件上传
和文件夹上传
它们实现原理是不同的。(Arco_Upload也是如此)。
所以,今天我们就来自己手搓一个文件上传。它所拥有的能力如下
- 支持{多}文件上传
- 拖拽上传
- 文件内容上传
- {多}文件上传 + 文件夹上传
也就是说,我们的文件上传可以上传你本地的任何文件。(除了系统文件
,这个我们会提到)。
实现效果如下所示:
有人会说,人家都给你提供了组件,你为啥不用,你这不是重复造轮子吗?其实还真不是,之所以会出现这个问题,是因为现有的解决方案不支持我们的需求,我们才会大费周折的去找解决方案。
最后但同样重要的是:本文会提供一种解决方案,并且也会实现上述的所有功能,但是到后面文件上传到服务器的部分,这里就不做介绍了。也就是说,我们最后,通过操作能获取到Files
信息,就认为这个组件封装成功了。
好了,天不早了,干点正事哇。
我们能所学到的知识点
- 项目初始化
- 拖拽功能
- 处理input
- 处理文件&回调
- 唤起弹窗
1. 项目初始化
因为,我们在做项目展示的时候,需要用到一些组件库和工具库,所以我们就抛弃vite/cra
了,我们这里就直接使用我们的f_cli直接构建一个前端项目。
shell
f_cli create upload_demo
如果你是一个老粉,你就知道,我们的f_cli
是支持组件库选择的。在项目初始化时,我们可以选择组件库。
针对此次的demo
我们就选择antd
。然后,其他配置都按照你的心意来就完事了。
一顿操作之后,我们就有了一个功能完备的前端项目。
随后,我们可以执行yarn dev
进行前端项目开发了。
也就说,我们下面的代码讲解和项目组织都是基于f_cli
生成项目的基础上。
2. 拖拽功能
其实,针对拖拽功能的处理,我们有很多解决方案。
-
利用原生特性- 在
DOM
原生上新增draggable
属性,然后监听dragstart/dragend
等。可行吗,必须可行。但是,你需要处理和监听的事情很多。- 如果对这块还有些陌生,可以参考MDN_drag对这块的解释
-
利用库,有很多业界比较出名的拖拽库能处理我们的问题,使用库的好处就是我们通过简单的
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>
);
}
上面的代码大致分为三部分
- 定义了状态(
isDragActive
)和方法 useEffect
中监听了keydown
的Escape
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
但是,在上面代码中我们丝毫没看到关于input
和getInputProps
的处理。只是用children
将子组件进行了展示。
对咯,我们将input
放置到了children
中了。为什么会这么做呢,且看下面的分解。
3. 处理input
如果大家用原生写过上传,那势必就逃不过input
的操作。
我们从MDN_Input_File可以窥探一二。
如上所示,我们可以
- 给
<input/>
添加type="file"
属性,就可以实现一个简单的文件上传的功能。 - 如果要实现多文件上传,可以新增
multiple
属性。 - 还可以设置
accept
来指定上传的文件格式
如果我们要实现文件夹上传,我们可以通过设置webkitdirectory
。
但是,使用webkitdirectory
有兼容性问题。这块大家需要注意。
结合,在第二节中我们使用react-dropzone
处理文件拖拽时,也需要一个<input/>
接收返回的getInputProps
属性。(<input {...getInputProps()} />
)
UploadInputs
我们可以将上面三种<input/>
做统一处理。也就是在这个组件中我们分别接收
getDragAndDropInputProps
处理拖拽的配置信息getFileSelectorInputProps
处理{多}文件上传的配置信息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,
};
}
上面就是一个功能的封装,按照返回值来看,其中有三个重要的功能点
getInputProps
:返回input
中的各个属性,其中根据directory
属性值来区分是否是文件夹上传。open
: 定义了一个方法,用于在外部执行文件获取的弹窗selectedFiles
: 收集用户选择的文件信息
然后,我们就可以在指定地方(src/Upload
)中执行了。
这样,我们就将三个input
的属性都配置好了。它们分别能处理useDropzone
/useFileInput(非文件夹)
/useFileInput(文件夹)
返回的inputPorps
。
也就是,此时我们实现了三种能力的文件收集功能。只不过,文件拖拽我们可以通过拖拽进行处理。而文件{夹}上传需要一些操作来触发其功能。
从上面截图中我们看到(绿色部分),有两类信息,我们还未处理
xxxFiles
:拖拽或者选中的文件信息open
: 针对文件{夹}上传的触发回调
我们还需要一个组件用于接收刚才选择的文件信息和触发文件{夹}上传的操作。
4. 处理文件&回调
我们先来看看该组件是如何调用的。
如图所示,我们在Uploader
中消费了webFileSelectorFiles
/webFolderSelectorFiles
/dragAndDropFiles
/openFileSelector
/openFolderSelector
其中uploadTypeSelectorView
用于控制弹窗的显隐,这个我们稍后会有介绍。
先来看Uploader
Uploader
如图所示,上面有几部分重要的函数。
- 定义了一个
state
/ref
- 监听
props.xxFiles
- 处理上传的逻辑
- 监听
webFiles
- 文件{夹}弹窗
我们就挑几个比较重要的部分来解析
监听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
的值。
上面的代码就是,不论是你拖拽还是文件{夹}上传,都会被存放到webFiles
的state
变量中。
监听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
来唤起一个弹窗,并且根据在弹窗中选择对应的上传类型来进行文件处理。
从上面的我们得知几件事情
Uploader
中接收了uploadTypeSelectorView
相关属性UploadButton
接收一个回调用于触发uploadTypeSelectorView
为true
UploadButton
其实,这个组件很简单,就是用于更新uploadTypeSelectorView
为true
。
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
其实上面的代码都是提供了一个最基本的上传操作。有些功能还是可以完善的。例如
- 约定文件类型
- 配置上传文件的大小
- 异步处理
- 在文件上传过程中,再次上传的逻辑(是失效还是进队列)
- 。。。。。
后记
分享是一种态度。
全文完,既然看到这里了,如果觉得不错,随手点个赞和"在看"吧。