React实现PDF预览功能与终极优化

 更新时间:2025年05月30日 16:21:06   作者:小尧1  
在前端开发中,PDF 预览是个常见需求,本文主要来带大家认识一个基于 react-pdf 的自定义 PDF 预览组件 PDFView,感兴趣的小伙伴可以了解下

 在前端开发中,PDF 预览是个常见需求。简单粗暴的方案是用 标签直接嵌入,但你有没有遇到过这样的问题:样式不好调、功能太单一、用户体验不够友好?今天,我要带你认识一个基于 react-pdf 的自定义 PDF 预览组件 PDFView,它不仅支持翻页、缩放、全屏,还能无缝集成到你的项目中。我们会拆解它的实现,对比 的优劣,最后用一个 Demo 展示它的实力。准备好了吗?让我们一起把 PDF 预览玩出新花样吧!

为什么需要自定义 PDF 预览?

先说说需求场景。假设你有个文件管理系统,用户上传 PDF 后需要在线预览。你可能会直接写:

<embed src="file.pdf#toolbar=0" type="application/pdf" width="100%" height="700px" />

这行代码确实能用,但问题不少:

  • 样式控制弱:背景、边框不好调整,工具栏难以隐藏。
  • 交互性差:没有翻页按钮、缩放功能,用户体验一般。
  • 功能单一:无法动态调整页面大小或全屏展示。

而我们的 PDFView 组件,基于 react-pdf,用 React 的方式解决问题,提供更灵活的控制和更优雅的体验。接下来,我们拆解它的代码,看看它是怎么“打败” 的!

核心代码拆解:从设计到实现

问题驱动开发

初版本的痛点:

  • 性能瓶颈:大文件一次性加载全部页面,内存占用高,加载慢。
  • 功能缺失:没有页面旋转,方向不对只能干瞪眼;没有多页预览,翻页全靠手动。

这些问题在实际场景中很常见。比如,用户上传一个 50 页的合同 PDF,如果加载卡顿,或者需要旋转查看签名页,原始版本就有点“力不从心”。优化后的 PDFView 将通过分页加载提升性能,新增旋转和缩略图功能,让体验飞起来!

优化后的核心实现

1. 性能优化:分页加载

问题:原始版本用 一次性加载所有页面,大文件时容易卡顿。

解决:引入 loadedPages 状态(Set 类型),只加载当前页和用户访问过的页面。

实现

  • 初始化仅加载第 1 页。
  • 用户翻页或跳转时动态添加加载页面。
  • 缩略图模式下未加载页面显示占位符,点击时加载。
useEffect(() => {
  if (pageNumber && !loadedPages.has(pageNumber)) {
    setLoadedPages(prev => new Set(prev).add(pageNumber));
  }
}, [pageNumber]);

2.功能增强:页面旋转

需求:支持用户调整页面方向(比如横向文档)。

实现

  • 新增 rotation 状态,默认 0°。
  • 提供 rotateLeft(-90°)和 rotateRight(+90°)函数。
  • 通过 Page 组件的 rotate 属性应用旋转。
const rotateLeft = () => setRotation((prev) => (prev - 90) % 360);
const rotateRight = () => setRotation((prev) => (prev + 90) % 360);

3.功能增强:多页预览

需求:用户想快速浏览所有页面,像缩略图一样。

实现

  • 新增 showThumbnails 状态,切换单页和缩略图模式。
  • 缩略图模式下渲染所有页面(小尺寸),点击跳转到对应页。
{showThumbnails ? (
  <div className={styles.thumbnailContainer}>
    {Array.from({ length: numPages }, (_, i) => i + 1).map((page) => (
      <div key={page} className={styles.thumbnail} onClick={() => { setPageNumber(page); setShowThumbnails(false); }}>
        {loadedPages.has(page) ? (
          <Page pageNumber={page} width={150} rotate={rotation} loading={<Spin />} />
        ) : (
          <div className={styles.thumbnailPlaceholder}>加载中...</div>
        )}
        <span>第 {page} 页</span>
      </div>
    ))}
  </div>
) : (
  <Page pageNumber={pageNumber} width={pageWidth} rotate={rotation} loading={<Spin size="large" />} />
)}

4.按需加载:只渲染当前页

思路:用 visiblePages 控制渲染页面,初始只加载元信息,动态加载当前页。

