React

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript类库 > React > React预览PDF

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

作者:小尧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 类型),只加载当前页和用户访问过的页面。

实现

useEffect(() => {
  if (pageNumber && !loadedPages.has(pageNumber)) {
    setLoadedPages(prev => new Set(prev).add(pageNumber));
  }
}, [pageNumber]);

2.功能增强:页面旋转

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

实现

const rotateLeft = () => setRotation((prev) => (prev - 90) % 360);
const rotateRight = () => setRotation((prev) => (prev + 90) % 360);

3.功能增强:多页预览

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

实现

{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 控制渲染页面,初始只加载元信息,动态加载当前页。

实现

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.禁用多余渲染:轻量化页面

<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. 组件设计:灵活与可控

输入参数

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

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

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

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

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;

使用效果

​编辑

性能对比:优化前后

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

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

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

1.性能飞跃

2.功能升级

3.用户体验

总结

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

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

您可能感兴趣的文章:
阅读全文