基于 React 实现移动端图片/视频预览组件

1,114 阅读3分钟

前言

当前需求有一个上传图片/视频成功后,点击放大预览的功能。但是,强大如 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 组件的样式效果如下:

image.png

实现 Preview 组件

考虑到放大后需要左右切换展示,经过实践放弃了 Ant Design MobileSwiper 组件,因为 Swiper.Item 组件高度无法自适应,在高度不一致的情况下会影响空白处的点击事件。最终选择 SwiperJS 来实现功能,因为它有 autoHeight 属性。

image.png

指路:SwiperApi 文档Swiper React Components 文档

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>
  </>
)

上述代码,即可在本地实现预览功能,效果如下:

20241206110842_rec_.gif

问题和解决思路

视频封面展示失败

在效果展示那里也可以看到,列表处的图片可以正常展示,但视频封面为空。

image.png

解决方案:在 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>
  </>
)

通过判断当前的设备类型,即可在双端成功预览视频。

最后

作为移动端领域的新手,记录一下自己实现相关功能的过程。

欢迎大家提出不足之处或其他建议,一起进步。