本文深入解析基于 React 和 PDF.js 构建 PDF 查看器的实现方案,该组件支持 PDF 渲染、图片打印和下载功能,并包含完整的加载状态与错误处理机制。
完整代码在最后
一个PDF 文件:
mozilla.github.io/pdf.js/web/...
效果展示:

安装 PDF.js
要使用 pdf.js
,你可以通过 npm 安装它。以下是安装和配置 pdf.js
的详细步骤:
1. 安装 pdfjs-dist
pdf.js
的官方 npm 包名为 pdfjs-dist
。可以通过以下命令安装:
npm install pdfjs-dist
pdfjs-dist
是 PDF.js 的分发版本,包含了核心功能和 Worker 文件。- 安装完成后,你可以在项目中直接引入并使用。
2. 设置 Worker 文件路径
PDF.js 需要一个 Worker 文件来处理 PDF 渲染任务。默认情况下,Worker 文件路径需要手动设置。
方法 1:使用 CDN
如果你不想将 Worker 文件打包到项目中,可以直接使用 CDN 提供的 Worker 文件:
js
import { GlobalWorkerOptions } from 'pdfjs-dist';
// 设置 Worker 文件路径
GlobalWorkerOptions.workerSrc = '//cdnjs.cloudflare.com/ajax/libs/pdf.js/2.11.338/pdf.worker.min.js';
方法 2:本地 Worker 文件
如果你希望将 Worker 文件打包到项目中,可以这样做:
-
在代码中引入 Worker 文件:
jsimport { GlobalWorkerOptions, getDocument } from 'pdfjs-dist'; import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry'; // 设置 Worker 文件路径 GlobalWorkerOptions.workerSrc = pdfjsWorker;
-
这种方式适合离线环境或需要完全控制依赖的情况。
技术架构解析
1. 核心依赖
- PDF.js:Mozilla 开源的 PDF 解析库,支持 Web 端 PDF 渲染
- React Hooks:使用 useState 管理状态,useEffect 处理副作用,useRef 操作 DOM
- Ant Design:采用 Button 组件构建操作界面
pdf.js 官网:mozilla.github.io/pdf.js/
2. 初始化配置
通过配置 Worker 文件实现 PDF 解析的 Web Worker 并行处理,避免阻塞主线程。
typescript
GlobalWorkerOptions.workerSrc = '//cdnjs.cloudflare.com/ajax/libs/pdf.js/2.11.338/pdf.worker.min.js';
核心功能实现
1. PDF 渲染流程
这段代码的核心功能是加载一个 PDF 文件,并将其第一页渲染到 <canvas>
元素中。
typescript
// 加载 PDF 并渲染到 canvas
useEffect(() => {
const abortController = new AbortController();
if (!pdfUrl) return;
setIsLoading(true);
setError(null);
getDocument(pdfUrl).promise
.then((pdf) => {
if (abortController.signal.aborted) return;
console.log('PDF loaded');
// 获取第一页
pdf.getPage(1).then((page) => {
if (abortController.signal.aborted) {
page.cleanup();
return;
}
console.log('Page 1 loaded');
const scale = 1.5;
const viewport = page.getViewport({ scale });
if (!canvasRef.current) {
throw new Error('Canvas element not found');
}
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
// 清除之前的画布内容
context?.clearRect(0, 0, canvas.width, canvas.height);
canvas.height = viewport.height;
canvas.width = viewport.width;
console.log('Canvas尺寸设置:', canvas.width, 'x', canvas.height);
// 渲染 PDF 页面到 canvas
const renderContext: any = {
canvasContext: context,
viewport: viewport,
};
const renderTask = page.render(renderContext);
renderTask.promise
.then(() => {
console.log('页面渲染完成');
setIsLoading(false);
})
.catch((renderError) => {
if (!abortController.signal.aborted) {
setError('页面渲染失败: ' + renderError.message);
}
});
});
})
.catch((error) => {
if (!abortController.signal.aborted) {
setError('PDF加载失败: ' + error.message);
console.error('Error loading PDF:', error);
}
})
.finally(() => {
if (!abortController.signal.aborted) {
setIsLoading(false);
}
});
return () => {
abortController.abort();
const canvas = canvasRef.current;
if (canvas) {
const context = canvas.getContext('2d');
context?.clearRect(0, 0, canvas.width, canvas.height);
}
};
}, [pdfUrl]);
- 初始化加载状态:设置加载提示和清理错误信息。
- 加载 PDF 文件 :使用
getDocument
加载 PDF,并获取第一页。 - 计算视口与画布尺寸 :根据缩放比例调整
<canvas>
的大小。 - 渲染 PDF 页面 :将 PDF 页面绘制到
<canvas>
上。 - 错误处理与清理:捕获加载和渲染中的错误,并在组件卸载时清理资源。
以下是核心实现原理与技术要点的详细解读:
1. useEffect
的作用
js
useEffect(() => {
// ...
}, [pdfUrl]);
useEffect
是 React 的生命周期钩子,用于处理副作用(如数据加载、DOM 操作等)。- 这里的依赖项
[pdfUrl]
表示当pdfUrl
发生变化时,这个useEffect
会重新执行。 - 如果
pdfUrl
为空或未改变,则不会触发新的加载。
2. 创建 AbortController
javascript
const abortController = new AbortController();
AbortController
是一个浏览器内置的 API,用于取消异步操作(如网络请求)。- 在这里,我们用它来确保在组件卸载或重新加载时,可以中断正在进行的 PDF 加载任务,避免内存泄漏或不必要的资源消耗。
3. 检查 pdfUrl
是否有效
javascript
if (!pdfUrl) return;
- 如果
pdfUrl
为空,则直接退出函数,避免无效操作。
4. 设置加载状态
javascript
setIsLoading(true);
setError(null);
- 将
isLoading
设置为true
,表示开始加载 PDF。 - 清空之前的错误信息(如果有)。
5. 加载 PDF 文件
javascript
getDocument(pdfUrl).promise
.then((pdf) => { ... })
.catch((error) => { ... })
.finally(() => { ... });
getDocument
是 PDF.js 提供的方法,用于加载 PDF 文件。- 它返回一个 Promise,成功时会返回一个
pdf
对象,失败时会抛出错误。
关键点:
-
abortController.signal.aborted
:javascriptif (abortController.signal.aborted) return;
- 如果组件已经被卸载或取消了加载任务,则直接退出,避免继续执行无意义的操作。
-
pdf.getPage(1)
:javascriptpdf.getPage(1).then((page) => { ... });
getPage(1)
获取 PDF 的第一页(索引从 1 开始)。
6. 计算视口与画布尺寸
javascript
const scale = 1.5;
const viewport = page.getViewport({ scale });
if (!canvasRef.current) {
throw new Error('Canvas element not found');
}
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
// 清除之前的画布内容
context?.clearRect(0, 0, canvas.width, canvas.height);
canvas.height = viewport.height;
canvas.width = viewport.width;
console.log('Canvas尺寸设置:', canvas.width, 'x', canvas.height);
关键点:
-
scale
:scale=1.5
表示将 PDF 页面放大 1.5 倍进行渲染。- 可以根据需求调整缩放比例。
-
getViewport
:getViewport({ scale })
根据缩放比例计算页面的视口尺寸(宽度和高度)。
-
清空画布:
- 使用
context.clearRect
清除之前的内容,确保每次渲染都是干净的。
- 使用
-
设置画布尺寸:
- 将
<canvas>
的宽高设置为视口的宽高,以匹配 PDF 页面的比例。
- 将
7. 渲染 PDF 页面到 Canvas
javascript
const renderContext: any = {
canvasContext: context,
viewport: viewport,
};
const renderTask = page.render(renderContext);
renderTask.promise
.then(() => {
console.log('页面渲染完成');
setIsLoading(false);
})
.catch((renderError) => {
if (!abortController.signal.aborted) {
setError('页面渲染失败: ' + renderError.message);
}
});
关键点:
-
renderContext
:-
包含两个主要属性:
canvasContext
:指向<canvas>
的 2D 上下文。viewport
:定义了渲染区域的尺寸和比例。
-
-
page.render
:- 调用
page.render
方法将 PDF 页面渲染到<canvas>
上。 - 返回一个
renderTask
对象,包含一个promise
,用于监听渲染完成或失败的状态。
- 调用
-
渲染完成后的回调:
- 当渲染成功时,将
isLoading
设置为false
,隐藏加载提示。 - 如果渲染失败,则记录错误信息。
- 当渲染成功时,将
8. 错误处理
javascript
.catch((error) => {
if (!abortController.signal.aborted) {
setError('PDF加载失败: ' + error.message);
console.error('Error loading PDF:', error);
}
})
.finally(() => {
if (!abortController.signal.aborted) {
setIsLoading(false);
}
});
关键点:
-
捕获加载错误:
- 如果 PDF 加载失败(如 URL 错误或网络问题),则设置错误信息并打印日志。
-
finally
块:- 无论成功还是失败,都会执行
finally
块。 - 确保即使发生错误,
isLoading
也会被重置为false
,避免加载状态卡住。
- 无论成功还是失败,都会执行
9. 清理逻辑
javascript
return () => {
abortController.abort();
const canvas = canvasRef.current;
if (canvas) {
const context = canvas.getContext('2d');
context?.clearRect(0, 0, canvas.width, canvas.height);
}
};
关键点:
-
取消加载任务:
abortController.abort()
中断正在进行的 PDF 加载任务,避免资源浪费。
-
清理画布:
- 在组件卸载时清除
<canvas>
的内容,确保没有残留的绘制数据。
- 在组件卸载时清除
2. 打印功能实现
效果展示:

