简介一套基于 PDF.js 的 JavaScript 在线 PDF 预览实现方案面向需要在前端项目中集成 PDF 查看功能的中高级 Web 开发者。压缩包共 402 个文件其中 bcmap 与 properties 为 PDF.js 渲染 PDF 所需的字符映射与字体描述数据js/html/css 构成可运行的示例应用png/svg 图标用于界面附带示例 PDF 便于直接验收总体仅 3.06MB轻量易部署已有 3138 人学习下载。方案覆盖 PDF.js 库引入、getDocument 加载文档、Canvas 渲染分页、逐页遍历、缩放与进度监听以及多页预览、Web Worker 后台解析等进阶实现并给出代码示例方便对照理解。资源同时涉及文本选择与搜索扩展点并提及 pdfjs-viewer 等封装工具可帮助开发者继续定制。对照源码可快速掌握在线预览 PDF 的核心链路适合用作项目脚手架或学习模板。 先聊个真实场景你做个管理后台、在线教育系统或者审批流平台用户上传了一堆PDF合同、报告、讲义结果你在页面上一个iframe srcxxx.pdf扔过去Chrome 里看着还行换到 Safari 直接白屏手机端干脆弹下载框老板过来一看当场黑脸。这种问题我在好几个项目里都遇到过根子就在于很多人把“在线查看 PDF”想得太简单觉得浏览器原生支持就不用管了。实际上“用 JS 实现 PDF 在线预览”这件事既能走到零代码的原生路线也能走到完全可控的pdf.js渲染路线选型和细节不同最终的体验和坑位完全两个世界。这篇文章我从方案选型讲起重点拆解 Mozilla 的pdf.js是怎么把 PDF 画到网页上的然后给一套完整的实操代码加载、翻页、缩放、进度显示、打印再把跨域、字体、Canvas 尺寸、大文件卡顿这些高频坑逐个说透。不管你是刚接手前端的小白还是已经在做在线文档类产品的开发者照着这条路走至少能少踩一半的坑。1. 方案选型先想清楚用哪种方式再动手写代码1.1 浏览器原生渲染iframe、embed、object 的得与失不少同学第一反应就是“直接用 iframe 嵌 PDF 链接不就行了”。对这个方案确实存在而且原理上是调用了浏览器内核自带的 PDF 渲染器比如 Chrome 内置的 PDF Viewer、Firefox 的 PDF.js 集成版。它的最大优点就是零代码一个标签PDF 就能显示出来。但它的缺点也很明显而且会在实际项目中集中爆发表现不一致Chrome、Firefox、Edge 内置渲染器都能看但 Safari 和移动端浏览器的表现差异很大尤其是 iOS 上经常直接把 PDF 下载下来而不是预览。不可控你无法得知用户是否真正打开了 PDF、看到了第几页、有没有加载失败想自定义工具栏、添加水印、做权限控制原生方案基本无能为力。体验割裂原生 PDF 查看器的 UI 和你的系统风格完全不同在办公系统里非常突兀。旧浏览器更糟IE 或者某些国产浏览器内核直接提示安装插件或下载文件连预览入口都没有。我在早期项目里遇到过最头疼的一次就是给客户做 OA 系统的附件预览他们都用某国产浏览器iframe 嵌 PDF 在开发机上好好的到了客户电脑上全部变成下载。后来我彻底放弃原生嵌入用 PDF.js 做了一套纯前端预览组件问题才算根治。1.2 PDF.js真正意义上的“用 JS 解析和渲染 PDF”PDF.js 是 Mozilla 官方出品的一个开源项目核心价值在于它用纯 JavaScript 实现了对 PDF 文件格式的解析然后通过 Canvas 2D API也有 WebGL 加速选项把每一页内容绘制到页面上。这跟“调用浏览器内置渲染器”有本质区别不依赖浏览器 PDF 插件或内置 Viewer所以跨浏览器表现高度一致桌面端、移动端、微信内置浏览器都能跑。可控性极强你可以监听加载进度、控制渲染比例、实现懒加载、自定义工具栏、添加水印、做文本搜索和选择。解析能力强能处理加密 PDF、带表单的 PDF、嵌入自定义字体的 PDF还支持注释、链接、图层等复杂结构。当然代价就是你要引入一堆 JS 文件包体积不小渲染性能也不如浏览器原生 C 实现的渲染器那样极致。但它是目前前端领域做 PDF 在线预览的“事实标准”很多商业产品也是基于它二次封装的。1.3 封装库与插件方案到底该用哪个除了直接使用pdfjs-dist社区里还衍生了不少封装库比如pdfobject、vue-pdf、react-pdf、ng2-pdf-viewer等。它们本质上都是对底层方案大多数是 PDF.js的再封装。做一个横向对比会更清楚方案原理优点缺点适用场景iframe/embed/object 原生嵌入浏览器内置 PDF 插件零依赖、最简单兼容性差、不可控、UI 割裂内部系统且浏览器统一PDFObject封装 embed 标签几行代码、轻量仍依赖浏览器原生渲染非核心场景快速展示pdfjs-distPDF.js 官方库解析可控、跨端一致、功能全接入成本高、包体积大正式产品、需要定制vue-pdf / react-pdf封装 PDF.js组件化、上手快版本滞后、默认功能弱Vue/React 快速开发我的建议是如果你要在正式系统里做“能看、能翻页、能打印、能适配手机”的 PDF 预览不要走捷径直接基于pdfjs-dist自己封装一层。封装库虽然起步快但一旦遇到需求变化比如加签章、改渲染模式、做批注你会被封装层限制住最终还是要回到pdfjs-dist上重写。2. PDF.js 核心原理它到底是怎么把 PDF 画出来的2.1 解析、Worker、Canvas 渲染的三步曲PDF.js 的工作流程可以理解为一个大三明治文档解析、页面绘制准备、Canvas 最终输出。先把 PDF 文件拿过来可以是 URL、ArrayBuffer、Blob 或者 Base64 字符串。PDF.js 通过getDocument()启动解析这一步会读取 PDF 的对象树、目录结构、每一页的资源字体、图片、图形指令等。为了不阻塞 UI 线程这一步默认会放到 Web Worker 里去执行Worker 才是真正“啃”PDF 二进制的地方。解析完成后你会拿到一个PDFDocumentProxy对象用它调getPage(pageNum)拿到某一页的PDFPageProxy。这一步会返回当前页的绘制指令列表调用page.render(renderParams)时PDF.js 会把指令逐条翻译成 Canvas 2D Context 的绘制调用——比如moveTo、lineTo、fillText、drawImage等等。这里有三个容易踩坑的点我单独列出来Worker 路径必须正确pdfjsLib.GlobalWorkerOptions.workerSrc必须指向pdf.worker.js新版是.mjs后缀否则要么回退到主线程解析卡到你怀疑人生要么直接报错白屏。render 是异步的它返回一个 Promise渲染完成后才能做后续操作比如读取像素、切换页面后更新状态很多人忽略 await 导致页面还没画完就操作了。每次渲染要重新创建 Canvas 尺寸viewport 的宽高决定了 Canvas 的像素大小你需要在渲染前手动把 canvas 的 width/height 设置为 viewport 的宽高否则画出来是模糊的小画布被拉伸。2.2 渲染一页的最小代码流程用一句话概括最小流程就是getDocument → getPage → getViewport → render。下面这段代码是去掉所有业务逻辑后最核心的部分import * as pdfjsLib from pdfjs-dist; // 设置 Worker 路径非常重要 pdfjsLib.GlobalWorkerOptions.workerSrc https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js; async function renderSinglePage(pdfUrl, canvas, pageNum 1) { const task pdfjsLib.getDocument(pdfUrl); const pdf await task.promise; const page await pdf.getPage(pageNum); // 计算视口scale1 表示按 100% 比例渲染实际按需传 const viewport page.getViewport({ scale: 1.5 }); const ctx canvas.getContext(2d); // 关键必须把 canvas 的像素尺寸设置为视口尺寸 canvas.width viewport.width; canvas.height viewport.height; await page.render({ canvasContext: ctx, viewport, }).promise; return pdf.totalPages; }这段代码虽然短但它已经把 PDF.js 最核心的链路串通了。scale就是缩放比例1 表示原始大小传 2 就是放大两倍在实际项目里我会用window.devicePixelRatio去修正高清屏的显示效果不然在 Retina 屏幕上渲染出来的 PDF 边缘总是发虚。2.3 文字层为什么有些 PDF 不能选中、搜索如果你只做 Canvas 渲染页面上的 PDF 看起来是一张“图”文字无法选中浏览器搜索功能也搜不到。这是因为 PDF 本身存储的是文本指令集合Canvas 把它画成像素后文本信息就丢了。PDF.js 也提供了解决方案对应的 TextLayer。它会另外生成一个透明的 DOM 层放到 Canvas 上方每一行文字用绝对定位的span渲染出来文字内容和 PDF 里的文本指令一一对应。这样用户在页面上可以选中、复制文字浏览器搜索也能命中。实际开发中有两个高频问题文字层和 Canvas 错位TextLayer 的容器宽度、高度必须和 Canvas 严格一致且父容器的 CSS 要设置position: relative否则文字位置会偏移。自定义字体加载导致文字层空白PDF 里嵌入的字体如果还没有完全解码文字层可能会先显示乱码或空白需要等page.getTextContent()返回完整数据后再渲染文字层。3. 实操过程把一个能用的 PDF 在线查看器写出来3.1 环境准备与依赖安装我这里以最通用的Vite Vue 3项目为例但其实换成 React、原生 JS 都可以PDF.js 本身是框架无关的。先装依赖npm install pdfjs-dist这里要特别提醒pdfjs-dist的版本更新得挺快而且部分版本存在 ESM 模块格式要求Node 环境下和浏览器环境下配置还有差异。我自己实测下来比较稳妥的做法是锁定一个稳定版本并且把 Worker 文件直接用 CDN 地址指定避免本地打包时 worker 路径解析出各种幺蛾子。我在项目里用的版本是3.11.174对应 worker 的 CDN 地址就是pdf.worker.min.js这个版本在多数现代浏览器上表现稳定。如果你用更高版本比如 4.x注意有些 CDN 地址已经变成了.mjs后缀配置错了会直接白屏。以 Vue 3 为例在组件初始化时设置 Workerimport * as pdfjsLib from pdfjs-dist; pdfjsLib.GlobalWorkerOptions.workerSrc https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js;3.2 核心组件实现加载、翻页、缩放、进度我这边封装的预览组件通常包含这些能力加载外部 PDF 地址、显示加载进度、渲染当前页、上一页/下一页切换、页码输入跳转、缩放按钮、打印按钮。下面我把核心代码拆开讲。先看模板结构Vue SFC 的 template 部分简化div classpdf-viewer div classtoolbar button clickprevPage上一页/button span{{ currentPage }} / {{ totalPages }}/span button clicknextPage下一页/button button clickzoomOut缩小/button span{{ Math.round(scale * 100) }}%/span button clickzoomIn放大/button button clickprintPdf打印/button /div div v-ifloading classprogress{{ progress }}%/div div classcanvas-wrap refwrapRef canvas refcanvasRef/canvas /div /div再看核心逻辑const props defineProps({ pdfUrl: { type: String, required: true }, }); const pdfDoc ref(null); const currentPage ref(1); const totalPages ref(0); const scale ref(1.2); const loading ref(false); const progress ref(0); async function loadPdf(url) { loading.value true; progress.value 0; const task pdfjsLib.getDocument(url); task.onProgress (evt) { if (evt.total) { progress.value Math.round((evt.loaded / evt.total) * 100); } }; const pdf await task.promise; pdfDoc.value pdf; totalPages.value pdf.numPages; loading.value false; await renderPage(currentPage.value); } async function renderPage(pageNum, targetScale scale.value) { if (!pdfDoc.value) return; const page await pdfDoc.value.getPage(pageNum); const viewport page.getViewport({ scale: targetScale }); const canvas canvasRef.value; const ctx canvas.getContext(2d); // 高清屏适配 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; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); await page.render({ canvasContext: ctx, viewport, }).promise; }这里有个我自己常用的细节渲染前要检查上一次的renderTask如果用户快速翻页上一次渲染可能还没完成就被取消。PDF.js 的render()会返回一个RenderTask可以调用renderTask.cancel()取消避免快速翻页时出现“白屏控制台报错”。优化后的翻页逻辑会是这样let renderTask null; async function renderPage(pageNum, targetScale scale.value) { if (renderTask) { renderTask.cancel(); renderTask null; } const page await pdfDoc.value.getPage(pageNum); const viewport page.getViewport({ scale: targetScale }); // ... 同上省略 canvas 尺寸设置 renderTask page.render({ canvasContext: ctx, viewport }); await renderTask.promise; renderTask null; }3.3 打印功能别用 window.print() 直接把页面打出来集成 PDF 预览后通常还要加打印。很多人第一反应是window.print()但这样会把整个页面包括按钮、菜单、其他控件都打印出来效果非常差。更合理的做法是把原始 PDF 重新生成一个 Blob URL打开新窗口或隐藏 iframe 再触发打印。如果 PDF 是通过getDocument({ url })加载的可以使用pdf.getData()拿回原始数据然后生成 Blobasync function printPdf() { if (!pdfDoc.value) return; const data await pdfDoc.value.getData(); const blob new Blob([data], { type: application/pdf }); const blobUrl URL.createObjectURL(blob); const printWindow window.open(blobUrl, _blank); if (printWindow) { printWindow.addEventListener(load, () { printWindow.print(); }, { once: true }); } }如果 PDF 是跨域拿到的注意 CORS 允许的情况下才可以用getData()拿原始字节如果后端限制很严格也可以直接把原来的 URL 传给新窗口打开打印但那样用户会先看到浏览器的 PDF 查看器而不是直接进入打印对话框体验稍差。3.4 性能优化与懒加载思路当 PDF 页数很多比如几十页甚至上百页时一次性渲染所有页面既卡又占内存。我通常采用两个策略按需渲染只渲染当前正在看的页翻页时取消上一页的渲染任务。这也是我在 3.2 里保留renderTask的原因。延迟渲染如果用户快速翻过多页不立即渲染中间页而是等用户停下来几百毫秒后再渲染目标页。可以用一个新值打个标记翻页时只更新页码通过setTimeout做节流。还有一个非常容易被忽略的点Canvas 的尺寸是有上限的。在一些宽幅图纸、超长页面的 PDF 上如果你按 100% 甚至更高比例渲染Canvas 宽度或高度超过浏览器限制画面会变成空白。此时可以降低 scale 或者限制最大渲染宽度实际测试中把 Canvas 目标宽度控制在 4096 像素以内是比较稳妥的。4. 高频问题与排坑实录4.1 跨域问题CORS 不配置PDF.js 直接罢工只要你的 PDF 地址和页面不在同一个域名下getDocument()默认会通过 fetch 去拉文件而 fetch 受同源策略限制。所以最常见的问题就是开发环境一切正常部署后线上白屏打开控制台一堆 CORS 报错。解决办法有三个后端配置 CORS 头推荐在 Nginx 或对象存储OSS/COS中给 PDF 所在目录加上Access-Control-Allow-Origin: *或指定域名。这是最干净的方式。后端代理中转前端请求自己服务端的代理接口服务端再去拉远端 PDF 并返回字节流前端把响应结果转成 ArrayBuffer 再传给getDocument({ data })。前端 fetch 再转 Buffer直接fetch(url).then(res res.arrayBuffer())然后把 ArrayBuffer 传给getDocument({ data })但 fetch 本身也受 CORS 限制所以这个方案只是在“后端已允许跨域”的前提下更灵活。我自己最常用的是第三种因为很多时候 PDF 地址是后端动态拼的我可以在请求头里带 Token使用getDocument({ data })比getDocument({ url })更可控。4.2 中文 PDF 渲染异常、文字层乱码中文 PDF 如果字体没有正确嵌入或者嵌入的是子集字体而 PDF.js 版本过低你可能会看到渲染出来的中文是方框或者乱码。这种情况第一步是升级pdfjs-dist到最新稳定版新版对中文字体的解析支持已经好了很多。如果是文字层错位或乱码多半是字体解码和布局时序问题。我的做法是渲染完 Canvas 后再用page.getTextContent()拿文字内容渲染 TextLayer并且监听textlayerrendered事件后再去对齐位置。不要在page.render()完成后立即渲染文字层那样容易出现字体还没解码完成的竞态问题。4.3 大 PDF 文件卡顿与内存占用过高PDF 文件动辄四五十兆打开时占内存高、渲染慢是必然的。优化策略包括加载时显示进度条用task.onProgress、只渲染当前页、避免 Canvas 尺寸过大、及时调用page.cleanup()释放不再使用的页面资源。另外也可以在后端对 PDF 做分页转图片预览但这就脱离“JS 在线查看 PDF”本意了适合对兼容性要求极高的场景。4.4 完全白屏时怎么定位问题白屏是最抓狂的我的排查顺序一直是固定的你照着查基本能锁定问题先看控制台有没有 JS 报错。最常见的是 worker 路径加载失败检查GlobalWorkerOptions.workerSrc指向的地址能否直接访问。再看网络请求。pdf.worker.*.js是否返回 200PDF 文件本身是否返回 200有没有 CORS 报错。再看页面元素。如果 canvas 已经创建但没画上内容多半是 render 没执行完或者 Canvas 尺寸设置不对。最后看版本兼容。某些pdfjs-dist版本对浏览器要求高低版本浏览器直接白屏但不报错的情况很常见。4.5 打印时弹窗被浏览器拦截用window.open()打开打印窗口如果是在异步回调里调用浏览器会默认拦截弹窗。解决办法是在用户点击打印按钮的同步事件里先const w window.open(, _blank)拿到窗口引用后再异步往里面写内容或跳转这样就不会被拦截。const printWindow window.open(, _blank); // 后续异步逻辑使用 printWindow.location.href blobUrl最后再分享一点我的个人体会这套方案我在几个正式项目里跑下来最大的感触是PDF 在线预览的复杂度往往不在“显示出来”而在各种边界情况——跨域、字体、高强度翻页、打印弹窗、移动端适配。pdf.js本身非常强大但它的学习曲线是值得投入的因为一旦你理解了 Worker、Viewport、RenderTask、TextLayer 这几个核心概念后面不管遇到什么问题排查方向都会非常清晰。另一个让我印象深刻的是版本管理。以前我图省事直接引 cdn 的最新版结果某天升级后线上突然出现字体乱码。后来我学乖了所有依赖都锁定具体版本Worker 文件和主库保持同版本发布前造一份包含中文字体和多页扫描件的测试 PDF 专门用来回归测试。这一套流程虽然简单但真的能避免很多“莫名其妙”的线上事故。如果你现在正准备做类似功能我建议不要一上来就追求花哨的 UI先把最核心的“能看、能翻、能打印”跑通再逐步加缩放、缩略图、文本选中这些增强能力。留好扩展点后面做需求迭代会轻松很多。本文还有配套的精品资源点击获取