PDF 文件是现代 Web 应用中常见的文档格式,广泛用于展示报告、合同、书籍等内容。在前端开发中,预览 PDF 文件需要高效的渲染能力和良好的用户体验。PDF.js 是 Mozilla 开发的强大开源库,能够在浏览器中直接渲染 PDF 文件,无需依赖原生插件。结合 React 的组件化特性,开发者可以构建交互式、响应式的 PDF 预览功能,满足多样化的业务需求。
然而,PDF.js 的集成和优化并非易事。大文件加载可能导致性能瓶颈,跨浏览器兼容性问题可能影响渲染效果,复杂的交互功能(如搜索、书签)需要额外的开发工作。本文通过构建一个基于 React 和 PDF.js 的文档管理应用,深入探讨 PDF 预览的实现流程,从基础渲染到高级功能(如缩放、搜索、注释),并提供性能优化、可访问性和手机端适配的实践方案。通过详细的代码示例和场景分析,开发者将掌握如何在 React 中高效使用 PDF.js。
在现代 Web 应用中,PDF 文件预览是一项常见需求,涵盖文档管理、在线阅读和电子合同等场景。PDF.js 是一个功能强大的 JavaScript 库,能够在浏览器中直接解析和渲染 PDF 文件,无需依赖原生插件或服务器端处理。结合 React 的组件化开发模式,开发者可以构建高效、交互式的 PDF 预览功能,支持页面导航、缩放、搜索、书签和注释等特性。
尽管 PDF.js 提供了强大的渲染能力,其在 React 项目中的集成仍面临诸多挑战。例如,大型 PDF 文件可能导致加载缓慢,复杂的交互功能需要精细的状态管理,跨浏览器兼容性和可访问性问题也需特别关注。本文通过一个基于 React 的文档管理应用,全面探讨 PDF.js 的集成、功能实现和优化实践。我们将从基础渲染开始,逐步实现高级功能(如动态缩放、文本搜索、书签导航),并提供性能优化、可访问性和手机端适配的解决方案。
通过本项目,您将学习到:
本文面向有经验的开发者,假设您熟悉 HTML、CSS、JavaScript、React 和 TypeScript 基础知识。内容详实且实用,适合深入学习 PDF.js 和 React 的集成。
在动手编码之前,我们需要明确文档管理应用的功能需求。一个清晰的需求清单能指导开发过程并帮助我们优化 PDF 预览功能。以下是项目的核心需求:
这些需求覆盖了 PDF 预览的核心场景,同时为学习 PDF.js 和 React 的集成提供了实践机会:
在实现文档管理应用之前,我们需要选择合适的技术栈。以下是本项目使用的工具和技术,以及选择它们的理由:
这些工具组合不仅易于上手,还能帮助开发者掌握 PDF.js 和 React 的最佳实践。
现在进入核心部分——代码实现。我们将从项目搭建开始,逐步实现 PDF 文件加载、渲染、交互功能、性能优化、可访问性和部署。
使用 Vite 创建一个 React + TypeScript 项目:
npm create vite@latest pdf-viewer -- --template react-ts
cd pdf-viewer
npm install
npm run dev
安装必要的依赖:
npm install pdfjs-dist @tanstack/react-query framer-motion tailwindcss postcss autoprefixer
初始化 Tailwind CSS:
npx tailwindcss init -p
编辑 tailwind.config.js
:
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
在 src/index.css
中引入 Tailwind:
@tailwind base;
@tailwind components;
@tailwind utilities;
配置 PDF.js Worker:
src/pdf.worker.ts
:
import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';
我们将应用拆分为以下组件:
src/
├── components/
│ ├── PDFViewer.tsx
│ ├── PDFControls.tsx
│ ├── PDFOutline.tsx
│ ├── PDFAnnotations.tsx
│ └── AccessibilityPanel.tsx
├── hooks/
│ └── usePDF.ts
├── types/
│ └── index.ts
├── assets/
│ └── sample.pdf
├── pdf.worker.ts
├── App.tsx
├── main.tsx
└── index.css
src/hooks/usePDF.ts
:
import { useState, useCallback } from 'react';
import * as pdfjsLib from 'pdfjs-dist';
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
interface PDFState {
document: PDFDocumentProxy | null;
currentPage: number;
totalPages: number;
scale: number;
}
export function usePDF(url: string) {
const [state, setState] = useState<PDFState>({
document: null,
currentPage: 1,
totalPages: 0,
scale: 1,
});
const loadPDF = useCallback(async () => {
try {
const pdf = await pdfjsLib.getDocument(url).promise;
setState(prev => ({
...prev,
document: pdf,
totalPages: pdf.numPages,
}));
} catch (error) {
console.error('PDF 加载失败:', error);
}
}, [url]);
const renderPage = useCallback(
async (pageNum: number, canvas: HTMLCanvasElement) => {
if (!state.document) return;
const page = await state.document.getPage(pageNum);
const viewport = page.getViewport({ scale: state.scale });
const context = canvas.getContext('2d');
if (!context) return;
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({
canvasContext: context,
viewport,
}).promise;
},
[state.document, state.scale]
);
return { state, loadPDF, renderPage, setState };
}
src/components/PDFViewer.tsx
:
import { useEffect, useRef } from 'react';
import { usePDF } from '../hooks/usePDF';
import PDFControls from './PDFControls';
function PDFViewer({ url }: { url: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const { state, loadPDF, renderPage } = usePDF(url);
useEffect(() => {
loadPDF();
}, [loadPDF]);
useEffect(() => {
if (canvasRef.current && state.document) {
renderPage(state.currentPage, canvasRef.current);
}
}, [state.currentPage, state.scale, state.document, renderPage]);
return (
<div className="p-4 bg-white rounded-lg shadow">
<h2 className="text-xl font-bold mb-4">PDF 预览</h2>
<PDFControls
currentPage={state.currentPage}
totalPages={state.totalPages}
scale={state.scale}
setState={state.setState}
/>
<canvas ref={canvasRef} className="w-full" aria-label={`PDF 第 ${state.currentPage} 页`} />
</div>
);
}
export default PDFViewer;
实现过程:
pdfjsLib.getDocument
加载 PDF 文件。
元素。避坑:
workerSrc
配置正确,防止 Worker 加载失败。src/components/PDFControls.tsx
:
import { useCallback } from 'react';
import type { PDFState } from '../hooks/usePDF';
interface PDFControlsProps {
currentPage: number;
totalPages: number;
scale: number;
setState: React.Dispatch<React.SetStateAction<PDFState>>;
}
function PDFControls({ currentPage, totalPages, scale, setState }: PDFControlsProps) {
const prevPage = useCallback(() => {
setState(prev => ({
...prev,
currentPage: Math.max(1, prev.currentPage - 1),
}));
}, [setState]);
const nextPage = useCallback(() => {
setState(prev => ({
...prev,
currentPage: Math.min(prev.totalPages, prev.currentPage + 1),
}));
}, [setState]);
const setScale = useCallback(
(newScale: number) => {
setState(prev => ({ ...prev, scale: newScale }));
},
[setState]
);
return (
<div className="flex items-center space-x-4 mb-4">
<button
onClick={prevPage}
disabled={currentPage === 1}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
aria-label="上一页"
>
上一页
</button>
<span>
第 {currentPage} 页 / 共 {totalPages} 页
</span>
<button
onClick={nextPage}
disabled={currentPage === totalPages}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
aria-label="下一页"
>
下一页
</button>
<select
value={scale}
onChange={e => setScale(Number(e.target.value))}
className="p-2 border rounded-lg"
aria-label="缩放比例"
>
<option value={0.5}>50%</option>
<option value={1}>100%</option>
<option value={1.5}>150%</option>
<option value={2}>200%</option>
</select>
</div>
);
}
export default PDFControls;
避坑:
disabled
属性,优化用户体验。src/hooks/usePDF.ts
(更新):
import { useState, useCallback } from 'react';
import * as pdfjsLib from 'pdfjs-dist';
import type { PDFDocumentProxy, PDFPageProxy, TextItem } from 'pdfjs-dist';
interface SearchResult {
page: number;
index: number;
text: string;
}
interface PDFState {
document: PDFDocumentProxy | null;
currentPage: number;
totalPages: number;
scale: number;
searchResults: SearchResult[];
currentSearchIndex: number;
}
export function usePDF(url: string) {
const [state, setState] = useState<PDFState>({
document: null,
currentPage: 1,
totalPages: 0,
scale: 1,
searchResults: [],
currentSearchIndex: -1,
});
const searchText = useCallback(
async (query: string) => {
if (!state.document || !query) return;
const results: SearchResult[] = [];
for (let pageNum = 1; pageNum <= state.totalPages; pageNum++) {
const page = await state.document.getPage(pageNum);
const textContent = await page.getTextContent();
textContent.items.forEach((item: TextItem, index) => {
if ('str' in item && item.str.toLowerCase().includes(query.toLowerCase())) {
results.push({ page: pageNum, index, text: item.str });
}
});
}
setState(prev => ({
...prev,
searchResults: results,
currentSearchIndex: results.length > 0 ? 0 : -1,
currentPage: results.length > 0 ? results[0].page : prev.currentPage,
}));
},
[state.document, state.totalPages]
);
const navigateSearch = useCallback(
(direction: 'next' | 'prev') => {
setState(prev => {
if (prev.searchResults.length === 0) return prev;
const newIndex =
direction === 'next'
? (prev.currentSearchIndex + 1) % prev.searchResults.length
: (prev.currentSearchIndex - 1 + prev.searchResults.length) % prev.searchResults.length;
return {
...prev,
currentSearchIndex: newIndex,
currentPage: prev.searchResults[newIndex].page,
};
});
},
[]
);
return { state, loadPDF, renderPage, searchText, navigateSearch, setState };
}
src/components/PDFControls.tsx
(更新):
import { useState } from 'react';
interface PDFControlsProps {
currentPage: number;
totalPages: number;
scale: number;
searchResults: SearchResult[];
currentSearchIndex: number;
setState: React.Dispatch<React.SetStateAction<PDFState>>;
searchText: (query: string) => void;
navigateSearch: (direction: 'next' | 'prev') => void;
}
function PDFControls({
currentPage,
totalPages,
scale,
searchResults,
currentSearchIndex,
setState,
searchText,
navigateSearch,
}: PDFControlsProps) {
const [query, setQuery] = useState('');
return (
<div className="flex flex-col space-y-4 mb-4">
<div className="flex items-center space-x-4">
<button
onClick={prevPage}
disabled={currentPage === 1}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
aria-label="上一页"
>
上一页
</button>
<span>
第 {currentPage} 页 / 共 {totalPages} 页
</span>
<button
onClick={nextPage}
disabled={currentPage === totalPages}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
aria-label="下一页"
>
下一页
</button>
<select
value={scale}
onChange={e => setScale(Number(e.target.value))}
className="p-2 border rounded-lg"
aria-label="缩放比例"
>
<option value={0.5}>50%</option>
<option value={1}>100%</option>
<option value={1.5}>150%</option>
<option value={2}>200%</option>
</select>
</div>
<div className="flex items-center space-x-4">
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
className="p-2 border rounded-lg"
placeholder="搜索文本"
aria-label="搜索 PDF 内容"
/>
<button
onClick={() => searchText(query)}
className="px-4 py-2 bg-blue-500 text-white rounded-lg"
aria-label="搜索"
>
搜索
</button>
<button
onClick={() => navigateSearch('prev')}
disabled={searchResults.length === 0}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
aria-label="上一个搜索结果"
>
上一个
</button>
<button
onClick={() => navigateSearch('next')}
disabled={searchResults.length === 0}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
aria-label="下一个搜索结果"
>
下一个
</button>
<span>
{searchResults.length > 0 ? `结果 ${currentSearchIndex + 1}/${searchResults.length}` : '无结果'}
</span>
</div>
</div>
);
}
避坑:
src/components/PDFOutline.tsx
:
import { useEffect, useState } from 'react';
import type { PDFDocumentProxy, OutlineNode } from 'pdfjs-dist';
import { usePDF } from '../hooks/usePDF';
function PDFOutline({ url }: { url: string }) {
const { state } = usePDF(url);
const [outline, setOutline] = useState<OutlineNode[]>([]);
useEffect(() => {
if (state.document) {
state.document.getOutline().then(setOutline);
}
}, [state.document]);
const navigateTo = async (dest: string | any[]) => {
if (!state.document) return;
const ref = Array.isArray(dest) ? dest[0] : await state.document.getDestination(dest);
const pageIndex = await state.document.getPageIndex(ref);
state.setState(prev => ({ ...prev, currentPage: pageIndex + 1 }));
};
return (
<div className="p-4 bg-white rounded-lg shadow">
<h2 className="text-xl font-bold mb-4">书签</h2>
<ul>
{outline.map((item, index) => (
<li key={index} className="p-2">
<button
onClick={() => navigateTo(item.dest)}
className="text-blue-500 hover:underline"
aria-label={`跳转到书签 ${item.title}`}
>
{item.title}
</button>
</li>
))}
</ul>
</div>
);
}
export default PDFOutline;
避坑:
getOutline
可能返回 null)。src/components/PDFAnnotations.tsx
:
import { useState, useCallback } from 'react';
import type { PDFState } from '../hooks/usePDF';
interface Annotation {
page: number;
text: string;
x: number;
y: number;
}
function PDFAnnotations({ state, canvasRef }: { state: PDFState; canvasRef: React.RefObject<HTMLCanvasElement> }) {
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const addAnnotation = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const text = prompt('输入注释内容:');
if (text) {
setAnnotations(prev => [...prev, { page: state.currentPage, text, x, y }]);
}
},
[state.currentPage, canvasRef]
);
return (
<div className="relative">
<canvas ref={canvasRef} className="w-full" onClick={addAnnotation} aria-label="PDF 页面" />
{annotations
.filter(anno => anno.page === state.currentPage)
.map((anno, index) => (
<div
key={index}
className="absolute bg-yellow-200 p-2 rounded-lg"
style={{ left: anno.x, top: anno.y }}
role="tooltip"
>
{anno.text}
</div>
))}
</div>
);
}
避坑:
src/components/PDFViewer.tsx
(更新):
import { useEffect, useRef } from 'react';
import { useInView } from 'framer-motion';
import { usePDF } from '../hooks/usePDF';
import PDFControls from './PDFControls';
function PDFViewer({ url }: { url: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isInView = useInView(containerRef, { once: false });
const { state, loadPDF, renderPage } = usePDF(url);
useEffect(() => {
if (isInView) loadPDF();
}, [isInView, loadPDF]);
useEffect(() => {
if (canvasRef.current && state.document && isInView) {
renderPage(state.currentPage, canvasRef.current);
}
}, [state.currentPage, state.scale, state.document, renderPage, isInView]);
return (
<div ref={containerRef} className="p-4 bg-white rounded-lg shadow">
<h2 className="text-xl font-bold mb-4">PDF 预览</h2>
<PDFControls
currentPage={state.currentPage}
totalPages={state.totalPages}
scale={state.scale}
searchResults={state.searchResults}
currentSearchIndex={state.currentSearchIndex}
setState={state.setState}
searchText={state.searchText}
navigateSearch={state.navigateSearch}
/>
<PDFAnnotations state={state} canvasRef={canvasRef} />
</div>
);
}
优点:
useInView
延迟加载非可见页面。避坑:
IntersectionObserver
准确触发。src/hooks/usePDF.ts
(更新):
import { useMemo } from 'react';
export function usePDF(url: string) {
const [state, setState] = useState<PDFState>({ ... });
const pageCache = useMemo(() => new Map<number, HTMLCanvasElement>(), []);
const renderPage = useCallback(
async (pageNum: number, canvas: HTMLCanvasElement) => {
if (pageCache.has(pageNum)) {
const cachedCanvas = pageCache.get(pageNum)!;
canvas.getContext('2d')?.drawImage(cachedCanvas, 0, 0);
return;
}
if (!state.document) return;
const page = await state.document.getPage(pageNum);
const viewport = page.getViewport({ scale: state.scale });
const context = canvas.getContext('2d');
if (!context) return;
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({
canvasContext: context,
viewport,
}).promise;
pageCache.set(pageNum, canvas);
},
[state.document, state.scale, pageCache]
);
return { state, loadPDF, renderPage, searchText, navigateSearch, setState };
}
避坑:
src/components/AccessibilityPanel.tsx
:
import { useState } from 'react';
function AccessibilityPanel() {
const [highContrast, setHighContrast] = useState(false);
return (
<div className="p-4 bg-white rounded-lg shadow">
<h2 className="text-xl font-bold mb-4">可访问性设置</h2>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={highContrast}
onChange={() => setHighContrast(!highContrast)}
className="p-2"
aria-label="启用高对比度模式"
/>
<span>高对比度模式</span>
</label>
<div className={highContrast ? 'bg-black text-white' : ''}>
<p aria-live="polite">测试文本:{highContrast ? '高对比度' : '正常'}</p>
</div>
</div>
);
}
export default AccessibilityPanel;
避坑:
aria-label
描述页面内容。src/components/PDFViewer.tsx
(更新):
function PDFViewer({ url }: { url: string }) {
return (
<div ref={containerRef} className="p-2 md:p-4 bg-white rounded-lg shadow">
<h2 className="text-lg md:text-xl font-bold mb-4">PDF 预览</h2>
<PDFControls
currentPage={state.currentPage}
totalPages={state.totalPages}
scale={state.scale}
searchResults={state.searchResults}
currentSearchIndex={state.currentSearchIndex}
setState={state.setState}
searchText={state.searchText}
navigateSearch={state.navigateSearch}
/>
<div className="overflow-x-auto">
<PDFAnnotations state={state} canvasRef={canvasRef} />
</div>
</div>
);
}
避坑:
overflow-x-auto
支持横向滚动。src/App.tsx
:
import PDFViewer from './components/PDFViewer';
import PDFOutline from './components/PDFOutline';
import AccessibilityPanel from './components/AccessibilityPanel';
function App() {
return (
<div className="min-h-screen bg-gray-100 p-2 md:p-4">
<h1 className="text-2xl md:text-3xl font-bold text-center p-4">文档管理器</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-5xl mx-auto">
<PDFViewer url="/sample.pdf" />
<PDFOutline url="/sample.pdf" />
<AccessibilityPanel />
</div>
</div>
);
}
export default App;
npm run build
npm run build
dist
避坑:
问题:大型 PDF 文件导致加载时间长。
解决方案:
pdfjsLib.getDocument({ url, rangeChunkSize: 65536 });
问题:Safari 或 Edge 渲染异常。
解决方案:
问题:多页 PDF 导致内存占用高。
解决方案:
pageCache.clear();
state.document?.destroy();
为巩固所学,设计一个练习:为 PDF 预览器添加下载功能。
src/components/PDFControls.tsx
(更新):
import { useState } from 'react';
function PDFControls({ ...props }: PDFControlsProps) {
const [downloading, setDownloading] = useState(false);
const downloadPDF = async () => {
if (!props.state.document) return;
setDownloading(true);
const data = await props.state.document.getData();
const blob = new Blob([data], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'document.pdf';
link.click();
URL.revokeObjectURL(url);
setDownloading(false);
};
return (
<div className="flex flex-col space-y-4 mb-4">
{/* 其他控件 */}
<button
onClick={downloadPDF}
disabled={downloading}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
aria-label="下载 PDF"
>
{downloading ? '下载中...' : '下载 PDF'}
</button>
</div>
);
}
目标:
getData
获取 PDF 数据。通过这个文档管理应用,您深入了解了 React 和 PDF.js 的集成流程,掌握了 PDF 文件加载、渲染、交互功能和优化的关键技术。这些技能将帮助您构建高效、交互式的 PDF 预览应用,满足复杂业务需求。希望您继续探索 PDF.js 的高级功能,如表单交互和数字签名,打造卓越的用户体验!