实现

  • 移除 loadedPages,用 visiblePages 精确控制。
  • 单页模式只渲染 pageNumber,缩略图模式限制前后几页。
useEffect(() => {
  if (!showThumbnails) {
    setVisiblePages([pageNumber]);
  } else {
    const start = Math.max(1, pageNumber - 2);
    const end = Math.min(numPages, pageNumber + 2);
    setVisiblePages(Array.from({ length: end - start + 1 }, (_, i) => start + i));
  }
}, [pageNumber, showThumbnails, numPages]);

5.禁用多余渲染:轻量化页面

  • 思路:关闭文本层和注释层,只渲染图像内容。
  • 实现:在 组件中设置 renderTextLayer={false} 和 renderAnnotationLayer={false}。
<Page
  pageNumber={pageNumber}
  width={pageWidth}
  rotate={rotation}
  loading={<Spin size="large" />}
  renderTextLayer={false}
  renderAnnotationLayer={false}
/>

6.优化缩略图:避免过载

思路:缩略图模式下不一次性加载所有页面,用占位符替代未加载页。

实现:仅渲染当前页附近的页面,其他显示静态文本。

  {visiblePages.includes(page) ? (
    <Page pageNumber={page} width={150} rotate={rotation} loading={<Spin />} renderTextLayer={false} renderAnnotationLayer={false} />
  ) : (
    <div className={styles.thumbnailPlaceholder}>第 {page} 页</div>
  )}

展示完整代码

import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Spin, Tooltip, Input } from 'antd';
import {
  LeftOutlined,
  RightOutlined,
  PlusCircleOutlined,
  MinusCircleOutlined,
  FullscreenExitOutlined,
  FullscreenOutlined,
  CloseCircleOutlined,
  ExclamationCircleOutlined,
  RotateLeftOutlined,
  RotateRightOutlined,
  UnorderedListOutlined,
} from '@ant-design/icons';
import  './index.less';
import { Document, Page, pdfjs } from 'react-pdf';
import pdfjsWorker from 'react-pdf/dist/esm/pdf.worker.entry';

pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;

