React + antd 实现文件预览与下载组件(支持图片、PDF、Office)

一、组件介绍

本文介绍一个基于React + Ant Design实现的高服用组件预览与下载组件,支持图片,PDF,Office文档(Word,Excel,PPT)等常见文件类型。

二、适用场景

  • 文件管理后台的文件预览与下载
  • 富文本、附件、合同等场景的文件快速预览
  • 支持多种文件类型的统一预览入口
  • 需要高扩展性、可插拔的文件预览能力

三、核心实现思路

  • 类型判断:通过工具函数判断文件类型(图片、视频、Office、PDF等)。
  • 分发渲染:根据类型分发到不同的预览组件(如图片用 Antd Image,PDF 用 pdfjs 渲染,Office 用微软在线预览)。
  • 下载能力:集成 file-saver 实现一键下载。
  • 高扩展性:类型判断、渲染逻辑均可扩展,便于后续支持更多类型。
  • 注释友好:关键逻辑均有中文注释,便于团队协作和二次开发。

四、关键代码实现

1. 实现文件类型判断工具
js 复制代码
import { UploadFile } from 'antd';
/**
 * 判断是否为图片
 * @param file 上传的文件对象
 * @returns true 表示是图片,false 表示不是
 */
export const isImageUrl = (file: UploadFile): boolean => {
   if (file.type && !file.thumbUrl) {
    return isImageFileType(file.type);
  }
  const url: string = (file.thumbUrl || file.url || '') as string;
  const extension = extname(url);
  if (/^data:image\//.test(url) || /(webp|svg|png|gif|jpg|jpeg|jfif|bmp|dpg|ico|heic|heif)$/i.test(extension)) {
    return true;
  }
  if (/^data:/.test(url)) {
    // other file types of base64
    return false;
  }
  if (extension) {
    // other file types which have extension
    return false;
  }
  return true;
};

/**
 * 判断是否为视频
 * @param file 上传的文件对象
 * @returns true 表示是视频文件,false 表示不是
 */
export const isVideoFileType = (type: string): boolean => type.indexOf('video/') === 0;
export const isVideoUrl = (file: UploadFile): boolean => {
  if (file.type && !file.thumbUrl) {
    return isVideoFileType(file.type);
  }
  const url: string = (file.thumbUrl || file.url || '') as string;
  const extension = extname(url.toLowerCase()); // 转换为小写以进行不区分大小写的比较
  if (
    /^data:video\//.test(url) ||
    /\.(mp4|avi|mov|mkv|wmv|flv|webm|m4v|mpeg|mpg)$/i.test(extension)
  ) {
    return true;
  }
  if (/^data:/.test(url)) {
    // 其他非视频类型的base64数据
    return false;
  }
  if (extension) {
    // 其他有扩展名的文件类型
    return false;
  }
  return true;
};

/**
 * 判断文件是否为微软 Office 文件
 * @param file 上传的文件对象
 * @returns true 表示是 Office 文件,false 表示不是
 */
export const isMicrosoftOfficeFile = (file: UploadFile): boolean => {
  if (file.type && !file.thumbUrl) {
    // 通过 MIME 类型判断文件类型
    return isMicrosoftOfficeMimeType(file.type);
  }

  const url: string = (file.thumbUrl || file.url || '') as string;
  const extension = extname(url.toLowerCase()); // 转换为小写以进行不区分大小写的比较

  // 通过文件扩展名判断文件类型
  if (
    /\.(docx|xlsx|pptx|doc|xls|ppt|dotx|xltx|potx)$/i.test(extension)
  ) {
    return true;
  }

  // 判断文件是否为 base64 编码的微软文件(一般不会是这种情况,但可以作为补充)
  if (/^data:/.test(url)) {
    return false;
  }

  return false;
};

/**
 * 判断是否为PDF文件
 * @param file 上传的文件对象
 * @returns true 表示是PDF文件,false 表示不是
 */
export const isPdfFile=(url: string)=> {
  return url?.toLowerCase().endsWith('.pdf');
}
2. 预览组件核心实现
js 复制代码
import { Drawer, UploadFile, Image } from "antd";
import * as pdfjsLib from 'pdfjs-dist/build/pdf';
import React, { forwardRef, useImperativeHandle, useState, useEffect } from "react";
import { isImageUrl, isVideoUrl, isMicrosoftOfficeFile, isPdf } from "@/utils/tools/file";
import Video, { VideoPlayerModalRef } from "./components/VideoPlayer";
import fileSaver from "file-saver";

/**
 * Office 文件在线预览
 */
const OfficeFilePreview: React.FC<{ url: string }> = ({ url }) => {
  const office_prefix = `https://view.officeapps.live.com/op/embed.aspx?src=`;
  return (
    <iframe
      title="Office File Preview"
      src={`${office_prefix}${url}`}
      width="100%"
      height="100%"
      style={{ border: 0 }}
    />
  );
};

/**
 * PDF 文件预览容器
 */
const PdfFilePreview: React.FC<{ url: string; containerRef: React.RefObject<HTMLDivElement> }> = ({ url, containerRef }) => {
  return <div ref={containerRef} style={{ width: '100%' }} />;
};

/**
 * 通用文件预览与下载组件
 */
