
简介PDF.js是Mozilla团队开源的JavaScript PDF渲染库这份资源面向需要在Web应用中无缝集成PDF阅读功能的开发者解决传统浏览器对PDF支持依赖插件、体验不一致的问题在HTML5环境下即可完成文档解析、Canvas绘制与交互操作。压缩包共200个文件以properties配置与js脚本构成核心渲染引擎配以png、svg图标和css样式文件完善阅读器界面并包含worker多线程处理脚本与示例页面整体45.04MBzip格式便于下载后直接解压部署。目前已有2878人学习下载。通过目录中的示例页面与配套脚本开发者可以快速掌握pdf.js基础API理解worker异步解析机制并利用内置资源实现搜索、缩放、缩略图、书签、连续阅读等高级特性针对跨域限制、大文件渲染性能等常见问题也可参考库中模块结构进行针对性调试与优化。这份资源对前端开发者、需要自建在线文档系统的团队均有直接参考价值既能作为学习PDF.js原理的入门材料也可作为生产环境集成的功能基底。1. pdf.js文件下载不是加个链接就完事「pdf.js文件下载」这个需求几乎每个用 pdf.js 做在线预览的人都绕不过去。你搭好了预览白屏、翻页、缩放都正常了最后却发现下载按钮没那么听话直接写个a hrefpdfUrl download要么拿到的是空文件要么文件名乱码要么点击后毫无反应。更隐蔽的情况是你从 pdf.js 内部拿数据去保存存下来的 PDF 用阅读器打开居然提示损坏。这篇文章把 pdf.js 场景下做文件下载的完整链路拆开从数据源选型到文件名解析、跨域、大文件进度逐层过一遍给出一份能直接落地的实现。适合做在线文档预览的前端开发也适合在桌面端套壳应用里集成下载逻辑的工程师。2. 下载前的数据链路getData 与原始请求到底该信谁2.1 pdf.js 渲染流程里数据被「动过手脚」pdf.js 的加载流程大致是你传入一个 URL 或 ArrayBuffer库内部交给 Worker 线程解析渲染时拿到的是解析后的 PDF 数据。很多人以为「既然 pdf.js 能解析那我从它内部拿数据保存肯定没问题」于是翻文档找到pdfDocument.getData()用返回的 ArrayBuffer 去存文件。结果存下来的文件页面内容确实在但文件大小变了有时候注释、表单、书签会消失甚至部分阅读器直接拒绝打开。原因在于getData()返回的并不一定是原始字节。pdf.js 在解析过程中会重写部分对象结构比如压缩流被解压后再编码、交叉引用表被重组、某些对象编号被调整。它返回的是一个「经过 pdf.js 理解的、可被重新解析的数据」不是服务器上原始文件的一比一拷贝。如果你的业务只要求「能打开的 PDF」那无所谓但如果用户上传的是什么下载的就应该是什么这条链路就不可靠。所以下载功能的第一个选型原则能用原始请求拿数据就不要从 pdf.js 内部掏数据。原始请求是指直接用fetch(url)拉取 ArrayBuffer这条路拿到的和浏览器地址栏直接下载的字节一致。pdf.js 预览也可以复用这份 ArrayBuffer避免一次 PDF 被网络层拉两遍。2.2 为什么用 Blob 而不是 base64 保存文件确定了数据源接下来是保存形态。常见做法是拿到 ArrayBuffer 后转成 base64再拼一个data:URL 触发下载。这在文件小于几 MB 时没问题但 PDF 动辄几十 MBbase64 会把体积放大三分之一而且整个字符串驻留在 JS 内存里移动端低端机很容易把页面拖垮。我一般会用 Blobconst blob new Blob([arrayBuffer], { type: application/pdf }); const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download fileName; link.click(); URL.revokeObjectURL(url);逻辑说明ArrayBuffer 直接作为 Blob 的构造参数Blob 内部引用这份二进制数据但不复制一份到 JS 字符串空间。URL.createObjectURL生成一个临时对象 URLa.click()触发浏览器原生的下载行为。最后revokeObjectURL释放临时 URL避免长时间占用内存。参数说明type: application/pdf是 MIME 类型这个不能省某些浏览器会根据它决定是预览还是下载。fileName建议自带.pdf后缀Windows 下缺失后缀会导致系统不认识文件类型。需要注意revokeObjectURL不能紧跟click()同步调用部分浏览器在点击事件还没绑定完时就撤销 URL下载会无声失败稳妥做法是放在setTimeout里延迟一下或者干脆不主动调用依靠页面卸载时自动回收。3. 一套可复用的下载实现从预览到落盘3.1 单文件场景fetch 一次预览和下载共用数据常见的做法是让预览和下载共用同一次网络请求。页面加载时先fetchPDF 得到 ArrayBuffer把它同时交给 pdf.js 做预览并暂存在一个变量里。用户点下载时直接把这个 ArrayBuffer 转成 Blob 保存class PdfViewer { constructor(container) { this.container container; this.rawBuffer null; // 暂存原始字节 } async load(url) { const response await fetch(url, { credentials: include }); if (!response.ok) { throw new Error(加载失败HTTP ${response.status}); } this.rawBuffer await response.arrayBuffer(); // 交给 pdf.js 渲染 const pdf await pdfjsLib.getDocument({ data: this.rawBuffer }).promise; // 渲染第一页 const page await pdf.getPage(1); const viewport page.getViewport({ scale: 1.5 }); const canvas this.container.querySelector(canvas); await page.render({ canvasContext: canvas.getContext(2d), viewport: viewport }).promise; } download(fileName document.pdf) { if (!this.rawBuffer) return; const blob new Blob([this.rawBuffer], { type: application/pdf }); const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(() URL.revokeObjectURL(url), 1000); } }逻辑说明load方法里只发一次请求ArrayBuffer 同时服务于渲染和下载。pdfjsLib.getDocument({ data })传入的是 ArrayBufferpdf.js 内部会自己拷贝一份给 Worker不会 detach 掉你暂存的这份所以this.rawBuffer可以安全复用。参数说明credentials: include决定是否携带 Cookie。如果你部署的站点和 PDF 文件在同一个域名下这个参数加不加区别不大但如果 PDF 在另一个子域或经过登录鉴权不带上 Cookie 可能会拿到 401。scale: 1.5是渲染清晰度按业务需要调下载逻辑与此无关。还有个细节容易被忽略link需要先插入 DOM 再click()。某些版本的浏览器在元素未入文档时忽略download属性直接在新标签页打开 PDF。removeChild是为了清掉这个临时节点避免页面出现不可见元素残留。3.2 面对文件服务器用 Content-Disposition 兜底文件名上面的实现里文件名是调用方硬编码的。实际生产环境文件名往往由服务器决定——用户上传的合同、报告、票据文件名本身就承载信息。最可靠的做法是解析响应头里的Content-Dispositionfunction parseFileNameFromHeader(response, url) { const header response.headers.get(Content-Disposition) || ; const utf8Match header.match(/filename\*UTF-8([^;])/i); if (utf8Match utf8Match[1]) { return decodeURIComponent(utf8Match[1].replace(//g, )); } const plainMatch header.match(/filename?([^;])?/i); if (plainMatch plainMatch[1]) { return plainMatch[1]; } // 兜底从 URL 尾部解析 const pathSegment decodeURIComponent(url.split(?)[0].split(/).pop()); return pathSegment || download.pdf; }逻辑说明优先解析filename*这是 RFC 5987 规定的、专门用于携带非 ASCII 文件名的格式值为 URL 编码后的 UTF-8 字符串所以拿到后要decodeURIComponent。解析不到时回退到普通filename字段。两个都没有就从 URL 最后一段路径里取仍取不到就给一个固定默认名。参数说明response.headers.get()是否能拿到Content-Disposition取决于服务器是否正确返回了Access-Control-Expose-Headers。跨域场景下默认只有六个「安全响应头」可读Content-Disposition不在其中需要服务端显式暴露否则这里永远拿不到。同域场景不受影响。实际业务里我把这个解析函数单独抽出来配合fetch无论加载还是下载都走同一个入口保证文件名规则全局一致。4. 文件名与跨域两个最容易翻车的隐藏点4.1 中文文件名乱码不是玄学是编码规则没对齐中文文件名乱码是最常见的「看着像玄学、其实是标准问题」的场景。一个文件名使用说明.pdf在响应头里有两种可能形式Content-Disposition: attachment; filenameuse_guide.pdf; filename*UTF-8%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E.pdf服务器把 ASCII 兜底名放filename把真实中文名放filename*。浏览器下载时优先看filename*你的解析逻辑也应该如此。而坑在于很多服务端框架在返回filename时直接把中文塞进去比如filename使用说明.pdf不同的网络库对它的编码处理不一致有的按 UTF-8 发有的按系统默认编码发客户端解析时如果只做decodeURIComponent就会得到一串乱码。我的处理策略是如果filename*存在以它为准如果只有filename先按 UTF-8 解码解码结果里如果出现\ufffd这种替换字符说明服务端没按 UTF-8 编码就不做任何解码直接使用至少保持和服务器字节一致。这个兜底逻辑可以在前面的parseFileNameFromHeader里加一层校验。注意文件名里的引号、斜杠、反斜杠需要过滤。Windows 下\/:*?|这些字符不允许出现在文件名里不处理的话下载会报销。加一行正则过滤即可fileName fileName.replace(/[\\/:*?|]/g, _);4.2 跨域下载CORS 拦的不只是预览还有二进制的完整性pdf.js 的预览和下载如果涉及跨域fetch必须走 CORS 才能拿到数据。部分场景为了图省事会让后端把 PDF 接口设成允许所有来源访问这在预览上没问题但下载时还要多确认一件事响应头是否暴露了Content-Length和Content-Disposition。Content-Disposition的问题已经在上一节提过。Content-Length的暴露与否影响你能否提前拿到文件大小。没有大小下载进度条就只能做到「假进度」——要么转圈要么按已下载字节数估算百分比。如果后端控制不了 CORS 响应头有一个临时绕法请求时带着mode: cors是拿不到非暴露响应头的但你可以试着用no-cors模式发起请求。它拿不到响应体无法用于下载所以这条路走不通。唯一可靠的路径还是让服务端加上Access-Control-Expose-Headers: Content-Length, Content-Disposition这个头加上后前端才能同时拿到文件大小和真实文件名。我在某图像处理 Demo 里就遇到过这个坑当时服务器是现成的改不了一行配置最后是通过代理把 PDF 接口转发到同域才解决。5. 避坑指南下载功能的五个高频问题与排查路径5.1 下载的 PDF 打开提示损坏现象点击下载后文件能存下来但用阅读器打开报错显示文件损坏或不是有效 PDF。原因绝大多数情况是数据源选错了。用了pdfDocument.getData()拿解析后的数据而不是原始网络字节。pdf.js 重写过内部结构某些阅读器对此兼容性差。解决改用fetch原始请求拿 ArrayBuffer并在加载后暂存。已经用 getData 的切换数据源后测试同一份文件对比两者文件大小通常原始请求的文件更接近服务器的真实体积。5.2 大文件下载时页面卡死甚至崩溃现象50MB 以上的 PDF点击下载后浏览器标签页长时间无响应随后白屏。原因可能是把 ArrayBuffer 转成了 base64导致内存瞬间多占用三分之一加上 pdf.js 渲染本身要占内存低端设备撑不住。也可能是触发下载时同步调用了revokeObjectURL造成浏览器反复重建 Blob URL。解决用 Blob 而非 data URL。按前面的实现走createObjectURL后再clickrevokeObjectURL延迟执行。如果内存仍然吃紧考虑直接让浏览器导航到原始 URL——把window.open地址指向 PDF由浏览器原生下载或预览绕开二次组装。5.3 点击下载没任何反应控制台也不报错现象下载函数执行了DOM 检查也看到a标签生成了但就是不下发导航。原因浏览器对不是由用户手势触发的下载有限制。虽然click是模拟触发的但如果download的赋值在事件循环的后续异步回调里部分浏览器会认为该行为不是用户发起的直接静默拦截。另一个常见原因是link没有被追加到document.body脱离 DOM 的节点点击事件无法冒泡。解决确保click()在用户点击事件流的同步代码里完成不要在setTimeout或Promise.then之后调用link先appendChild到 body 再click。移动端浏览器如果还拦换用window.location.href blobUrl的方式但这个方案放弃自定义文件名不到万不得已不用。5.4 下载下来的文件名带一串 % 符号现象文件名形如%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E.pdf完全没有解码。原因服务端返回的filename*段确实存在但解析逻辑先匹配了普通filename字段做了解码或者正则写错了把filename*那段 URL 编码内容当作纯文件名。解决检查解析顺序filename*优先。且decodeURIComponent只对filename*的值执行普通filename出现 URL 编码字符时按原样透传不做二次解码。5.5 iOS 内置浏览器下载到一半提示失败现象相同代码在桌面端正常iOS 内置浏览器上部分 PDF 点击下载后提示「无法下载」或文件只有几十 KB。原因iOS 内置浏览器对 Blob URL 的下载支持比较挑剔文件过大时会把整个 Blob 一次性读入内存容易失败另外部分旧版本对download属性支持不完整会忽略文件名直接预览 PDF。解决文件较小5MB 以下时用 Blob 方案观察结果文件较大时改为新标签页打开原始 URL让浏览器原生处理。如果业务需要强制下载而非预览服务端返回时必须带上Content-Disposition: attachment响应头前端直接window.open这个带响应头的地址浏览器就会触发下载。6. 进阶带真实进度的大文件下载方案大文件下载的场景里用户需要知道还剩多久。常见做法是把整个 ArrayBuffer 一次性拉完再生成 Blob这期间没有任何反馈。用fetch的ReadableStream可以拿到分块数据边下边更新进度async function downloadPdfWithProgress(url, fileName, onProgress) { const response await fetch(url, { credentials: include }); if (!response.ok) { throw new Error(下载失败HTTP ${response.status}); } const total Number(response.headers.get(Content-Length)) || 0; const reader response.body.getReader(); const chunks []; let received 0; while (true) { const { done, value } await reader.read(); if (done) break; chunks.push(value); received value.length; if (total onProgress) { onProgress(Math.min(1, received / total), received, total); } } // Uint8Array 合并 let totalLength 0; chunks.forEach(chunk { totalLength chunk.length; }); const merged new Uint8Array(totalLength); let offset 0; chunks.forEach(chunk { merged.set(chunk, offset); offset chunk.length; }); const blob new Blob([merged.buffer], { type: application/pdf }); const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(() URL.revokeObjectURL(url), 1000); }逻辑说明response.body.getReader()拿到流式读取器每次read()返回一块Uint8Array累加字节数算出百分比并回调给 UI 层。合并阶段先把所有分块塞进数组再统一拷贝到一个新的Uint8Array里最后用merged.buffer构造 Blob避免多次 Blob 拼接造成的内存碎片。参数说明Content-Length取不到比如服务器启用了 chunked 传输时total为 0进度回调不会触发UI 层应显示不确定进度状态而不是卡死的假进度。onProgress回调的入参分别是小数进度、已收字节、总字节适合直接喂给进度条组件。用这套方案时要注意reader.read()拉流的过程中如果用户取消下载需要调用reader.cancel()并释放已收集的内存块否则这次操作会一直占用内存直到页面关闭。从那以后我每次做带进度的 PDF 下载都强制检查取消分支和Content-Length兜底这两个细节分别决定了体验的下限和上限。希望帮到你。本文还有配套的精品资源点击获取