const PDFView = ({
  file,
  parentDom,
  onClose,
}: {
  file?: string | null;
  parentDom?: HTMLDivElement | null;
  onClose?: () => void;
}) => {
  const defaultWidth = 600;
  const pageDiv = useRef<HTMLDivElement>(null);
  const [numPages, setNumPages] = useState<number>(0);
  const [pageNumber, setPageNumber] = useState<number>(1);
  const [pageWidth, setPageWidth] = useState<number>(defaultWidth);
  const [fullscreen, setFullscreen] = useState<boolean>(false);
  const [rotation, setRotation] = useState<number>(0);
  const [showThumbnails, setShowThumbnails] = useState<boolean>(false);
  const [visiblePages, setVisiblePages] = useState<number[]>([1]); // 控制可见页面

  const parent = parentDom || document.body;

  // 加载 PDF 元信息,不渲染全部页面
  const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => {
    setNumPages(numPages);
  }, []);

  const lastPage = () => pageNumber > 1 && setPageNumber(pageNumber - 1);
  const nextPage = () => pageNumber < numPages && setPageNumber(pageNumber + 1);
  const onPageNumberChange = (e: { target: { value: string } }) => {
    let value = Math.max(1, Math.min(numPages, Number(e.target.value) || 1));
    setPageNumber(value);
    setVisiblePages([value]); // 只加载当前页
  };

  const pageZoomIn = () => setPageWidth(pageWidth * 1.2);
  const pageZoomOut = () => pageWidth > defaultWidth && setPageWidth(pageWidth * 0.8);
  const pageFullscreen = () => {
    setPageWidth(fullscreen ? defaultWidth : parent.offsetWidth - 50);
    setFullscreen(!fullscreen);
  };

  const rotateLeft = () => setRotation((prev) => (prev - 90) % 360);
  const rotateRight = () => setRotation((prev) => (prev + 90) % 360);
  const toggleThumbnails = () => setShowThumbnails(!showThumbnails);

  // 动态更新可见页面
  useEffect(() => {
    if (!showThumbnails) {
      setVisiblePages([pageNumber]);
    } else {
      // 缩略图模式下限制加载数量,避免卡顿
      const start = Math.max(1, pageNumber - 2);
      const end = Math.min(numPages, pageNumber + 2);
      setVisiblePages(Array.from({ length: end - start + 1 }, (_, i) => start + i));
    }
  }, [pageNumber, showThumbnails, numPages]);

  useEffect(() => setPageNumber(1), [file]);
  useEffect(() => {
    if( pageDiv.current){
     (pageDiv.current.scrollTop = 0)
    }
  }, [pageNumber]);

  const renderContent=()=>(<div className='view'>
    <div className='viewContent' >
      <div className='pageMain' ref={pageDiv}>
        <div className='pageContainer'>
            <Document
              file={file}
              onLoadSuccess={onDocumentLoadSuccess}
              error={
                <div style={{ textAlign: 'center', width: defaultWidth + 'px' }}>
                  <ExclamationCircleOutlined style={{ fontSize: '150px', color: '#fe725c', margin: '100px' }} />
                </div>
              }
              loading={<div style={{ textAlign: 'center', width: defaultWidth + 'px' }}><Spin size="large" style={{ margin: '200px' }} /></div>}
            >
              {showThumbnails ? (
                <div className='thumbnailContainer'>
                  {Array.from({ length: numPages }, (_, i) => i + 1).map((page) => (
                    <div
                      key={page}
                      className='thumbnail'
                      onClick={() => {
                        setPageNumber(page);
                        setShowThumbnails(false);
                      }}
                    >
                      {visiblePages.includes(page) ? (
                        <Page
                          pageNumber={page}
                          width={150}
                          rotate={rotation}
                          loading={<Spin />}
                          renderTextLayer={false} // 禁用文本层,提升性能
                          renderAnnotationLayer={false} // 禁用注释层
                        />
                      ) : (
                        <div className='thumbnailPlaceholder'>第 {page} 页</div>
                      )}
                      <span>第 {page} 页</span>
                    </div>
                  ))}
                </div>
              ) : (
                <Page
                  pageNumber={pageNumber}
                  width={pageWidth}
                  rotate={rotation}
                  loading={<Spin size="large" />}
                  renderTextLayer={false} // 禁用文本层
                  renderAnnotationLayer={false} // 禁用注释层
                  error={() => setPageNumber(1)}
                />
              )}
            </Document>
        </div>
      </div>
      <div className='pageBar'>
        <div className='pageTool'>
          <Tooltip title={pageNumber === 1 ? '已是第一页' : '上一页'}>
            <LeftOutlined onClick={lastPage} />
          </Tooltip>
          <Input
            value={pageNumber}
            onChange={onPageNumberChange}
            onPressEnter={onPageNumberChange as any}
            type="number"
          />{' '}
          / {numPages}
          <Tooltip title={pageNumber === numPages ? '已是最后一页' : '下一页'}>
            <RightOutlined onClick={nextPage} />
          </Tooltip>
          <Tooltip title="放大">
            <PlusCircleOutlined onClick={pageZoomIn} />
          </Tooltip>
          <Tooltip title="缩小">
            <MinusCircleOutlined onClick={pageZoomOut} />
          </Tooltip>
          <Tooltip title="向左旋转">
            <RotateLeftOutlined onClick={rotateLeft} />
          </Tooltip>
          <Tooltip title="向右旋转">
            <RotateRightOutlined onClick={rotateRight} />
          </Tooltip>
          <Tooltip title={showThumbnails ? '关闭缩略图' : '显示缩略图'}>
            <UnorderedListOutlined onClick={toggleThumbnails} />
          </Tooltip>
          <Tooltip title={fullscreen ? '恢复默认' : '适合窗口'}>
            {fullscreen ? <FullscreenExitOutlined onClick={pageFullscreen} /> : <FullscreenOutlined onClick={pageFullscreen} />}
          </Tooltip>
          {onClose && (
            <Tooltip title="关闭">
              <CloseCircleOutlined onClick={onClose} />
            </Tooltip>
          )}
        </div>
      </div>
    </div>
  </div>)
  if(parentDom){
    return renderContent()
  }
  return createPortal(
    renderContent(),
    parent,)
};

export default PDFView;

优化后的样式 (index.less)

.view {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 999;
}

.viewContent {
  position: relative;
  width: 100%;
  height: 100%;
}

.pageMain {
  display: flex;
  justify-content: center;
  width: 100%;
  height: 100%;
  overflow: auto;
  background: #444;
}

