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-03react如何同步獲取useState的最新狀態(tài)值
這篇文章主要介紹了react如何同步獲取useState的最新狀態(tài)值問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-01-01react-router 路由切換動畫的實現(xiàn)示例
這篇文章主要介紹了react-router 路由切換動畫的實現(xiàn)示例,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-12-12react反向代理使用http-proxy-middleware問題
這篇文章主要介紹了react反向代理使用http-proxy-middleware問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-07-07React.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