相关文章推荐

在前端开发中会涉及到很多上传的需求,比如用户上传图片视频,中后台流程中上传流程相关的图片视频等,同时,面对需要二次上传的情况,组件也要处理旧的列表和新上传内容之间的区别

而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;
    	// 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不用依赖,只执行一次

    	useEffect(() => {
    		setFilelist(
    			uploadFileList.map((item) => ({
    				uid: Math.random(), //映射uid,不然preview的时候会出错
    				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) => {
    			//进行过滤,控制上传文件是否有效
    			// 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打印出来就是一个对象,那么是不是可以直接修改呢

    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
    ) => {
    	//覆盖掉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发挥作用的地方,我们取出它,进行类型的手动判别即可

    //这个是在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与否的时候进行挂载和卸载即可。直接截图粘贴的文件在去重时候难以判断,修改日期等等都不同,因此这里没有实现粘贴的去重,有大佬可以实现的话欢迎指出!

    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写

    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…

    原文链接:https://juejin.cn/post/7328273660790439988 作者:Canals

     
    推荐文章