简介:这是一份基于Mozilla开源项目PDF.js的实战Demo,面向需要在Web端实现PDF在线预览与打印的前端开发者,尤其适合希望摆脱插件依赖、用纯JavaScript完成文档渲染的中级学习者。压缩包共376个文件,约2.49MB,以bcmap字体映射文件、properties语言资源、png与svg图标、js脚本及html页面为主,另含少量pdf示例、css样式与license说明,完整保留了PDF.js运行所需的静态资源与源码结构。Demo覆盖文档加载、Canvas渲染、页面缩放、本地文件读取与浏览器打印等核心环节,并涉及Web Worker后台解析、分页加载与缓存等性能优化思路,同时提示了同源策略与XSS安全注意事项。目前已有9927人学习下载,读者可据此快速搭建可运行的预览环境,理解PDFDocumentProxy与page.render等关键API的调用方式,并在此基础上扩展注释、搜索或书签等应用场景。
1. 浏览器里跑通 PDF 预览:为什么我最后选了 pdf.js 这个 Demo
前阵子帮一个做内部文档系统的朋友看问题,他们的需求很朴素:用户上传 PDF 后,不装任何插件、不跳转第三方服务,直接在网页里翻页看,还要能点一下打印。听起来简单,真动手才发现坑不少——用<iframe>嵌 PDF,Chrome 和 Firefox 表现不一致;用浏览器自带的 PDF 查看器,打印按钮的样式和回调完全不受控;想自己画,又得处理字体、编码、分页。折腾一圈,最后还是回到 pdf.js 这个纯 JS 方案上。
这份 Demo 的价值就在这儿:它把「PDF 在线预览 + 打印」这条链路用纯前端 JS 走通了,不依赖后端渲染服务,也不需要用户装任何东西。核心是 pdf.js 负责解析和渲染 PDF 到 canvas,打印则借助浏览器原生的打印能力配合渲染结果。适合谁?做后台管理系统、在线教育资料页、合同/报表预览这类场景的前端,尤其是那些不想引入重型 PDF 商业库、又对可控性有要求的团队。下面我按「它怎么工作 → 怎么跑起来 → 参数怎么调 → 坑在哪」的顺序拆一遍。
2. pdf.js 的渲染链路:从二进制流到 canvas 到底发生了什么
2.1 为什么不是 iframe,也不是浏览器内置查看器
先说选型。很多人第一反应是<iframe src="xxx.pdf">,这在桌面 Chrome 上确实能显示,但问题在于:移动端 Safari 和部分安卓 WebView 根本不渲染,直接变成下载;就算渲染了,你也拿不到页码、缩放、打印的控制权。浏览器内置查看器(比如 Chrome 的 PDF viewer)本质是个扩展,你的 JS 无法和它通信,想自定义工具栏、想监听「打印完成」事件,门都没有。
pdf.js 的思路完全不同:它把 PDF 当成一份需要解析的数据,自己在 JS 里实现解析器,把每一页的绘制指令翻译成 canvas 上的像素。这样一来,页码、缩放、旋转、文本层、打印,全部在你手里。代价是要自己管渲染性能,后面会讲。
2.2 三个核心对象:getDocument、page、renderTask
pdf.js 的 API 其实就围绕三个东西转:
pdfjsLib.getDocument(url):加载 PDF,返回一个PDFDocumentProxy,里面带着总页数numPages。pdfDoc.getPage(n):拿到第 n 页的PDFPageProxy,可以查它的视口getViewport({scale})。page.render({canvasContext, viewport}):真正把这一页画到 canvas 上,返回一个RenderTask,可以.cancel()。
理解这三层,后面所有参数调整都有落脚点。比如缩放,本质就是改viewport的 scale;比如清晰度,本质是 canvas 的物理尺寸和 CSS 尺寸的比例。
2.3 一个最小可跑的渲染函数
先看核心渲染逻辑,这是整个 Demo 的心脏:
// 假设 pdfjsLib 已经通过 script 标签或模块引入 async function renderPage(pdfDoc, pageNum, canvas, scale) { // 1. 取页 const page = await pdfDoc.getPage(pageNum); // 2. 按缩放算视口,scale=1 时是 72dpi 的原始尺寸 const viewport = page.getViewport({ scale }); // 3. 处理高分屏:canvas 物理像素 = CSS 像素 * devicePixelRatio const dpr = window.devicePixelRatio || 1; canvas.width = viewport.width * dpr; canvas.height = viewport.height * dpr; canvas.style.width = viewport.width + 'px'; canvas.style.height = viewport.height + 'px'; const ctx = canvas.getContext('2d'); // 4. 关键:把上下文按 dpr 放大,否则高分屏会糊 ctx.scale(dpr, dpr); // 5. 渲染,返回的 task 可用于取消 const renderTask = page.render({ canvasContext: ctx, viewport: viewport, }); await renderTask.promise; return renderTask; }逻辑说明:第 3、4 步是新手最容易漏的。如果只设canvas.width = viewport.width,在 Retina 屏上浏览器会把 canvas 拉伸,字就发虚。正确做法是物理尺寸乘devicePixelRatio,再用ctx.scale把绘制坐标系还原,这样渲染出来的像素密度才够。
参数说明:scale是缩放系数,1 表示原始尺寸;viewport里的width/height是 CSS 像素;renderTask一定要留着引用,翻页太快时旧任务不取消会和新任务抢 canvas,出现花屏。
3. 把 Demo 跑起来:目录结构、加载顺序与打印实现
3.1 引入方式与 worker 配置
pdf.js 有个绕不开的点:它默认用 Web Worker 做解析,避免阻塞主线程。所以除了主库pdf.js,还得有pdf.worker.js。常见做法是:
<script src="./build/pdf.js"></script> <script> // 告诉主库 worker 文件在哪,路径写错会静默降级到主线程 pdfjsLib.GlobalWorkerOptions.workerSrc = './build/pdf.worker.js'; </script>注意:workerSrc必须是能直接访问的 URL,跨域或路径不对时,pdf.js 会回退到「假 worker」在主线程跑,大文件直接卡死 UI。这是排查「为什么一打开就卡」的第一检查点。
3.2 加载与翻页的完整流程
把加载、翻页、缩放串起来,一个能用的预览器大概长这样:
let pdfDoc = null; let currentPage = 1; let currentScale = 1.2; let currentTask = null; async function loadPdf(url) { // 用 arraybuffer 方式加载,便于后续做鉴权头等扩展 const loadingTask = pdfjsLib.getDocument({ url: url, // 常见做法:带 cookie 的请求需要这个 withCredentials: false, }); pdfDoc = await loadingTask.promise; document.getElementById('total').textContent = pdfDoc.numPages; await goToPage(1); } async function goToPage(num) { // 翻页前先取消上一次渲染,避免抢画布 if (currentTask) { currentTask.cancel(); currentTask = null; } currentPage = num; const canvas = document.getElementById('pdf-canvas'); currentTask = await renderPage(pdfDoc, num, canvas, currentScale); document.getElementById('current').textContent = num; }逻辑说明:getDocument返回的是loadingTask,它本身也有.promise和.destroy()。翻页时先cancel旧任务,是防止快速点击「下一页」时两页内容叠在一起。参数上,withCredentials决定是否带 cookie,做需要登录的文档系统时要按后端策略设。
3.3 打印:别直接 print,先把所有页渲染出来
打印是这份 Demo 里最容易被低估的部分。直接window.print()只会打印当前 DOM,而你的 canvas 只有一页。正确思路是:打印前把所有页渲染到一个隐藏容器里,再触发打印。
async function printAll() { const container = document.getElementById('print-container'); container.innerHTML = ''; for (let i = 1; i <= pdfDoc.numPages; i++) { const page = await pdfDoc.getPage(i); // 打印用固定 scale,保证每页尺寸一致 const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.createElement('canvas'); const dpr = window.devicePixelRatio || 1; canvas.width = viewport.width * dpr; canvas.height = viewport.height * dpr; canvas.style.width = '100%'; canvas.style.height = 'auto'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); await page.render({ canvasContext: ctx, viewport }).promise; container.appendChild(canvas); } window.print(); }逻辑说明:打印容器平时用 CSSdisplay: none藏起来,打印时通过@media print显示、隐藏主预览区。参数上,打印 scale 建议固定,别跟着屏幕缩放走,否则每页大小不一。另外window.print()是同步阻塞的,渲染没完成就调用会打出空白页,所以必须await完所有页。
配套的打印样式:
@media print { #preview-area { display: none; } #print-container { display: block; } #print-container canvas { page-break-after: always; } }page-break-after: always保证每张 canvas 单独一页,不然浏览器会把它们挤在一起。
4. 参数与性能:scale、dpr、分页加载怎么配才不卡
4.1 scale 和清晰度的取舍
scale直接决定渲染像素量。scale=1 时一页 A4 约 595×842 CSS 像素,乘 dpr=2 后 canvas 是 1190×1684,约 200 万像素;scale 提到 2,像素量翻四倍,渲染时间也差不多翻倍。我的经验是:屏幕预览用 1.2~1.5 足够,打印用 1.5~2,再高收益很小但内存涨得凶。
| 场景 | 建议 scale | 说明 |
|---|---|---|
| 普通屏幕预览 | 1.0~1.3 | 兼顾清晰与流畅 |
| 高分屏预览 | 1.2~1.5 | 配合 dpr 已足够锐利 |
| 打印输出 | 1.5~2.0 | 固定值,保证页间一致 |
| 缩略图 | 0.2~0.3 | 只做导航用,别用全尺寸 |
4.2 大文件的分页与懒渲染
几十页的 PDF 一次性全渲染,内存直接爆。常见做法是只渲染当前页和前后各一页,其余用占位。判断「当前页」可以用 IntersectionObserver 监听每页容器。参数上,预渲染页数别超过 3,否则滚动时反而更卡。
4.3 文本层与选中复制
如果需求里有「能选中文字」,光画 canvas 不够,还得叠加 pdf.js 的文本层getTextContent(),把文字按坐标绝对定位到 canvas 上方。这一步会明显增加 DOM 节点数,页数多时建议只在当前页生成文本层,翻页即销毁。
提示:文本层的字体要和 canvas 渲染的视觉对齐,否则选中高亮会错位,这是很多人反馈「选中框歪了」的根因。
5. 避坑与排查:那些让我返工三次的问题
5.1 现象:页面一片空白,控制台无报错
原因:workerSrc路径错误,pdf.js 静默回退主线程,但某些版本回退逻辑不完整,导致渲染 promise 一直 pending。 解决:打开 Network 面板确认pdf.worker.js是否 200;路径用绝对路径或基于import.meta.url计算,别用相对路径硬拼。
5.2 现象:高分屏上文字发虚、边缘模糊
原因:只设了 canvas 的 CSS 尺寸,没设物理尺寸,也没做ctx.scale(dpr, dpr)。 解决:按 2.3 的写法,物理尺寸乘 dpr,绘制前 scale。改完如果还糊,检查是不是 CSS 里又对 canvas 做了非整数缩放。
5.3 现象:快速翻页时两页内容叠在一起
原因:旧renderTask没取消,新任务和旧任务同时往一个 canvas 上画。 解决:翻页前currentTask.cancel(),并await新任务。注意cancel()后 promise 会 reject,要 catch 掉,别让它冒泡成未处理异常。
5.4 现象:打印出来是空白或只有第一页
原因:window.print()在渲染完成前被调用;或者打印容器被display: none挡住,浏览器不渲染隐藏内容。 解决:await所有页渲染完再 print;打印容器用@media print切换显示,别用visibility: hidden,那个仍占位但不打印。
5.5 现象:跨域 PDF 加载失败,报 CORS
原因:getDocument默认用 fetch 拉文件,跨域时后端没给Access-Control-Allow-Origin。 解决:让后端加 CORS 头,或改用同源代理。注意withCredentials: true时后端不能把Allow-Origin设成*,必须回显具体域名。
6. 进阶技巧:用 canvas 转图片做缩略图导航与验证
预览器做完了,怎么验证它真的靠谱?我一般会加一个「缩略图侧栏」,顺便当成渲染质量的验收手段。思路很简单:对每一页用低 scale 渲染成小 canvas,再toDataURL转成图片塞进侧栏。这样既能导航,又能一眼看出哪些页渲染异常。
async function buildThumbnails(pdfDoc) { const sidebar = document.getElementById('thumbnails'); for (let i = 1; i <= pdfDoc.numPages; i++) { const page = await pdfDoc.getPage(i); // 缩略图用低 scale,省内存 const viewport = page.getViewport({ scale: 0.25 }); const canvas = document.createElement('canvas'); canvas.width = viewport.width; canvas.height = viewport.height; const ctx = canvas.getContext('2d'); await page.render({ canvasContext: ctx, viewport }).promise; const img = document.createElement('img'); // 转成 dataURL,避免保留大量 canvas 对象 img.src = canvas.toDataURL('image/jpeg', 0.7); img.onclick = () => goToPage(i); sidebar.appendChild(img); } }逻辑说明:缩略图 scale 压到 0.25,一页 canvas 只有几十 KB,几十页也不心疼。转dataURL后原来的 canvas 可以被 GC 回收,比一直挂着 canvas 省内存。参数上,JPEG 质量 0.7 是清晰度和体积的平衡点,再低就糊了。
验证方法:渲染完缩略图后,肉眼扫一遍,如果某页全白或全黑,基本是那页 PDF 本身有问题(比如扫描件用了特殊编码),而不是你的代码。这时候可以单独getPage那页,看getOperatorList()有没有异常,定位是解析层还是渲染层。
从那以后我每次接 PDF 预览需求,都强制先跑一遍「缩略图全渲染」——它能在五分钟内暴露 90% 的兼容性和性能问题,比翻页点半天高效得多。希望帮到你。
本文还有配套的精品资源,点击获取