前言
当前需求有一个上传图片/视频成功后,点击放大预览的功能。但是,强大如 Ant Design Mobile 也只支持图片预览。那就开始干活吧。
组件的设计与实现
实现 MediaItem 组件
首先,针对图片和视频两种不同类型,分别实现 MediaItem 组件:
// MediaItem.tsx
import { useEffect, useRef, useState } from 'react';
import { cn } from 'lib/utils'; // classnames 工具封装
import styles from './MediaItem.m.scss';
interface RemovableButtonProps {
isRemovable: boolean;
onRemove: () => void;
}
export const RemovableButton: React.FC<RemovableButtonProps> = ({ isRemovable, onRemove }) => {
return isRemovable ? <div className={styles.remove} onClick={onRemove} /> : null;
};
interface MediaItemProps {
src: string; // URL
className?: string; // 图片/视频样式
overlayClassName?: string; // 图片/视频遮罩层样式
isRemovable?: boolean; // 是否展示删除按钮
onRemove?: () => void; // 删除事件
onClick?: () => void; // 点击事件
[key: string]: any;
}
const handleClick = (e: React.MouseEvent, onClick?: () => void) => {
if (!onClick) return;
e.stopPropagation();
onClick();
};
export const ImageMediaItem: React.FC<MediaItemProps> = ({
src,
className = '',
overlayClassName = '',
isRemovable = true,
onRemove,
onClick,
}) => {
// 是否正在加载中
const [isLoaded, setIsLoaded] = useState(false);
return (
<div className={styles.imageWrapper}>
<!-- 加载图片时,展示遮罩层样式 -->
{!isLoaded && <div className={cn(styles.imageOverlay, overlayClassName)}/>}
<img
src={src}
alt={src}
className={cn(styles.imageItem, className)}
onClick={(e) => handleClick(e, onClick)}
onLoad={() => setIsLoaded(true)}
/>
<RemovableButton isRemovable={isRemovable} onRemove={onRemove} />
</div>
);
};
export const VideoMediaItem: React.FC<MediaItemProps> = ({
src,
className = '',
overlayClassName = '',
isRemovable = true,
onRemove,
onClick,
}) => {
// 是否正在加载中
const [isLoaded, setIsLoaded] = useState(true);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const handleLoadedMetadata = () => {
// 成功读取 video 元素后,再修改加载状态
if (videoRef.current) {
setIsLoaded(false);
}
};
const videoElement = videoRef.current;
if (videoElement) {
videoElement.addEventListener('loadedmetadata', handleLoadedMetadata);
}
return () => {
if (videoElement) {
videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata);
}
};
}, [src]);
return (
<div className={styles.videoWrapper}>
<!-- 加载视频时,则展示遮罩层样式,可以在这个 div 中添加需要展示的信息 -->
<div
className={cn(styles.videoOverlay, overlayClassName)}
style={{ backgroundColor: isLoaded ? '#eeebff' : 'transparent' }}
onClick={(e) => handleClick(e, onClick)}
/>
<video className={cn(styles.videoItem, className)} preload="auto" ref={videoRef}>
<source src={src} type="video/mp4" />
<source src={src} type="video/quicktime" />
</video>
<RemovableButton isRemovable={isRemovable} onRemove={onRemove} />
</div>
);
};
// MediaItem.module.scss
.basicItem {
width: 109px;
height: 109px;
background-color: #fff;
border-radius: 8px;
overflow: hidden;
}
.basicOverlay {
position: absolute;
top: 0;
left: 0;
width: 109px;
height: 109px;
background-color: #eeebff;
border-radius: 8px;
z-index: 1;
}
.remove {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background-color: rgba(#16002c, 0.5);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
&::before,
&::after {
content: '';
position: absolute;
width: 12px;
height: 1px;
background-color: #fff;
}
&::before {
transform: rotate(45deg);
}
&::after {
transform: rotate(-45deg);
}
}
.imageWrapper {
position: relative;
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.imageOverlay {
@extend .basicOverlay;
}
.imageItem {
@extend .basicItem;
object-fit: cover;
}
}
.videoWrapper {
position: relative;
display: inline-block;
.videoOverlay {
@extend .basicOverlay;
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 28px;
background: linear-gradient(180deg, rgba(22, 0, 44, 0) 6.44%, #16002c 100%);
opacity: 0.4;
border-radius: 0 0 8px 8px;
}
}
.videoItem {
@extend .basicItem;
}
}
MediaItem 组件的样式效果如下:
实现 Preview 组件
考虑到放大后需要左右切换展示,经过实践放弃了 Ant Design Mobile 的 Swiper 组件,因为 Swiper.Item 组件高度无法自适应,在高度不一致的情况下会影响空白处的点击事件。最终选择 SwiperJS 来实现功能,因为它有 autoHeight 属性。
Preview 组件的代码如下:
// Preview.tsx
import { useRef, useMemo, useState, useEffect, useCallback } from 'react';
import { Swiper, SwiperSlide } from 'swiper/react';
import ReactDOM from 'react-dom';
import styles from './index.m.scss';
import 'swiper/swiper-bundle.css';
interface PreviewItem {
type: string; // 文件类型:image、video
src: string; // 访问的文件 URL
}
interface PreviewProps {
items: PreviewItem[]; // 预览列表
previewIndex: number; // 初始预览索引
setIsPreview: (isPreview: boolean) => void; // 是否进入预览模式
}
export default function Preview(props: PreviewProps) {
const { items, setIsPreview, previewIndex } = props;
const swiperRef = useRef<any>(null);
const previewRef = useRef<any>(null);
// 当前预览索引
const [currentPreviewIndex, setCurrentPreviewIndex] = useState(previewIndex);
// 点击预览元素,退出预览
const handleClick = useCallback(() => {
setIsPreview(false);
}, [setIsPreview]);
useEffect(() => {
setCurrentPreviewIndex(previewIndex);
}, [previewIndex]);
const handleVideoPlay = useRef((activeIndex: number) => {
const video = previewRef.current?.querySelector('video');
// 如果 activeIndex 所对应的元素是视频就播放视频,否则暂停播放
if (video) {
if (items[activeIndex] && items[activeIndex].type === 'video') {
video?.play();
} else {
video?.pause();
}
}
});
// 打开预览组件时,先判断当前元素是否为视频,是则自动播放视频
useEffect(() => {
handleVideoPlay.current(currentPreviewIndex);
}, [currentPreviewIndex]);
const previewContent = useMemo(
() => (
<div className={styles.previewMask} onClick={handleClick} ref={previewRef}>
<!-- 当前索引 -->
<div className={styles.indicator}>
{currentPreviewIndex + 1} / {items.length}
</div>
<!-- 轮播预览 -->
<Swiper
autoHeight
className={styles.previewSwiper}
direction="horizontal"
initialSlide={previewIndex}
onSlideChange={({ activeIndex }) => {
<!-- 当 Slide 变化时,修改索引并切换 Slide -->
setCurrentPreviewIndex(activeIndex);
previewRef.current?.swipeTo(activeIndex);
}}
onSwiper={(swiper) => {
<!-- 当 Swiper 变化时,保存当前 Swiper 并修改索引 -->
swiperRef.current = swiper;
setCurrentPreviewIndex(swiper.activeIndex);
handleVideoPlay.current(swiper.activeIndex);
}}
speed={100}
>
{items.map((item, index) => (
<SwiperSlide key={`${item.type}-${item.src}-${index}`}>
<div className={styles.previewItemWrap}>
{item.type === 'video' ? (
<video
className={styles.previewItem}
src={item.src}
preload="auto"
playsInline
muted
/>
) : (
<img
className={styles.previewItem}
src={item.src}
alt={item.src}
/>
)}
</div>
</SwiperSlide>
))}
</Swiper>
</div>
),
[items, currentPreviewIndex, handleClick, previewIndex],
);
// 将预览内容渲染到 document.body
return ReactDOM.createPortal(previewContent, document.body);
}
// Preview.module.scss
.previewMask {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
background-color: #000;
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.indicator {
position: absolute;
top: 50px;
left: 50%;
transform: translateX(-50%);
font-size: 16px;
color: #fff;
z-index: 10;
}
.previewSwiper {
display: flex;
align-items: center;
justify-content: center;
.previewItemWrap {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.previewItem {
width: 100%;
height: auto;
}
}
// 禁用 Swiper 的过渡动画
:global(.swiper-wrapper) {
transition: none;
}
实现 usePreview 钩子
接下来,实现 usePreview 钩子。后续在需要预览功能的地方使用它即可。
// usePreview.ts
import { useCallback, useState, useEffect } from 'react';
export default function usePreview() {
const [isPreview, setIsPreview] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0);
const handlePreview = useCallback(
(index: number) => {
setPreviewIndex(index);
setIsPreview(true);
},
[],
);
return {
isPreview,
setIsPreview,
previewIndex,
handlePreview,
};
}
组件的实际使用
// index.tsx
interface IUploadedFile {
type: string; // 文件类型:image、video
fSource: File; // 文件对象
fullSrc: string; // 上传成功后,服务端返回的文件 URL
isUploading: boolean; // 是否正在上传
}
const [uploadedFiles, setUploadedFiles] = useState<IUploadedFile[]>([]);
const handleUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files[0];
if (!file) return;
// 对文件大小和类型的校验
const isValid = validateFile(file);
if (!isValid) return;
// 保存当前上传的文件数据
const tempSrc = URL.createObjectURL(file);
const newFile = {
type: file.type.startsWith('video') ? 'video' : 'image',
fSource: file,
fullSrc: tempSrc, // 当上传成功时,更新为服务器返回的 URL
isUploading: true,
};
setUploadedFiles((prevFiles) => [...prevFiles, newFile]);
try {
// 上传接口,当接口中返回 fullFileSrc 字段时,说明上传成功
const res = await uploadFile(file);
if (res.fullFileSrc) {
setUploadedFiles((prevFiles: IUploadedFile[]) => {
return prevFiles.map((f) => {
// 更新当前文件的状态
if (f.fSource.lastModified === newFile.fSource.lastModified) {
return { ...f, fullSrc: res.fullFileSrc, isUploading: false };
}
return f;
});
});
Toast.show('上传成功');
}
} catch (e) {
console.error('上传失败', e.message);
Toast.show('上传失败');
}
};
const handleRemove = (type: string, index: number) => {
setUploadedFiles((prevFiles) => prevFiles.filter((f, i) => i !== index));
};
// 图片/视频预览
const { isPreview, setIsPreview, previewIndex, handlePreview } = usePreview();
const renderMediaItem = useCallback(
(file: IUploadedFile, index: number) => {
const MediaItem = file.type === 'video' ? VideoMediaItem : ImageMediaItem;
return (
<MediaItem
src={file.fullSrc}
onClick={() => handlePreview(index)}
onRemove={() => handleRemove(file.type, index)}
/>
);
},
[],
);
return (
<>
<!-- 其他逻辑 -->
<div className={styles.mediaList}>
{uploadedFiles.length > 0 && (
<>
{uploadedFiles.map((file, index) => (
<div
key={`${file.fullSrc}-${index}`}
className={styles.mediaItemWrap}
>
{file.isUploading ? (
<!-- 文件上传中,可自定义样式 -->
<div className={styles.mask}>...</div>
) : (
renderMediaItem(file, index)
)}
</div>
))}
</>
)}
<!-- 不满九张时,可继续添加 -->
{uploadedFiles.length < 9 && (
<div className={styles.addBtn}>
<input
accept="image/*,video/*"
className={styles.input}
onChange={handleUpload}
type="file"
/>
</div>
)}
{isPreview && (
<Preview
items={uploadedFiles.map((file) => ({
type: file.type,
src: file.fullSrc,
}))}
setIsPreview={setIsPreview}
previewIndex={previewIndex}
/>
)}
</div>
</>
)
上述代码,即可在本地实现预览功能,效果如下:
问题和解决思路
视频封面展示失败
在效果展示那里也可以看到,列表处的图片可以正常展示,但视频封面为空。
解决方案:在 video 元素的 src 属性后添加 #t=%d,其中 %d 可以是 0.05、0.1 等值。例如,#t=0.05 表示视频从 0.05 秒开始播放,这样可以有效避免加载时出现空白。
// MediaItem.tsx
// ...
<video className={cn(styles.videoItem, className)} preload="auto" ref={videoRef}>
<source src={`${src}#t=0.05`} type="video/mp4" />
<source src={`${src}#t=0.05`} type="video/quicktime" />
</video>
修改过后,在真机上就可以看到视频封面正常展示了。
视频预览失败
这个问题的解决方式依赖客户端实现,因此只是提供一个解决思路。
一开始,在点击放大图片/视频时,我直接获取上传成功后服务端返回的资源 URL 进行展示,发现图片可以正常预览,但视频预览失败。经过不断尝试,我得到了一个解决方案:
-
在
iOS系统上,当文件上传成功后,保持读取服务端返回的资源URL进行访问; -
在
Andriod系统上,当文件上传时,将File文件对象保存下来,并为其创建一个可访问的URL;等上传成功后,读取这个URL进行访问。
改动代码如下:
// index.tsx
interface IUploadedFile {
// ...
blobSrc: string; // 访问文件对象的 URL(针对 Andriod 设备)
}
const handleUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files[0];
if (!file) return;
const isValid = validateFile(file);
if (!isValid) return;
// 保存当前上传的文件数据
const tempSrc = URL.createObjectURL(file);
const newFile = {
// ...
blobSrc: tempSrc,
};
setUploadedFiles((prevFiles) => [...prevFiles, newFile]);
// ...
};
// ...
// 图片/视频预览
const { isPreview, setIsPreview, previewIndex, handlePreview } = usePreview();
const renderMediaItem = useCallback(
(file: IUploadedFile, index: number) => {
// 判断是不是 Android 设备,如果是则读取 blobSrc,否则读取 fullSrc
const src = isAndroid() ? file.blobSrc : file.fullSrc;
const MediaItem = file.type === 'video' ? VideoMediaItem : ImageMediaItem;
return (
<MediaItem
src={src}
onClick={() => handlePreview(index)}
onRemove={() => handleRemove(file.type, index)}
/>
);
},
[],
);
return (
<>
<!-- 其他逻辑 -->
<div className={styles.mediaList}>
{uploadedFiles.length > 0 && (
<>
{uploadedFiles.map((file, index) => (
<div
key={`${file.fullSrc}-${index}`}
className={styles.mediaItemWrap}
>
{file.isUploading ? (
<!-- 自定义文件上传中样式 -->
<div className={styles.mask}>...</div>
) : (
renderMediaItem(file, index)
)}
</div>
))}
</>
)}
<!-- 不满九张时,可继续添加 -->
{uploadedFiles.length < 9 && (
<div className={styles.addBtn}>
<input
accept="image/*,video/*"
className={styles.input}
onChange={handleUpload}
type="file"
/>
</div>
)}
{isPreview && (
<Preview
items={uploadedFiles.map((file) => ({
type: file.type,
src: file.blobSrc, <!-- 修改为 blobSrc -->
}))}
setIsPreview={setIsPreview}
previewIndex={previewIndex}
/>
)}
</div>
</>
)
通过判断当前的设备类型,即可在双端成功预览视频。
最后
作为移动端领域的新手,记录一下自己实现相关功能的过程。
欢迎大家提出不足之处或其他建议,一起进步。