在我们的项目中,PDF文档的展示是一个比较常见需求。最初,我们的PDF查看器做成了分页展示。但是对于用户而言,尤其是移动端用户,分页没有滚动操作方便,所以我们又做成了滚动查看。
无论哪种查看形势,我们都做的是全量加载,这样的做法又产生了新的问题,阅读按钮在加载完成之后才展示,所以有些用户反馈总是看不到按钮。
这时候,我意识到,对于大型PDF文件,全量加载方式会导致严重的性能问题:内存占用高、渲染时间长、用户体验差。
于是,我开始思考,如何解决大文件加载的性能瓶颈问题。
本文将从主流库的优缺点为起点,分享虚拟滚动技术实现懒加载优化从调研到实践的全过程。通过本文,您将掌握一套完整的PDF性能优化方案,包括技术选型、实现细节、性能调优等关键环节,这些经验可直接应用于您的实际项目中。
在React生态中,有多个可用于渲染PDF的第三方库,每个库都有其特点和适用场景:
库名称 | 懒加载 | 缩略图 | 移动端适配 | 社区活跃度 ★ |
---|---|---|---|---|
react-pdf | ✅ | ❌ | ✅ | ★★★★☆ |
react-pdf-viewer | ✅ | ✅ | ✅ | ★★★★ |
react-file-viewer | ⚠️ | ❌ | ⚠️ | ★★★ |
react-nexlif | ✅ | ✅ | ✅ | ★★ |
pdf.js-react | ❌ | ⚠️ | ✅ | ★★★★☆ |
基于项目需求和技术评估,我们选择react-pdf
作为基础库,原因如下:
react-pdf
提供了三个核心组件:
import { Document, Page, pdfjs } from 'react-pdf';
// 配置PDF.js worker路径(必须)
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`;
function PDFViewer({ file }) {
const [numPages, setNumPages] = useState(null);
function onDocumentLoadSuccess({ numPages }) {
setNumPages(numPages);
}
return (
<Document
file={file}
onLoadSuccess={onDocumentLoadSuccess}
>
{Array.from(new Array(numPages), (el, index) => (
<Page key={`page_${index + 1}`} pageNumber={index + 1} />
))}
</Document>
);
}
Document组件关键参数:
file
:PDF文件源,支持URL、base64、Uint8Array等多种格式。onLoadSuccess
:文档加载完成回调,返回文档元信息。loading
:自定义加载状态UI。error
:自定义错误状态UI。Page组件关键参数:
pageNumber
:当前渲染的页码(从1开始)。scale
:缩放比例,默认为1.0。width
:自定义页面宽度(高度自动计算)。renderTextLayer
:是否渲染文本层(支持文本选择)。renderAnnotationLayer
:是否渲染注释层。基础实现的性能问题
虽然基础实现可以正常工作,但在处理大型PDF(如100+页)时会遇到明显性能瓶颈:
虚拟滚动(Virtual Scrolling)通过仅渲染可视区域内的内容,大幅减少DOM节点数量:
结合react-pdf
和虚拟滚动技术,我们实现分页懒加载功能:
import { useState, useRef, useEffect } from 'react';
import { Document, Page } from 'react-pdf';
const PDFLazyViewer = ({ file }) => {
const [numPages, setNumPages] = useState(null);
const [visiblePages, setVisiblePages] = useState(new Set());
const containerRef = useRef(null);
// 文档加载回调
const onDocumentLoadSuccess = ({ numPages }) => {
setNumPages(numPages);
// 初始加载前几页
setVisiblePages(new Set([1, 2, 3]));
};
// 滚动事件处理
useEffect(() => {
const handleScroll = () => {
if (!containerRef.current || !numPages) return;
const { scrollTop, clientHeight } = containerRef.current;
const pageHeight = clientHeight; // 假设每页高度与容器相同
const currentPage = Math.floor(scrollTop / pageHeight) + 1;
// 计算预加载范围(当前页前后各2页)
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(numPages, currentPage + 2);
const newVisiblePages = new Set();
for (let i = startPage; i <= endPage; i++) {
newVisiblePages.add(i);
}
setVisiblePages(newVisiblePages);
};
const container = containerRef.current;
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [numPages]);
return (
<div
ref={containerRef}
style={{ height: '100vh', overflow: 'auto' }}
>
<Document
file={file}
onLoadSuccess={onDocumentLoadSuccess}
>
{Array.from({ length: numPages }, (_, index) => {
const pageNumber = index + 1;
return visiblePages.has(pageNumber) ? (
<Page
key={`page_${pageNumber}`}
pageNumber={pageNumber}
width={800}
loading={<div>Loading page {pageNumber}...</div>}
/>
) : (
<div
key={`placeholder_${pageNumber}`}
style={{ height: '1100px', background: '#f5f5f5' }}
/>
);
})}
</Document>
</div>
);
};
关键优化点解析
1. 动态页面渲染:
visiblePages
集合中的页面2. 滚动事件优化:
3. 内存管理:
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { Spin, message } from 'antd';
import debounce from 'lodash/debounce';
import './PDFViewer.css';
// 配置PDF worker
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`;
const PDFLazyViewer = ({
file,
onFirstPageLoad,
onAllPagesLoad,
onPageVisibilityChange,
pageHeight = 1100,
preloadRange = 2,
}) => {
const [numPages, setNumPages] = useState(null);
const [visiblePages, setVisiblePages] = useState(new Set());
const [loadingStates, setLoadingStates] = useState({});
const containerRef = useRef(null);
// 文档加载成功回调
const onDocumentLoadSuccess = useCallback(({ numPages }) => {
setNumPages(numPages);
// 初始加载封面页和前几页
const initialPages = new Set();
for (let i = 1; i <= Math.min(3, numPages); i++) {
initialPages.add(i);
}
setVisiblePages(initialPages);
}, []);
// 页面加载状态回调
const handlePageLoad = useCallback((pageNumber, status) => {
setLoadingStates(prev => {
const newState = { ...prev, [pageNumber]: status };
// 触发回调
if (pageNumber === 1 && status === 'loaded') {
onFirstPageLoad?.(file, pageNumber);
}
// 检查是否全部加载完成
if (numPages && Object.values(newState).filter(s => s === 'loaded').length === numPages) {
onAllPagesLoad?.(file);
}
return newState;
});
}, [numPages, onFirstPageLoad, onAllPagesLoad, file]);
// 计算可见区域
const calculateVisiblePages = useCallback(() => {
if (!containerRef.current || !numPages) return;
const { scrollTop, clientHeight } = containerRef.current;
const currentPage = Math.floor(scrollTop / pageHeight) + 1;
// 计算预加载范围
const startPage = Math.max(1, currentPage - preloadRange);
const endPage = Math.min(numPages, currentPage + preloadRange);
const newVisiblePages = new Set();
for (let i = startPage; i <= endPage; i++) {
newVisiblePages.add(i);
}
setVisiblePages(prev => {
if (prev.size === newVisiblePages.size &&
[...prev].every(p => newVisiblePages.has(p))) {
return prev; // 避免不必要的重渲染
}
// 触发页面可见性变化回调
const becameVisible = [...newVisiblePages].filter(p => !prev.has(p));
const becameHidden = [...prev].filter(p => !newVisiblePages.has(p));
if (onPageVisibilityChange) {
onPageVisibilityChange({
becameVisible,
becameHidden,
currentVisible: [...newVisiblePages],
});
}
return newVisiblePages;
});
}, [numPages, pageHeight, preloadRange, onPageVisibilityChange]);
// 防抖滚动处理
const debouncedScrollHandler = useCallback(
debounce(calculateVisiblePages, 50, { leading: true, trailing: true }),
[calculateVisiblePages]
);
// 事件监听
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('scroll', debouncedScrollHandler);
window.addEventListener('resize', debouncedScrollHandler);
// 初始计算
calculateVisiblePages();
return () => {
container.removeEventListener('scroll', debouncedScrollHandler);
window.removeEventListener('resize', debouncedScrollHandler);
debouncedScrollHandler.cancel();
};
}, [debouncedScrollHandler, calculateVisiblePages]);
// 页面卸载时清理
useEffect(() => {
return () => {
// 清理PDF worker资源
pdfjs.cleanup();
};
}, []);
return (
<div
ref={containerRef}
className="pdf-viewer-container"
style={{ height: '100vh', overflow: 'auto' }}
>
<Document
file={file}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={(error) => message.error(`PDF加载失败: ${error.message}`)}
loading={
<div className="document-loading">
<Spin size="large" tip="PDF文档加载中..." />
</div>
}
>
{numPages && Array.from({ length: numPages }, (_, index) => {
const pageNumber = index + 1;
if (visiblePages.has(pageNumber)) {
return (
<div
key={`page_${pageNumber}`}
className="page-wrapper"
style={{ height: `${pageHeight}px` }}
>
<Page
pageNumber={pageNumber}
width={800}
renderTextLayer={true}
renderAnnotationLayer={false}
onLoadSuccess={() => handlePageLoad(pageNumber, 'loaded')}
onLoadError={() => handlePageLoad(pageNumber, 'error')}
loading={
<div className="page-loading">
<Spin tip={`正在加载第 ${pageNumber} 页...`} />
</div>
}
/>
</div>
);
}
return (
<div
key={`placeholder_${pageNumber}`}
className="page-placeholder"
style={{ height: `${pageHeight}px` }}
/>
);
})}
</Document>
</div>
);
};
export default React.memo(PDFLazyViewer);
/* PDFViewer.css */
.pdf-viewer-container {
position: relative;
width: 100%;
background: #f0f2f5;
}
.document-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.page-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 20px 0;
box-sizing: border-box;
}
.page-placeholder {
background: repeating-linear-gradient(
45deg,
#fafafa,
#fafafa 10px,
#f0f0f0 10px,
#f0f0f0 20px
);
}
.page-loading {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(255, 255, 255, 0.8);
}
/* 响应式调整 */
@media (max-width: 768px) {
.page-wrapper {
padding: 10px 0;
}
.page-wrapper .react-pdf__Page {
width: 100% !important;
}
}
通过本文的学习,我们了解了 React 生态中多种 PDF 查看器插件的选择,掌握了 react-pdf
插件的使用方法,学会了如何结合虚拟滚动技术实现大文件的懒加载。
PDF性能优化是前端开发中具有挑战性的任务,需要深入理解浏览器渲染机制和内存管理原理。希望本文能为React复杂文档处理场景提供可复用的技术方案。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。