在前端开发中会涉及到很多上传的需求,比如用户上传图片视频,中后台流程中上传流程相关的图片视频等,同时,面对需要二次上传的情况,组件也要处理旧的列表和新上传内容之间的区别
而ant design提供的上传组件Upload功能比较有限,但其
拓展性良好
,在实际的业务需求中需要二次封装和拓展,本文提供了一些封装拓展的思路,并进行了代码实践。
在
文章的最后
也提供了npm包,
所有代码的GitHub仓库
,供大家参考和使用
实现了什么
优化Upload后
图片和视频预览,非破坏性替换UI
,图片视频可以横向切换预览,对不能预览的情况,比如视频除了MP4,Ogg,WebM这几种格式外的情况,浏览器不能播放则可以提供
一个提示
让用户转变格式后上传,或后续进行服务端转码
通过onChange调整上传逻辑,可控制上传文件的类型,大小等等进行上传限制
对需要二次上传的情况,即既需要旧的展示(纯URL数组)也需要新上传内容的情况,兼容化处理,让新旧内容的
增删改查同步
,保证了现实的一致性
支持图片的粘贴上传,对同页面中有
多个上传组件
的情况进行处理,根据鼠标是否hover控制粘贴
antd upload中的示例对比
可以看到只能预览图片,并且modal不能实现横向的切换预览下一张图片,并且无法预览视频文件
自己实现的这个组件的效果如下
从上面的对比可以发现,主要修改的地方就是
预览列表
的渲染,点击预览后显示的
大图预览
的渲染,上传时组件对上传内容的
处理逻辑以及粘贴上传
预览列表:可以通过Upload组件的
itemRender属性
,进行自定义的预览项渲染
大图预览:用另外一个组件替换掉单图的Modal预览,
多图可以使用Image.PreviewGroup
实现,并且利用
imageRender
属性替换掉展现出的预览页面
处理逻辑:在
onChange
中统一处理上传逻辑
粘贴上传:在整个组件的最外层div中监听onMouseEnter和onMouseLeave事件,控制hover状态是否为true来开关粘贴上传
实现基础横向切换预览
如果只想要基础的横向切换预览,实际上只要把AntD官网中的内容稍微修改即可,在Upload后加上一个Image.PreviewGroup,进行一些调整,即可实现
在Image.PreviewGroup中,要让大图预览有内容,
必须提供src属性
(不然是空的),将Upload的onChange传入的uploadInfo(有file和filelist两个属性),打印出来,可以发现经过Upload处理后
已经计算出了小图的本地访问链接thumbUrl
,将其赋值给src即可
previewVisible是控制大图预览是否展现的属性,Upload的 onPreview和 Image的onVisibleChange里控制即可
currentPreview是控制大图预览的下标,只需要通过上传后的文件对象
的uid
找即可
如果在Upload的组件属性上写customRequest={() => {}},那就只会展示正在上传的状态,直接不传入customRequest和action,就可以看到后面截图的上传失败结果(向当前URL传了个POST请求),customRequest用于覆盖Upload组件默认的上传行为
const
[previewVisible, setPreviewVisible] =
useState
(
false
);
const
[filelist, setFilelist] = useState<
Array
<
any
>>([]);
const
[currentPreview, setCurrentPreview] =
useState
(
1
);
const
handlePreview
= (
file: UploadFile
) => {
const
resList = filelist.
map
(
(
file
) =>
{
file.
src
= file.
thumbUrl
;
return
file;
const
current = filelist.
findIndex
(
(
item
) =>
item.
uid
=== file.
uid
);
setCurrentPreview
(current);
setFilelist
(resList);
setPreviewVisible
(
true
);
const
uploadButton = (
<
button
style
=
{{
border:
0
,
background:
"
none
" }}
type
=
"button"
>
<
PlusOutlined
/>
<
div
style
=
{{
marginTop:
8
}}>
Upload
</
div
>
</
button
>
return
<>
<
Upload
fileList
=
{filelist}
showUploadList
=
{true}
onPreview
=
{handlePreview}
onChange
=
{(uploadInfo)
=>
{
setFilelist(uploadInfo.fileList);
{uploadButton}
</
Upload
>
<
Image.PreviewGroup
items
=
{filelist}
preview
=
{{
onChange:
(
current:
SetStateAction
<
number
>
) => {
setCurrentPreview(current);
visible: previewVisible,
onVisibleChange: (vis: boolean) => setPreviewVisible(vis),
current: currentPreview,
UI上看的确都实现了,也不是不能用,但在实际业务场景中,很容易就会遇到下面的需求点
我只想允许上传的类型是jpg/png等等,但现在没限制所有的东西都可以传
误操作点了很多次同一个文件,但他们都可以上传,没有去重
大图预览怎么还是这么小,只是把列表预览的图简单地放到了大图预览里,是略缩图
视频预览不了,甚至在列表里都是一个文件图标占位,根本看不出是视频
我有时候想截图QQ/微信等聊天窗口,用他们内置的截图工具一截,直接粘贴上去,但这里没有
去重和控制上传
现在是我们完全定制化了整个上传逻辑,我们也显然需要给用户一个
二次确认,不想在用户放入文件后立刻上传到服务器
,于是我们需要把customRequest设为空函数customRequest={() => {}},并在
onChange
里处理上传逻辑。不用beforeUpload的原因是不能看到文件
最终到Upload的filelist里状态
,信息较少,于是统一都用onChange
由于是最终状态,Upload自己维护的filelist实际已经改变,并且和我们自己定义的filelist
不是同一个数组,对onChange里的filelist直接修改没有任何作用。
需要借助uploadInfo里的另一个属性file,即最后上传的一个文件,进行判断。filelist由于已经
包含了上传的file,需要通过uid过滤掉本身
,
uid对同一个文件多次上传,每次也会不一样
,所以需要根据文件其他属性进行判断是否一致。
const CheckExist = (fileList: any[], file: any) => {
let filtedList = fileList.filter((arrFile) => arrFile.uid !== file.uid);
return filtedList.some(
(arrayFile) =>
file.lastModified === arrayFile.lastModified &&
file.name === arrayFile.name &&
file.size === arrayFile.size &&
file.type === arrayFile.type
为了之后的拓展,我们把onChange内的处理逻辑也都聚合为一个函数,把整个组件uploader处理逻辑都放在另一个文件handler中
<Upload
customRequest={() => {}}
onChange={(uploadInfo) => {
setListOnUploadChange(uploadInfo, setFilelist);
{你的上传按钮}
</Upload>
const setListOnUploadChange = (
{ fileList, file }: any,
setUploadFileList: Function,
) => {
if (CheckExist(fileList, file)) {
message.warning(`${file.name}已存在`);
file.alreadyExist = true;
const resList = getDisplayableFileList(fileList);
setUploadFileList(resList);
由于之后还需要检查文件是否满足要求(大小,类型),所以这里先打上一个标记,之后再统一清理
控制上传类型,大小
上面的getDisplayableFileList是用来统一化视频图片上传,新旧文件上传后可以方便地渲染到列表预览,因此起了这个名字。对于测试文件类型是否被允许,可以检查上传的file中的type,下面是一些对应,如果是flv,type将为一个空字符串,需要额外的判断。
比较大小只需要检查size的数值(单位Byte)是否在允许的区间内,注意这里是2的10次方也就是1024
为了方便拓展以及类型提示检查,还可以把检查有效性的参数都抽离为一个对象。解构赋值options,如果options没有这个属性,则用默认提供的值,反之则使用option上的值
const defaultVividImageTypes = ["image/jpeg", "image/png"];
const defaultVividVideoTypes = [
"video/mp4",
"video/avi",
"video/mov",
"video/wmv",
"video/quicktime",
"video/x-ms-wmv",
type testVividFileOptionProps = {
vividImageTypes?: string[];
vividVideoTypes?: string[];
showMessage?: boolean;
maxSize?: number;
minSize?: number;
fileTypeWarning?: string;
fileSizeWarning?: string;
enableflv?: boolean;
const testVividFile = (file: any, options: testVividFileOptionProps) => {
const {
vividImageTypes = defaultVividImageTypes,
vividVideoTypes = defaultVividVideoTypes,
showMessage = true,
maxSize = 1024 * 1024 * 50,
minSize = 0,
fileTypeWarning = "仅支持图片、视频文件 图片仅支持:JPG、PNG格式 视频仅支持:mp4、flv、avi、wmv、mov格式 ",
fileSizeWarning = "文件过大",
enableflv = true
} = c;
const isJpgOrPng = vividImageTypes.includes(file.type);
const isVideo =
vividVideoTypes.includes(file.type) || (enableflv && file.name?.endsWith("flv"))
const isvividSize = file.size < maxSize || file.size > minSize;
console.log(file, maxSize, minSize);
if (showMessage) {
if (!isJpgOrPng && !isVideo) {
message.error(fileTypeWarning);
} else if (!isvividSize) {
message.error(fileSizeWarning);
return (isJpgOrPng || isVideo) && isvividSize;
同理修改使用testVividFile 的两个函数,加上testOptions并提供类型
const setListOnUploadChange = (
{ fileList, file }: any,
setUploadFileList: Function,
testOptions?: testVividFileOptionProps
) => {
if (CheckExist(fileList, file)) {
message.warning(`${file.name}已存在`);
file.alreadyExist = true;
const resList = getDisplayableFileList(fileList, testOptions);
setUploadFileList(resList);
const getDisplayableFileList = (
rawList: any[],
testOptions?: testVividFileOptionProps
) => {
const fileList = rawList
.filter((file) => {
if (file.alreadyExist) return false;
return testVividFile(file, testOptions || {});
return fileList;
现在,组件就能限制文件的上传了
合并新旧渲染逻辑
在一些需要二次更改上传内容的场景,即filelist中已经有存储到服务端的内容,这里我们假设服务端返回的内容都是url,即filelist是一个url数组
由于前面已经在handlePreview里进行了file.src = file.thumbUrl操作,于是只需要在初始化时加上一个useEffect进行处理添加thumbUrl即可,并且添加上uid进行唯一性处理
在uploader文件中,写下面的代码,useEffect不用依赖,只执行一次
useEffect(() => {
setFilelist(
uploadFileList.map((item) => ({
uid: Math.random(),
thumbUrl: item,
}, []);
同时,我们在检测上传文件有效性的地方,也要进行处理,过滤掉是url的文件,因为对于新上传的文件,Upload处理的thumbUrl是data开头,不会是http开头
const getDisplayableFileList = (
rawList: any[],
testOptions?: testVividFileOptionProps
) => {
const fileList = rawList
.filter((file) => {
if (file.alreadyExist) return false;
if (file.thumbUrl?.startsWith("http")) return true;
return testVividFile(file, testOptions || {});
return fileList;
核心:替换ReactNode处理预览渲染
这部分就是这个组件最核心的地方,实现了重写整个预览显示逻辑同时保持UI和原始Upload的UI没有太大的区别,看上去只是增加了一些功能而不是完全破坏性替换UI
查阅官方文档可知Upload提供了itemRender来进行预览列表的渲染替换,Image.PreviewGroup则可以通过imageRender来处理大图渲染的逻辑
先想想思路,预渲染我们需要从文件对象生成出url,以供video或img标签的src属性使用。但本地未上传到服务器的文件如何获取url呢,我们先对比下面两个常用的办法
createObjectURL 和使用 base64 数据生成方法都是在Web开发中用于处理和展示图像的技术,但它们之间有一些关键的区别。
数据格式:
createObjectURL: 该方法是通过使用 Blob 对象来生成一个 URL,它通常用于将二进制数据(比如图像文件)转换为可在浏览器中显示的 URL。这个 URL 不包含实际的图像数据,而只是一个指向内存中二进制数据的引用。
base64: 使用 base64 编码,将二进制数据直接嵌入到 URL 中。这意味着 URL 包含了实际的图像数据,而不仅仅是一个引用。
性能和内存使用:
createObjectURL: 由于它只是创建一个指向内存中数据的引用,而不是在URL中直接包含数据,因此相对于 base64 方法来说更加高效。它在处理大文件时可能更有优势,因为不需要在 URL 中传输整个数据。
base64: 在 URL 中包含实际的图像数据,可能会导致较大的 URL 大小,因此在处理大文件时可能会增加网络传输的负担。
适用情况:
createObjectURL: 通常在需要处理大文件、需要提高性能或在Web Workers中使用时更为合适。
base64: 适用于较小的图像,或者在需要直接在 HTML 或 CSS 中嵌入图像数据时,例如在样式表中使用 background-image。
除了上面的区别,base64生成的图片可以认为是略缩图,
createObjectURL由于是通过引用指向内存,显示出的是原文件,可以方便地满足预览的放大缩小的需求
然而,createObjectURL生成的url,除了是blob开头,没有其他的分辨方法,图像类型,视频类型生成的url格式相同,还需要更多判断。又由于它是url,在其上添加哈希值不会影响解析,因此可以通过加上哈希,打上自定义的tag进行更多的判断。
对于老文件,即以url显示,在服务器存储的文件,如果是视频,就给他添加上mp4的后缀,让浏览器能识别到服务端转码的视频文件
此时,我们修改getDisplayableFileList如下,一些细节通过注释写在下面
const playableVideoTypes = ["video/mp4", "video/webm"];
function checkNeedMP4(src: string) {
var ext = src?.split(".").pop();
switch (ext) {
case "flv":
case "avi":
case "wmv":
case "mov":
return true;
default:
return false;
const getDisplayableFileList = (
rawList: any[],
testOptions?: testVividFileOptionProps
) => {
const fileList = rawList
.filter((file) => {
if (file.alreadyExist) return false;
if (file.thumbUrl?.startsWith("http")) return true;
return testVividFile(file, testOptions || {});
.map((file) => {
const url = file.thumbUrl || file.url;
file.status = "done";
file.src = file.thumbUrl;
if (url?.startsWith("http")) {
if (checkNeedMP4(url)) {
file.thumbUrl = url + ".mp4";
return file;
file.thumbUrl = URL.createObjectURL(file.originFileObj);
if (file.type?.includes("video")) {
if (playableVideoTypes.includes(file.type)) {
file.thumbUrl += "#playvideo";
} else {
file.thumbUrl += "#video";
} else if (file.type?.includes("image")) {
file.thumbUrl += "#image";
return file;
return fileList;
ReactNode级别替换UI
替换Upload组件的列表渲染项,可以通过itemRender属性自己创建一个函数渲染,参数如下,本组件实际只用了originNode和file两个参数
args: [originNode: React.ReactElement<any, string | React.JSXElementConstructor>, file: UploadFile, fileList: UploadFile[], actions: {
download: () => void;
preview: () => void;
remove: () => void;
}]
想要通过itemRender替换UI,首先就要观察原来渲染的node是什么
观察发现,渲染node里的children第一个下标对应的孩子就是渲染图片的地方,其他的是预览标签,删除标签,以及遮罩mask,我们只想替换第一个孩子,保持UI的一致性
同时也发现,originNode打印出来就是一个对象,那么是不是可以直接修改呢
originNode.props.children[0] = <video
key={Math.random()}
width={85}
height={80}
src={url}
尝试的结果是整个控制台的报错,大意是我们在尝试修改一个只读的对象,显然,这是为了维护React的不可变性而出现的报错
那到底要怎么才能只修改一个属性呢,不妨换个思路,创建一个新node复制属性,而不是在原地修改,这样就可以得到下面的代码,使用React.cloneElement就可实现。因为Upload的itemRender方法可以直接得到上传的file,方便了许多。对于已经上传的以url指向服务器的文件,直接将其赋给src即可
const playableUploadItemRender = (
originNode: any,
file: any,
_fileList: any,
_actions: any
) => {
let remoteUrl: string = file?.thumbUrl;
if (file?.type?.includes("video") || file?.name?.endsWith("flv")) {
if (playableVideoTypes.includes(file.type)) {
const url = file.thumbUrl;
const newNode = React.cloneElement(originNode, {
children: [
<video
key={Math.random()}
width={85}
height={80}
src={url}
...originNode.props.children.slice(1),
return newNode;
} else {
const newNode = React.cloneElement(originNode, {
children: [
<div key={Math.random()} style={{ textAlign: "center" }}>
此视频格式在上传转码后才可播放
</div>,
...originNode.props.children.slice(1),
return newNode;
} else if (remoteUrl?.includes("video") || checkVideoSrc(remoteUrl)) {
if (checkNeedMP4(remoteUrl)) remoteUrl = remoteUrl + ".mp4";
return React.cloneElement(originNode, {
children: [
<video
key={Math.random()}
width={85}
height={80}
src={remoteUrl}
...originNode.props.children.slice(1),
return originNode;
替换大图渲染
对于image.previewGroup里面的imageRender的渲染替换,会轻松很多,不需要进入Node级别的渲替换,直接利用方法返回即可,这里就是哈希tag发挥作用的地方,我们取出它,进行类型的手动判别即可
const playableImageRender = (originNode: any, _info: any) => {
let url: string = originNode?.props?.src;
let [src, type] = url?.split("#") || [];
if (url?.includes("blob")) {
if (type === "playvideo") {
return (
<video
key={Math.random()}
width={"100%"}
height={"80%"}
src={url}
controls
} else if (type === "video") {
return (
<div key={Math.random()} style={{ textAlign: "center" }}>
此视频格式在上传转码后才可播放
</div>
} else if (type === "image") {
return originNode;
} else if (checkVideoSrc(url)) {
if (checkNeedMP4(url)) url = url + ".mp4";
return (
<video
key={Math.random()}
width={"100%"}
height={"80%"}
src={url}
controls
} else return originNode;
至此,UI替换就完全实现了
粘贴上传就是监听了paste事件,并且在hover与否的时候进行挂载和卸载即可。直接截图粘贴的文件在去重时候难以判断,修改日期等等都不同,因此这里没有实现粘贴的去重,有大佬可以实现的话欢迎指出!
const [hover, setHover] = useState(false);
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
if (!hover) return;
if (!event.clipboardData) return;
const item = event.clipboardData.items[0];
if (item.kind === "file") {
let originfile = item.getAsFile();
let file = pastedFileFormat(originfile!);
setListOnUploadChange(
{ file: file, fileList: filelist.concat(file) },
setFilelist,
document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("paste", handlePaste);
}, [hover]);
由于需要和Upload处理的文件对象对齐,直接粘贴的文件对象需要加工后才能进行处理
handler写
const pastedFileFormat = (file: File) => {
const rcFile = {
uid: String(Date.now()),
size: file.size,
name: file.name,
type: file.type,
lastModified: file.lastModified,
originFileObj: file,
return rcFile;
代码仓库与总结
至此,所有代码均已实现,几乎是用80%的时间完成了20%的最后工作,但文件上传逻辑确实处理复杂,非破坏性的UI修改需要很多细节处理。
我也将这个组件打包为了一个包发布在npm,同时下面也把所有代码放到了GitHub仓库,觉得还可以的话拜托点个star哦
npm地址 www.npmjs.com/package/ant…
npm包名 antd_previewable_uploader
GitHub github.com/Canals233/a…
原文链接:https://juejin.cn/post/7328273660790439988 作者:Canals