在前端开发中,PDF 预览是个常见需求。简单粗暴的方案是用
先说说需求场景。假设你有个文件管理系统,用户上传 PDF 后需要在线预览。你可能会直接写:
这行代码确实能用,但问题不少:
而我们的 PDFView 组件,基于 react-pdf,用 React 的方式解决问题,提供更灵活的控制和更优雅的体验。接下来,我们拆解它的代码,看看它是怎么“打败”
初版本的痛点:
这些问题在实际场景中很常见。比如,用户上传一个 50 页的合同 PDF,如果加载卡顿,或者需要旋转查看签名页,原始版本就有点“力不从心”。优化后的 PDFView 将通过分页加载提升性能,新增旋转和缩略图功能,让体验飞起来!
useEffect(() => {
if (pageNumber && !loadedPages.has(pageNumber)) {
setLoadedPages(prev => new Set(prev).add(pageNumber));
}
}, [pageNumber]);
const rotateLeft = () => setRotation((prev) => (prev - 90) % 360);
const rotateRight = () => setRotation((prev) => (prev + 90) % 360);
{showThumbnails ? (
{Array.from({ length: numPages }, (_, i) => i + 1).map((page) => (
{ setPageNumber(page); setShowThumbnails(false); }}>
{loadedPages.has(page) ? (
} />
) : (
加载中...
)}
第 {page} 页
))}
) : (
} />
)}
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]);
}
renderTextLayer={false}
renderAnnotationLayer={false}
/>
{visiblePages.includes(page) ? (
} renderTextLayer={false} renderAnnotationLayer={false} />
) : (
第 {page} 页
)}
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(null);
const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [pageWidth, setPageWidth] = useState(defaultWidth);
const [fullscreen, setFullscreen] = useState(false);
const [rotation, setRotation] = useState(0);
const [showThumbnails, setShowThumbnails] = useState(false);
const [visiblePages, setVisiblePages] = useState([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=()=>(
}
loading={ }
>
{showThumbnails ? (
{Array.from({ length: numPages }, (_, i) => i + 1).map((page) => (
{
setPageNumber(page);
setShowThumbnails(false);
}}
>
{visiblePages.includes(page) ? (
}
renderTextLayer={false} // 禁用文本层,提升性能
renderAnnotationLayer={false} // 禁用注释层
/>
) : (
第 {page} 页
)}
第 {page} 页
))}
) : (
}
renderTextLayer={false} // 禁用文本层
renderAnnotationLayer={false} // 禁用注释层
error={() => setPageNumber(1)}
/>
)}
{' '}
/ {numPages}
{fullscreen ? : }
{onClose && (
)}
.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;
}
我们用一个表格对比
特性 | PDFView | |
---|---|---|
实现方式 | 原生 HTML 标签 | React 组件,基于 react-pdf |
样式控制 | 有限(仅宽高) | 完全自定义(背景、工具栏、页面样式) |
交互功能 | 内置工具栏(可隐藏但不灵活) | 自定义翻页、缩放、全屏,手动控制页码 |
加载提示 | 无 | 支持加载和错误提示 |
全屏支持 | 依赖浏览器 | 一键切换全屏 |
代码维护性 | 无需维护 | React 组件化,易扩展 |
依赖性 | 无需额外库 | 依赖 react-pdf 和 pdfjs-dist |
该组件已集成到 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(null);
const ref = useRef(null);
const [visible, setVisible] = useState(false);
const handleFileChange = (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
if (file) {
setFileUrl(URL.createObjectURL(file))
};
};
return (
{fileUrl&& {
setFileUrl(null)
}}
/>}
);
};
export default App;
特性 | 优化前 | 优化后 |
---|---|---|
30 页加载 | 卡顿数秒 | 秒开,仅加载当前页 |
内存占用 | 高(全量解析) | 低(按需加载) |
缩略图性能 | 全渲染,易卡 | 部分渲染,轻量快捷 |
响应速度 | 慢 | 快 |
优化后,30 页 PDF 从“卡到怀疑人生”变成了“快如闪电”,用户体验和性能双双起飞!
优化后的 PDFView 堪称 PDF 预览的“性能王”,30 页大文件不卡,加载快如闪电。通过按需加载和轻量化渲染,它解决了卡顿难题;加上旋转和多页预览,功能也更强大。试着把它丢进你的项目,上传个大 PDF 测试一下,感受性能飞跃的快感吧!有其他需求或优化思路?欢迎留言,我们一起把它打磨得更牛!
关键词:React PDF 预览、大文件优化、按需加载、性能提升、前端 PDF 处理。