资讯动态

Vue 3中使用pdfjs-dist实现PDF预览:Canvas渲染与虚拟滚动优化

发布时间:2026/9/10 3:00:31 来源:尧图企业网站定制
我最早碰到“在系统里预览PDF”这个需求是在给一个后台管理项目做“合同附件预览”的时候。当时产品提的需求很简单不要下载直接在网页里打开手机端也能用最好还能控制打印权限。第一反应是直接用浏览器原生的iframe撑开但实测下来一肚子苦水有些浏览器直接下载而不是预览iframe跨域受限移动端手势缩放体验稀碎更别提还要做“加水印”“禁下载”“记录预览次数”这些定制逻辑。后来把方案换成pdfjs-dist基于Vue 3重写了一个PDF预览组件才算真正把这套流程捋顺。这篇文章就是想把我的实现过程原样拆出来。整体会覆盖pdfjs-dist的版本选择、Worker配置、Canvas分页渲染、文本层、缩放导航、虚拟滚动性能优化还有我实际踩过的坑和排查思路。不管你是刚接触PDF预览还是已经在用但被各种玄学Bug卡住这篇应该都能给你一些参考。2. 为什么实现PDF预览我最终锁定了pdfjs-dist在动手之前我其实把市面上的方案都过了一遍。这步很关键因为选型选错了后面返工成本特别高。我当时对比了三条主流路线。第一是浏览器原生embed、iframe挂PDF文件地址。好处是零成本几行代码搞定但致命问题也很明显不同浏览器行为不统一比如旧版Edge和部分移动端浏览器看到PDF地址会直接触发下载如果PDF是从后端接口动态生成、带鉴权头的iframe没法自定义请求头经常拿到401而且预览界面用的是浏览器自带的阅读器你想在上面加公司logo、操作按钮、防下载逻辑基本没戏。如果你只是内部工具临时看个文件这条线可以做成正式功能我建议直接放弃。第二是用pdf.js官方库的完整浏览器版本。Mozilla出的pdf.js本身是个非常完整的阅读器项目官方把整个viewer打包好你把它嵌入到项目里几乎等于内置了一个浏览器。这个方案好不好好但太重了而且跟现代前端框架集成时样式冲突、路由冲突、构建配置冲突都能写一篇长文。试过之后我觉得如果要深度定制UI反而不如只引入核心库自己搭一层。第三就是本次的主角pdfjs-dist。它是pdf.js官方发布到npm的构建产物保留了核心的PDF解析和渲染引擎你可以把渲染、翻页、缩放、文本选择这些底层能力全部拿来自定义UI和交互完全由自己控制。坏处是需要自己写一些胶水代码但换来的是完整可控性。在我这个场景里公司要的是“集成到自身业务后台的定制化预览器”所以这条路是最明智的。顺便说一句从版本历史看pdfjs-dist现在跟mozilla/pdf.js仓库保持同步发布所以只要这个维护节奏不变它就不会突然过气。安全性和解析能力也是目前开源库里的第一梯队因为很多浏览器本身都在用它做PDF渲染内核。3. 动手前的版本选型和环境准备3.1 Vue 3 Vite环境怎么选版本先说环境。我的项目用的是Vue 3.4 Vite 5 Node 18一套目前比较主流的组合。pdfjs-dist目前主流版本是3.x和4.x两个版本在核心API上差别不大但有几个细节你要注意。3.x系列里我建议直接锁定3.11.174这个版本是目前社区里验证得最多的版本稳定性最好API文档也齐全。4.x开始项目内部对现代浏览器特性的依赖更强比如一些ES2022的语法如果你要兼容老旧WebView或Chrome 80以下的旧设备就得额外引入legacy构建版本。由于我这套系统还有不少用户在用安卓工控机上的老浏览器我当时直接选了3.11.174实测最省心。如果你的用户群体都是现代浏览器可以大胆用最新的4.x支持更完整解析能力也会强一些。3.2 引入方式和构建配置安装命令很简单npm install pdfjs-dist3.11.174这里有个大坑很多同学装完直接按网上的老教程引入pdfjs-dist/lib/pdf.js或pdfjs-dist/es5/build/pdf.js结果在Vite里各种报错。官方3.x的模块入口有两种风格一个是build/pdf.jsUMD风格一个是build/pdf.mjsESM风格。Vite项目里我推荐统一用build/pdf.mjs这样Tree Shaking和后续维护都比较舒服。我的Vue组件里最初是这样引入的import * as pdfjsLib from pdfjs-dist;这种方式在打包时可能存在模块解析问题特别是4.x版本里你必须明确指定pdfjs-dist/build/pdf.mjs否则Vite会解析到package.json里的exports字段指定的默认文件不同版本指向还不一样容易踩坑。建议写成这样import * as pdfjsLib from pdfjs-dist/build/pdf.mjs;为了兼容性你可以在vite.config.js里给resolve.alias配一个别名自己做一次映射。当然用默认导出写import * as pdfjsLib from pdfjs-dist在很多版本上也行但显式指定构建文件更安心。3.3 Worker的加载和跨域问题很多人在pdfjs-dist上碰到的第一个拦路虎就是Worker。pdfjs-dist解析PDF时主线程只做调度真正的高CPU解析工作放在Web Worker里执行这样页面才不会卡死。它需要你在运行前指定Worker脚本的地址。传统做法是去node_modules/pdfjs-dist/build/里把pdf.worker.min.js复制到public目录然后从外部路径引用。但这样会多一份静态资源分发的维护工作而且如果产品部署在CDN子路径下路径写错又是问题。在Vite项目里有一种更优雅的搞法直接用?url后缀把Worker文件当资源导入让构建工具帮你处理路径。代码是这样import { GlobalWorkerOptions } from pdfjs-dist/build/pdf.mjs; import PdfWorker from pdfjs-dist/build/pdf.worker.min.mjs?url; GlobalWorkerOptions.workerSrc PdfWorker;这里有一个非常关键的注意点pdf.worker.min.mjs和pdf.worker.min.js是有区别的。3.x版本的Worker文件名大概率是pdf.worker.min.js但如果ESM模式下需要?url导入Vite可能适配更好的是.mjs后缀的Worker。你要去看node_modules里实际有什么文件以实际文件名为准。我见过一堆项目死活用?url导入报404就是因为文件名写错。注意如果你用了?url它会按文件形式打包并输出一个绝对的静态资源路径一般会带上hash。本地开发时Vite会自动开启跨域代理支持基本不会触发跨域问题。如果你手动把Worker放到public目录部署到CDN时记得配置跨域头否则日志会刷“Setting up fake worker failed”。4. 核心预览组件从零到一4.1 组件props设计和文件加载流程我先定了组件的props这是整个功能正常运转的地基。const props defineProps({ pdfUrl: { type: String, required: true }, useDownload: { type: Boolean, default: true } });实际项目里后端接口往往需要鉴权直接给一个带token的URL给pdfjs-dist请求头要么带不上要么Token会暴露在日志里。所以我更推荐的做法是外面先用fetch把PDF文件流取回来转成blob再用URL.createObjectURL生成一个内部临时地址传给getDocument。async function loadPdfFromUrl(url) { const response await fetch(url, { headers: { Authorization: Bearer ${token} } }); if (!response.ok) throw new Error(PDF加载失败请确认权限); const blob await response.blob(); return URL.createObjectURL(blob); }然后调用pdfjs的加载逻辑async function initPdf(pdfUrl) { destroyPdf(); loading.value true; try { const task pdfjsLib.getDocument({ url: pdfUrl, cMapUrl: https://cdn.jsdelivr.net/npm/pdfjs-dist3.11.174/cmaps/, cMapPacked: true }); pdfDoc.value await task.promise; pageNum.value 1; totalPage.value pdfDoc.value.numPages; await renderPage(pageNum.value); } catch (err) { errorMsg.value err.message || PDF解析失败; } finally { loading.value false; } }getDocument返回的是一个PDFDocumentLoadingTask对象它身上有一个promise属性resolve之后返回PDFDocumentProxy实例。这个就是我们操作PDF的主对象后面所有页面渲染都靠它。destroyPdf方法是我自己写的一个清理函数用来取消上一个没渲染完的任务、释放canvas画布和objectURL避免切换文件时内存堆积和页面错乱。4.2 高DPI下的清晰渲染渲染页面的底层原理不复杂pdfjs会把PDF某一页解析成二进制操作指令最终输出到canvas的2D上下文。你只需要提供一个canvas元素和一份viewport配置就行。但是如果你不做任何处理直接按viewport尺寸设canvas宽高在Retina屏上会模糊得没法看。原因是canvas的“物理像素”和“CSS像素”不一致。viewport算出来的尺寸是CSS尺寸canvas内部需要乘以devicePixelRatio才能保证清晰。我的渲染函数是这样的async function renderPage(num, scale currentScale.value) { if (!pdfDoc.value) return; if (renderTask.value) { renderTask.value.cancel(); } const page await pdfDoc.value.getPage(num); const baseViewport page.getViewport({ scale: 1 }); const dpr window.devicePixelRatio || 1; const viewport page.getViewport({ scale: scale * dpr }); const canvas canvasRef.value; const ctx canvas.getContext(2d); canvas.width Math.floor(viewport.width); canvas.height Math.floor(viewport.height); canvas.style.width Math.floor(baseViewport.width * scale) px; canvas.style.height Math.floor(baseViewport.height * scale) px; const renderContext { canvasContext: ctx, viewport: viewport, transform: dpr ! 1 ? [dpr, 0, 0, dpr, 0, 0] : null }; renderTask.value page.render(renderContext); await renderTask.value.promise; codePageRendered({ pageNum: num, scale }); }我这边是把DPR乘进了viewport的scale再用CSS把画布压回逻辑尺寸同时传了一个transform矩阵。这里两种写法都能生效要么先把canvas.width设为dpr倍再用setTransform把坐标系放大要么像我这样在renderContext里传transform。如果两个都做就会放大两次画面直接糊掉加裁切这个一定要注意。4.3 分页导航与快捷键操作PDF预览不带翻页就说不过去。我的实现分两块UI按钮和键盘快捷键。UI上一左一右两个按钮中间一个可输入的页码框。function prevPage() { if (pageNum.value 1) return; pageNum.value--; } function nextPage() { if (pageNum.value totalPage.value) return; pageNum.value; } function jumpToPage(value) { const parsed parseInt(value, 10); if (isNaN(parsed)) return; const target Math.max(1, Math.min(totalPage.value, parsed)); pageNum.value target; }页码跳到指定页后组件内部会对这个变量做监听自动触发渲染。键盘快捷键这块我建议只在预览组件获得焦点时生效否则会跟页面其他快捷键冲突。用window.addEventListener监听keydown处理ArrowLeft、ArrowRight、PageUp、PageDown这些键。有个体验细节连续快速翻页时会连续触发渲染渲染任务互相打断屏幕上会闪好几个版本。解决办法是每次新渲染前先调用上一次的renderTask.cancel()这就相当于告诉旧任务“别画了我已经有新的指令了”同时避免canvas内容错乱。上面代码里的renderTask.value.cancel()干的就是这件事。4.4 缩放功能与手势冲突缩放功能这块我做了一个“适合宽度”“适合页面”“手动缩放”三档。实现不难难点在于缩放之后要重新渲染当前页而且要保证页码不变、滚动位置大致不变。function zoomIn() { currentScale.value Math.min(5, currentScale.value * 1.2); renderPage(pageNum.value, currentScale.value); } function zoomOut() { currentScale.value Math.max(0.5, currentScale.value / 1.2); renderPage(pageNum.value, currentScale.value); }要让缩放维持在页面上可以先记下当前页面在父容器里的offsetTop渲染完成后设置滚动容器scrollTop到那个位置再微调。移动端本来想把“双指捏合缩放”一起做掉但实测下来跟浏览器的原生手势冲突很严重尤其是我们的客户大多用自研的WebView行为不统一。所以我最后用了页面内缩放按钮加双击放大双指手势没启用移动端体验虽然不算惊艳但足够稳定。4.5 文本层支持划词复制和搜索高亮说到底PDF预览如果只能看不能选文字那跟图片没区别。pdfjs提供了renderTextLayer方法可以在页面画布上方覆盖一层透明的“文字层”用户划选的时候实际选的是这一层透明文字视觉效果就像在PDF上选中了文字。实现思路是给canvas容器包一层相对定位的divcanvas正常渲染文本层的div绝对定位铺在canvas上方。文本层的样式要参考pdf.js官方viewer里的那套CSS特别是--scale-factor变量必须设置否则文字坐标会偏移。async function renderTextLayer(page, viewport, container) { const textContent await page.getTextContent(); const textLayerDiv document.createElement(div); textLayerDiv.className textLayer; container.appendChild(textLayerDiv); pdfjsLib.renderTextLayer({ textContentSource: textContent, container: textLayerDiv, viewport: viewport }); }注意3.x里有的版本用textContent作为key4.x才统一改成textContentSource。如果你的页面报“textContentSource undefined”的错先看API版本。有了文本层之后想要做关键词高亮、全文搜索、PDF数据提取都是围绕这个textContent数据结构展开的后面扩展空间很大。5. 实际开发中踩过的坑与排查速查表5.1 worker加载失败控制台刷“fake worker”这是出现频率最高的问题日志一般是Setting up fake worker failed: Cannot load script at...原因几乎都是GlobalWorkerOptions.workerSrc没有正确指向Worker脚本或者CDN跨域了。检测方法很简单打开Network面板搜worker关键字看看有没有请求成功如果请求直接没有那就是路径没对上如果请求被CORS拦截F12里红线提示非常明显。我用?url导入后这个问题基本绝迹因为路径是构建工具生成的不会写死。如果是部署到子路径也没问题构建时会按相对路径处理。5.2 PDF文件包含中文路径或者中文文件名本地开发没问题一放到测试环境URL带中文或空格PDF解析直接挂掉。这个问题本质上是前端在拼接文件地址时没做URL编码。解决方式很简单外部传入的URL统一用encodeURI做一次编码。同时服务端返回的Content-Disposition里的文件名最好是RFC 5987标准编码不然导出下载时有乱码风险。5.3 canvas画布过大页面直接白屏有客户上传了几张“超宽幅工程图纸”PDF页面崩了。原因是canvas尺寸超过浏览器单canvas的最大尺寸限制比如Chrome一般是32767像素或面积上限16兆像素。pdfjs解析时如果页面的宽非常高、scale又拉得大canvas就会爆。处理思路很简单在渲染函数里做一次画布宽高上限的校验超过上限就降低scale或者提示用户“此页面超过浏览器渲染上限建议缩放后再看”。对于工程图纸类场景更好的做法是提供“单页截图导出”而不是整页一键预览。5.4 渲染出来的PDF缺字体中文变方框pdfjs解析PDF依赖CMap目录来映射一些字体编码尤其是简体中文和日韩字体。如果你没配cMapUrl部分PDF的中文会显示成一堆方框或者乱码。解决方式加载时传入cmaps地址。我是直接指向版本对应的CDN你要做内网部署也可以把node_modules/pdfjs-dist/cmaps整个目录拷贝到静态资源目录然后配置过去。const task pdfjsLib.getDocument({ url: pdfUrl, cMapUrl: ${import.meta.env.BASE_URL}cmaps/, cMapPacked: true });5.5 大文件加载卡顿或内存暴涨两个因素PDF文件本身的体积以及渲染后canvas对象占用的内存。Canvas是一个很吃内存的东西一个宽高2000的canvas在内存里就要占2000x2000x4字节大约16MB。如果做100页预览全量把canvas画出来页面必崩。这就要用到下面说的虚拟滚动和页缓存淘汰机制把同时存在的canvas数量控制住。6. 长文档场景下的性能优化方案6.1 只渲染可视区附近的页面我的预览组件容器是一个固定高度的滚动区域PDF文件里的每一页并不是直接全部渲染出来而是先用一个占位div撑起高度等滚动区暴露出某一页的占位元素时才触发那一页的画布渲染。实现上我用了一个visiblePdfPages数组配合IntersectionObserver监听页面容器。这个API在主流浏览器里兼容性不错也容易在Vue里封装。const observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { const pageNum Number(entry.target.dataset.pageNum); renderPageIfNeeded(pageNum); } }); }, { root: scrollContainer, rootMargin: 0px 0px 200px 0px });这里加rootMargin的目的就是预加载往下滚的时候多提前200像素渲染让用户感知不到翻页白屏。核心逻辑是把渲染动作放在一个队列里避免同一个页面被重复触发渲染多次。6.2 页对象缓存与LRU淘汰对于一个数百页的PDF如果把每个page的PDFPageProxy对象都缓存住内存会很大。我的做法是缓存最近访问的页面对象最多保留10页超过之后就用类似LRU的策略清理掉前面的对象。因为从pdfDoc.value.getPage(num)拿到的是同一个对象引用清理时只需要把它从一个Map里移除就行。Canvas对象我也复用一个Map换页时把不用的画布内容用null填充并且主动调用一次canvas.width 0释放GPU/内存资源。不要小看这个操作不主动回收Tab页一多就会把用户电脑卡到不能呼吸。6.3 首屏提速延迟加载侧边栏和缩略图如果你做的是那种带左侧缩略图目录的预览器缩略图渲染的瓶颈很大。我这边首版把缩略图做成了“点击侧边栏时才加载对应缩略图”并且缩略图用的是低分辨率渲染scale固定为0.2渲染代价很小。等用户闲下来再用空闲时间requestIdleCallback去预渲染后续页的缩略图。省下来的这些计算量对老旧办公电脑上的客户体验至关重要。6.4 大数据量PDF的流式加载pdfjs的getDocument支持disableAutoFetch参数设为true可以关闭自动拉取后续部分的数据改成需要哪一页去拿哪一页。对于超大PDF首屏会快很多。代价是翻页时如果网络比较慢会有一次网络等待。我一般这样配pdfjsLib.getDocument({ url: pdfUrl, disableAutoFetch: true, disableStream: false });从实测来看如果一个PDF有30MB、500页用自动拉取时首屏要等整个文件下载完才能显示改成按需加载之后首屏基本几百毫秒就能出第一页。这个优化收益极大几乎没有副作用。7. 我还扩展的实用能力打印、下载与移动端适配7.1 打印当前预览的PDF打印需求几乎绕不开。我一开始用window.print()直接打印整个页面结果打印出来的是页面框架PDF内容缩在角落。后来去翻了pdf.js官方源码发现常规做法是引入一个隐藏的iframe指向PDF文件本身再调用iframe里的contentWindow.print()这样就会调起浏览器的PDF阅读器打印插件。但如果你不想让用户看到浏览器原生的PDF阅读器工具栏也可以走Canvas渲染后打印。方案就是新建一个隐藏窗口把当前可见页面的canvas绘制到新窗口再调用打印。缺点是每页都要单独处理适合只打印单页的场景。我这里最终保留了iframe方式并且通过useDownload这个prop控制是否允许用户操作下载按钮。7.2 图片导出和部分下载有个客户需求是“把PDF某一页导出成PNG图片发给别人看”。这个用Canvas天然支持实现成本极低function downloadCurrentPageAsImage() { const canvas canvasRef.value; const link document.createElement(a); link.download 导出图片-第${pageNum.value}页.png; link.href canvas.toDataURL(image/png); link.click(); }但也有一点要注意canvas跨域污染问题。如果PDF是通过objectURL加载的那么我们渲染出来的canvas本身没有跨域污染toDataURL可以正常输出如果你直接用远程URL加载且没开启跨域可能会出现SecurityError。所以还是建议先转blob再操作。7.3 移动端适配的几个细节移动端主要是两个问题布局和触控。布局上我让预览区自动占满视口高度工具栏用fixed悬浮在底部避免浏览器地址栏自动收缩导致布局跳动。触控上单指滑动时页面正常滚动双指缩放暂时不做自定义让浏览器原生缩放生效。具体到交互的细节页码跳转的输入框在移动端要弹数字键盘我加了inputmodenumeric点击按钮要有触摸反馈不然客户会觉得按钮没反应。另外移动端滚动容器要设置-webkit-overflow-scrolling: touch否则滚动会发飘。8. 对几次现场问题的排查实录这里挑两个比较有代表性的真实问题说一下排查过程。第一个是客户反馈“我们上传的PDF在你们系统里显示空白但下载下来是好的”。我远程看了控制台有一个InvalidPDFException的错误。当时第一猜是文件本身有问题后来让客户把文件发过来用本地解析发现没问题但传到测试环境就失败。最后发现是文件流经过中间层网关时响应头被改成了application/octet-stream而pdfjs对MIME类型不敏感其实不影响真正的原因是网关对超大文件做了gzip压缩PDF本身已经是压缩格式二次压缩导致文件流被截断。排查思路就是先确认后端返回的文件字节完整再怀疑解析器。第二个是“每次打开PDF预览都有跨域错误”。这是因为pdfjs内部默认去CDN加载一些资源比如CMap目录和外层Worker。公司内网环境访问外网CDN是受限的所以我把CMap也一起打包到了本地静态资源。凡是在网络受限环境部署建议所有资源全量本地化别依赖第三方CDN。从这些排查看pdfjs-dist本身不是问题反而是集成环境里的网络、文件流、构建配置更容易出问题。遇到问题先从前置请求看起再考虑是不是解析库的问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价