☰
PDF.js在线预览实战:本地文件、服务器鉴权与阅读进度全解析
2026/10/2 7:31:31 网站建设 项目流程

先问一个问题:你手头的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 的工作流程,我用大白话总结成四个步骤:

  1. 获取PDF文件的数据:可以是一个网络 URL,也可以是本地文件读取出来的 ArrayBuffer,或者 Blob 对象。
  2. 初始化 PDF.js 的加载任务:调用getDocument()方法,把文件数据喂进去。
  3. 获取指定页面的渲染任务:通过pdf.getPage(pageNum)拿到某一页的 Page 对象。
  4. 把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-OriginPDF文件接口不允许跨域后端配CORS,或用同源中转接口
中文乱码或豆腐块没配置 CMapgetDocument参数加上cMapUrl和cMapPacked
页面一直白屏、不渲染workerSrc 路径错误检查 worker 是否成功加载,路径用绝对路径或动态计算
文件接口必须带 tokenPDF.js 请求默认不带 header自己 fetch 带 header,转 ArrayBuffer 后传入
控制台报Setting up fake workerworkerSrc 没设置成功显式设置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 就能跑起来。但要在真实项目里用得顺手,跨域、鉴权、中文字体、阅读进度这几座山都得翻一遍。上面这些内容就是我一路踩过来的经验,希望能帮你少走几步弯路,一次把方案做对。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询