简介:PDF.js 开源库的完整项目包,面向需要在浏览器中实现 PDF 文档免插件渲染的网页前端开发者,解决嵌入 PDF 阅读功能的集成与部署问题。资源包含 200 个文件,涵盖核心脚本、样式文件、配置属性与大量图标资源,压缩包约 45MB,可支撑核心库、工作线程、查看器逻辑等模块协同工作,覆盖从基础渲染到高级交互的完整链路。已有 2876 人学习下载,适合具备一定脚本语言基础、希望深入理解 PDF.js 接口与渲染原理的开发者;无论是基础入门还是二次开发,都能从中获得有效参考。包内提供可直接运行的示例页面与配套图标,便于本地运行调试,可对照学习 PDF 加载、绘制、事件监听及自定义工具栏。同时支持搜索、缩略图、连续阅读等高级功能,为在现有项目中二次开发提供清晰可扩展的代码基础,便于按需调整界面与交互逻辑。 如果只是把PDF当做一个静态资源丢给用户下载,那确实不需要费劲研究什么。但实际项目里,需求往往是"在网页里先预览PDF,再提供一个下载按钮",或者"后端接口返回的是文件流,浏览器却直接给渲染成预览页了",再或者"用户点了下载,iPad上的Safari非要把PDF打开成预览,就是不弹下载"。这些场景凑到一起,就会变成一个绕不开的技术点:如何用pdf.js这个库,把PDF文件从"能看"变成"能下、下得对、下得稳"。
这篇东西就是围绕"pdf.js文件下载"这个主题写的,内容包含pdf.js的基本引入方式、利用它获取文件数据、再配合Blob机制实现可控下载的核心代码,以及我在实际项目中踩过的那些"下载变预览""下载文件名乱码""iOS Safari不弹下载"之类的坑,还有对应的排查思路和解决方案。适合正在做Web端PDF模块、尤其是用Vue或原生JS开发并遇到下载问题的前端同学参考。
1. 为什么"下载PDF"在网页里会翻车:浏览器默认行为的三个陷阱
先聊一个反直觉的事实:在网页里把PDF下载这件事做好,比很多人想象中要麻烦得多。表面上看,一个<a href="xxx.pdf" download>标签就能搞定,但浏览器对PDF的处理有一套自己的逻辑,这套逻辑经常和业务需求对着干。
第一个陷阱是浏览器的内置PDF预览器。桌面端Chrome、Edge、Firefox都把PDF直接渲染在了标签页里,用户点开链接看到的是预览,不是下载。如果产品经理想要的是"用户一点按钮就直接弹出保存窗口",那默认行为就完全不满足需求。你需要在代码层面强制浏览器走下载而不是预览。
第二个陷阱是download属性并不总是生效。download属性看起来是标准方案,但它有一个关键限制:跨域资源下,浏览器会忽略这个属性。现在很多项目的PDF文件都存在OSS、CDN或独立文件服务器上,由前端直接拼URL访问,这种情况下你写一百遍download也没用,浏览器照样给你打开预览。
第三个陷阱是移动端Safari,尤其是iPad。iOS Safari对download属性、对Blob URL的支持都有自己的一套行为逻辑,经常出现"安卓上能正常下载,iPhone一打开就变预览"的诡异现象。如果项目有一定规模的移动端用户,这个坑基本绕不开。
所以结论很明确:要靠a标签加一个URL搞定PDF下载,只适用于"文件同源且浏览器行为恰好符合预期"的理想场景。一旦遇到跨域、移动端、需要自定义文件名、需要统计下载进度这些真实需求,就必须把PDF文件的数据拿回到前端手里,自己去控制下载流程——而拿数据、解析数据这件事,正是pdf.js的看家本领。
2. pdf.js的两种加载方式和Worker配置:下载功能的地基
pdf.js是Mozilla家出的开源PDF解析引擎,底层用Web Worker解析PDF二进制数据,再通过Canvas把每一页渲染出来。很多人对它的印象是"一个PDF预览组件",实际上它拆开来看是两层能力:第一层是把PDF文件数据拉回来并解析成文档对象;第二层才是把页面渲染成Canvas。下载功能真正依赖的是第一层能力。
2.1 引入pdf.js的正确姿势
如果是传统页面,直接用CDN引入是最快的:
<script src="https://unpkg.com/pdfjs-dist@3.11.174/build/pdf.min.js"></script>如果项目是Vue或React这类工程化项目,建议走npm安装:
npm install pdfjs-dist@3.11.174这里有第一个需要注意的细节:pdf.js的版本差异比较大,2.x和3.x在API上有明显的区别,网上很多教程用的是老版本API,直接抄到新版项目里会报错。我用的版本是3.x,下面所有代码也都按3.x写。
还需要重点提一下Worker的配置。pdf.js的解析工作默认在Worker线程里跑,不配置Worker的话,库会退化到主线程执行解析,同时会在控制台打出一行警告(大概意思就是"你忘了配worker了"),大文件预览时页面会明显卡顿。下载场景虽然主要是拿数据,但如果你的功能是"先预览再下载",那Worker配置就是一个必须做的优化。CDN方式下这样配:
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://unpkg.com/pdfjs-dist@3.11.174/build/pdf.worker.min.js';如果是Webpack或Vite工程,可以这样:
import * as pdfjsLib from 'pdfjs-dist'; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.js', import.meta.url ).toString();2.2 getDocument的两种传参方式
pdf.js加载PDF的核心方法是getDocument,它能接受一个URL字符串,也能直接接受二进制数据。这两种方式对应着下载功能的两条实现路径。
// 方式一:直接传URL,适合同源或后端允许跨域的PDF地址 const loadingTask = pdfjsLib.getDocument(pdfUrl); // 方式二:传ArrayBuffer,适合已经有文件数据的场景 const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });第一种方式最省事,但受跨域限制;第二种方式更灵活,只要你能用fetch把文件流拿到手,后面想怎么处理都行。这个区分很重要,后面讲下载实现方案时会反复用到。
3. 核心方案拆解:用pdf.js和Blob实现可控的PDF下载
现在进入正题。先明确一下我最终采用的下载方案,它不是单纯靠pdf.js的某一个API,而是把pdf.js的数据能力、fetch的流获取能力、Blob的对象URL机制组合起来,形成一条可控的下载链路。
3.1 整体思路:预览与下载分离
我的做法是:预览走pdf.js渲染,下载走fetch取流。两个功能看着都是围绕同一个PDF,但数据链路分开,职责清晰,出了问题也好排查。
下载的完整链路是这样的:
- 根据PDF的地址(或者后端接口返回的文件流),使用
fetch请求文件数据 - 将返回的Response转换为
Blob对象 - 通过
URL.createObjectURL(blob)生成一个临时的对象URL - 创建
a标签,设置href为对象URL,download属性为自定义文件名 - 触发点击,完成下载
- 下载后立即释放对象URL,避免内存泄漏
这里把pdf.js放进来,原因是:很多业务场景中PDF地址不是简单的前端写死URL,而是需要带上token认证、或者需要从pdf.js已加载的文档中获取元数据来决定文件名、又或者后端返回的是加密的二进制流需要先让pdf.js能解析成功才说明数据没问题。这种情况下,直接给a标签喂URL是走不通的,你得先把数据拿回来、确认能解析、再生成下载。
3.2 核心代码示例
下面这段代码是我在Vue项目里实际用过的精简版本,核心逻辑可以平移到任何框架:
async function downloadPdf(pdfUrl, fileName) { // 第一步:把PDF文件流拉回来 const response = await fetch(pdfUrl); if (!response.ok) { throw new Error('PDF文件请求失败,HTTP状态码:' + response.status); } // 第二步:转成Blob对象 const blob = await response.blob(); // 第三步:生成临时对象URL const blobUrl = URL.createObjectURL(blob); // 第四步:创建a标签并模拟点击 const link = document.createElement('a'); link.href = blobUrl; link.download = fileName || 'document.pdf'; document.body.appendChild(link); link.click(); document.body.removeChild(link); // 第五步:释放对象URL URL.revokeObjectURL(blobUrl); }这段代码看起来简单,但里面有三个细节容易被忽略。
第一个细节:link必须添加到body里再触发点击,不添加直接click()在某些浏览器版本里不生效,这是有实际教训的。
第二个细节:URL.revokeObjectURL(blobUrl)要放在click()之后,但最好不要在同步代码里立刻执行。有些浏览器在点击后还没来得及读取Blob数据就撤销了URL,会导致下载失败。稳妥的做法是加一个setTimeout延迟释放:
setTimeout(() => { URL.revokeObjectURL(blobUrl); }, 1000);第三个细节:从后端接口获取文件时,如果项目里封装了axios,注意设置responseType: 'blob'。用axios默认的JSON类型去接收二进制流,数据会被转成奇怪的字符串,下载下来的文件打不开。这是一个非常高频的翻车点。
3.3 如果一定要用pdf.js加载后的数据来下载
有些场景下文件地址不能直接fetch,比如做了权限控制、只能通过pdf.js已经建立的会话来获取数据(这种场景不多,但确实存在)。这时可以利用pdf.js的getDocument拿到文档后,通过loadingTask的原始数据属性来拿数据,不过说实话这个操作在3.x版本里比较绕,我的建议是直接从网络层解决:要么后端加一个下载接口,要么在fetch请求里带上同样的认证头。硬要从pdf.js内部取数据,代码会变得不直观,维护成本也高,没必要。
4. 踩坑实录:从"下载变预览"到"iOS Safari不下载"的完整排查链
这个章节我想用"问题现象 + 排查过程 + 根因 + 解决"的方式来写,因为下载PDF的坑,基本套路都差不多,但你得知道怎么一步步定位。
4.1 问题一:点击下载按钮,浏览器直接打开了PDF预览
现象:按钮、a标签、URL全部正常,点击后桌面Chrome直接开了一个新标签页把PDF渲染出来,下载根本没触发。
排查过程:先检查href指向的PDF地址和当前页面是不是同源。如果是同源且写法是<a href="file.pdf" download="xxx.pdf">,理论上Chrome应该直接下载。试了以后发现依然预览,于是打开控制台看网络面板,发现请求的响应头里有一个关键字段:Content-Disposition: inline; filename="file.pdf"。当后端或静态服务器返回inline时,浏览器就会认为"这个资源应该被内联展示",download属性也会被它压制。
解决:前端做不了什么来改变响应头里的Content-Disposition,所以走fetch拿Blob的时候,其实是在绕过这个响应头对浏览器行为的控制——因为fetch拿到的只是数据,展示或下载的决策权完全在前端手里。
4.2 问题二:下载文件名变成一串乱码或者直接被忽略
现象:文件成功下载了,但保存下来的文件名不是预期的中文名,而是一串URL编码或者直接被浏览器命名成了"下载"。
排查过程:用fetch拿Blob再download指定文件名时,一般不会出现这个问题,因为这个方案的download属性是标准生效的。但如果代码里漏了download属性,浏览器就会根据响应头里的Content-Disposition去取名,后端如果没设置filename*,中文名大概率乱码。另一种情况是后端虽然返回了Content-Disposition: attachment,但用的文件名编码格式不对,没处理filename*=UTF-8''这种形式。
解决:前端方案里强制给a标签设置download属性,文件名自己拼。注意文件名如果包含中文,不用手动做URL编码,直接赋值就可以。如果要做得更严谨,可以先从响应头里解析Content-Disposition,拿到后端推荐的filename*,解析不出来再用自己自定义的名字。
4.3 问题三:iPad/iPhone上的Safari打开下载按钮后还是预览
现象:同样的代码,安卓手机和桌面浏览器都正常,iPhone和iPad点下载按钮后,PDF直接在Safari里打开成预览页,顶部没有下载选项(或者说藏得很深),用户根本找不到保存入口。
排查过程:这是移动端Safari的经典行为差异。iOS Safari对a标签的download属性支持一直不完整,Blob URL的下载行为在iOS 13以前基本是废弃状态,iOS 13以后虽然部分支持,但在iPad上仍然不稳定,尤其是PDF这种Safari有能力原生预览的文件类型,系统就是优先做预览。
解决:我在实际项目中最终采用的是两步兼容方案。第一步,页面里同时放一个提示:"如无法下载请长按链接,选择'下载链接文件'"。第二步,对iOS设备做UA判断,降级使用window.open打开Blob URL,让Safari自己处理预览,用户通过系统分享按钮保存文件。虽然体验不如一键下载那么顺畅,但至少用户有路可走。这里贴一下兼容逻辑的核心片段:
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); if (isIOS) { // iOS Safari降级方案:打开预览,让用户通过系统分享保存 window.open(blobUrl, '_blank'); } else { // 正常下载逻辑 const link = document.createElement('a'); link.href = blobUrl; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); }4.4 问题四:大文件下载把浏览器内存吃爆了
现象:一个几十MB甚至上百MB的PDF,点下载后页面卡死,或者下载下来的文件损坏无法打开。
排查过程:response.blob()会把整个文件加载进内存,加上pdf.js预览时也要占用一份内存,两者叠加后大文件场景下必然吃紧。如果文件有几百MB,前端做全量下载本来就不合理。
解决:两个方向。一是大文件不建议前端走blob链路,让后端直接返回一个带Content-Disposition: attachment的文件地址,用最朴素的方式下载;二是如果一定要前端处理,至少加一个文件大小判断,超过某个阈值(比如100MB)就提示用户走后端直链下载。另外,下载完成后务必释放Blob URL,这个前面已经强调过了。
5. 从"能下载"到"下载体验好":进度提示、多文件下载和状态联动
下载功能做到能跑只是第一步,实际集成时还有不少体验层面的细节。这里整理几个我做过且值得做的增强点。
5.1 下载进度提示
fetch返回的Response对象带有一个body,它是一个ReadableStream,可以监听数据接收的进度。把Blob的组装过程改成流式读取,就能算百分比:
async function downloadPdfWithProgress(pdfUrl, fileName, onProgress) { const response = await fetch(pdfUrl); const contentLength = +response.headers.get('Content-Length') || 0; const reader = response.body.getReader(); let receivedLength = 0; const chunks = []; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); receivedLength += value.length; if (contentLength && onProgress) { onProgress(Math.round((receivedLength / contentLength) * 100)); } } const blob = new Blob(chunks); // 后续的下载逻辑同上 }需要注意:这个方案需要后端接口返回Content-Length响应头,如果后端是分块传输(chunked),拿不到总长度,进度条就只能显示"下载中"这种状态了。
5.2 多文件批量下载
如果业务是"勾选多个PDF,一键打包下载",前端方案一般是用JSZip把多个Blob打包成一个zip再下载:
import JSZip from 'jszip'; async function downloadMultiplePdfs(fileList) { const zip = new JSZip(); for (let i = 0; i < fileList.length; i++) { const response = await fetch(fileList[i].url); const blob = await response.blob(); zip.file(fileList[i].fileName, blob); } const zipBlob = await zip.generateAsync({ type: 'blob' }); // 再走a标签下载zipBlob }这个方案适合文件数量不多、单个文件不算大的场景。文件数量超过20个或者总体积特别大时,还是建议后端打包,前端直接下载zip文件,处理速度和稳定性都更好。
5.3 下载按钮与pdf.js加载状态的联动
常见的问题是:用户刚进页面就点下载,此时pdf.js还在加载文件,下载按钮也同时发起请求,造成双重请求,白白浪费带宽甚至触发后端的频率限制。
我的做法是在pdf.js的loadingTask.promise里控制下载按钮的disabled状态。只有预览文档加载完成后,下载按钮才可点击。这样既避免了重复请求,又保证了用户看到过PDF内容再决定下载,符合"先预览后下载"的产品逻辑。
5.4 下载后的资源清理
这个是最容易忽略但最重要的细节。每次用URL.createObjectURL生成的对象URL都会占用内存,如果不手动释放,不断下载多个文件后,页面内存占用会持续上涨,最终导致卡顿。
释放的时机有两个选择:一是在click()之后立刻释放(某些浏览器有概率出问题);二是把Blob URL存到一个数组中,在页面卸载或者组件销毁时统一释放。我的经验是:单文件下载用setTimeout延迟1秒释放即可,多文件连续下载则用统一释放策略更稳妥。
6. 给PDF下载方案做的最后一点总结性建议
我在实际项目中处理"pdf.js文件下载"这件事,前后迭代了三版方案,最终沉淀下来的是这套组合逻辑:pdf.js负责预览和解析,fetch负责拉数据,Blob加URL.createObjectURL负责生成可下载的临时地址,再配合a标签的download属性和iOS兼容降级。这套链路能覆盖桌面端、安卓端、iOS端的大部分场景,代码量不长,维护成本也低。
最后说一个容易被忽略的关键点:前端的下载方案无论怎么折腾,边界都很明显——它受浏览器策略、文件大小、跨域配置这几方面约束。一旦遇到"文件特别大""后端不愿意配合加下载接口""跨域又拿不到CORS头"这类情况,最省事的路径反而是让后端给一个直链,前端就负责跳转和提示。技术方案没有银弹,结合自家项目的后端能力和文件存储方式选型,比强行套一个最优方案更现实。
本文还有配套的精品资源,点击获取