一步步实现一个粘贴,查重,滑动预览的图片和视频上传组件

需求来源

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

而ant design提供的上传组件Upload功能比较有限,但其拓展性良好,在实际的业务需求中需要二次封装和拓展,本文提供了一些封装拓展的思路,并进行了代码实践。

文章的最后 也提供了npm包,所有代码的GitHub仓库,供大家参考和使用

实现了什么

  1. 优化Upload后图片和视频预览,非破坏性替换UI ,图片视频可以横向切换预览,对不能预览的情况,比如视频除了MP4,Ogg,WebM这几种格式外的情况,浏览器不能播放则可以提供一个提示让用户转变格式后上传,或后续进行服务端转码
  2. 通过onChange调整上传逻辑,可控制上传文件的类型,大小等等进行上传限制
  3. 对需要二次上传的情况,即既需要旧的展示(纯URL数组)也需要新上传内容的情况,兼容化处理,让新旧内容的增删改查同步,保证了现实的一致性
  4. 支持图片的粘贴上传,对同页面中有多个上传组件的情况进行处理,根据鼠标是否hover控制粘贴

antd upload中的示例对比

可以看到只能预览图片,并且modal不能实现横向的切换预览下一张图片,并且无法预览视频文件

自己实现的这个组件的效果如下

代码实现

思路分析

从上面的对比可以发现,主要修改的地方就是预览列表 的渲染,点击预览后显示的大图预览 的渲染,上传时组件对上传内容的处理逻辑以及粘贴上传

  1. 预览列表:可以通过Upload组件的itemRender属性,进行自定义的预览项渲染
  2. 大图预览:用另外一个组件替换掉单图的Modal预览,多图可以使用Image.PreviewGroup 实现,并且利用imageRender属性替换掉展现出的预览页面
  3. 处理逻辑:在onChange中统一处理上传逻辑
  4. 粘贴上传:在整个组件的最外层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开发中用于处理和展示图像的技术,但它们之间有一些关键的区别。

  1. 数据格式 :
    • createObjectURL : 该方法是通过使用 Blob 对象来生成一个 URL,它通常用于将二进制数据(比如图像文件)转换为可在浏览器中显示的 URL。这个 URL 不包含实际的图像数据,而只是一个指向内存中二进制数据的引用
    • base64: 使用 base64 编码,将二进制数据直接嵌入到 URL 中。这意味着 URL 包含了实际的图像数据,而不仅仅是一个引用。
  2. 性能和内存使用 :
    • createObjectURL : 由于它只是创建一个指向内存中数据的引用,而不是在URL中直接包含数据,因此相对于 base64 方法来说更加高效。它在处理大文件时可能更有优势,因为不需要在 URL 中传输整个数据。
    • base64: 在 URL 中包含实际的图像数据,可能会导致较大的 URL 大小,因此在处理大文件时可能会增加网络传输的负担。
  3. 适用情况 :
    • 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...

相关推荐
真的很上进2 分钟前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
web130933203988 分钟前
vue elementUI form组件动态添加el-form-item并且动态添加rules必填项校验方法
前端·vue.js·elementui
NiNg_1_23429 分钟前
Echarts连接数据库,实时绘制图表详解
前端·数据库·echarts
如若1231 小时前
对文件内的文件名生成目录,方便查阅
java·前端·python
滚雪球~2 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语2 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport2 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
brrdg_sefg2 小时前
WEB 漏洞 - 文件包含漏洞深度解析
前端·网络·安全
胡西风_foxww2 小时前
【es6复习笔记】rest参数(7)
前端·笔记·es6·参数·rest
m0_748254882 小时前
vue+elementui实现下拉表格多选+搜索+分页+回显+全选2.0
前端·vue.js·elementui