JS
// 打印 PDF 页面
const handlePrint = () => {
const canvas = canvasRef.current;
if (canvas) {
// 创建隐藏的iframe
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
document.body.appendChild(iframe);
// 将canvas内容转换为图片
const imgData = canvas.toDataURL('image/png');
const printDocument = iframe.contentWindow?.document;
if (printDocument) {
printDocument.open();
printDocument.write(`
<html>
<head>
<title>Print</title>
</head>
<body style="margin: 0;">
<img src="${imgData}" style="width: 100%; height: auto;" />
</body>
</html>
`);
printDocument.close();
// 延迟执行打印以确保内容加载
setTimeout(() => {
iframe.contentWindow?.print();
document.body.removeChild(iframe);
}, 500);
}
}
};
- 获取
<canvas>
内容:将画布内容转换为 Base64 编码的图片数据。 - 创建隐藏的
<iframe>
:提供一个独立的文档环境,用于打印特定内容。 - 向
<iframe>
中写入 HTML :将图片嵌入到<iframe>
的文档中。 - 触发打印 :延迟调用浏览器的打印功能,并在完成后清理
<iframe>
。
1. 函数定义
javascript
const handlePrint = () => { ... }
- 这是一个 React 组件中的方法,用于处理 PDF 页面的打印操作。
- 当用户点击"打印"按钮时,会调用这个函数。
2. 获取 <canvas>
元素
javascript
const canvas = canvasRef.current;
if (canvas) { ... }
- 使用
canvasRef.current
获取当前组件中引用的<canvas>
元素。 - 如果
<canvas>
存在,则继续执行打印逻辑;否则直接退出。
3. 创建隐藏的 <iframe>
javascript
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
document.body.appendChild(iframe);
关键点:
-
为什么使用
<iframe>
?- 浏览器的打印功能通常会打印整个页面的内容。为了只打印特定内容(如
<canvas>
的内容),我们将其封装到一个隐藏的<iframe>
中。 <iframe>
是一个独立的文档环境,不会影响主页面的内容。
- 浏览器的打印功能通常会打印整个页面的内容。为了只打印特定内容(如
-
隐藏
<iframe>
- 设置
iframe.style.display = 'none'
将其隐藏,避免用户看到额外的 UI 元素。
- 设置
-
添加到 DOM
- 使用
document.body.appendChild(iframe)
将<iframe>
添加到页面的 DOM 树中。
- 使用
4. 将 <canvas>
内容转换为图片
javascript
const imgData = canvas.toDataURL('image/png');
关键点:
-
toDataURL
方法<canvas>
提供了toDataURL
方法,可以将画布内容转换为 Base64 编码的图片数据。- 参数
'image/png'
指定输出的图片格式为 PNG。
-
Base64 图片数据
-
imgData
是一个字符串,包含图片的 Base64 编码。例如:bashdata:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
-
5. 向 <iframe>
中写入 HTML 内容
javascript
const printDocument = iframe.contentWindow?.document;
if (printDocument) {
printDocument.open();
printDocument.write(`
<html>
<head>
<title>Print</title>
</head>
<body style="margin: 0;">
<img src="${imgData}" style="width: 100%; height: auto;" />
</body>
</html>
`);
printDocument.close();
}
关键点:
-
获取
<iframe>
的文档对象iframe.contentWindow?.document
获取<iframe>
的document
对象。- 使用可选链操作符
?.
确保contentWindow
存在。
-
写入 HTML 内容
-
调用
printDocument.open()
打开文档流。 -
使用
printDocument.write()
向文档中写入 HTML 内容。 -
写入的内容包括:
- 一个
<img>
标签,其src
属性设置为 Base64 图片数据。 - 样式设置为
width: 100%
和height: auto
,确保图片自适应打印区域。
- 一个
-
调用
printDocument.close()
关闭文档流。
-
6. 延迟执行打印
javascript
setTimeout(() => {
iframe.contentWindow?.print();
document.body.removeChild(iframe);
}, 500);
关键点:
-
延迟执行
- 使用
setTimeout
延迟 500 毫秒后再执行打印操作。 - 这是为了确保
<iframe>
中的内容已经完全加载,避免打印时出现空白或不完整的情况。
- 使用
-
触发打印
- 调用
iframe.contentWindow?.print()
触发浏览器的打印对话框。 - 用户可以选择打印机或保存为 PDF。
- 调用
-
清理
<iframe>
- 打印完成后,使用
document.body.removeChild(iframe)
从 DOM 中移除<iframe>
,释放资源。
- 打印完成后,使用
3. 下载功能实现
将 <canvas>
中的内容转换为图片,并提供下载功能。用户点击"下载"按钮后,会触发这个函数,将画布内容保存为 PNG 文件。
效果展示:

typescript
// 下载 PDF 页面
const handleDownload = () => {
const canvas = canvasRef.current;
if (canvas) {
const imgData = canvas.toDataURL('image/png');
const fileName = pdfUrl.split('/').pop()?.replace(/\.pdf$/i, '') || 'download';
const link = document.createElement('a');
link.download = `${fileName}.png`;
link.href = imgData;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
- 获取
<canvas>
内容:将画布内容转换为 Base64 编码的图片数据。 - 生成文件名 :根据
pdfUrl
提取文件名,并移除.pdf
后缀。 - 创建下载链接 :动态生成一个
<a>
元素,并设置其href
和download
属性。 - 触发下载:通过模拟点击事件触发浏览器的下载功能,并在完成后清理 DOM。
1. 函数定义
javascript
const handleDownload = () => { ... }
- 这是一个 React 组件中的方法,用于处理 PDF 页面的下载操作。
- 当用户点击"下载"按钮时,会调用这个函数。
2. 获取 <canvas>
元素
javascript
const canvas = canvasRef.current;
if (canvas) { ... }
- 使用
canvasRef.current
获取当前组件中引用的<canvas>
元素。 - 如果
<canvas>
存在,则继续执行下载逻辑;否则直接退出。
3. 将 <canvas>
内容转换为图片
javascript
const imgData = canvas.toDataURL('image/png');
关键点:
-
toDataURL
方法<canvas>
提供了toDataURL
方法,可以将画布内容转换为 Base64 编码的图片数据。- 参数
'image/png'
指定输出的图片格式为 PNG。
-
Base64 图片数据
-
imgData
是一个字符串,包含图片的 Base64 编码。例如:bashdata:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
-
4. 生成文件名
javascript
const fileName = pdfUrl.split('/').pop()?.replace(/.pdf$/i, '') || 'download';
关键点:
-
提取文件名
pdfUrl.split('/')
将pdfUrl
按/
分隔成数组。.pop()
获取数组的最后一个元素,通常是文件名(如example.pdf
)。
-
移除
.pdf
后缀-
.replace(/.pdf$/i, '')
使用正则表达式将文件名中的.pdf
后缀替换为空字符串。 -
/.pdf$/i
的含义:.
匹配点号(.
是特殊字符,需要用反斜杠转义)。pdf
匹配字符串 "pdf"。$
表示匹配字符串的结尾。i
表示忽略大小写。
-
-
默认文件名
- 如果
pdfUrl
为空或无法提取文件名,则使用默认值'download'
。
- 如果
5. 创建下载链接
javascript
const link = document.createElement('a');
link.download = `${fileName}.png`;
link.href = imgData;
关键点:
-
创建
<a>
元素document.createElement('a')
创建一个 HTML 锚点(<a>
)元素。- 这个元素用于模拟下载行为。
-
设置下载属性
link.download =
${fileName}.png` 设置下载文件的名称。- 例如,如果
fileName
是example
,则下载的文件名为example.png
。
-
设置链接地址
link.href = imgData
将 Base64 图片数据赋值给href
属性。- 浏览器会将其识别为一个可下载的资源。
6. 触发下载
javascript
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
关键点:
-
添加到 DOM
- 使用
document.body.appendChild(link)
将<a>
元素临时添加到页面的 DOM 树中。 - 这是为了确保浏览器能够识别并触发下载行为。
- 使用
-
触发点击事件
- 调用
link.click()
模拟用户点击下载链接。 - 浏览器会自动开始下载文件。
- 调用
-
清理 DOM
- 使用
document.body.removeChild(link)
从 DOM 中移除<a>
元素。 - 避免页面上留下多余的元素。
- 使用
总结
本文实现的 PDF 查看器具有以下特点:
特性 | 实现方案 | 优势 |
---|---|---|
按需加载 | 首屏仅加载第一页 | 快速呈现 |
打印/下载 | Canvas 转图片方案 | 跨浏览器兼容性好 |
错误恢复 | 状态隔离 + 错误边界 | 增强组件健壮性 |
内存安全 | AbortController + 清理函数 | 防止内存泄漏 |
该方案为基本的 PDF 预览需求提供了可靠实现,开发者可根据具体业务场景进行扩展优化。对于需要完整 PDF 功能(如文本选择、表单填写等)的场景,建议结合 PDF.js 的完整功能 API 进行深度定制。
完整代码:
js
import React, { useEffect, useRef, useState } from 'react';
import { getDocument } from 'pdfjs-dist';
import { GlobalWorkerOptions } from 'pdfjs-dist';
import { Button } from 'antd';
// 设置 Worker 文件路径
GlobalWorkerOptions.workerSrc = '//cdnjs.cloudflare.com/ajax/libs/pdf.js/2.11.338/pdf.worker.min.js';
// 为 pdfUrl 参数添加 string 类型注解,解决隐式 any 类型问题
const PDFViewer = ({ pdfUrl }: { pdfUrl: string }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 打印 PDF 页面
const handlePrint = () => {
const canvas = canvasRef.current;
if (canvas) {
// 创建隐藏的iframe
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
document.body.appendChild(iframe);
// 将canvas内容转换为图片
const imgData = canvas.toDataURL('image/png');
const printDocument = iframe.contentWindow?.document;
if (printDocument) {
printDocument.open();
printDocument.write(`
<html>
<head>
<title>Print</title>
</head>
<body style="margin: 0;">
<img src="${imgData}" style="width: 100%; height: auto;" />
</body>
</html>
`);
printDocument.close();
// 延迟执行打印以确保内容加载
setTimeout(() => {
iframe.contentWindow?.print();
document.body.removeChild(iframe);
}, 500);
}
}
};
// 下载 PDF 页面
const handleDownload = () => {
const canvas = canvasRef.current;
if (canvas) {
const imgData = canvas.toDataURL('image/png');
const fileName = pdfUrl.split('/').pop()?.replace(/\.pdf$/i, '') || 'download';
const link = document.createElement('a');
link.download = `${fileName}.png`;
link.href = imgData;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
// 加载 PDF 并渲染到 canvas
useEffect(() => {
const abortController = new AbortController();
if (!pdfUrl) return;
setIsLoading(true);
setError(null);
getDocument(pdfUrl).promise
.then((pdf) => {
if (abortController.signal.aborted) return;
console.log('PDF loaded');
// 获取第一页
pdf.getPage(1).then((page) => {
if (abortController.signal.aborted) {
page.cleanup();
return;
}
console.log('Page 1 loaded');
const scale = 1.5;
const viewport = page.getViewport({ scale });
if (!canvasRef.current) {
throw new Error('Canvas element not found');
}
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
// 清除之前的画布内容
context?.clearRect(0, 0, canvas.width, canvas.height);
canvas.height = viewport.height;
canvas.width = viewport.width;
console.log('Canvas尺寸设置:', canvas.width, 'x', canvas.height);
// 渲染 PDF 页面到 canvas
const renderContext: any = {
canvasContext: context,
viewport: viewport,
};
const renderTask = page.render(renderContext);
renderTask.promise
.then(() => {
console.log('页面渲染完成');
setIsLoading(false);
})
.catch((renderError) => {
if (!abortController.signal.aborted) {
setError('页面渲染失败: ' + renderError.message);
}
});
});
})
.catch((error) => {
if (!abortController.signal.aborted) {
setError('PDF加载失败: ' + error.message);
console.error('Error loading PDF:', error);
}
})
.finally(() => {
if (!abortController.signal.aborted) {
setIsLoading(false);
}
});
return () => {
abortController.abort();
const canvas = canvasRef.current;
if (canvas) {
const context = canvas.getContext('2d');
context?.clearRect(0, 0, canvas.width, canvas.height);
}
};
}, [pdfUrl]);
return (
<div style={{ position: 'relative', width: '100%' }}>
<Button onClick={handlePrint}>打印</Button>
<Button onClick={handleDownload}>下载</Button>
{isLoading && <div>加载中...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
<div id="printPdfButton"></div>
<canvas
key={pdfUrl}
ref={canvasRef}
style={{ width: '100%', height: 'auto' }}
/>
</div>
);
};
export default PDFViewer;
使用此组件:
js
import React from "react";
import "./index.less";
import PDFViewer from "./PDFViewer";
const ImgePage = () => {
return (
<div
style={{ height: "80vh", border: "1px solid #ddd", position: "relative" }}
>
<PDFViewer
pdfUrl={
"https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf"
}
/>
</div>
);
};
export default ImgePage;