React實現(xiàn)PDF預(yù)覽功能與終極優(yōu)化
在前端開發(fā)中,PDF 預(yù)覽是個常見需求。簡單粗暴的方案是用 標(biāo)簽直接嵌入,但你有沒有遇到過這樣的問題:樣式不好調(diào)、功能太單一、用戶體驗不夠友好?今天,我要帶你認(rèn)識一個基于 react-pdf 的自定義 PDF 預(yù)覽組件 PDFView,它不僅支持翻頁、縮放、全屏,還能無縫集成到你的項目中。我們會拆解它的實現(xiàn),對比 的優(yōu)劣,最后用一個 Demo 展示它的實力。準(zhǔn)備好了嗎?讓我們一起把 PDF 預(yù)覽玩出新花樣吧!
為什么需要自定義 PDF 預(yù)覽?
先說說需求場景。假設(shè)你有個文件管理系統(tǒng),用戶上傳 PDF 后需要在線預(yù)覽。你可能會直接寫:
<embed src="file.pdf#toolbar=0" type="application/pdf" width="100%" height="700px" />
這行代碼確實能用,但問題不少:
- 樣式控制弱:背景、邊框不好調(diào)整,工具欄難以隱藏。
- 交互性差:沒有翻頁按鈕、縮放功能,用戶體驗一般。
- 功能單一:無法動態(tài)調(diào)整頁面大小或全屏展示。
而我們的 PDFView 組件,基于 react-pdf,用 React 的方式解決問題,提供更靈活的控制和更優(yōu)雅的體驗。接下來,我們拆解它的代碼,看看它是怎么“打敗” 的!
核心代碼拆解:從設(shè)計到實現(xiàn)
問題驅(qū)動開發(fā)
初版本的痛點:
- 性能瓶頸:大文件一次性加載全部頁面,內(nèi)存占用高,加載慢。
- 功能缺失:沒有頁面旋轉(zhuǎn),方向不對只能干瞪眼;沒有多頁預(yù)覽,翻頁全靠手動。
這些問題在實際場景中很常見。比如,用戶上傳一個 50 頁的合同 PDF,如果加載卡頓,或者需要旋轉(zhuǎn)查看簽名頁,原始版本就有點“力不從心”。優(yōu)化后的 PDFView 將通過分頁加載提升性能,新增旋轉(zhuǎn)和縮略圖功能,讓體驗飛起來!
優(yōu)化后的核心實現(xiàn)
1. 性能優(yōu)化:分頁加載
問題:原始版本用 一次性加載所有頁面,大文件時容易卡頓。
解決:引入 loadedPages 狀態(tài)(Set 類型),只加載當(dāng)前頁和用戶訪問過的頁面。
實現(xiàn):
- 初始化僅加載第 1 頁。
- 用戶翻頁或跳轉(zhuǎn)時動態(tài)添加加載頁面。
- 縮略圖模式下未加載頁面顯示占位符,點擊時加載。
useEffect(() => {
if (pageNumber && !loadedPages.has(pageNumber)) {
setLoadedPages(prev => new Set(prev).add(pageNumber));
}
}, [pageNumber]);
2.功能增強:頁面旋轉(zhuǎn)
需求:支持用戶調(diào)整頁面方向(比如橫向文檔)。
實現(xiàn):
- 新增 rotation 狀態(tài),默認(rèn) 0°。
- 提供 rotateLeft(-90°)和 rotateRight(+90°)函數(shù)。
- 通過 Page 組件的 rotate 屬性應(yīng)用旋轉(zhuǎn)。
const rotateLeft = () => setRotation((prev) => (prev - 90) % 360); const rotateRight = () => setRotation((prev) => (prev + 90) % 360);
3.功能增強:多頁預(yù)覽
需求:用戶想快速瀏覽所有頁面,像縮略圖一樣。
實現(xiàn):
- 新增 showThumbnails 狀態(tài),切換單頁和縮略圖模式。
- 縮略圖模式下渲染所有頁面(小尺寸),點擊跳轉(zhuǎn)到對應(yīng)頁。
{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.按需加載:只渲染當(dāng)前頁
思路:用 visiblePages 控制渲染頁面,初始只加載元信息,動態(tài)加載當(dāng)前頁。
實現(xiàn):
- 移除 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.禁用多余渲染:輕量化頁面
- 思路:關(guān)閉文本層和注釋層,只渲染圖像內(nèi)容。
- 實現(xiàn):在 組件中設(shè)置 renderTextLayer={false} 和 renderAnnotationLayer={false}。
<Page
pageNumber={pageNumber}
width={pageWidth}
rotate={rotation}
loading={<Spin size="large" />}
renderTextLayer={false}
renderAnnotationLayer={false}
/>
6.優(yōu)化縮略圖:避免過載
思路:縮略圖模式下不一次性加載所有頁面,用占位符替代未加載頁。
實現(xiàn):僅渲染當(dāng)前頁附近的頁面,其他顯示靜態(tài)文本。
{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]); // 只加載當(dāng)前頁
};
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);
// 動態(tài)更新可見頁面
useEffect(() => {
if (!showThumbnails) {
setVisiblePages([pageNumber]);
} else {
// 縮略圖模式下限制加載數(shù)量,避免卡頓
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="向左旋轉(zhuǎn)">
<RotateLeftOutlined onClick={rotateLeft} />
</Tooltip>
<Tooltip title="向右旋轉(zhuǎn)">
<RotateRightOutlined onClick={rotateRight} />
</Tooltip>
<Tooltip title={showThumbnails ? '關(guān)閉縮略圖' : '顯示縮略圖'}>
<UnorderedListOutlined onClick={toggleThumbnails} />
</Tooltip>
<Tooltip title={fullscreen ? '恢復(fù)默認(rèn)' : '適合窗口'}>
{fullscreen ? <FullscreenExitOutlined onClick={pageFullscreen} /> : <FullscreenOutlined onClick={pageFullscreen} />}
</Tooltip>
{onClose && (
<Tooltip title="關(guān)閉">
<CloseCircleOutlined onClick={onClose} />
</Tooltip>
)}
</div>
</div>
</div>
</div>)
if(parentDom){
return renderContent()
}
return createPortal(
renderContent(),
parent,)
};
export default PDFView;
優(yōu)化后的樣式 (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. 組件設(shè)計:靈活與可控
輸入?yún)?shù):
- file:PDF 文件的 URL 或數(shù)據(jù)。
- parentDom:渲染的目標(biāo)容器,默認(rèn) document.body。
- onClose:關(guān)閉回調(diào)。
渲染方式:用 createPortal 將組件掛載到指定 DOM,實現(xiàn)模態(tài)效果。
2. 狀態(tài)管理:交互的核心
- numPages 和 pageNumber:控制總頁數(shù)和當(dāng)前頁。
- pageWidth:動態(tài)調(diào)整頁面寬度,默認(rèn) 600px。
- fullscreen:切換全屏狀態(tài)。
3. 功能實現(xiàn):用戶體驗的加分項
- 翻頁:lastPage 和 nextPage 控制前后翻頁,Input 支持手動輸入頁碼。
- 縮放:pageZoomIn(放大 1.2 倍)、pageZoomOut(縮小 0.8 倍,限制最小值)。
- 全屏:pageFullscreen 切換寬度至容器大小。
- 滾動重置:頁面切換時自動滾動到頂部。
4. UI 與樣式:美觀與實用并存
- 布局:深色背景、白底頁面、居中展示。
- 工具欄:懸浮底部,包含翻頁、縮放、全屏按鈕,帶 Tooltip 提示。
- 加載與錯誤:用 Spin 和圖標(biāo)提示,提升用戶感知。
Embed vs 自定義:誰更勝一籌?
我們用一個表格對比 和 PDFView:
| 特性 | PDFView | |
|---|---|---|
| 實現(xiàn)方式 | 原生 HTML 標(biāo)簽 | React 組件,基于 react-pdf |
| 樣式控制 | 有限(僅寬高) | 完全自定義(背景、工具欄、頁面樣式) |
| 交互功能 | 內(nèi)置工具欄(可隱藏但不靈活) | 自定義翻頁、縮放、全屏,手動控制頁碼 |
| 加載提示 | 無 | 支持加載和錯誤提示 |
| 全屏支持 | 依賴瀏覽器 | 一鍵切換全屏 |
| 代碼維護性 | 無需維護 | React 組件化,易擴展 |
| 依賴性 | 無需額外庫 | 依賴 react-pdf 和 pdfjs-dist |
選擇 PDFView 的理由
- 靈活性:自定義樣式和交互,適配復(fù)雜需求。
- 用戶體驗:翻頁、縮放、全屏一應(yīng)俱全,加載和錯誤狀態(tài)友好。
- 可維護性:組件化設(shè)計,易于集成和擴展。
適合簡單場景,但一旦需求復(fù)雜,它就顯得力不從心。PDFView 則是“全能選手”,尤其在需要深度定制的項目中表現(xiàn)亮眼。
使用場景:從 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,僅渲染當(dāng)前頁,響應(yīng)迅速。
- 翻頁與跳轉(zhuǎn):左右箭頭或輸入頁碼切換,滾動自動歸頂。
- 旋轉(zhuǎn):點擊旋轉(zhuǎn)按鈕,頁面順時針或逆時針調(diào)整。
- 縮略圖:點擊列表圖標(biāo),顯示所有頁面預(yù)覽,點擊跳轉(zhuǎn)。
- 縮放與全屏:放大縮小頁面,或一鍵鋪滿屏幕。
性能對比:優(yōu)化前后
| 特性 | 優(yōu)化前 | 優(yōu)化后 |
|---|---|---|
| 30 頁加載 | 卡頓數(shù)秒 | 秒開,僅加載當(dāng)前頁 |
| 內(nèi)存占用 | 高(全量解析) | 低(按需加載) |
| 縮略圖性能 | 全渲染,易卡 | 部分渲染,輕量快捷 |
| 響應(yīng)速度 | 慢 | 快 |
優(yōu)化后,30 頁 PDF 從“卡到懷疑人生”變成了“快如閃電”,用戶體驗和性能雙雙起飛!
技術(shù)亮點:為什么它這么強
1.性能飛躍:
- 分頁加載避免內(nèi)存爆炸,大文件也能輕松應(yīng)對。
- 動態(tài)加載邏輯清晰,體驗流暢。
2.功能升級:
- 頁面旋轉(zhuǎn)解決方向問題,實用性拉滿。
- 多頁預(yù)覽提供全局視角,操作更直觀。
3.用戶體驗:
- 縮略圖模式與單頁模式無縫切換。
- 工具欄新增圖標(biāo),交互更友好。
總結(jié)
優(yōu)化后的 PDFView 堪稱 PDF 預(yù)覽的“性能王”,30 頁大文件不卡,加載快如閃電。通過按需加載和輕量化渲染,它解決了卡頓難題;加上旋轉(zhuǎn)和多頁預(yù)覽,功能也更強大。試著把它丟進你的項目,上傳個大 PDF 測試一下,感受性能飛躍的快感吧!有其他需求或優(yōu)化思路?歡迎留言,我們一起把它打磨得更牛!
到此這篇關(guān)于React實現(xiàn)PDF預(yù)覽功能與終極優(yōu)化的文章就介紹到這了,更多相關(guān)React預(yù)覽PDF內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
ReactNative?狀態(tài)管理redux使用詳解
這篇文章主要介紹了ReactNative?狀態(tài)管理redux使用詳解2023-03-03
react如何同步獲取useState的最新狀態(tài)值
這篇文章主要介紹了react如何同步獲取useState的最新狀態(tài)值問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-01-01
react-router 路由切換動畫的實現(xiàn)示例
這篇文章主要介紹了react-router 路由切換動畫的實現(xiàn)示例,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-12-12
react反向代理使用http-proxy-middleware問題
這篇文章主要介紹了react反向代理使用http-proxy-middleware問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-07-07
React.memo 和 useMemo 的使用問題小結(jié)
隨著代碼的增加,每次的狀態(tài)改變,頁面進行一次 reRender ,這將產(chǎn)生很多不必要的 reRender 不僅浪費性能,從而導(dǎo)致頁面卡頓,這篇文章主要介紹了React.memo 和 useMemo 的使用問題小結(jié),需要的朋友可以參考下2022-11-11
在react項目中webpack使用mock數(shù)據(jù)的操作方法
這篇文章主要介紹了在react項目中webpack使用mock數(shù)據(jù)的操作方法,本文給大家介紹的非常詳細,感興趣的朋友跟隨小編一起看看吧2024-06-06

