
简介这份pdf.js使用demo是一份面向Web前端开发者的实践示例以Mozilla团队开源的PDF.js为核心演示如何在浏览器中嵌入PDF文档的解析与渲染解决开发者不熟悉库集成与配置的痛点。压缩包解压后共6个文件3个HTML示例页面分别演示不同应用场景2个JavaScript核心文件提供PDF解析与worker线程处理能力1个PDF文档用于测试渲染效果整个包仅601KB轻量易用部署到IIS或Apache等Web服务器后即可通过HTTP访问。示例代码覆盖了PDF加载、页面缩放、自定义导航等常见操作并可根据需要调整配置项、监听相关事件。开发者在研究这些代码时还能了解worker线程机制、UI样式定制以及错误处理和性能优化的实践思路从而快速搭建属于自己的PDF在线阅读功能。目前已有1791人学习下载适合希望快速集成PDF阅读能力的前端工程师与学习者通过对照示例源码可以大幅缩短从零接入PDF.js的摸索时间。 领导上周丢给我一个需求网页里要展示一份合同PDF不能弹新窗口不能依赖浏览器插件还要能定制上下页按钮和缩放比例。我第一反应是iframe但iframe的样式操控能力太弱PDF插件的外观在Chrome和Edge里完全不受网页控制移动端更是直接拉起系统预览体验割裂得很。折腾一圈之后我还是老老实实回到pdf.js写了一个demo才把需求收了尾。这篇内容就围绕这个demo展开。pdf.js是Mozilla开源的JavaScript PDF渲染引擎核心能力是在浏览器端直接解析PDF文件并绘制到canvas上前端可以完全接管渲染结果的样式和交互。无论你是要做一个在线预览页面还是想在自己写的管理后台里内嵌文档展示甚至做一套完整的在线签名流程pdf.js都是绕不开的基础库。它适合所有需要前端原生渲染PDF的场景尤其适合那些对UI有定制需求、不想用开箱即用组件的团队。1. 为什么PDF预览这事儿前端绕不开1.1 浏览器自带预览的三大限制大多数前端遇到PDF预览的第一反应不是iframe就是embed标签这俩确实能用但用起来浑身难受。第一个限制是UI完全不可控Chrome的PDF阅读器带自己的工具栏你没法在页面上加一个确认已阅读的按钮也没法把底部的缩放条换成自己的样式。第二个限制是移动端体验分裂Android上的行为跟随系统浏览器iOS上则可能直接调用QuickLook同一个页面在不同设备上长得完全不一样。第三个限制是网络请求和权限体系被架空你想统计用户在第几页停留了多久想控制文档只允许预览前三页这类需求用iframe基本做不了。iframe唯一的好处是零代码但一碰到稍微深入一点的需求就立刻触顶。如果你的项目仅仅是把一个PDF挂在页面上不影响订阅、不统计行为、不做权限控制那iframe确实合适。但实际情况是文档类产品一定会往查看、标记、审批的方向演进早一点迁到可控渲染方案后面会少走很多弯路。1.2 pdf.js的定位与边界pdf.js不是PDF阅读器它是一个解析器和渲染引擎。它把PDF内部的文字、字体、图片、矢量路径解析成前端可用的对象再通过canvas把每一页画出来本质上就是PDF转canvas的过程。它不限制你最终的交互形态你可以拿它做阅读器也可以做缩略图墙甚至可以批量提取页面截图。但它的边界同样要心里有数。它不是一个PDF编辑器虽然你能在canvas上叠加批注但那需要自己实现一套坐标换算和绘制层逻辑。它也不是PDF解析库就能搞定一切遇到带复杂加密、自定义签名算法、内嵌复杂字体的文件解析失败或者渲染错位的概率真实存在。我见过有人在群里抱怨pdf.js乱码最后发现是直接把加密过的PDF原文件塞给了前端这种场景本来就不该让前端硬扛应当在服务端先解密或转换。2. 最简demo一个页面把PDF第一页渲染出来2.1 引入pdf.js的三种路径选择用之前先解决引入方式。三种主流路径CDN、npm、源码构建。CDN的方式最简单适合快速写demo或者在公司内网直接引用如下面这句script srchttps://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js/scriptnpm方式适合正经工程化项目装依赖跑构建一把梭npm install pdfjs-dist然后按模块方式导入现代打包工具都能处理好。源码构建的方式除非你要深度定制worker逻辑否则没必要碰维护成本高收益不大。我的建议是demo直接CDN生产项目直接npm不要贪图省事在正式项目里挂CDN因为第三方CDN的稳定性、版本更新、域名封禁风险都不可控这是上过线的人都会同意的结论。2.2 完整可跑的demo代码这个demo的目标很简单浏览器加载一份PDF文件把第一页画在canvas上。完整代码如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 titlepdf.js 最简 demo/title script srchttps://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js/script script pdfjsLib.GlobalWorkerOptions.workerSrc https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js; /script /head body canvas idpdfCanvas stylewidth: 100%;/canvas script const canvas document.getElementById(pdfCanvas); const ctx canvas.getContext(2d); pdfjsLib.getDocument(sample.pdf).promise .then(pdf pdf.getPage(1)) .then(page { const viewport page.getViewport({ scale: 1.5 }); canvas.width viewport.width; canvas.height viewport.height; return page.render({ canvasContext: ctx, viewport }).promise; }) .then(() { console.log(第一页渲染完成); }); /script /body /html这段代码的逻辑分三步加载PDF文档、拿到第一页、用viewport决定画布尺寸然后调用render绘制。注意getDocument返回的是一个PDFDocumentLoadingTask对象真正的加载结果要通过.promise获取这是一开始最容易踩的坑我在第一次写的时候直接拿getDocument的返回值当pdf对象用结果报了一堆类型错误。2.3 渲染流程里最容易忽略的viewport概念viewport是pdf.js里最容易忽略但极其核心的概念。一个PDF页面有自己的固有尺寸单位PDF内部单位是point1 point约等于1/72英寸。浏览器里的CSS像素和真实屏幕像素之间还有devicePixelRatio的差异。viewport就是做这个换算的中间层你告诉它一个scale比例它告诉你对应的像素宽高。代码里canvas.width直接用了viewport.width这是逻辑像素如果你的屏幕是2倍屏物理上会有模糊感。更好的做法是canvas.width设为viewport.width乘以devicePixelRatio同时用ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)做坐标系缩放这样渲染出来的字才锐利。这个细节我在第3节会专门展开因为它是demo和可用组件之间的一道分水岭。3. 从demo到可用组件把翻页和缩放加上3.1 页面状态与渲染触发只渲染第一页跟玩具没有区别要让demo真正可用至少得把翻页、页数展示、缩放这几个功能补上。这里的关键不是各自怎么实现而是怎么组织状态。我用一组变量管理当前状态let pdfDoc null; let currentPage 1; let totalPages 0; let currentScale 1.2;翻页逻辑很好写页数限制好边界就行function goToPage(pageNum) { if (pageNum 1 || pageNum totalPages) return; currentPage pageNum; renderPage(currentPage); document.getElementById(pageInfo).textContent ${currentPage} / ${totalPages}; }这个过程中的教训是渲染完成后更新页数展示效率很高但真正的问题是快速点击下一页时会出现渲染错乱。用户连点两次上一次的渲染任务还没结束下一次渲染已经开始canvas最后显示的是哪一页全看谁先执行完这就是渲染竞态。解决方式我放在3.3节先不剧透。3.2 缩放时的清晰度问题devicePixelRatio在普通笔记本上不明显在MacBook的Retina屏幕上直接用viewport.width作为canvas.width会让文字发虚。原因前面提过viewport.width是CSS像素不是物理像素。物理像素需要乘以devicePixelRatiofunction renderPage(pageNum) { return pdfDoc.getPage(pageNum).then(page { const baseViewport page.getViewport({ scale: currentScale }); const dpr window.devicePixelRatio || 1; canvas.width baseViewport.width * dpr; canvas.height baseViewport.height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const transform dpr ! 1 ? [dpr, 0, 0, dpr, 0, 0] : null; return page.render({ canvasContext: ctx, viewport: baseViewport, transform }).promise; }); }这里做了三件事canvas的像素尺寸按dpr放大canvas的实际样式尺寸仍然交给CSS控制保证不撑破布局渲染时通过transform参数把画布内容放大。这样在高清屏上文字边缘干净在普通屏上也不会多出额外下载量。3.3 渲染竞态的坑用渲染任务编号渲染竞态是一个非常隐蔽的bug。用户快速点击下一页或者拖动缩放条连续触发多次渲染后发起的任务被先发起的任务覆盖最后屏幕上显示的页面是错的甚至还会看到一条明显的老页面残留。我的解决办法是给每次渲染任务分配一个自增编号渲染开始前先记录编号渲染完成的回调里只认最新编号的结果let renderTaskId 0; function renderPage(pageNum) { const taskId renderTaskId; return pdfDoc.getPage(pageNum).then(page { // 设置canvas尺寸... return page.render({ canvasContext: ctx, viewport }).promise; }).then(() { if (taskId ! renderTaskId) { console.log(丢弃过期渲染结果当前页已变化); return; } // 执行UI更新页码、按钮状态等 }); }这个方案理解成本低排查还简单。另外如果之前有未完成的RenderTask可以调用它的cancel方法主动取消掉比单纯丢弃结果更省资源。不过cancel之后render的promise会走reject需要单独捕获别让错误冒泡到全局去。4. 接真实文件时踩到的坑4.1 跨域加载与CORS的绕过思路demo里sample.pdf跟页面同源一切正常。一旦把PDF换成对象存储的地址比如阿里云OSS、腾讯云COS或者私有S3就会遇到跨域问题。浏览器直接加载跨域PDF会报错报错信息类似file origin does not match viewers。解决思路有三条。一是通过fetch把PDF文件以ArrayBuffer方式拉回来再传给getDocumentfetch(pdfUrl) .then(res res.arrayBuffer()) .then(buffer pdfjsLib.getDocument({ data: buffer }).promise)二是在存储服务端配置CORS跨域头让浏览器允许直接加载。三是绕过浏览器跨域限制在服务端加一个代理接口由后端去取文件再转给前端。我实际项目里最常用的是第一条因为不需要动服务端配置前端可控性最强。但要警惕一次大文件全量拉到内存再解析对内存是双重压力后面4.3节会说怎么处理大文件。4.2 中文字体丢失和乱码的处理pdf.js对标准PDF字体的支持很成熟但中文PDF经常内嵌私有子集字体解析后偶发乱码、方块或者某些字符偏移。这个问题在v2时代比较严重v3之后内置了CMap映射中文症状减轻不少但并没有绝迹尤其是很多国产办公软件导出的PDF字体表写得不太规范渲染时会有偏差。通用处理方法是把缺失字体作为外部字体文件传给render在page.render的renderContext里设置fontFamily或者通过pdfjsLib.setFonts配置自定义字体路径。但这需要你知道原始PDF用的是什么字体实际操作中更常用的下策是转图片服务端先把PDF转成高清图片前端直接渲染图片PDF.js只负责交互框架。这个方法在打印输出、合同归档、签名存证这类要求100%还原的场景里反而更常见牺牲文本选择的代价换稳定性。4.3 大PDF的内存与加载卡顿一份几百页的扫描版PDF每一页都是一张大图前端一次性渲染会让浏览器内存直接报警。我踩过最惨的一次一份300页的财务文档加载完整个DOM都卡死鼠标移动都掉帧。处理大文件的核心思路是懒加载与按需释放。懒加载是指只有页面进入可视区才调用getPage和render按需释放是指在翻页离开后清掉整页Canvas画布内容或者复用一块固定大小的canvas避免每一页都重新分配canvas对象。同时getDocument时建议开启cMapUrl和cMapPacked对中文PDF的加载速度和内存占用都有改善pdfjsLib.getDocument({ url: pdfUrl, cMapUrl: https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/cmaps/, cMapPacked: true }).promise如果是超大文件前端方案再优化也有限应该引入服务端预处理把PDF按需转成按页切分的图片流前端走图片懒加载。这套方案在很多电子合同平台是标准做法原因就是PDF.js在前端处理超大文件的天花板很明显。5. 把demo收尾前最后再做的几个优化5.1 预加载相邻页面翻页体验的卡顿感很大程度是下一页的渲染工作才开始。做完前面的基础功能后我做了一个简单的预加载当前页渲染完成之后在浏览器空闲时间预取下一页的数据。if (requestIdleCallback in window) { requestIdleCallback(() { const nextPage currentPage 1; if (nextPage totalPages) { pdfDoc.getPage(nextPage).catch(() {}); } }); }注意这里只做getPage的预取不做render。原因是render会真正占用canvas资源预渲染下一页可能影响当前页的展示性能预取到Page对象已经能把后续渲染时间缩短一截这个性价比最合适。5.2 worker引入与UI线程解耦pdf.js默认有一个web worker负责解析PDF数据但如果你把worker.workerSrc忘了配置它会在主线程上运行解析逻辑文件一复杂就会卡住整个页面。不少demo只写了加载pdf.min.js不写worker配置看起来能跑其实是降级方案。显式配置worker是必做项而且worker的版本必须与主文件一致两边版本不一致会出现一些莫名其妙的解析错误。打包环境下还要注意路径问题pdfjs-dist会把worker文件单独输出用打包工具时要确保worker文件能被正确复制到静态资源目录。5.3 文本选择与搜索的后续方向如果文档是可复制文本型PDFpdf.js还能通过TextLayer把文字按位置叠加到canvas上。TextLayer的原理是获取每个文本片段在页面上的坐标和尺寸生成对应的div覆盖在canvas上方让那些文字真正可以被鼠标选中。这个功能对很多产品来说是硬需求做的时候就两个注意点一是textContent解析需要单独调用page.getTextContent()二是TextLayer的dom节点定位要和viewport严格对齐不然文字跟画面错位用户一选中就暴露。搜索关键词高亮、批注定位、目录跳转都是在这个textLayer基础上扩展的。换句话说你把这一步打通pdf.js的方案才算完整。我在实际使用中发现很多团队在pdf.js这个库上的抱怨追到最后都不是库本身的问题而是接入姿势问题要么是版本不匹配要么是没理解viewport要么是没考虑大文件。所以回到出发点这个demo虽然只有几十行但把viewport、worker、渲染任务这三根支柱立住了后面加功能、加样式、加性能优化都不会有大返工。顺着这个方向往下做你会发现pdf.js的前端渲染能力确实被频繁低估。本文还有配套的精品资源点击获取