十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PDF.JS实现Web端PDF预览:从本地文件到服务器渲染的完整指南

PDF.JS实现Web端PDF预览:从本地文件到服务器渲染的完整指南 做后台管理系统开发的朋友大概率都遇到过同一个需求用户上传了一个 PDF或者服务端存了一批合同、报告、说明书要求在网页里直接预览而不是下载到本地再打开。早期我图省事直接用iframe指向文件地址后来发现体验完全不可控Chrome 自带的 PDF 查看器虽然能用但样式和业务系统格格不入Windows 在预览来自服务器的文件时还会出现你尝试预览的文件可能对你的计算机有害这类系统弹窗用户一看就以为系统中毒了。直到换用 PDF.JS 把 PDF 渲染成 Canvas这些糟心事才算彻底解决。这一篇我打算把 PDF.JS 的使用从头到尾梳理一遍重点覆盖两类最常见的场景一类是本地文件预览也就是用户从电脑里选一个 PDF在页面上立刻看到内容另一类是服务器文件预览文件在后端接口或静态路径上需要加载并渲染。文章里会讲清楚核心 API 的工作逻辑、完整的示例代码以及在文件加载、跨域、性能、阅读进度等方面踩过的一些坑。适合刚接触 PDF.JS 的初学者也适合已经用过但想系统查漏补缺的同学。1. 为什么做 Web 预览时PDF.JS 还是那个绕不开的选项先说结论在浏览器里预览 PDF市面上的方案其实不少但 PDF.JS 依然是最通用、最可控的那一个。原因得从它的实现原理说起。PDF.JS 是 Mozilla 团队维护的开源项目它的核心能力是在浏览器里用 JavaScript 解析 PDF 文件格式把每一页的内容重新绘制到 Canvas 元素上。也就是说它把 PDF 当成了一种数据格式来处理而不是依赖浏览器内置的查看插件。这带来一个很实际的好处预览过程完全发生在你的业务页面里你可以控制工具栏、控制翻页逻辑、控制渲染样式甚至可以在渲染后的 Canvas 上叠加水印、添加批注、记录用户阅读到第几页。相比之下iframe或embed的做法是直接把文件交给浏览器内核去渲染。它的致命问题是不可控不同浏览器的 PDF 查看器长不一样Chrome 是 Chrome 的样子Firefox 是 Firefox 的样子Safari 可能直接就变成下载了。无法和业务系统做交互比如你不知道用户看到哪一页也无法在页面上叠加任何自定义内容。移动端兼容性差部分手机浏览器对 PDF 查看器的支持基本等于没有。还有一个容易被忽视的体验问题就是开头提到的 Windows 安全提示。当服务器上的 PDF 文件带上了 Windows 的来自 Internet文件标记用户用本地阅读器打开时系统会弹警告。用 PDF.JS 把文件读成字节流、在 Canvas 上渲染就走不到操作系统文件预览那一步自然没有这个弹窗。注意这是预览行为层面的选择不是用来绕过安全机制的手段涉及敏感文件时后端仍然要做权限校验。从选型角度做个对比就非常直观了对比维度iframe/embed浏览器默认 PDF 查看器PDF.JS交互控制基本没有有限完全可控自定义 UI不能不能可以阅读进度记录不能不能可以移动端兼容差不稳定好集成成本最低最低中等依赖插件否否否PDF.JS 官方其实给了两套使用方式一套是直接把官方viewer.html当成一个完整项目引入它有工具栏、缩略图、搜索、打印开箱即用另一套是用pdfjsLib这个 API 库自己写渲染逻辑。定制化要求不高就直接用官方 viewer但如果要做深度集成比如嵌进自己的管理后台、对接自己的用户权限、做特定交互那么掌握 API 层的用法就是必须的。下面这篇文章重点讲的也是后者。2. 先摸清 PDF.JS 的三板斧getDocument、getPage、render 是怎么配合的很多朋友第一次接触 PDF.JS 时一上来就复制网上的 Demo然后发现换个场景就不知道怎么改了。其实 PDF.JS 的 API 看似很多核心链路就三条加载文档、获取页面、渲染页面。把这三步的底层逻辑搞清楚后面所有代码都是围绕它们展开的。2.1 worker 到底在干什么为什么少了它页面白屏在写第一行代码之前必须先把 worker 的概念弄清楚。PDF.JS 解析 PDF 是一个计算密集型的操作如果直接放在主线程做UI 会被卡死用户滚动页面、点击按钮都毫无反应。所以 PDF.JS 默认把解析工作放在 Web Worker 线程里执行主线程只负责拿解析结果去画 Canvas。启用 worker 的方式很简单pdfjsLib.GlobalWorkerOptions.workerSrc https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js;workerSrc就是 worker 脚本的地址。这里有一个非常重要的要求worker 文件版本必须和主 pdf.js 文件版本一致。我之前遇到过页面始终白屏控制台报了一堆奇怪的错最后发现是 CDN 上主库引了 3.xworker 却引了 2.x版本不匹配导致的。这个坑后面避坑章节还会细说。还有一个大家容易疑惑的点你不知道 worker 加载是不是成功了。在 Chrome 开发者工具里你根本看不到一个叫pdf.worker的脚本因为 worker 是在独立线程运行的。如果页面迟迟不渲染先在 Network 面板里确认 worker 文件有没有 404这是排查的第一步。2.2 getDocumentPDF 文件的入口钥匙pdfjsLib.getDocument()负责加载 PDF 文件。它接受多种形式的参数这也是它能同时兼容本地文件和服务器文件的原因参数类型使用场景URL 字符串服务器文件传/uploads/demo.pdfURL 对象同上但规范化处理后的 URLArrayBuffer后端接口返回文件流或本地 FileReader 读出的内容Uint8Array对 ArrayBuffer 进一步处理后的字节数组参数对象{ url, httpHeaders, withCredentials }等配置的集合调用方式很统一返回都是一个 Promiseconst loadingTask pdfjsLib.getDocument(source); const pdfDoc await loadingTask.promise;pdfDoc是一个PDFDocumentProxy对象它就是整个 PDF 文档在内存中的代表。通过它可以拿到页数、元数据、大纲等。核心属性是numPages总页数和getPage(pageNumber)获取指定页。这里有个值得了解的内部机制PDF.JS 加载 URL 形式文件时并不是一次性把整个文件读进内存。它会先请求文件头部获取必要的信息然后通过 HTTP Range 请求按需读取数据块。这也是为什么它加载一个几百兆的大 PDF 依然能秒开第一页。但这也带来一个衍生要求服务器必须支持 Range 请求否则 PDF.JS 会回退到整体下载方式大文件体验会下降。这个在服务器文件章节再展开。2.3 getPage getViewport render渲染一页的完整流程拿到pdfDoc之后要渲染某一页需要三个步骤第一步获取页面对象const page await pdfDoc.getPage(1);page是一个PDFPageProxy对象它代表 PDF 中的一页。第二步生成视口viewportconst viewport page.getViewport({ scale: 1.5 });viewport可以理解成这一页画出来应该占多大尺寸。PDF 内部使用点point作为单位1 point 1/72 英寸。如果scale: 1那么渲染出来的像素尺寸就是按 72 DPI 计算的原始大小scale: 1.5就是放大 1.5 倍。这个参数本质上是控制页面在 Canvas 上的缩放比例。第三步渲染const canvas document.getElementById(pdfCanvas); const ctx canvas.getContext(2d); canvas.width viewport.width; canvas.height viewport.height; const renderTask page.render({ canvasContext: ctx, viewport }); await renderTask.promise;render返回一个RenderTask它的promise会在这一页绘制完毕后 resolve。注意render是异步且耗时的操作如果用户快速切换页数而你不做控制上一次渲染还没结束下一次又开始了页面会闪屏甚至报错。处理方案是调用上一次renderTask.cancel()取消未完成的渲染任务后面性能章节会给出完整代码。用一句生活化的话总结getDocument是打开一本书getPage是翻到某一页viewport是决定这本书以多大字号呈现在纸上render才是真正落笔把内容画出来。3. 本地文件预览从 file input 到 Canvas 渲染的完整路径本地文件预览的需求在管理后台特别常见典型场景是用户在表单里上传 PDF选择完立刻在右边看到预览效果确认无误再提交。实现这件事有两个技术路线我先说结论绝大多数情况下我更推荐把 File 对象转成 ArrayBuffer 再交给 PDF.JS而不是用 URL.createObjectURL。原因下面结合代码讲。3.1 先把完整代码放出来!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titlePDF.JS 本地文件预览/title style #toolbar { display: flex; align-items: center; gap: 12px; padding: 12px; background: #f5f6f7; border-radius: 6px; margin-bottom: 12px; } #pdfCanvas { width: 100%; max-width: 900px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fff; } /style /head body input typefile idfileInput acceptapplication/pdf div idtoolbar button idprevPage上一页/button span idpageInfo1 / 1/span button idnextPage下一页/button button idzoomIn放大/button button idzoomOut缩小/button /div canvas idpdfCanvas/canvas 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; const fileInput document.getElementById(fileInput); const canvas document.getElementById(pdfCanvas); const ctx canvas.getContext(2d); const pageInfo document.getElementById(pageInfo); const prevBtn document.getElementById(prevPage); const nextBtn document.getElementById(nextPage); const zoomInBtn document.getElementById(zoomIn); const zoomOutBtn document.getElementById(zoomOut); let pdfDoc null; let currentPage 1; let currentScale 1.5; fileInput.addEventListener(change, async (e) { const file e.target.files[0]; if (!file) return; try { // 方式一直接读取 ArrayBuffer const buffer await file.arrayBuffer(); const data new Uint8Array(buffer); pdfDoc await pdfjsLib.getDocument({ data }).promise; pageInfo.textContent 1 / pdfDoc.numPages; await renderPage(1); } catch (err) { console.error(PDF 加载失败, err); alert(文件无法解析请确认是有效的 PDF 文件); } }); async function renderPage(pageNum) { const page await pdfDoc.getPage(pageNum); const viewport page.getViewport({ scale: currentScale }); canvas.width viewport.width; canvas.height viewport.height; await page.render({ canvasContext: ctx, viewport }).promise; pageInfo.textContent currentPage / pdfDoc.numPages; } prevBtn.addEventListener(click, () { if (currentPage 1) return; currentPage--; renderPage(currentPage); }); nextBtn.addEventListener(click, () { if (currentPage pdfDoc.numPages) return; currentPage; renderPage(currentPage); }); zoomInBtn.addEventListener(click, () { currentScale Math.min(3, currentScale 0.25); renderPage(currentPage); }); zoomOutBtn.addEventListener(click, () { currentScale Math.max(0.5, currentScale - 0.25); renderPage(currentPage); }); /script /body /html这段代码覆盖了完整流程用户选择文件 →file.arrayBuffer()读取文件内容 →getDocument({ data })解析 →getPagerender渲染第一页 → 通过工具栏翻页和缩放。3.2 为什么不优先用 URL.createObjectURL网上很多老教程会用URL.createObjectURL(file)生成一个临时地址然后传给getDocument。这种方式代码上是可行的但你一定要注意它的生命周期。createObjectURL生成的是一个浏览器内部的 blob 地址PDF.JS 拿到这个地址后会以流式方式读取内容。如果你在渲染完成返回之后就立刻调用URL.revokeObjectURL()释放掉这个地址后续 PDF.JS 内部再发起 Range 请求读取其他数据块时就会失败表现为第一页偶尔能出来、翻页时突然白屏或者报错。正确的做法有两个要么不在渲染过程结束前 revoke等整个 PDF 预览关闭后再清理要么干脆像我上面的代码一样直接用file.arrayBuffer()读取全部字节。这样数据就是纯粹的内存字节数组没有 URL 生命周期的问题也没有二次请求的隐患。从可靠性和心智负担角度后者明显更优。代价是如果文件特别大比如几百 MB一次性读入内存会有一定开销但对大多数业务场景来说完全够用。// 如果你确实要用 createObjectURL可以这样处理 const objectUrl URL.createObjectURL(file); try { pdfDoc await pdfjsLib.getDocument(objectUrl).promise; // ... 渲染逻辑 } finally { // 不要立即 revoke等文档销毁后再 revoke // 这里 revoke 是为了防止内存泄漏但要在合适时机执行 setTimeout(() URL.revokeObjectURL(objectUrl), 10000); }3.3 文件输入控件的细节accept 和 type 校验input typefile acceptapplication/pdf只是给用户一个文件选择器的默认过滤并不能真正阻止用户选择其他类型文件。所以读取之后的校验不能省if (file.type ! application/pdf !file.name.toLowerCase().endsWith(.pdf)) { alert(请选择 PDF 文件); return; }这里补充一句file.type在部分浏览器里可能为空字符串所以稳妥做法是两个条件都判断。4. 服务器文件预览三种加载姿势各自适合什么场景服务器文件预览的变体比较多不同业务场景下的最佳实践也不一样。总结下来有三种主流做法我按推荐程度和使用频率来展开。4.1 姿势一直接把文件 URL 扔给 getDocument最省事的写法就是这样const pdfDoc await pdfjsLib.getDocument(/uploads/contract.pdf).promise;PDF.JS 内部会自己完成请求、解析、按需加载的过程。这种方式的适用条件有三个文件是静态资源或者接口就是直接返回文件流的。服务器允许跨域请求如果前端和后端不在同一个域名下。服务器支持 Range 请求这样大文件预览才有好体验。如果满足这三个条件我建议优先用这种方案代码最少性能和内存表现也最好。因为 PDF.JS 是按需加载数据块的而不是一次性把整个文件读入内存。4.2 姿势二带请求头的加载解决登录态和权限问题真实业务里PDF 文件往往不是公开静态资源而是需要登录后才能访问的。如果直接传 URL 给getDocumentPDF.JS 发起的请求是普通的 GET不带 Cookie 之外的任何自定义头后端就没办法识别用户身份。这时候可以用httpHeaders参数让 PDF.JS 在内部请求时带上自定义请求头const pdfDoc await pdfjsLib.getDocument({ url: /api/pdf/download?id123, httpHeaders: { Authorization: Bearer getToken() } }).promise;如果接口依赖 Cookie 的情况下需要允许携带凭证还可以加上withCredentials: trueconst pdfDoc await pdfjsLib.getDocument({ url: /api/pdf/download?id123, withCredentials: true }).promise;这种方案比先 fetch 再喂 ArrayBuffer优雅因为它依然保持了 PDF.JS 内部的流式加载能力。不过要注意使用httpHeaders自定义头之后后端接口必须正确响应 CORS 预检请求OPTIONS 请求否则浏览器会直接拦截。跨域配置这一块下面避坑章节会细聊。4.3 姿势三先 fetch 拿 ArrayBuffer再交给 PDF.JS第三种方案是前端自己把文件全部拉下来再交给 PDF.JS 解析const response await fetch(/api/pdf/download?id123, { headers: { Authorization: Bearer getToken() } }); if (!response.ok) { throw new Error(文件请求失败 response.status); } const arrayBuffer await response.arrayBuffer(); const pdfDoc await pdfjsLib.getDocument({ data: new Uint8Array(arrayBuffer) }).promise;它的优点非常明确可以拿到完整的响应方便做权限校验、错误处理、加载进度条。不依赖服务器支持 Range 请求因为整个文件已经在前端内存里了。可以加任意的请求头、自定义拦截逻辑灵活性最高。缺点同样明显大文件一次性读入内存内存峰值高首屏速度也不如流式加载。所以我的建议是文件不大几 MB 以内、接口有鉴权、或者需要做复杂错误处理时用这种方式文件很大、且后端支持 Range 时用姿势一或姿势二。4.4 三种方式一张表看懂怎么选场景推荐方式原因静态文件无鉴权支持跨域直接传 URL代码最少性能最好静态文件带鉴权 Header支持跨域httpHeaders 传 URL保留流式加载又能带请求头后端接口有登录态但不好配置 CORSfetch拿 ArrayBuffer完全绕开 PDF.JS 内部的跨域请求大文件几十 MB 以上httpHeaders 传 URL流式加载内存友好需要自定义加载进度条fetchresponse.body流式读取可以计算 loaded/total拿我实际做过的一个项目举例合同管理系统里合同文件存在 OSS 上但预览接口需要带用户 token 做权限判断。我当时的方案是后端提供一个/api/contract/preview?idxxx接口前端用fetch拿 ArrayBuffer 再渲染。因为合同单个体积都不大最大也就十几 MBArrayBuffer 方式足够用而且鉴权和统计逻辑都集中在一个接口里后续加权限、加水印都有清晰的位置。4.5 服务端接口的几种返回格式前端要注意什么服务器返回 PDF 给前端,常见有两种格式。第一种是直接把application/pdf文件流返回响应头是Content-Type: application/pdf前端可以用姿势一直接加载也可以用fetch接收 ArrayBuffer。第二种是包装成 JSON比如{ code: 0, data: { base64: ... } }。这种明显是为了统一接口规范。前端拿到后需要把 base64 转成 Uint8Array 再交给 PDF.JSconst base64 response.data.base64; const binary atob(base64); const bytes new Uint8Array(binary.length); for (let i 0; i binary.length; i) { bytes[i] binary.charCodeAt(i); } const pdfDoc await pdfjsLib.getDocument({ data: bytes }).promise;注意base64 转 Uint8Array 在非常大的文件上有性能开销而且 base64 会比原始文件体积大 33% 左右传输层面也吃亏。能直接返回文件流最好不要走这种包装。4.6 加载进度条怎么做如果用getDocument加载 URL 形式的文件可以监听onProgress获取加载进度const loadingTask pdfjsLib.getDocument({ url: /uploads/big-file.pdf }); loadingTask.onProgress (progressData) { const percent progressData.loaded / progressData.total * 100; console.log(加载进度 percent.toFixed(2) %); }; const pdfDoc await loadingTask.promise;进度的loaded和total单位是字节。如果你用fetch拿 ArrayBuffer那就得用response.body.getReader()流式读取来计算进度代码会复杂不少。我实际使用下来getDocument自带的onProgress在多数浏览器里表现都不错只有在服务器不返回Content-Length时total会是 undefined需要做兜底。5. 翻页、缩放与多页渲染把预览从能看做成好用能渲染出第一页只是起步一个真正可用的预览组件必须处理翻页边界、缩放控制、渲染竞态和性能问题。这一章把每个点的实现思路和常见坑位说透。5.1 翻页逻辑的边界控制翻页看起来简单但很容易写出边界 bug。核心就是当前页不能小于 1不能大于numPages。上面第 3 章的示例里已经给了prevBtn和nextBtn的基础实现这里再补一个输入框跳页版本const pageInput document.getElementById(pageInput); const jumpBtn document.getElementById(jumpBtn); jumpBtn.addEventListener(click, () { let target parseInt(pageInput.value, 10); if (isNaN(target) || target 1) target 1; if (target pdfDoc.numPages) target pdfDoc.numPages; currentPage target; pageInput.value target; renderPage(currentPage); });一个小技巧跳页时把目标值 clamp 到合法区间而不是直接 alert 报错。用户输了个 999 不应该让页面崩溃静默修正到最后一页体验更好。5.2 缩放的本质理解 scale 与 DPI 的关系很多人不太理解getViewport({ scale })里的 scale 到底该怎么取值。这里把原理讲透。PDF 内部页面尺寸用的是 point72 point 1 inch。一个标准的 A4 页面是 595 x 842 point。当scale: 1时渲染出来的 Canvas 像素宽度就是595 * 1 595。这个数值和你在屏幕上100%的视觉大小并不完全一致因为它取决于屏幕 DPI。在普通显示器上72 DPI 的页面看起来会偏小所以 PDF 阅读器默认都会做一定的缩放适配。这也是为什么很多项目里默认 scale 设成 1.5 或 2.0视觉上才接近原始大小。在实际业务里缩放通常给一个范围限制比如 0.5 到 3 之间步长 0.25。不能无限制放大否则渲染成本和内存都会飙升也不能缩太小否则文字完全看不清。function setScale(newScale) { currentScale Math.min(3.0, Math.max(0.5, newScale)); renderPage(currentPage); }这里还有一个高分屏优化问题。上面示例代码里canvas.width viewport.width在高 DPI 屏幕上会显得模糊因为物理像素比 CSS 像素多。想要高清需要把 Canvas 的实际分辨率乘以devicePixelRatio再用 CSS 把显示尺寸限制为原来的逻辑尺寸async function renderPage(pageNum) { const page await pdfDoc.getPage(pageNum); const viewport page.getViewport({ scale: currentScale }); 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; const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); await page.render({ canvasContext: ctx, viewport }).promise; }这段代码的铁律是Canvas 的width和height是物理像素style.width和style.height是 CSS 像素两者必须区分。如果不乘 DPR高分屏上文字边缘会有明显的锯齿如果乘了 DPR 但没设 style 尺寸Canvas 会被放大显示页面看起来只有左上角一块这是特别常见的翻车点。5.3 多页滚动预览用懒加载别一把梭渲染所有页有的需求是像浏览器阅读器一样整个页面可以滚动所有页从上到下连续排开。最直接的做法是循环渲染所有页但这对大文件是灾难几十页的 PDF 一次性渲染轻则内存飙升重则页面卡死。正确做法是滚动懒加载只渲染可视区域附近的页面。一个成熟的思路是用一个容器包裹所有页面占位符每个占位符高度按viewport.height预留。用IntersectionObserver监控占位符是否进入视口。进入视口的占位符才真正去getPagerender。简化版实现const viewportContainer document.getElementById(pdfPages); // 创建占位符并监控 for (let i 1; i pdfDoc.numPages; i) { const placeholder document.createElement(div); placeholder.dataset.pageNum i; placeholder.classList.add(page-placeholder); viewportContainer.appendChild(placeholder); } const observer new IntersectionObserver(async (entries) { for (const entry of entries) { if (entry.isIntersecting) { const pageNum parseInt(entry.target.dataset.pageNum, 10); await renderPageInto(entry.target, pageNum); observer.unobserve(entry.target); } } }, { rootMargin: 200px }); document.querySelectorAll(.page-placeholder).forEach(el observer.observe(el));rootMargin: 200px意味着提前 200 像素开始渲染这样用户滚动到时页面已经画好了体感更流畅。5.4 renderTask.cancel 是快速翻页的定心丸还有一个非常隐蔽的 bug用户快速连续点击下一页时上一次渲染还没完成下一次渲染又开始了。Canvas 是共享资源两次渲染同时进行会互相覆盖轻则显示出错重则直接抛异常。解决办法是在每次渲染前取消掉上一次的渲染任务let currentRenderTask null; async function renderPage(pageNum) { if (currentRenderTask) { currentRenderTask.cancel(); } const page await pdfDoc.getPage(pageNum); const viewport page.getViewport({ scale: currentScale }); canvas.width viewport.width; canvas.height viewport.height; const renderTask page.render({ canvasContext: ctx, viewport }); currentRenderTask renderTask; try { await renderTask.promise; } catch (err) { if (err.name RenderingCancelledException) { // 这是主动取消不需要报错处理 return; } throw err; } }注意RenderingCancelledException是 PDF.JS 在任务被取消时抛出的特殊异常它不是真正的错误不要在那个 catch 里弹错误提示否则用户会看到莫名其妙的报错。6. 实战避坑worker 加载失败、白屏、内存泄漏的排查链路PDF.JS 接入时最常见的报错其实就集中在几个点上。我把这些年踩过的坑按排查链路整理出来按照数据 → 解析 → 渲染三层去看问题能省很多时间。6.1 Worker 加载不了页面一直是空白现象控制台没有明显报错或者报Failed to fetch和Setting up fake worker failed页面 Canvas 始终是空的。排查步骤打开 Network 面板搜索worker确认pdf.worker.min.js是否成功加载。如果 404检查GlobalWorkerOptions.workerSrc的路径是否正确。如果路径没问题但还是加载不了检查是不是跨域问题。worker 脚本跨域加载会受限。一个容易混淆的点PDF.JS 在 worker 加载失败时会尝试降级使用fake worker在主线程解析这个降级过程经常报错且性能极差。所以宁可加载失败就报错也不要让它静默降级。正确配置示例// 方式一CDN 完整路径 pdfjsLib.GlobalWorkerOptions.workerSrc https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js; // 方式二放到自己项目的 public 目录 pdfjsLib.GlobalWorkerOptions.workerSrc /static/pdf.worker.min.js; // 方式三Vite 环境下更推荐的做法缓存友好且自动打包 import * as pdfjsLib from pdfjs-dist; pdfjsLib.GlobalWorkerOptions.workerSrc new URL( pdfjs-dist/build/pdf.worker.min.js, import.meta.url ).toString();网上有些教程推荐用pdfjs-worker-loader之类的插件把 worker 打包成 inline 字符串我试过几次字数多、调试不便非特殊场景不推荐。6.2 服务器文件预览遇到跨域浏览器直接拦截如果用 URL 形式加载 PDF且前端页面和后端文件域名不一致就会遇到跨域问题。错误表现是控制台报Access to fetch at http://file-server.com/demo.pdf from origin http://localhost:3000 has been blocked by CORS policy解法有三个方向让后端在响应头加上Access-Control-Allow-Origin: http://localhost:3000使用同源代理后端写一个转发接口比如/api/pdf-proxy?urlxxx前端永远只请求同源接口由后端从文件服务器取回数据再转发给前端。前端用fetch加mode: cors并且后端允许携带自定义头如果有时后端还要处理 OPTIONS 预检。从工程实践看方案 2 是最稳的。因为生产环境里后端文件服务器可能有自己的鉴权、限流、日志逻辑统一走代理接口最有可控性。6.3 本地双击 HTML 文件打开时 worker 报错这是很多新手第一个会踩的坑把写好的 HTML 文件放在本地双击用 file:// 协议打开结果 PDF.JS 一直报错。原因在于浏览器对 file:// 协议下 worker 的加载有严格限制而且fetch在 file:// 下也基本不可用。PDF.JS 从设计上就不是为 file:// 场景准备的。要本地调试最标准的方法是开启一个本地 HTTP 服务# 在项目目录下执行 npx serve . # 或者 python3 -m http.server 8080然后通过http://localhost:8080访问页面。这几乎是使用 PDF.JS 之前的隐藏前置条件踩一次就记住了。6.4 大文件导致的内存泄漏和卡顿如果你发现同样的 PDF 反复打开关闭后页面内存一路飙升大概率是下面几个原因每次重新加载 PDF 时没有调用pdfDoc.destroy()释放上一个文档对象。渲染完成后 Canvas 元素没有从 DOM 中移除或者ctx上下文一直持有大量引用。懒加载时已经渲染过的页面没有做缓存或没有控制并发。正确的销毁流程async function closePdf() { if (pdfDoc) { await pdfDoc.destroy(); pdfDoc null; } ctx.clearRect(0, 0, canvas.width, canvas.height); }pdfDoc.destroy()会释放 PDF.JS 内部的 worker 和缓存。切换文件之前调用它是防止内存泄漏的关键。我之前遇到过管理后台里预览了几十次 PDF 后浏览器 Tab 崩溃加上这一行就再也没出现过。6.5 加密 PDF 和损坏 PDF 的处理getDocument遇到需要密码的 PDF 时会触发onPassword回调。处理方式如下const loadingTask pdfjsLib.getDocument({ data: bytes }); loadingTask.onPassword (updatePassword, reason) { const userPassword prompt(这个 PDF 需要密码); if (userPassword) { updatePassword(userPassword); } }; const pdfDoc await loadingTask.promise;如果密码错误onPassword会再次被调用你需要给它一个终止机制不然会无限弹窗。可以加一个计数器let retryCount 0; loadingTask.onPassword (updatePassword) { if (retryCount 3) { loadingTask.destroy(); return; } retryCount; const userPassword prompt(请输入 PDF 密码); if (userPassword) { updatePassword(userPassword); } };对于损坏的 PDFgetDocument().promise会 reject错误信息类似Invalid PDF structure。前端代码必须用 try/catch 包住加载逻辑给用户一个友好的提示而不是静默白屏。6.6 白屏排查的完整心智模型把以上问题总结成一个排查链路第一层数据链路文件有没有正常拿到看 Network 面板请求是否 200响应是否真的是 PDF 字节流。第二层解析链路getDocument是否成功在这一步 console 打印pdfDoc.numPages能打印出来说明解析没问题。第三层渲染链路getPage是否成功viewport的宽高是不是正数render的 promise 有没有 rejectCanvas 在 DOM 里有没有实际尺寸按照这个链路排查90% 的问题都能快速定位。我自己的习惯是第一步就在getDocument后打印一行日志确认解析成功再往下走避免在渲染层瞎猜数据层的问题。7. 延伸阅读进度记录、Vue 封装与后续玩法PDF 预览基本功能做完之后业务往往会提出更多要求。最典型的就是记录用户阅读到哪一页这个热搜词对应的需求在后台系统里非常高频。7.1 阅读进度的保存与恢复实现思路不复杂每次渲染成功当前页之后把页码存起来。本地临时记录用localStorage要跨设备记录就通过接口上报给后端。// 渲染成功后记录 function saveCurrentPage() { try { localStorage.setItem(pdf_last_page_ pdfId, String(currentPage)); } catch (e) { // localStorage 可能被用户禁用这里静默失败 } } // 打开 PDF 后恢复 async function openPdf(source, pdfId) { pdfDoc await pdfjsLib.getDocument(source).promise; const lastPage parseInt(localStorage.getItem(pdf_last_page_ pdfId), 10); const validPage Math.min(Math.max(lastPage || 1, 1), pdfDoc.numPages); currentPage validPage; await renderPage(currentPage); }关键细节是恢复时要做合法性校验用户上次读到 50 页但这次文件换了总共只有 30 页直接跳到 50 页会崩。所以必须clamp到合法范围。要上报到数据库的话把currentPage作为参数 POST 到后端接口即可注意控制频率不要每翻一页就发一次请求可以用节流每 3 秒或每翻 5 页上报一次。let lastReportTime 0; function reportPageToServer() { const now Date.now(); if (now - lastReportTime 3000) return; lastReportTime now; fetch(/api/pdf/progress, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ pdfId, page: currentPage }) }); }7.2 在 Vue 3 里封装成一个 composable如果你用的是 Vue 3可以很自然地把 PDF 预览逻辑封装进一个usePdfPreviewcomposable 里组件里只需要关心数据和事件不用关心 PDF.JS 细节。// usePdfPreview.js import * as pdfjsLib from pdfjs-dist; import { ref, onBeforeUnmount } from vue; pdfjsLib.GlobalWorkerOptions.workerSrc new URL( pdfjs-dist/build/pdf.worker.min.js, import.meta.url ).toString(); export function usePdfPreview() { const pdfDoc ref(null); const currentPage ref(1); const numPages ref(0); const currentScale ref(1.5); let renderTask null; async function open(source) { close(); pdfDoc.value await pdfjsLib.getDocument(source).promise; numPages.value pdfDoc.value.numPages; await renderPage(1); } async function renderPage(pageNum) { if (!pdfDoc.value) return; if (renderTask) renderTask.cancel(); const page await pdfDoc.value.getPage(pageNum); const viewport page.getViewport({ scale: currentScale.value }); currentPage.value pageNum; return { page, viewport }; } async function nextPage() { if (currentPage.value numPages.value) return; await renderPage(currentPage.value 1); } async function prevPage() { if (currentPage.value 1) return; await renderPage(currentPage.value - 1); } function close() { if (pdfDoc.value) { pdfDoc.value.destroy(); pdfDoc.value null; } } onBeforeUnmount(close); return { open, close, renderPage, nextPage, prevPage, currentPage, numPages, currentScale }; }组件里使用时把返回的page和viewport交给渲染函数或者直接让 loading 与错误状态由父组件管理。这种封装方式让多页面复用预览能力非常方便不用在每个组件里重复写 PDF.JS 的初始化逻辑。7.3 打印、下载与更多玩法渲染到 Canvas 之后打印当前页和下载当前页图片也变成了一个常规问题。用 Canvas 的能力就能实现// 打印当前页 function printCurrentPage() { const win window.open(, _blank); win.document.write(img src${canvas.toDataURL(image/png)} /); win.print(); } // 下载当前页为图片 function downloadCurrentPage() { const link document.createElement(a); link.download page- currentPage .png; link.href canvas.toDataURL(image/png); link.click(); }如果想让 PDF 里的文字可以检索、选中、复制PDF.JS 也提供了page.getTextContent()接口拿到文本片段后自己做搜索或高亮。这在合规审计类系统里很有用比如搜索合同编号、定位某个关键词所在的页面。实现思路是遍历文本片段的位置信息把匹配到的区域用覆盖层高亮复杂但不难值得单独开一篇去讲。我自己实际接项目时最大的体会是PDF.JS 这个库的 API 设计相当稳定版本升级虽然有变化但核心的getDocument/getPage/render链路和第一版比起来并没有本质改变。你只要围绕这条主线去理解什么缩略图、文本选择、打印下载都只是在这条主线上挂分支。而真正让一个 PDF 预览功能从能用变成好用的往往不是库本身的能力而是你对渲染竞态、生命周期、内存释放这些工程细节的处理。希望这篇文章的踩坑经验能帮你避开我当年一个个试出来的问题。
返回列表