pdf.js实战:从零实现前端PDF预览与交互

发布时间:2026/9/2 21:04:24
pdf.js实战:从零实现前端PDF预览与交互 简介面向需要在浏览器中集成 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-dist3.11.174/cmaps/, cMapPacked: true, standardFontDataUrl: https://unpkg.com/pdfjs-dist3.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点在普通屏幕devicePixelRatio1下scale1时Canvas的物理像素就是612x792看起来清晰。但在Retina屏devicePixelRatio2上同样的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的文本层机制。文本层是一个绝对定位的透明divpdf.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 vitelatest pdf-demo -- --template vanilla cd pdf-demo npm install pdfjs-dist3.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 langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titlepdf.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 classtoolbar button idprevBtn上一页/button span idpageNum1 / 1/span button idnextBtn下一页/button button idzoomInBtn放大/button span idzoomLevel100%/span button idzoomOutBtn缩小/button /div div idpdfContainer classpdf-container/div /body /htmlimport * 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-dist3.11.174/cmaps/, cMapPacked: true, standardFontDataUrl: https://unpkg.com/pdfjs-dist3.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的过程其实也是把这些年踩过的坑重新捋了一遍。如果你照着上面的代码本地跑起来再试着改成自己的项目场景遇到问题可以对照“常见问题”那一节排查大多数坑都能顺着找到根源。本文还有配套的精品资源点击获取