.pageContainer {
  width: max-content;
  max-width: 100%;
  margin: 25px 0;
  background: #fff;
  box-shadow: rgba(0, 0, 0, 0.2) 0px 2px 4px 0px;
  // :global {
  //   .react-pdf__Page__textContent { display: none; }
  // }
}

.pageBar {
  position: absolute;
  bottom: 35px;
  width: 100%;
  text-align: center;
}

.pageTool {
  display: inline-block;
  padding: 8px 15px;
  color: white;
  background: rgba(66, 66, 66, 0.5);
  border-radius: 15px;
  box-shadow: rgba(0, 0, 0, 0.2) 0px 2px 4px 0px;
  span {
    margin: 0 5px;
    padding: 5px;
    &:hover { background: #333; }
  }
  input {
    display: inline-block;
    width: 50px;
    height: 24px;
    margin-right: 10px;
    text-align: center;
  }
  input::-webkit-outer-spin-button,
  input::-webkit-inner-spin-button { -webkit-appearance: none; }
  input[type='number'] { -moz-appearance: textfield; }
}

.thumbnailContainer {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 20px;
  padding: 20px;
}

.thumbnail {
  cursor: pointer;
  text-align: center;
  background: #fff;
  padding: 10px;
  border-radius: 5px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  &:hover { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); }
}

.thumbnailPlaceholder {
  width: 150px;
  height: 200px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f0f0f0;
  color: #666;
}

1. 组件设计:灵活与可控

输入参数

  • file:PDF 文件的 URL 或数据。
  • parentDom:渲染的目标容器,默认 document.body。
  • onClose:关闭回调。

渲染方式:用 createPortal 将组件挂载到指定 DOM,实现模态效果。

2. 状态管理:交互的核心

  • numPages 和 pageNumber:控制总页数和当前页。
  • pageWidth:动态调整页面宽度,默认 600px。
  • fullscreen:切换全屏状态。

3. 功能实现:用户体验的加分项

  • 翻页:lastPage 和 nextPage 控制前后翻页,Input 支持手动输入页码。
  • 缩放:pageZoomIn(放大 1.2 倍)、pageZoomOut(缩小 0.8 倍,限制最小值)。
  • 全屏:pageFullscreen 切换宽度至容器大小。
  • 滚动重置:页面切换时自动滚动到顶部。

4. UI 与样式:美观与实用并存

  • 布局:深色背景、白底页面、居中展示。
  • 工具栏:悬浮底部,包含翻页、缩放、全屏按钮,带 Tooltip 提示。
  • 加载与错误:用 Spin 和图标提示,提升用户感知。

Embed vs 自定义:谁更胜一筹?

我们用一个表格对比 和 PDFView:

特性PDFView
实现方式原生 HTML 标签React 组件,基于 react-pdf
样式控制有限(仅宽高)完全自定义(背景、工具栏、页面样式)
交互功能内置工具栏(可隐藏但不灵活)自定义翻页、缩放、全屏,手动控制页码
加载提示支持加载和错误提示
全屏支持依赖浏览器一键切换全屏
代码维护性无需维护React 组件化,易扩展
依赖性无需额外库依赖 react-pdf 和 pdfjs-dist

选择 PDFView 的理由

  • 灵活性:自定义样式和交互,适配复杂需求。
  • 用户体验:翻页、缩放、全屏一应俱全,加载和错误状态友好。
  • 可维护性:组件化设计,易于集成和扩展。

适合简单场景,但一旦需求复杂,它就显得力不从心。PDFView 则是“全能选手”,尤其在需要深度定制的项目中表现亮眼。

使用场景:从 Demo 看效果

如何使用这个组件?

该组件已集成到 react-nexlif 开源库中。你可以通过以下方式引入并使用:

示例代码

import React, { useState,useRef } from 'react';
import { PDFView } from 'react-nexlif';
import { Button, Modal } from 'antd';
const App: React.FC = () => {
  const [fileUrl, setFileUrl] = useState<string | null>(null);
  const ref = useRef<HTMLDivElement>(null);
  const [visible, setVisible] = useState(false);
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      setFileUrl(URL.createObjectURL(file))
    };
  };
  return (
    <div ref={ref} style={{ position: 'relative', height: '100%',width: '100%' }}>
      <input  type="file" accept=".pdf" onChange={handleFileChange} />
      
          <div ref={ref} style={{ position: 'relative', minHeight: '100vh',width:1100,height:'100%'}}>
       {fileUrl&& <PDFView
          parentDom={ref.current}
          file={fileUrl}
          onClose={() => {
            setFileUrl(null)
          }}
        />}
        </div>
    </div>
  );
};

