先问一个问题:你手头的PDF预览需求,是不是也经历过这样的尴尬——产品经理一句话“在线预览一下PDF就行”,你转头用<iframe>套了个文件地址,结果跨域、下载、权限控制全部失控?说实话,如果只是自己电脑上双击打开一个PDF,Chrome自带的阅读器完全够用。但一旦进入真实业务,“能不能在线预览”背后往往跟着一连串隐藏需求:用户传的本地PDF要立刻回显,服务器上的加密PDF要按权限放行,甚至要记录用户读到了第几页、下次打开继续跳转。这些需求靠浏览器原生能力做不了,这时候就需要 PDF.js。
PDF.js 是 Mozilla 开源的一个纯前端PDF渲染引擎,基于 HTML5 Canvas/WebGL 工作,不需要后端参与,不需要浏览器插件,只要你项目里能用 JavaScript,就能把它集成进去。这篇文章我不打算写官方文档的翻译版,就按照自己实际项目里踩过的坑、总结出的方案,把本地文件预览、服务器文件预览、阅读进度记录这几个高频场景完整拆开讲一遍。
1. 内容整体设计与思路拆解
1.1 为什么偏偏选 PDF.js,而不是 iframe 或 PDFObject
很多人在接到“在线预览PDF”这个需求时,第一反应是:直接塞个 iframe 不就行了?这里就产生了第一个认知误区。iframe 方案的本质是让浏览器自己去渲染PDF,你完全无法控制渲染结果。
举个例子:你用<iframe src="http://your-server.com/file.pdf">去加载一个服务器文件,一旦这个服务器接口需要带 token 才能访问,iframe 的 src 根本没办法加自定义请求头。你说那把 token 放到 URL 参数里?先不说把鉴权信息暴露在 URL 里有多危险,光是“服务器返回的是二进制流而不是可直接访问的静态文件地址”这一条,就把 iframe 堵死了。
我之前还试过 PDFObject 这个库,它本质上还是在页面里帮你创建一个<embed>标签,受限于浏览器生态,在不同内核、不同版本下的表现差异极大。比如在部分国产浏览器里,<embed>直接不渲染,留一个空白区域,用户看到的就是个白屏。
PDF.js 则完全绕开了浏览器自身的PDF渲染能力,它自己实现了PDF解析器和渲染器。也就是说,无论什么浏览器,渲染逻辑全部由 PDF.js 用 Canvas 画出来,所见即所得,样式统一。另外还有个隐藏优势:因为PDF的所有解析都在前端完成,后端只需要返回原始文件数据,不需要额外安装转换服务。
1.2 核心流程,其实就四步
PDF.js 的工作流程,我用大白话总结成四个步骤:
- 获取PDF文件的数据:可以是一个网络 URL,也可以是本地文件读取出来的 ArrayBuffer,或者 Blob 对象。
- 初始化 PDF.js 的加载任务:调用
getDocument()方法,把文件数据喂进去。 - 获取指定页面的渲染任务:通过
pdf.getPage(pageNum)拿到某一页的 Page 对象。 - 把Page对象渲染到 Canvas 上:调用
page.render(),传入 Canvas 上下文和渲染参数。
这套流程是所有基于 PDF.js 的自定义开发的基础。你不管集成什么框架,底层都是这四个步骤循环执行。
1.3 两条技术路线:预构建 Viewer 还是模块化自研
PDF.js 官方提供了两种使用形态,很多初学者在这里会开始蒙圈。
第一种是使用官方预构建的viewer.html。你可以直接引入 PDF.js 发行包里的web/viewer.html这个文件,这就是一个完整可用的PDF阅读器界面,自带工具栏、缩放、缩略图、搜索、翻页,开箱即用。适合想快速上线、不做太多 UI 定制的场景。之前很多若依(RuoYi)项目里集成 PDF.js,走的就是这条路线。
第二种是把它当 npm 包,在自己的代码里按需调用 API,自己用 Canvas 画渲染区域,自己写上一页下一页按钮。灵活性最高,适合需要深度定制 UI 的场景。比如你要在页面某个特定区域显示PDF,工具栏要用自己设计的组件,那就必须走这条路。
两种方案并行不悖,后面我会分别给出可落地的例子。
2. 核心细节解析与实操要点
2.1 选版本:锁定文件版本,别盲目追新
PDF.js 的迭代速度相当快,GitHub 上 release 一个接一个。但我的建议是:在正式项目里,不要一味追最新,选一个稳定版本锁死。
我自己项目里用得比较多的是2.x系列,比如很多人在用的2.16.105。这个版本很成熟,API 稳定,社区资料多,遇到问题一搜一大把。而3.x、4.x版本改动较大,引入了一些新的模块化方式,某些旧写法直接废弃。如果你照着一个2.x的博客配置去用4.x的包,很可能会踩“API不存在”的坑。
这里告诉你们一个判断技巧:凡是那种以pdf.js@2.x开头的 CDN 链接,基本对应传统全局pdfjsLib对象方式,还是window.pdfjsLib挂载。而从 3.x 开始,官方逐渐转向 ES Module 方式,如果你用的是老式<script>标签直接引入,可能会发现window.pdfjsLib是 undefined。
2.2 最关键的一步:设置 Worker
我第一次集成 PDF.js 的时候,按照文档把pdf.min.js引进来,然后调用getDocument(),控制台直接报错。排查了半天,发现是 worker 没设置。
PDF.js 的解析和渲染逻辑默认跑在 Web Worker 里,避免阻塞页面主线程,保证UI流畅。如果你不设置workerSrc,它内部会尝试通过相对路径去加载pdf.worker.js,但很多时候这个相对路径是错的,于是彻底罢工。
设置方式很简单:
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.worker.min.js';或者,如果你把pdf.worker.min.js下载到本地项目了:
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js';这里有一个比较隐蔽的坑:如果你的项目部署在二级目录,比如https://example.com/admin/,那么绝对路径/static/js/pdf.worker.min.js会指向域名根目录,导致 404。稳妥的做法是用相对路径,或者动态计算:
const scriptPath = document.currentScript.src; pdfjsLib.GlobalWorkerOptions.workerSrc = scriptPath.substring(0, scriptPath.lastIndexOf('/') + 1) + 'pdf.worker.min.js';2.3 本地文件预览:FileReader 和 Blob URL 怎么选
本地文件预览是另一个高频场景:页面上放一个<input type="file">,用户选完PDF后立刻预览,不上传服务器。
实现思路有两种,我来对比一下。
第一种:用 FileReader 读取为 ArrayBuffer。
const fileInput = document.getElementById('fileInput'); fileInput.addEventListener('change', function (e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function (ev) { const arrayBuffer = ev.target.result; const task = pdfjsLib.getDocument({ data: arrayBuffer }); task.promise.then(function (pdf) { // pdf 对象已经拿到,接下来渲染页面 }); }; reader.readAsArrayBuffer(file); });第二种:用URL.createObjectURL(file)生成一个临时链接。
const blobUrl = URL.createObjectURL(file); const task = pdfjsLib.getDocument({ url: blobUrl });这两种方案里,我更推荐第一种。大多数现代浏览器里第二种也正常,但在部分旧版 Firefox 内核下,PDF.js 对 blob URL 的支持存在兼容问题,渲染到一半会突然报“Missing PDF"之类错误。而 ArrayBuffer 是纯内存数据,不依赖浏览器对 URL 协议的支持,兼容性最好。
另外提醒一句:URL.createObjectURL创建的链接记得在不用的时候revokeObjectURL释放,否则会造成内存泄漏。
2.4 服务器文件预览:跨域、鉴权一个都不能少
服务器文件预览比本地文件复杂,因为涉及网络请求。PDF.js 的getDocument({ url })底层默认用的是fetch或XMLHttpRequest,它会去请求你给的 URL 来拿PDF数据。这就会碰到两个经典问题。
第一个是跨域。如果你的前端部署在https://a.com,PDF 文件在服务器https://b.com,直接加载会报跨域错误。解决方式有两个方向:让 B 服务器在响应头加Access-Control-Allow-Origin,允许 A 域名访问;或者后端做一个中转接口,由后端去 B 服务器拉取文件流,再通过同源接口返回给前端。
第二个是鉴权。如果文件接口需要登录态,比如要通过Authorization请求头带 token,那么直接给 PDF.js 一个 URL 是不够的,因为它默认不带自定义 header。解决办法是:先自己用fetch带上 header,拿到响应数据后转成 ArrayBuffer,再喂给getDocument()。
const response = await fetch('/api/pdf/download', { headers: { 'Authorization': 'Bearer ' + token } }); const arrayBuffer = await response.arrayBuffer(); const task = pdfjsLib.getDocument({ data: arrayBuffer });这一招非常关键,很多项目里的“PDF打不开”最终都是因为这个:接口明明200,但PDF.js用默认请求去加载就被拦截了。
3. 实操过程与核心环节实现
3.1 快速出活:集成官方 Viewer.html
如果你的需求是“先做一个能用的预览页面出来”,最快的办法就是直接用官方预构建的viewer.html。
操作步骤如下:
第一步,下载pdfjs-dist发行包。你可以去 npm 仓库下载压缩包,也可以用 CDN:
# 以2.16.105为例 wget https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.16.105.tgz tar -zxvf pdfjs-dist-2.16.105.tgz解压后你会看到build/和web/两个目录。build/里面是pdf.min.js、pdf.worker.min.js等核心库,web/里面是viewer.html、viewer.js、viewer.css等界面文件。
第二步,把build/和web/整个拷贝到你的项目静态资源目录下,比如static/pdfjs/。
第三步,浏览器访问http://你的域名/static/pdfjs/web/viewer.html?file=xxx.pdf。
是的,就是这么简单,它能直接工作。viewer.html会通过 URL 参数file去加载PDF文件。
但这里注意第一个坑:viewer.html里加载文件默认走同源策略,如果你传的是一个跨域地址,需要在服务端配CORS,或者使用viewer.html暴露的file参数来处理。第二种方式是直接在viewer.html后面用#方式传文件流数据,但比较复杂,不推荐新手用。
还有一点,官方viewer.html默认没有做鉴权处理。如果文件接口要 token,不建议直接用 URL 方式传参给 viewer。这种情况可以放弃 viewer.html,转用自研渲染方案,就是我们下一节要说的。
3.2 自研渲染方案:从一个最小可运行页面说起
下面我给出一个完整的自研渲染页面的核心代码,不依赖任何框架,原生 JS 就能跑。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>PDF.js 在线预览</title> <style> #pdfContainer { width: 100%; max-width: 800px; margin: 0 auto; background: #f5f5f5; padding: 20px; } .pdf-page { margin-bottom: 15px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .pdf-page canvas { width: 100%; height: auto; display: block; } .toolbar { text-align: center; margin: 15px 0; } .toolbar button { margin: 0 6px; } </style> </head> <body> <div class="toolbar"> <button id="prevPage">上一页</button> <span>第 <span id="currentPage">1</span> / <span id="totalPages">0</span> 页</span> <button id="nextPage">下一页</button> <input type="file" id="fileInput" accept="application/pdf" /> </div> <div id="pdfContainer"></div> <script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.min.js"></script> <script> pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.worker.min.js'; let pdfDoc = null; let currentPage = 1; const container = document.getElementById('pdfContainer'); function renderPage(pageNum) { pdfDoc.getPage(pageNum).then(function (page) { const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.createElement('canvas'); canvas.className = 'pdf-page'; canvas.width = viewport.width; canvas.height = viewport.height; const ctx = canvas.getContext('2d'); container.innerHTML = ''; container.appendChild(canvas); page.render({ canvasContext: ctx, viewport: viewport, }).promise.then(function () { document.getElementById('currentPage').textContent = pageNum; }); }); } function loadPdf(url) { pdfjsLib.getDocument(url).promise.then(function (pdf) { pdfDoc = pdf; document.getElementById('totalPages').textContent = pdf.numPages; renderPage(1); }); } document.getElementById('fileInput').addEventListener('change', function (e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function (ev) { pdfjsLib.getDocument({ data: ev.target.result }).promise.then(function (pdf) { pdfDoc = pdf; document.getElementById('totalPages').textContent = pdf.numPages; currentPage = 1; renderPage(currentPage); }); }; reader.readAsArrayBuffer(file); }); document.getElementById('prevPage').addEventListener('click', function () { if (currentPage <= 1) return; currentPage--; renderPage(currentPage); }); document.getElementById('nextPage').addEventListener('click', function () { if (currentPage >= pdfDoc.numPages) return; currentPage++; renderPage(currentPage); }); // 默认加载一个服务器文件做演示 loadPdf('/assets/sample.pdf'); </script> </body> </html>这个例子麻雀虽小,五脏俱全。包含:本地文件读取、服务器文件加载、上一页/下一页、页码展示。你可以直接把这套代码落地,再看下面的进阶内容。
3.3 服务器文件加载:从“能打开”到“带鉴权”
很多人把 PDF.js 集成到项目里之后,遇到最典型的一个问题就是:在管理后台里,用<iframe src="viewer.html?file=/api/pdf/1">打开的PDF,接口返回 200,但页面就是渲染不出来。控制台报错五花八门,最常见的是:
Failed to fetch这个问题通常有三个原因:跨域被拦截、接口需要鉴权头、接口返回的不是PDF而是JSON错误信息。
我建议你看一眼 Network 面板,如果接口返回的状态码是 200,但响应体是项目的统一格式{"code":401,"msg":"未登录"},而不是PDF二进制内容,那 PDF.js 当然解析不了。
正确做法是:先用fetch带鉴权头把数据拿回来,再转成 ArrayBuffer 喂给 PDF.js。我把核心代码抽出来:
async function loadPdfWithAuth(url, token) { const resp = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, }, }); if (!resp.ok) { throw new Error('PDF加载失败: HTTP ' + resp.status); } const arrayBuffer = await resp.arrayBuffer(); const task = pdfjsLib.getDocument({ data: arrayBuffer }); return task.promise; }用这个函数替代之前的loadPdf(url),鉴权问题就解决了。
还有一个我觉得很实用的进阶方案:后端不直接返回文件地址,而返回文件的 Base64 字符串,前端接收到之后转成Uint8Array再喂给 PDF.js。
const base64Str = '...后端文件流...'; const raw = atob(base64Str); const uint8Array = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) { uint8Array[i] = raw.charCodeAt(i); } const task = pdfjsLib.getDocument({ data: uint8Array });这个方案的好处是彻底绕开跨域,缺点是 Base64 会让数据体积膨胀约三分之一,大文件要考虑一下内存占用。
3.4 中文PDF渲染乱码的根源:CMap 配置
前面基础示例里我们只渲染了英文版PDF,一切正常。但哪天你拿个中文PDF一跑,发现页面上全是豆腐块或者乱码,多半是因为 PDF.js 没找到中文字体映射表。
PDF.js 解析 PDF 时,遇到内嵌字体还好,遇到非内嵌字体就需要依赖 CMap(字符映射表)来正确解码,尤其是中文PDF。如果你没有配置cMapUrl和cMapPacked,它就会用默认的空映射,导致文字错乱。
解决办法是在getDocument的配置参数里加上:
const task = pdfjsLib.getDocument({ url: pdfUrl, cMapUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/cmaps/', cMapPacked: true, });把cmaps目录部署到自己服务器上更好,CDN地址容易有跨域风险。这个参数,对于处理计算机类教材PDF、扫描版图书等含大量中文的文档,几乎是必选项。
3.5 进阶需求:把“阅读到第几页”记录到数据库
从热搜词里我看到很多人搜“pdf.js如何把阅读到哪一页记录到数据库里”,我也专门做一下这个。
需求场景很明确:用户看一份合同到第8页,关掉浏览器,下次打开还想从第8页继续。这个功能实现起来分三步。
第一步:监听 PDF.js 的页码变化。在自研方案里,我们每次调renderPage(pageNum)时,就知道用户当前看到了哪一页。可以在渲染完成后上报:
function renderPage(pageNum) { pdfDoc.getPage(pageNum).then(function (page) { // 渲染逻辑... page.render({ canvasContext: ctx, viewport: viewport }).promise.then(function () { // 上报页码到后端 saveReadingProgress(fileId, pageNum); }); }); } function saveReadingProgress(fileId, pageNum) { fetch('/api/pdf/progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileId: fileId, pageNum: pageNum }), }); }注意,渲染完成事件里上报,比点击翻页按钮时上报更准确。因为用户可能通过滚动、快捷键、搜索跳页等不同方式改变页码,而日志都是在渲染完成后才能确定当前真在展示哪一页。
第二步:在后端保存阅读进度。这个很简单,建一张表存file_id、user_id、page_num、update_time。
第三步:下次打开时,先向接口要阅读进度,然后从指定页开始渲染。
async function loadWithProgress(fileId, url, token) { const progressResp = await fetch(`/api/pdf/progress?fileId=${fileId}`, { headers: { 'Authorization': 'Bearer ' + token }, }); const progressData = await progressResp.json(); const startPage = progressData.pageNum || 1; const arrayBuffer = await loadArrayBufferWithAuth(url, token); const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; renderPage(startPage); }这套方案实测下来很稳,而且不依赖第三方插件,纯前端 + 一个进度存储接口,成本很低。
4. 常见问题与排查技巧实录
4.1 问题速查表
我在项目里遇到过的问题五花八门,这里整理一个速查表,你们碰到了可以直接对着查。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
Failed to fetch且 Network 显示接口 200 | 响应体不是PDF而是JSON,或接口返回了文本 | 检查 Network 响应体,确认是二进制流 |
跨域报错Access-Control-Allow-Origin | PDF文件接口不允许跨域 | 后端配CORS,或用同源中转接口 |
| 中文乱码或豆腐块 | 没配置 CMap | getDocument参数加上cMapUrl和cMapPacked |
| 页面一直白屏、不渲染 | workerSrc 路径错误 | 检查 worker 是否成功加载,路径用绝对路径或动态计算 |
| 文件接口必须带 token | PDF.js 请求默认不带 header | 自己 fetch 带 header,转 ArrayBuffer 后传入 |
控制台报Setting up fake worker | workerSrc 没设置成功 | 显式设置GlobalWorkerOptions.workerSrc |
| 大PDF文件出现内存暴涨 | 一次性渲染所有页面 | 改为按需渲染,一屏只渲染一页,或配合虚拟滚动 |
| PDF 显示比例过大/过小 | viewport scale 不适合 | 动态计算 scale,让页面宽度适配容器 |
| 本地文件在 Firefox 无法打开 | blob URL 兼容问题 | 改用 FileReader 读 ArrayBuffer |
4.2 大文件分页渲染的性能优化
核心问题:会卡。
PDF.js 渲染是一个相对耗时的 CPU 密集型操作,尤其是那种动辄几十MB的高清扫描版PDF。你要是像基础示例里那样,把每一页都渲染出来然后一次性塞进 DOM,浏览器直接卡死。
我的优化思路是:只渲染当前可视区域的那一页,滚动到哪页渲染哪页,渲染过的页面可以保留 Canvas 缓存,不必重复渲染。用IntersectionObserver来判断哪些页面进入可视区域。
const observer = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { const pageNum = Number(entry.target.dataset.pageNum); renderPage(pageNum); observer.unobserve(entry.target); } }); }, { root: container });当然,这是针对高定制化自渲染方案的优化。如果用官方viewer.html,官方已经内置了虚拟滚动逻辑,大文件表现还不错,可以直接用,省心很多。
4.3 部署二级目录时,静态资源路径怎么处理
还有一个很烦人的部署问题。当你把 PDF.js 相关文件部署到https://example.com/site/这种二级目录时,所有基于/static/pdfjs/...的绝对路径都会失效。这个问题坑过很多人。
我建议在项目里建立一个pdfjsConfig.js,把所有路径集中统一管理:
window.PDFJS_CONFIG = { // 动态获取当前脚本所在目录 baseUrl: document.currentScript ? document.currentScript.src.substring( 0, document.currentScript.src.lastIndexOf('/') + 1 ) : '/static/pdfjs/', workerSrc: 'pdf.worker.min.js', cMapUrl: 'cmaps/', };然后在初始化时拼接完整路径:
pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_CONFIG.baseUrl + PDFJS_CONFIG.workerSrc; const task = pdfjsLib.getDocument({ url: pdfUrl, cMapUrl: PDFJS_CONFIG.baseUrl + PDFJS_CONFIG.cMapUrl, cMapPacked: true, });这样做的好处是,不管你的项目部署到哪个子目录,只要pdf.min.js和配置脚本的相对关系不变,路径都不会错。
4.4 判断PDF.js版本的一些线索
最后说这个,是因为好多人在网上抄代码,抄到一半发现 API 对不上,这时第一步就是判断当前用的是哪个版本。
看 console 是最快的。打开浏览器控制台,输入:
console.log(pdfjsLib.version);如果是2.x,那GlobalWorkerOptions、getDocument().promise、page.render().promise这些写法都是可用的。如果是3.x以上,或者你是通过 ES Module 的import * as pdfjsLib from 'pdfjs-dist'方式引入的,那很多老博客的写法可能就需要微调。
我个人建议:不要为了“新”而新,选一个你项目里最能稳定运行的版本就好。2.16.105这个版本我实际用了很久,build: 172ccdbe5对应的就是官方发布包里的版本号,遇到问题去 GitHub 搜 issue,直接搜版本号加报错信息,基本都是秒出答案。
最后补充一个我在实际项目里反复用的小技巧
如果你用的是官方viewer.html,想控制它展示后自动跳到指定页,不要傻乎乎地去改viewer.js源码,直接在 URL 参数后面加#page=5就行了。比如:
/static/pdfjs/web/viewer.html?file=/uploads/docs.pdf#page=5这个#page是官方 viewer 内置支持的书签参数,和阅读进度记录功能搭配起来很顺手:用户上次读到第8页,下次进入时后端返回页码,前端拼一下 URL,直接定位到第8页,不需要任何额外代码。
PDF.js 这个东西,入门门槛真的很低,一个脚本加一个 worker 就能跑起来。但要在真实项目里用得顺手,跨域、鉴权、中文字体、阅读进度这几座山都得翻一遍。上面这些内容就是我一路踩过来的经验,希望能帮你少走几步弯路,一次把方案做对。