PDF预览这件事,说简单也简单,说麻烦也麻烦。尤其当你接到一个“把PDF传到网页上展示”的需求,第一反应可能是<iframe>一塞完事,可一旦遇到需要翻页、缩放、记住阅读进度、甚至提取文字的场景,原生能力就捉襟见肘了。
pdf.js使用的场景几乎覆盖了所有前端PDF渲染需求:PC端的合同预览、移动端的电子书阅读、后台管理系统的附件预览、甚至在线教育里的课件展示。它是Mozilla出品的一个纯前端PDF渲染引擎,核心思路是把PDF解析成 Canvas,再一页一页画到页面上。这篇内容适合刚接触pdf.js、准备接入项目但不知道从哪下手的前端开发,也适合已经能用、但想搞明白常见报错和优化细节的同行。
先说结论:只要你的项目本质上是“在网页里看PDF”,pdf.js基本就是最优解,没有太多需要纠结的备选方案。
1. 从零开始:pdf.js的引入方式与选型思路
我见过不少项目刚开始用pdf.js时,直接在官网下载了一个pdf.js文件丢进static目录,结果页面白屏,报错一个接一个。归根结底是没搞清楚pdf.js的构建产物结构。它不像jquery那样一个文件就完事,常规构建出来的包里有pdf.mjs、pdf.worker.mjs这俩核心文件,如果你用的是旧版本,还会看到pdf.js和pdf.worker.js这种传统模块文件。
1.1 引入方式的横向对比
| 引入方式 | 适用项目类型 | 优点 | 缺点 |
|---|---|---|---|
| CDN + script标签 | 传统页面、快速Demo、低版本浏览器兼容 | 打开即用、不参与打包 | 依赖外网、版本锁定麻烦、不利于离线 |
| npm包引入 | Vite/Webpack工程化项目 | 版本可控、懒加载方便、配合框架顺手 | 需要正确处理Worker路径、打包体积略大 |
| 本地静态文件 | 内网部署、离线环境 | 可控性最强、不依赖外部资源 | 需要手动维护文件版本、找不到合适版本时容易踩坑 |
从实际维护角度讲,我强烈建议工程化项目直接走npm,把pdfjs当成一个正常依赖来管理。后面所有的示例代码我都按npm方式来写。
1.2 工程化项目里Worker路径的错误处理思路
很多人在接入时遇到的第一道坎就是Worker的引入方式。pdf.js的运行机制是:主线程负责解析配置、调度任务,真正的PDF解析和渲染计算在Worker线程里完成。如果你没设置Worker,它会尝试用自身的默认路径去加载pdf.worker.mjs,这个路径在Vite或Webpack打包时十有八九对不上,于是控制台出现:
Failed to fetch dynamically imported module或者直接报找不到pdf.worker。
正确做法是显式指定workerSrc:
import * as pdfjsLib from 'pdfjs-dist'; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.mjs', import.meta.url ).toString();在Vite里这段代码可以正常工作。如果你用Webpack,也可以这样:
import workerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url'; pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl;注意:老版本是
.worker.js,新版本是.worker.mjs,版本不同文件名不同,写错就直接白屏。建议先在node_modules/pdfjs-dist/build/目录里确认一下实际文件名。
1.3 模块化加载的工程化实践
除了主库和Worker,pdf.js新版本还支持按需加载特定的能力模块。比如你只需要基础渲染,不需要某些高级OCR或者加密相关的功能,可以通过动态import来减小初始加载体积。不过这属于进阶优化,日常项目中保证主库正确加载、Worker路径不写错,就已经解决掉80%的接入问题。
一个比较稳妥的懒加载写法是这样的:
async function createPdfInstance(url) { const pdfjsLib = await import('pdfjs-dist'); const worker = await import('pdfjs-dist/build/pdf.worker.mjs?url'); pdfjsLib.GlobalWorkerOptions.workerSrc = worker.default || worker; return pdfjsLib.getDocument(url).promise; }这样pdf.js相关代码不会进入首包,而是在用户点击预览按钮时才真正拉取,首屏体验会好不少。
2. 核心API拆解:从文档加载到页面渲染
搞清楚引入方式之后,真正写代码之前,必须把pdf.js的几个核心对象和它们的关系理清楚,否则看文档会一头雾水。整个流程大致是:getDocument返回PDFDocumentProxy,通过它拿到PDFPageProxy,再通过PDFPageProxy.render把页面画到 Canvas上。
2.1 第一步:加载PDF文档
加载文档是最简单的一步,也是唯一一个入口。它接收的参数可以是URL字符串、Uint8Array、ArrayBuffer,也可以是File对象。最常用的是URL和本地File。
# 安装依赖(写博客时用的版本,具体以你安装到的为准) npm install pdfjs-distimport * as pdfjsLib from 'pdfjs-dist'; import workerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url'; pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl; const loadingTask = pdfjsLib.getDocument({ url: '/files/sample.pdf', cMapUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.x/cmaps/', cMapPacked: true, }); const pdf = await loadingTask.promise; console.log('总页数:', pdf.numPages);这里有个经常被忽略的参数cMapUrl。如果你的PDF文档里包含生僻字映射、部分CID字体,比如从某些专业排版软件导出的小语种PDF,不配置cMapUrl会出现字体错乱,甚至文字完全空白。日常简单文档可能感觉不到,但一旦遇到就会耗费不少时间排查。
另外,getDocument返回的是一个PDFDocumentLoadingTask,它本身也有onProgress回调,可以用来做加载进度条:
loadingTask.onProgress = (progressData) => { const percent = (progressData.loaded / progressData.total) * 100; console.log('加载进度:', percent.toFixed(2)); };这个回调在加载大体积PDF时非常有用,我做过一个几百MB的工程图纸预览,没有进度条时用户总以为白屏卡死了。
2.2 第二步:拿到PDFPageProxy实例
拿到pdf对象后,通过getPage来获取指定页面的代理对象:
const page = await pdf.getPage(1); // 页码从1开始 console.log('页面尺寸:', page.viewBox);很多新手在这里会犯一个方向性错误:认为page.viewBox就是最终渲染宽度。其实getPage拿到的页面对象包含的是PDF内部的尺寸信息,单位是PDF Point,而渲染到屏幕时还需要结合scale参数进行换算。这个地方如果不理解,后面做缩放功能时一定会混乱。
2.3 渲染到Canvas,这一步的关键在DPI
页面渲染是核心操作。基本代码框架如下:
async function renderPage(pdf, pageNumber, canvas) { const page = await pdf.getPage(pageNumber); // 计算合适的scale:让PDF宽度铺满容器 const containerWidth = canvas.parentElement.clientWidth; const baseViewport = page.getViewport({ scale: 1 }); const scale = containerWidth / baseViewport.width; const viewport = page.getViewport({ scale }); // 设置canvas尺寸 const context = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.width = `${viewport.width}px`; canvas.style.height = `${viewport.height}px`; const renderContext = { canvasContext: context, viewport, }; await page.render(renderContext).promise; }这段代码跑起来能看到PDF,但如果你用手机看,会很模糊。原因在于没有处理设备像素比(devicePixelRatio, 即DPR)。
现在的屏幕普遍是2倍、3倍物理分辨率,Canvas的CSS宽度是viewport.width像素,但物理像素是它的2倍。想清晰,就得让Canvas内部实际像素等于CSS尺寸乘以DPR,再通过CSS把显示尺寸压回去。
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`; context.scale(dpr, dpr);2.4 渲染参数与错误处理
render方法接收的renderContext里,除了必填的canvasContext和viewport,还有几个参数值得关注:
intent:可以传'print',渲染出更高精度的内容供打印;background:设置渲染背景色,默认是白色,如果PDF本身没背景色、你是暗色主题,可以改成transparent配合页面主题;annotationMode:控制是否渲染注释,配合表单场景用。
render返回一个RenderTask对象,它有一个promise属性和一个cancel()方法。需要注意,渲染失败时 promise 会 reject,而且这个错误往往不是常规的业务错误,而是带编号的异常,比如RenderingCancelledException。如果你不做UI反馈,用户看到的就是突然白屏且毫无提示。
保险的做法是:
const renderTask = page.render(renderContext); try { await renderTask.promise; } catch (err) { if (err.name === 'RenderingCancelledException') { // 用户快速翻页导致上一个渲染被主动取消,不用报错 } else { console.error('渲染异常', err); } }3. 直接可用的完整页面:预览、翻页、缩放、下载
API理清楚后,最直接的落地方式就是拼一个完整的最小功能页面:左侧显示PDF,底部翻页,右侧是页码,支持放大缩小和下载原文件。这套组合基本能覆盖80%的常见业务场景。
3.1 渲染第一页
先初始化一个canvas容器,加载PDF后直接渲染第一页:
const containerRef = document.getElementById('pdfContainer'); const canvasRef = document.getElementById('pdfCanvas'); let pdfDoc = null; let currentPage = 1; let currentScale = 1; async function initPdf(url) { const pdfjsLib = await import('pdfjs-dist'); const workerUrl = (await import('pdfjs-dist/build/pdf.worker.mjs?url')).default; pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl; const task = pdfjsLib.getDocument(url); task.onProgress = (p) => { const percent = Math.round((p.loaded / p.total) * 100); updateProgressBar(percent); }; pdfDoc = await task.promise; document.getElementById('totalPages').textContent = pdfDoc.numPages; await renderPage(currentPage); }页面的HTML结构大致是:
<div id="pdfContainer"> <canvas id="pdfCanvas"></canvas> </div> <div class="toolbar"> <button id="prevBtn">上一页</button> <span id="pageNum">1</span>/<span id="totalPages">0</span> <button id="nextBtn">下一页</button> <button id="zoomIn">放大</button> <button id="zoomOut">缩小</button> <a id="downloadBtn" download>下载PDF</a> </div>3.2 基础翻页与页码同步
翻页本质上就是重复执行getPage+renderPage,但要注意几点:
async function renderPage(pageNumber) { const page = await pdfDoc.getPage(pageNumber); const viewport = page.getViewport({ scale: currentScale }); const dpr = window.devicePixelRatio || 1; const canvas = canvasRef; const ctx = canvas.getContext('2d'); canvas.width = viewport.width * dpr; canvas.height = viewport.height * dpr; canvas.style.width = `${viewport.width}px`; canvas.style.height = `${viewport.height}px`; if (renderTask) { renderTask.cancel(); } renderTask = page.render({ canvasContext: ctx, viewport, transform: [dpr, 0, 0, dpr, 0, 0], }); await renderTask.promise; currentPage = pageNumber; document.getElementById('pageNum').textContent = currentPage; }翻页事件绑定:
document.getElementById('prevBtn').addEventListener('click', () => { if (currentPage > 1) { renderPage(currentPage - 1); } }); document.getElementById('nextBtn').addEventListener('click', () => { if (currentPage < pdfDoc.numPages) { renderPage(currentPage + 1); } });有一个细节要注意:每次渲染新页面之前,要把上一次的renderTask取消掉。不然用户快速点击翻页,上一个渲染任务还没结束,新的渲染任务又启动了,二者抢同一个Canvas,会导致画面跳变甚至短暂黑屏。
3.3 缩放功能的正确实现方式
缩放不应通过修改CSS宽度来实现,而是用scale重新计算viewport,再重新渲染。这里可以缓存一个基础缩放比例,然后在它的基础上增减0.1或0.25:
const zoomStep = 0.25; document.getElementById('zoomIn').addEventListener('click', () => { currentScale = Math.min(currentScale + zoomStep, 5); renderPage(currentPage); }); document.getElementById('zoomOut').addEventListener('click', () => { currentScale = Math.max(currentScale - zoomStep, 0.5); renderPage(currentPage); });如果你希望缩放时以鼠标位置为中心而不是以页面左上角为中心,那必须引入平移和中心的换算,这个复杂度会明显上升。一个折中方案是把缩放后的页面放入一个可滚动的容器,配合overflow: auto,让浏览器来处理平移体验,用户用起来也不别扭。
3.4 下载原PDF
下载功能不归pdf.js管,但要记得:如果PDF是通过getDocument({ url })加载的,可以直接触发浏览器下载;如果传的是ArrayBuffer,就先转成Blob再下载。
document.getElementById('downloadBtn').addEventListener('click', () => { const a = document.createElement('a'); a.href = '/files/sample.pdf'; a.download = 'sample.pdf'; a.click(); });这块比较无脑,但很容易被忽略的一点是:如果不加download属性,浏览器可能直接在标签页打开PDF,而不是下载。
4. 进阶能力:文本提取、元数据与PDF信息读取
除了渲染页面,pdf.js还提供了很多解析能力。我在实际项目里最常用的两个是文本提取和元数据读取,前者用于做搜索高亮,后者用于展示文档标题、作者、创建时间。
4.1 文本提取的实现与坑点
PDFPageProxy上有一个getTextContent()方法,它会返回页面上所有文本节点和对应坐标:
const textContent = await page.getTextContent(); const text = textContent.items.map((item) => item.str).join(' ');这个items数组里每个元素大致长这样:
{ str: '你好PDF', transform: [1, 0, 0, 1, 100, 200], width: 50, height: 11, hasEOL: false }transform里放着文本的变换矩阵,前两个是缩放,后两个是平移,即文本的x、y坐标。想做“点击页面里的某段文字就定位到对应位置”这类交互,就需要靠这些坐标信息。
主要坑点有三个:
- 文字从右往左的PDF(阿拉伯语、希伯来语),
getTextContent返回的顺序可能不是视觉顺序,做多段拼接时需要额外处理。 - 带勾选框、签名的扫描PDF,可能没有文本层,
getTextContent返回的是空数组。 - 字体子集化后,某些字符提取出来是乱码,这是PDF本身编码导致的,pdf.js没有太完美的兜底方案。
4.2 读取文档元数据
通过pdf.getMetadata()可以拿到PDF的标题、作者、创建时间等:
const meta = await pdf.getMetadata(); console.log(meta.info.Title); console.log(meta.info.Author);这个能力用来做什么呢?比如后台系统里上传合同,你可以在预览页顺带展示合同创建时间、版权信息。如果是动态生成的PDF文件,元数据还可以当成版本信息来用,省去了额外的接口请求。
4.3 页面尺寸与方向信息
有些PDF页面是横版或竖版混排的,page.getViewport({ scale: 1 })能返回一个包含width、height、rotation的对象。在做缩略图(侧边栏页码预览)时,你需要按这个信息计算缩略图的宽高比,否则横版页面会被压得很难看。
5. 性能与体验优化:让预览经得起大数据量考验
pdf.js在处理普通文档时性能足够,但如果你面对的是几十页上百页的大文件,或者用户在低端手机上浏览,不做优化就会出现明显的卡顿和掉帧。
5.1 Canvas复用与离屏渲染
页面结构里有多个Canvas(比如当前页和下一页预渲染),不要反复创建销毁,创建上下文本身是有开销的。常用的做法是维护一个Canvas池:
- 2-3个Canvas轮换使用;
- 当前页需要渲染时,从池里取空闲Canvas;
- 渲染结束移入忙碌列表;
- 页面跳走后把Canvas重置并归还。
这个方案对RM端快速上下滑动的场景很有效。
5.2 内存清理不要忘记
你用完一个PDFDocumentProxy后,要调用destroy()来释放内存:
async function closePdf() { if (pdfDoc) { await pdfDoc.destroy(); pdfDoc = null; } }这里尤其注意,在Vue或React组件卸载时需要调用,否则SPA页面前后切换几次,内存占用会明显上涨。移动端尤其明显,有次我测试一套单页应用连续打开了十几份PDF,最后浏览器内存占用直接到2GB,卡到没法操作。
5.3 渲染任务取消与竞态控制
前面提到renderTask.cancel(),这是控制竞态的关键,但cancel之后如果马上又执行新任务,可能会有短暂的黑屏。优化方案是:先让旧的RenderTask取消,等待它的promise settle(catch掉错误),再启动新的渲染。
async function safeRender(page, canvas, ctx, viewport) { if (currentRenderTask) { currentRenderTask.cancel(); try { await currentRenderTask.promise; } catch (e) { /* 忽略取消异常 */ } } currentRenderTask = page.render({ canvasContext: ctx, viewport }); await currentRenderTask.promise; }5.4 移动端适配的几个隐藏细节
移动端Canvas渲染有两个隐形杀手:一是内存,二是Canvas尺寸上限。
部分老设备的Canvas最大宽度或高度只有4096甚至2048像素,如果你的PDF是一张超长图纸,宽高乘以DPR后超过限制,Canvas直接不绘制。处理方法是检测canvas.width是否超过上限,超过则缩小DPR,或者强制把scale保持在一个范围内。
const MAX_CANVAS_SIZE = 4096; const scale = Math.min( containerWidth / baseViewport.width, MAX_CANVAS_SIZE / (baseViewport.height * dpr) );6. 常见问题与排查实录:这些报错我基本都遇到过
pdf.js在使用中遇到的报错种类并不算多,但每种都很有代表性。把最常见的几种整理成速查表,方便你做对照排查。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 页面白屏,控制台报Failed to fetch module | Worker路径不对,或CDN资源跨域不可访问 | 检查workerSrc路径,确认文件真实存在 |
| 文档能加载但页面始终不渲染 | Canvas尺寸设置不对,或者render没有调用 | 确保设置了canvas.width/height再调用render |
| 渲染文字全部变成方框或点号 | 缺少cMapUrl配置 | 配置cMapUrl和cMapPacked,或下载字体包到本地 |
| 输出模糊,尤其是移动端 | 未处理devicePixelRatio | Canvas真实像素乘以DPR,CSS显示尺寸不变 |
| 加载大文件时页面卡住 | 缺少进度反馈、无Worker调度优化 | 加onProgress回调,显示加载进度条 |
| 切换页面时闪烁 | 多个渲染任务并发抢用Canvas | 渲染前取消renderTask,或做Canvas池管理 |
| 在HTTPS页面里加载HTTP的PDF失败 | 安全策略禁止混合内容 | 将所有资源统一为HTTPS,或走后端代理 |
渲染报错包含Invalid PDF structure | PDF本身损坏或特殊加密 | 先用其他阅读器确认PDF能开,再排查解析参数 |
6.1 Worker加载失败这个老大难
Worker加载失败是最常见也最容易让人头疼的。除了路径写错外,还有一个隐蔽问题:你部署的静态服务器如果没有正确返回.mjs的MIME类型(application/javascript),浏览器也会拒绝加载模块脚本,表现也是白屏或报Failed to load module script。
这种问题在部分旧式服务器配置里很容易出现,尤其是内网部署环境用了老旧的Nginx配置。排查思路很简单,打开Network面板看pdf.worker.mjs的请求,如果状态码是200但Content-Type是application/octet-stream,就是MIME类型不对。处理方法是在Nginx配置里加一行:
location ~* \.mjs$ { types { application/javascript mjs; } }6.2 字体相关的问题定位思路
字体问题大多数是cMap没配好,但也可能是字体文件本身跨域导致无法加载。如果你发现PDF中的中文正常、部分生僻字或嵌入字体异常,优先查看Network里是否有字体文件加载失败记录。pdf.js的cMapPacked建议开启,压缩的bcmap体积小很多,加载更快。
6.3 拿到的文本是乱码或顺序不对
这个问题通常由PDF本身决定,不是pdf.js的bug。扫描版PDF提取出来是空文本是正常的,因为那根本不是文字而是图片。有效应对只有一个:在文本提取失败时,给用户展示“该PDF不支持文字选择”的提示,不要尝试用OCR兜底,成本和准确率都划不来。
6.4 渲染出来颜色不对
PDF里的颜色空间比较复杂,CMYK、DeviceN、CalRGB等在部分情况下需要pdf.js启用额外的颜色转换模块。默认情况下pdf.js能处理绝大多数文档,但如果遇到某些专业印刷文件颜色明显不对,可以在渲染上下文加enableXfa: true试试,确实有极小概率是跟XFA表单渲染逻辑有关。
结尾:几个我从踩坑里换来的经验
我在实际写PDF预览功能时,最花时间的不是渲染本身,而是把“渲染前取消上一次任务”“DPI换算正确”“Worker路径在不同构建环境下不失效”这几个细节一次做对。如果你能在项目第一天就把这些基础处理好,后面基本不会因为PDF预览的事再熬夜加班。
另外一个小建议:如果你的项目使用频率很高,可以考虑在懒加载pdf.js库的同时,把PDF的首个Canvas渲染结果用toDataURL保存成图片缓存,下一次打开只要文档没有更新,直接显示缓存图。这种方式在低端设备上体验提升非常明显,代价只是多占用一点存储空间。
PDF渲染这块技术方向比较独立,学会了之后基本是纯收益——不管做办公系统、学习平台还是个人工具,随时都能派上用场。