需求来源
在前端开发中会涉及到很多上传的需求,比如用户上传图片视频,中后台流程中上传流程相关的图片视频等,同时,面对需要二次上传的情况,组件也要处理旧的列表和新上传内容之间的区别
而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组件默认的上传行为
tsx
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对同一个文件多次上传,每次也会不一样,所以需要根据文件其他属性进行判断是否一致。
tsx
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中
tsx
<Upload
//其他属性
customRequest={() => {}}
onChange={(uploadInfo) => {
setListOnUploadChange(uploadInfo, setFilelist);
}}
>
{你的上传按钮}
</Upload>
tsx
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上的值
tsx
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并提供类型
tsx
const setListOnUploadChange = (
{ fileList, file }: any,
setUploadFileList: Function,
testOptions?: testVividFileOptionProps
) => {
if (CheckExist(fileList, file)) {
message.warning(`${file.name}已存在`);
file.alreadyExist = true;
}
// console.log(file, "file", fileList, "fileList");
const resList = getDisplayableFileList(fileList, testOptions);
setUploadFileList(resList);
};
const getDisplayableFileList = (
rawList: any[],
testOptions?: testVividFileOptionProps
) => {
const fileList = rawList
.filter((file) => {
// console.log(file, "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不用依赖,只执行一次
tsx
useEffect(() => {
setFilelist(
uploadFileList.map((item) => ({
uid: Math.random(), //映射uid,不然preview的时候会出错
thumbUrl: item,
}))
);
}, []);
同时,我们在检测上传文件有效性的地方,也要进行处理,过滤掉是url的文件,因为对于新上传的文件,Upload处理的thumbUrl是data开头,不会是http开头
tsx
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如下,一些细节通过注释写在下面
tsx
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) => {
//进行过滤,控制上传文件是否有效
// console.log(file, "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"; //这里设置done才能显示图片,否则会显示loading进度条
file.src = file.thumbUrl;
if (url?.startsWith("http")) {
if (checkNeedMP4(url)) {
//服务端转码MP4后的文件,视频文件后缀名不一定是mp4,所以需要加上.mp4后缀
file.thumbUrl = url + ".mp4";
}
return file;
}
file.thumbUrl = URL.createObjectURL(file.originFileObj);
//创建一个blob url,这个url可以直接用于video的src
if (file.type?.includes("video")) {
//加上tag,用于区分是video还是image
if (playableVideoTypes.includes(file.type)) {
//如果是mp4或者webm
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打印出来就是一个对象,那么是不是可以直接修改呢
tsx
originNode.props.children[0] = <video
key={Math.random()}
width={85}
height={80}
src={url}
/>
尝试的结果是整个控制台的报错,大意是我们在尝试修改一个只读的对象,显然,这是为了维护React的不可变性而出现的报错
那到底要怎么才能只修改一个属性呢,不妨换个思路,创建一个新node复制属性,而不是在原地修改,这样就可以得到下面的代码,使用React.cloneElement就可实现。因为Upload的itemRender方法可以直接得到上传的file,方便了许多。对于已经上传的以url指向服务器的文件,直接将其赋给src即可
tsx
const playableUploadItemRender = (
originNode: any,
file: any,
_fileList: any,
_actions: any
) => {
//覆盖掉antd返回的vdom的第一个子节点,就是展示图片的那个节点
let remoteUrl: string = file?.thumbUrl;
// console.log(originNode, file);
if (file?.type?.includes("video") || file?.name?.endsWith("flv")) {
//存在type,说明是上传的文件,而不是远程的url
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)) {
//远程url情况,除了本来就是mp4的,其他的都加上.mp4后缀
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发挥作用的地方,我们取出它,进行类型的手动判别即可
tsx
//这个是在image.previewGroup里面的imageRender
const playableImageRender = (originNode: any, _info: any) => {
// console.log(originNode,'image');
let url: string = originNode?.props?.src;
let [src, type] = url?.split("#") || [];
if (url?.includes("blob")) {
//存在blob,说明是上传的文件,而不是远程的url
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)) {
//远程url情况,除了本来就是mp4的,其他的都加上.mp4后缀
if (checkNeedMP4(url)) url = url + ".mp4";
return (
<video
key={Math.random()}
width={"100%"}
height={"80%"}
src={url}
controls
/>
);
} else return originNode;
};
至此,UI替换就完全实现了
粘贴上传
粘贴上传就是监听了paste事件,并且在hover与否的时候进行挂载和卸载即可。直接截图粘贴的文件在去重时候难以判断,修改日期等等都不同,因此这里没有实现粘贴的去重,有大佬可以实现的话欢迎指出!
tsx
const [hover, setHover] = useState(false);
useEffect(() => {
// console.log("hover", hover);
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写
tsx
const pastedFileFormat = (file: File) => {
const rcFile = {
uid: String(Date.now()), // 为确保唯一性,你可以根据需求设置一个唯一的 uid
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...