export default App;

使用效果

​编辑

  • 上传大文件:加载 50 页 PDF,仅渲染当前页,响应迅速。
  • 翻页与跳转:左右箭头或输入页码切换,滚动自动归顶。
  • 旋转:点击旋转按钮,页面顺时针或逆时针调整。
  • 缩略图:点击列表图标,显示所有页面预览,点击跳转。
  • 缩放与全屏:放大缩小页面,或一键铺满屏幕。

性能对比:优化前后

特性优化前优化后
30 页加载卡顿数秒秒开,仅加载当前页
内存占用高(全量解析)低(按需加载)
缩略图性能全渲染,易卡部分渲染,轻量快捷
响应速度

优化后,30 页 PDF 从“卡到怀疑人生”变成了“快如闪电”,用户体验和性能双双起飞!

技术亮点:为什么它这么强

1.性能飞跃

  • 分页加载避免内存爆炸,大文件也能轻松应对。
  • 动态加载逻辑清晰,体验流畅。

2.功能升级

  • 页面旋转解决方向问题,实用性拉满。
  • 多页预览提供全局视角,操作更直观。

3.用户体验

  • 缩略图模式与单页模式无缝切换。
  • 工具栏新增图标,交互更友好。

总结

优化后的 PDFView 堪称 PDF 预览的“性能王”,30 页大文件不卡,加载快如闪电。通过按需加载和轻量化渲染,它解决了卡顿难题;加上旋转和多页预览,功能也更强大。试着把它丢进你的项目,上传个大 PDF 测试一下,感受性能飞跃的快感吧!有其他需求或优化思路?欢迎留言,我们一起把它打磨得更牛!

到此这篇关于React实现PDF预览功能与终极优化的文章就介绍到这了,更多相关React预览PDF内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • ReactNative 状态管理redux使用详解

    ReactNative 状态管理redux使用详解

    这篇文章主要介绍了ReactNative 状态管理redux使用详解
    2023-03-03
  • react如何同步获取useState的最新状态值

    react如何同步获取useState的最新状态值

    这篇文章主要介绍了react如何同步获取useState的最新状态值问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2024-01-01
  • React Suspense解决竞态条件详解

    React Suspense解决竞态条件详解

    这篇文章主要为大家介绍了React Suspense解决竞态条件详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-11-11
  • React全家桶环境搭建过程详解

    React全家桶环境搭建过程详解

    本篇文章主要介绍了React全家桶环境搭建过程详解,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2018-05-05
  • react-router 路由切换动画的实现示例

    react-router 路由切换动画的实现示例

    这篇文章主要介绍了react-router 路由切换动画的实现示例,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2018-12-12
  • React项目使用ES6解决方案及JSX使用示例详解

    React项目使用ES6解决方案及JSX使用示例详解

    这篇文章主要为大家介绍了React项目使用ES6解决方案及JSX使用示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-12-12
  • react反向代理使用http-proxy-middleware问题

    react反向代理使用http-proxy-middleware问题

    这篇文章主要介绍了react反向代理使用http-proxy-middleware问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2024-07-07
  • React.memo 和 useMemo 的使用问题小结

    React.memo 和 useMemo 的使用问题小结

    随着代码的增加,每次的状态改变,页面进行一次 reRender ,这将产生很多不必要的 reRender 不仅浪费性能,从而导致页面卡顿,这篇文章主要介绍了React.memo 和 useMemo 的使用问题小结,需要的朋友可以参考下
    2022-11-11
  • react递归组件实现树的示例详解

    react递归组件实现树的示例详解

    在一些react项目中,常常有一些需要目录树这种结构,这篇文章主要为大家介绍了如何使用递归组件实现树,感兴趣的小伙伴可以了解下
    2024-10-10
  • 在react项目中webpack使用mock数据的操作方法

    在react项目中webpack使用mock数据的操作方法

    这篇文章主要介绍了在react项目中webpack使用mock数据的操作方法,本文给大家介绍的非常详细,感兴趣的朋友跟随小编一起看看吧
    2024-06-06

最新评论