React PDF 预览终极优化:30 页大文件不卡,加载快如闪电!
在前端开发中,PDF 预览是个常见需求。简单粗暴的方案是用 <embed> 标签直接嵌入,但你有没有遇到过这样的问题:样式不好调、功能太单一、用户体验不够友好?今天,我要带你认识一个基于 react-pdf 的自定义 PDF 预览组件 PDFView,它不仅支持翻页、缩放、全屏,还能无缝集成到你的项目中。我们会拆解它的实现,对比 <embed> 的优劣,最后用一个 Demo 展示它的实力。准备好了吗?让我们一起把 PDF 预览玩出新花样吧!
为什么需要自定义 PDF 预览?
先说说需求场景。假设你有个文件管理系统,用户上传 PDF 后需要在线预览。你可能会直接写:
<embed src="file.pdf#toolbar=0" type="application/pdf" width="100%" height="700px" />
这行代码确实能用,但问题不少:
- 样式控制弱:背景、边框不好调整,工具栏难以隐藏。
- 交互性差:没有翻页按钮、缩放功能,用户体验一般。
- 功能单一:无法动态调整页面大小或全屏展示。
而我们的 PDFView 组件,基于 react-pdf,用 React 的方式解决问题,提供更灵活的控制和更优雅的体验。接下来,我们拆解它的代码,看看它是怎么“打败” <embed> 的!
核心代码拆解:从设计到实现
问题驱动开发
初版本的痛点:
- 性能瓶颈:大文件一次性加载全部页面,内存占用高,加载慢。
- 功能缺失:没有页面旋转,方向不对只能干瞪眼;没有多页预览,翻页全靠手动。
这些问题在实际场景中很常见。比如,用户上传一个 50 页的合同 PDF,如果加载卡顿,或者需要旋转查看签名页,原始版本就有点“力不从心”。优化后的 PDFView 将通过分页加载提升性能,新增旋转和缩略图功能,让体验飞起来!
优化后的核心实现
1. 性能优化:分页加载
- 问题:原始版本用 <Document> 一次性加载所有页面,大文件时容易卡顿。
- 解决:引入 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. 禁用多余渲染:轻量化页面
- 思路:关闭文本层和注释层,只渲染图像内容。
- 实现:在 <Page> 组件中设置 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 自定义:谁更胜一筹?
我们用一个表格对比 <embed> 和 PDFView:
特性 | <embed> | PDFView |
---|---|---|
实现方式 | 原生 HTML 标签 | React 组件,基于 react-pdf |
样式控制 | 有限(仅宽高) | 完全自定义(背景、工具栏、页面样式) |
交互功能 | 内置工具栏(可隐藏但不灵活) | 自定义翻页、缩放、全屏,手动控制页码 |
加载提示 | 无 | 支持加载和错误提示 |
全屏支持 | 依赖浏览器 | 一键切换全屏 |
代码维护性 | 无需维护 | React 组件化,易扩展 |
依赖性 | 无需额外库 | 依赖 react-pdf 和 pdfjs-dist |
选择 PDFView 的理由
- 灵活性:自定义样式和交互,适配复杂需求。
- 用户体验:翻页、缩放、全屏一应俱全,加载和错误状态友好。
- 可维护性:组件化设计,易于集成和扩展。
<embed> 适合简单场景,但一旦需求复杂,它就显得力不从心。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 从“卡到怀疑人生”变成了“快如闪电”,用户体验和性能双双起飞!
技术亮点:为什么它这么强?
- 性能飞跃:
- 分页加载避免内存爆炸,大文件也能轻松应对。
- 动态加载逻辑清晰,体验流畅。
- 功能升级:
- 页面旋转解决方向问题,实用性拉满。
- 多页预览提供全局视角,操作更直观。
- 用户体验:
- 缩略图模式与单页模式无缝切换。
- 工具栏新增图标,交互更友好。
总结:你的 PDF 预览“性能王”
优化后的 PDFView 堪称 PDF 预览的“性能王”,30 页大文件不卡,加载快如闪电。通过按需加载和轻量化渲染,它解决了卡顿难题;加上旋转和多页预览,功能也更强大。试着把它丢进你的项目,上传个大 PDF 测试一下,感受性能飞跃的快感吧!有其他需求或优化思路?欢迎留言,我们一起把它打磨得更牛!
关键词:React PDF 预览、大文件优化、按需加载、性能提升、前端 PDF 处理。