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

需求来源

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

而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...

相关推荐
用户214118326360227 分钟前
首发!即梦 4.0 接口开发全攻略:AI 辅助零代码实现,开源 + Docker 部署,小白也能上手
前端
gnip2 小时前
链式调用和延迟执行
前端·javascript
SoaringHeart2 小时前
Flutter组件封装:页面点击事件拦截
前端·flutter
杨天天.2 小时前
小程序原生实现音频播放器,下一首上一首切换,拖动进度条等功能
前端·javascript·小程序·音视频
Dragon Wu3 小时前
React state在setInterval里未获取最新值的问题
前端·javascript·react.js·前端框架
Jinuss3 小时前
Vue3源码reactivity响应式篇之watch实现
前端·vue3
YU大宗师3 小时前
React面试题
前端·javascript·react.js
木兮xg3 小时前
react基础篇
前端·react.js·前端框架
ssshooter3 小时前
你知道怎么用 pnpm 临时给某个库打补丁吗?
前端·面试·npm
IT利刃出鞘4 小时前
HTML--最简的二级菜单页面
前端·html