export const Preview = forwardRef<FilePreviewDownloadRef, FilePreviewDownloadProps>((props, ref) => {
  const { children, width = 960, title = '预览文件', closeText = '关闭' } = props
  const [visible, setVisible] = useState(false)
  const [imagePreview, setImagePreview] = useState<boolean>(false)
  const [url, setUrl] = useState<string>('')
  const pdfContainer = React.useRef<HTMLDivElement>(null)
  const videoRef = React.useRef<VideoPlayerModalRef>(null)
  pdfjsLib.GlobalWorkerOptions.workerSrc = '//static.xyb2b.com/public/pdfjs/3.6.172/pdf.worker.js';

  // PDF 渲染逻辑
  const onPdfPreview = async () => {
    try {
      const pdf = await pdfjsLib.getDocument(url).promise;
      const numPages = pdf.numPages;
      for (let pageNum = 1; pageNum <= numPages; pageNum++) {
        const page = await pdf.getPage(pageNum);
        const scale = 1.5;
        const viewport = page.getViewport({ scale });
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');
        canvas.setAttribute("id", "the-canvas" + pageNum);
        canvas.height = viewport.height;
        canvas.width = viewport.width;
        // 优先渲染容器防止乱序
        pdfContainer.current?.appendChild(canvas);
        const renderContext = {
          canvasContext: ctx,
          viewport: viewport
        };
        await page.render(renderContext).promise;
      }
    } catch (error) {
      console.log('error', error)
    }
  }

  // 预览入口
  const onPreview = (file: UploadFile) => {
    const { url } = file || {}
    if (!url) return
    if (isImageUrl(file)) {
      setImagePreview(true);
      setUrl(url)
    } else if (isVideoUrl(file)) {
      videoRef.current?.open(url);
    } else {
      setUrl(url)
      setVisible(true)
    }
  }

  // 下载入口
  const onDownload = (file: UploadFile, setLoading?: (loading: boolean) => void) => {
    if (setLoading) setLoading(true);
    try {
      const { url, name } = file || {}
      fileSaver.saveAs(url as string, name);
      if (setLoading) setLoading(false);
    } catch (error) {
      throw new Error('下载失败: ' + error);
    }
  }

  useImperativeHandle(ref, () => ({
    onPreview,
    onDownload
  }), [url])

  /**
   * 渲染文件内容,根据类型分发
   */
  const RenderFileContent = useMemo(() => {
    const fakeFile = { url } as any;
    if (isMicrosoftOfficeFile(fakeFile)) {
      return <OfficeFilePreview url={url} />;
    }
    if (isPdfFile(url)) {
      return <PdfFilePreview url={url} containerRef={pdfContainer} />;
    }
    return <div>暂不支持该类型文件预览</div>;
  }, [url]);
  
   const clearPdfContainer = () => {
    if (pdfContainer.current) {
      pdfContainer.current.innerHTML = '';
    }
  };

  useEffect(() => {
    const isRenderPDF = url && isPdfFile(url) && visible
    if (isRenderPDF) {
      clearPdfContainer()
      onPdfPreview()
    }
    // 关闭时清理
    return () => clearPdfContainer();
  }, [url, visible])

  return (
    <>
      {visible && <Drawer
        title={title}
        width={width}
        visible={visible}
        onClose={() => setVisible(false)}
        destroyOnClose
      >
        <RenderFileContent />
      </Drawer>}
      <Image
        height={0}
        width={0}
        src={url}
        preview={{
          visible: imagePreview,
        }}
        onPreviewClose={() => setImagePreview(false)}
      />
      <Video.Modal ref={videoRef as any} />
      {children ?? <div>{children}</div>}
    </>)
})

五、使用方式

import 复制代码
import { UploadFile } from 'antd';

const file: UploadFile = {
  url: 'https://yourdomain.com/yourfile.pdf',
  name: 'yourfile.pdf',
  ...自定义属性
};

const previewRef = useRef<FilePreviewDownloadRef>(null);

previewRef.current?.onPreview(file);

previewRef.current?.onDownload(file);
相关推荐
然我6 分钟前
数组的创建与遍历:从入门到精通,这些坑你踩过吗? 🧐
前端·javascript·面试
豆豆(设计前端)14 分钟前
如何成为高级前端开发者:系统化成长路径。
前端·javascript·vue.js·面试·electron
今天你写算法了吗18 分钟前
ScratchCard刮刮卡交互元素的实现
前端·javascript
谢尔登30 分钟前
【React Native】布局和 Stack 、Slot
javascript·react native·react.js
FogLetter37 分钟前
深入浅出 JavaScript 数组:从基础到高级玩法
前端·javascript
一小池勺1 小时前
🚀 clsx vs shadcn/ui的cn函数:前端类名拼接工具大PK
前端
lens941 小时前
RSC、SSR傻傻分不清?一文搞懂所有渲染概念!
前端·next.js
spionbo1 小时前
前端部署VuePress Theme Hope主题部署到gitlab,使用pnpm构建,再同步到netlify绑定腾讯云域名实现
前端
小华同学ai1 小时前
惊喜! Github 10k+ star 的国产流程图框架,LogicFlow 能解你的图编辑痛点?
前端·后端·github
迷曳1 小时前
24、鸿蒙Harmony Next开发:不依赖UI组件的全局自定义弹出框 (openCustomDialog)
dialog·前端·ui·harmonyos·鸿蒙