简介:面向需要在浏览器中集成 PDF 查看功能的开发者,这份资源以实际可运行的 demo 演示 PDF.js 的典型用法,帮助解决 Web 端 PDF 在线预览与交互控制的落地问题。压缩包仅 6 个文件、约 601KB,包含 3 个 HTML 示例页面、2 个核心 JavaScript 文件以及 1 份 PDF 测试文档。主入口页面提供基础加载与渲染逻辑,两个演示页面分别展示页面缩放、导航控件等不同配置;js 目录下的核心库负责 PDF 解析、工作线程计算和默认界面样式,结构简洁,便于对照学习。部署时需放入 IIS 或 Apache 等 Web 服务器,通过 http 协议访问,避免 file:// 的安全限制。资源还涉及配置参数、加载方式、事件监听、错误处理与性能优化等关键点,适合想要快速上手 PDF.js 并定制阅读体验的初中级前端开发者。目前已有 1791 人学习下载,可作为搭建在线预览功能的直接参考资料。 pdf.js这个库,我从2018年开始用,当时内部系统要在网页里预览合同文件,试过一堆方案:Flash被禁、iframe直接套浏览器原生预览没法自定义样式、后端转图片又太占资源,最后老老实实回到Mozilla官方出的pdf.js。这些年用它搭过的Demo少说也有七八个,从最简单的“显示第一页”到带缩放、搜索、文本选区的完整阅读器都写过。如果你正在纠结怎么在浏览器里预览PDF,或者想自己写一个PDF查看器,这篇内容应该能帮你少走不少弯路。
我尽量按照“先理解原理、再实操、最后排坑”的顺序来讲,所有代码都是可以直接复制的水平,环境基于常规的Vite + JavaScript项目,pdf.js版本以3.11.174为例(这个版本比较稳定,官方也在长期维护)。
1. 为什么选pdf.js:核心设计思路拆解
1.1 三种前端PDF预览方案的对比
真正动手之前,得先明白pdf.js在技术选型里的位置。前端做PDF预览,我实际对比过三条路:
- 身份证/驾照之类的小文件直接用浏览器内置预览:iframe塞一个PDF路径就行,但样式、翻页、权限控制统统不可控,移动端表现也很随机。
- 后端转图片再前端展示:实现简单,但高清缩放、文字搜索、复制文本这些需求基本告别,而且服务端要额外做转码任务,并发一上来CPU压力不小。
- pdf.js前端纯解析渲染:PDF解析、Canvas绘制、文本层提取都在浏览器完成,服务端零压力,交互体验可以完全自定义,这也是它作为开源库能长期被选中的核心理由。
pdf.js最初是Mozilla为了在Firefox里内置PDF阅读器而开发的,所以它的定位从来不是“一个API”,而是一整套完整的解析与渲染引擎。你直接用它的getDocument、getPage、render这些核心方法,等于把Firefox的PDF渲染能力搬到了自己的网页里。
1.2 用官方Demo还是自己封装
pdf.js仓库里其实带着一个完整可运行的viewer.html,也就是官方Demo阅读器。这个reader拥有侧边栏、缩略图、搜索、缩放、打印等全套功能,很多项目图省事直接iframe嵌入这个viewer。但我不建议一上来就用它,原因有三:
- 整套viewer体积大、样式重,想深度定制反而麻烦。
- viewer内部实现了很多逻辑,你改一行CSS可能要牵出几百行依赖,后期维护成本高。
- 我们做Demo的初衷是理解原理,用官方viewer等于跳过学习过程,以后遇到问题还是两眼一抹黑。
所以这篇内容我会从零开始,手工只实现一个够用的阅读器核心:加载、渲染、翻页、缩放、文本选择。当你掌握这些基础能力,再回头看官方viewer,代码读起来就会轻松得多。
1.3 pdf.js的整体架构
简单说,pdf.js在浏览器的执行链路是这样的:
getDocument接收PDF文件地址或数据,交给Worker线程做解析;- Worker解析出页面的绘图指令、字体信息、文本内容;
- 主线程拿到页面对象后,通过
render方法把指令绘制到Canvas; - 同时可以用
getTextContent拿到文本块坐标,叠一层透明的div实现文字选择、搜索。
核心优势在于,解析PDF是CPU密集型操作,pdf.js把它放在Web Worker里执行,不会阻塞UI线程,这是我们做大文件预览不卡顿的关键前提。后续所有代码都要围绕这条链路展开。
2. 核心API与关键参数:每一步背后的原理
2.1 加载文档:getDocument的细节
在页面里引入pdf.js后,第一步是设置Worker路径。我的习惯写法是:
import * as pdfjsLib from 'pdfjs-dist'; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.js', import.meta.url ).toString();这里workerSrc必须指向pdf.worker.min.js的完整URL,不能省略。很多新手在这里踩坑:不设置或者路径写错,浏览器会报“Failed to fetch dynamically imported module”一类的错误,因为pdf.js主线程代码和工作线程代码是分离的两个文件。
然后加载文档:
const loadingTask = pdfjsLib.getDocument({ url: './sample.pdf', cMapUrl: 'https://unpkg.com/pdfjs-dist@3.11.174/cmaps/', cMapPacked: true, standardFontDataUrl: 'https://unpkg.com/pdfjs-dist@3.11.174/standard_fonts/' }); const pdf = await loadingTask.promise;cMapUrl和standardFontDataUrl这两个参数是处理中文和特殊字体显示的关键。PDF文件里嵌入的字体如果是CID编码,渲染时需要CMap文件来做字符映射;如果不配置,有些中文PDF会变成乱码或方块。这两个目录在npm包里默认就有,部署时记得一起拷贝到静态资源目录。
2.2 渲染页面:getPage与render配合
拿到pdf文档对象后,渲染一页核心就三步:
const page = await pdf.getPage(1); // 页码从1开始 const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.getElementById('pdfCanvas'); const ctx = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: ctx, viewport: viewport }).promise;getPage(1)的页码从1而不是0开始,这是pdf.js的设计习惯,跟数组索引不一样,写代码时容易犯迷糊。
getViewport里的scale参数代表缩放比例,1就是原始大小。viewport会返回width、height、scale以及旋转后的信息,Canvas宽高必须按viewport设置,否则画出来的内容会变形。
2.3 viewport和scale:缩放到底怎么算
经常有人问,为什么PDF渲染出来模糊?本质是Canvas的物理像素和CSS像素没对齐。
比如一个PDF页面原始尺寸是612x792点,在普通屏幕(devicePixelRatio=1)下,scale=1时Canvas的物理像素就是612x792,看起来清晰。但在Retina屏(devicePixelRatio=2)上,同样的Canvas尺寸在物理上只有一半点数,文字边缘就会发虚。
这里我推荐一个统一处理方式:
function getRenderViewport(page, scale = 1, devicePixelRatio = window.devicePixelRatio || 1) { const viewport = page.getViewport({ scale: scale * devicePixelRatio }); return { viewport, canvasWidth: Math.floor(viewport.width / devicePixelRatio), canvasHeight: Math.floor(viewport.height / devicePixelRatio) }; } // 使用时 const { viewport, canvasWidth, canvasHeight } = getRenderViewport(page, 1); canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.width = canvasWidth + 'px'; canvas.style.height = canvasHeight + 'px';这样Canvas物理像素是逻辑像素乘以dpr,样式宽度保持在视觉预期,高清屏下渲染锐利,普通屏也不受影响。这个细节我早期写Demo时完全没考虑,结果在Mac上一看全是毛边,后来才补齐。
2.4 文本层:实现可复制、可搜索的关键
Canvas画出来的PDF是一张图片,用户不能选中文字,搜索引擎也抓不到内容。解决方式是pdf.js的文本层机制。
文本层是一个绝对定位的透明div,pdf.js会把每个文本块的位置、尺寸、内容都计算出来,然后用<span>拼在对应坐标上。实际操作上,渲染页面时需要拿到textContent,再调用标准生成逻辑:
const textContent = await page.getTextContent(); const textLayerDiv = document.getElementById('textLayer'); textLayerDiv.innerHTML = ''; const textLayer = new pdfjsLib.TextLayer({ textContentSource: textContent, container: textLayerDiv, viewport: viewport, textDivs: [] }); await textLayer.render();需要特别注意的是,文本层div必须和Canvas重叠在同一个容器里,而且文本层的z-index要低于Canvas,否则鼠标事件会被Canvas挡住。更关键的是,它的position坐标基准必须和Canvas的CSS位置完全一致,文本层差一个像素,选中的文字就会和视觉位置错位。
3. Demo实操:从零搭一个能用的PDF阅读器
3.1 工程准备
我的建议是直接用Vite,创建项目几秒钟:
npm create vite@latest pdf-demo -- --template vanilla cd pdf-demo npm install pdfjs-dist@3.11.174 npm run dev选择一个稳定版本的pdfjs-dist很重要,我之所以不追最新版,是因为pdf.js的API在4.x之后有一些调整(比如TextLayer构造参数的修改、部分全局API移除),社区里大量老教程都是基于2.x和3.x写的,遇到问题更容易找到参考资料。等你有经验了,再迁移到新版也不迟。
3.2 完整的PDF加载渲染Demo
下面是整个Demo的核心代码,注释我尽量写得详细:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>pdf.js 阅读器Demo</title> <style> .pdf-container { position: relative; width: 100%; max-width: 800px; margin: 0 auto; background: #f5f5f5; overflow: hidden; } .pdf-page { position: relative; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); background: #fff; } .pdf-page canvas { display: block; width: 100%; } .text-layer { position: absolute; inset: 0; overflow: hidden; line-height: 1; text-align: initial; transform-origin: 0 0; z-index: 1; } .text-layer span { position: absolute; white-space: pre; transform-origin: 0% 0%; color: transparent; } .toolbar { display: flex; gap: 8px; align-items: center; max-width: 800px; margin: 16px auto; padding: 8px; background: #fff; border: 1px solid #ddd; border-radius: 8px; } </style> </head> <body> <div class="toolbar"> <button id="prevBtn">上一页</button> <span id="pageNum">1 / 1</span> <button id="nextBtn">下一页</button> <button id="zoomInBtn">放大</button> <span id="zoomLevel">100%</span> <button id="zoomOutBtn">缩小</button> </div> <div id="pdfContainer" class="pdf-container"></div> </body> </html>import * as pdfjsLib from 'pdfjs-dist'; import 'pdfjs-dist/web/pdf_viewer.css'; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.js', import.meta.url ).toString(); let pdfDoc = null; let currentPage = 1; let currentScale = 1; const container = document.getElementById('pdfContainer'); async function loadPdf(url) { const loadingTask = pdfjsLib.getDocument({ url, cMapUrl: 'https://unpkg.com/pdfjs-dist@3.11.174/cmaps/', cMapPacked: true, standardFontDataUrl: 'https://unpkg.com/pdfjs-dist@3.11.174/standard_fonts/' }); pdfDoc = await loadingTask.promise; document.getElementById('pageNum').textContent = `1 / ${pdfDoc.numPages}`; renderPage(1); } async function renderPage(pageNumber) { if (!pdfDoc) return; const page = await pdfDoc.getPage(pageNumber); const dp = window.devicePixelRatio || 1; const viewport = page.getViewport({ scale: currentScale * dp }); const pageWrap = document.createElement('div'); pageWrap.className = 'pdf-page'; pageWrap.style.width = viewport.width / dp + 'px'; pageWrap.style.height = viewport.height / dp + 'px'; const canvas = document.createElement('canvas'); canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.width = viewport.width / dp + 'px'; canvas.style.height = viewport.height / dp + 'px'; const textLayerDiv = document.createElement('div'); textLayerDiv.className = 'text-layer'; textLayerDiv.style.width = viewport.width / dp + 'px'; textLayerDiv.style.height = viewport.height / dp + 'px'; pageWrap.appendChild(canvas); pageWrap.appendChild(textLayerDiv); container.innerHTML = ''; container.appendChild(pageWrap); const renderContext = { canvasContext: canvas.getContext('2d'), viewport: viewport }; const renderTask = page.render(renderContext); await renderTask.promise; const textContent = await page.getTextContent(); const textLayer = new pdfjsLib.TextLayer({ textContentSource: textContent, container: textLayerDiv, viewport: viewport, textDivs: [] }); await textLayer.render(); } document.getElementById('prevBtn').addEventListener('click', () => { if (currentPage <= 1) return; currentPage--; renderPage(currentPage).then(() => { document.getElementById('pageNum').textContent = `${currentPage} / ${pdfDoc.numPages}`; }); }); document.getElementById('nextBtn').addEventListener('click', () => { if (currentPage >= pdfDoc.numPages) return; currentPage++; renderPage(currentPage).then(() => { document.getElementById('pageNum').textContent = `${currentPage} / ${pdfDoc.numPages}`; }); }); document.getElementById('zoomInBtn').addEventListener('click', () => { currentScale = Math.min(3, currentScale + 0.25); document.getElementById('zoomLevel').textContent = Math.round(currentScale * 100) + '%'; renderPage(currentPage); }); document.getElementById('zoomOutBtn').addEventListener('click', () => { currentScale = Math.max(0.5, currentScale - 0.25); document.getElementById('zoomLevel').textContent = Math.round(currentScale * 100) + '%'; renderPage(currentPage); }); loadPdf('./sample.pdf');实际跑起来你会发现一个问题:每次翻页或缩放都是先清空容器再重新创建Canvas和文本层,体验还行,但渲染大页面时会有短暂白屏。优化思路是双缓冲:预渲染下一页,等当前页显示完后再切换。不过作为Demo,这个简单版本已经足够说明整体工作流程了。
3.3 工具函数封装:把渲染逻辑抽出来复用
Demo写多了以后,我习惯把渲染过程抽成一个独立方法,避免在每个页面里重复写一大段:
async function renderPDFPage(pdf, pageNumber, targetElement, scale = 1) { const page = await pdf.getPage(pageNumber); const dpr = window.devicePixelRatio || 1; const viewport = page.getViewport({ scale: scale * dpr }); const wrap = document.createElement('div'); wrap.className = 'pdf-page'; const canvas = document.createElement('canvas'); canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.width = `${viewport.width / dpr}px`; canvas.style.height = `${viewport.height / dpr}px`; const textLayer = document.createElement('div'); textLayer.className = 'text-layer'; textLayer.style.width = `${viewport.width / dpr}px`; textLayer.style.height = `${viewport.height / dpr}px`; wrap.append(canvas, textLayer); targetElement.innerHTML = ''; targetElement.appendChild(wrap); await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise; const textContent = await page.getTextContent(); await new pdfjsLib.TextLayer({ textContentSource: textContent, container: textLayer, viewport, textDivs: [] }).render(); return { page, viewport }; }这样后续做PDF打印、多页连续滚动、甚至转图片导出都方便很多。核心思路是把“页面对象”和“DOM容器”解耦,后续你加任何功能都不会打乱主流程。
4. 常见问题与排查技巧实录
4.1 Worker加载失败
错误特征:控制台报Failed to fetch或pdf.workerundefined。
排查顺序就三步:先看workerSrc路径是否可访问,再确认该路径返回的是JS文件而不是HTML(有些开发服务器会对js请求做拦截),最后检查是否跨域——如果你把pdf.worker.min.js放在CDN上,而页面在另一个域下,就必须在CDN响应头配置Access-Control-Allow-Origin,或者干脆把worker文件放同域。
我在本地开发时常用Vite的public目录放worker文件,线上则用CDN地址。注意版本一定要和主库保持一致,混用版本会导致一些诡异报错。
4.2 中文和特殊字体乱码
如果PDF里的中文显示成方框或乱码,90%是cMapUrl没配置对。CMap文件本身也是加密过的二进制文件,后端部署时千万不能只拷贝pdf.min.js和pdf.worker.min.js,一定把cmaps目录和standard_fonts目录一起拷过去。如果整个项目是单页应用,可以把这两个目录放到静态资源根目录,再在getDocument里指相对路径:
pdfjsLib.getDocument({ url: './sample.pdf', cMapUrl: './cmaps/', cMapPacked: true, standardFontDataUrl: './standard_fonts/' });注意cMapPacked要设为true,因为npm包里的cmap文件是.bcmap格式,对应压缩类型,不设这个参数解析会失败。
4.3 大文件加载白屏或卡顿
PDF动辄几十MB时,如果一次性getDocument加载全部数据,用户等待时间会非常长。这里有两个技巧:
- 服务端支持Range请求时,pdf.js可以利用
rangeChunkSize参数控制在线的分段加载,比如getDocument({ url, rangeChunkSize: 65536 }),只下载当前需要渲染的部分。 - 前端可以做懒渲染,只在页面即将进入可视区域时才调用
renderPage,我采用的是监听滚动事件,结合节流函数来实现。
如果PDF文件存储在本地或者后端不支持Range,那也可以用data参数直接传ArrayBuffer,但这就失去了流式加载的优势,大文件体验会差一些。
4.4 文本层错位或文字选不中
这个我踩过好多次。文本层错位的核心原因是Canvas和textLayer的CSS尺寸、位置基准不一致。我建议统一用一个外层divposition: relative包住Canvas和文本层,文本层的定位用absolute且左上角和Canvas完全对齐,关键是transform-origin: 0 0和inset: 0。
还有一种情况是文字选不中,检查文本层是否被别的元素挡住了,或者z-index配低了。另外,如果页面本身是扫描件(图片PDF),没有任何文本内容,getTextContent返回空,那就不需要创建文本层了,直接跳过。我在有的项目里为了性能,会先判断textContent.items.length === 0再决定是否渲染文本层。
4.5 移动端适配问题
移动端上Canvas要特别注意缩放比例,刚才提到的devicePixelRatio处理不要省。还有一点,移动端触摸滚动时,如果文本层有透明span占据空间,滚动会不流畅,可以把文本层的pointer-events设成none,但这样会失去文本选择能力。折中方案是双击才进入文本选择模式,平时保持none。
5. 几个值得后续扩展的方向
写完了基础Demo,如果你还想往下深入,我个人比较推荐这几个方向:
- 打印功能:把当前PDF文档重新交给pdf.js的
print方法,或者自己拼接Canvas为图片再触发浏览器打印。注意不要用window.print()直接打印,那样打印出来只有当前页面可见部分,用户体验很差。 - 多页连续滚动:类似在线文档那种流式阅读,可以一次渲染多页,配合虚拟滚动只维护可见区域的几页,对性能优化是大的提升。
- PDF表单填写:pdf.js支持获取表单域,配合AnnotationLayer可以做一个简单的在线签批工具。
- 配合Codex等AI工具生成Demo:如果你只是想做概念验证,可以让AI先按官方文档生成基础框架,你再手工调样式和交互,效率确实高不少。但核心API和渲染流程还是要自己吃透,否则出了问题很难定位。
pdf.js这套东西说难不难,说简单也不简单,真正容易出问题的往往不是API本身,而是字体资源、Worker路径、跨域、Canvas尺寸这些工程细节。我写这篇Demo的过程,其实也是把这些年踩过的坑重新捋了一遍。如果你照着上面的代码本地跑起来,再试着改成自己的项目场景,遇到问题可以对照“常见问题”那一节排查,大多数坑都能顺着找到根源。
本文还有配套的精品资源,点击获取