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。正确做法是显式指定workerSrcimport * 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 模块化加载的工程化实践除了主库和Workerpdf.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-dist3.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 idpdfContainer canvas idpdfCanvas/canvas /div div classtoolbar button idprevBtn上一页/button span idpageNum1/span/span idtotalPages0/span button idnextBtn下一页/button button idzoomIn放大/button button idzoomOut缩小/button a iddownloadBtn download下载PDF/a /div3.2 基础翻页与页码同步翻页本质上就是重复执行getPagerenderPage但要注意几点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.25const 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 settlecatch掉错误再启动新的渲染。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 moduleWorker路径不对或CDN资源跨域不可访问检查workerSrc路径确认文件真实存在文档能加载但页面始终不渲染Canvas尺寸设置不对或者render没有调用确保设置了canvas.width/height再调用render渲染文字全部变成方框或点号缺少cMapUrl配置配置cMapUrl和cMapPacked或下载字体包到本地输出模糊尤其是移动端未处理devicePixelRatioCanvas真实像素乘以DPRCSS显示尺寸不变加载大文件时页面卡住缺少进度反馈、无Worker调度优化加onProgress回调显示加载进度条切换页面时闪烁多个渲染任务并发抢用Canvas渲染前取消renderTask或做Canvas池管理在HTTPS页面里加载HTTP的PDF失败安全策略禁止混合内容将所有资源统一为HTTPS或走后端代理渲染报错包含Invalid PDF structurePDF本身损坏或特殊加密先用其他阅读器确认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渲染这块技术方向比较独立学会了之后基本是纯收益——不管做办公系统、学习平台还是个人工具随时都能派上用场。
阅读完成 · 觉得有帮助?