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}。
<PagepageNumber={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'><Documentfile={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) => (<divkey={page}className='thumbnail'onClick={() => {setPageNumber(page);setShowThumbnails(false);}}>{visiblePages.includes(page) ? (<PagepageNumber={page}width={150}rotate={rotation}loading={<Spin />}renderTextLayer={false} // 禁用文本层,提升性能renderAnnotationLayer={false} // 禁用注释层/>) : (<div className='thumbnailPlaceholder'>第 {page} 页</div>)}<span>第 {page} 页</span></div>))}</div>) : (<PagepageNumber={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><Inputvalue={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&& <PDFViewparentDom={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 处理。