JS在线查看PDF:基于pdf.js与canvas实现页面内预览与交互
发布时间:2026/10/10 6:43:27 锦皓数字建站

简介面向Web前端开发者的PDF.js在线预览PDF完整示例包解决在浏览器中无需下载即可查看PDF文档的常见需求。包内包含PDF.js运行所需的全部静态文件共402个文件主要涵盖bcmap编码映射、properties字体属性、png/svg图标资源、js核心库与map源码映射以及可直接运行的html/css示例页面压缩包整体约3.06MB结构紧凑便于直接部署或二次开发。已有3140人学习下载适合初、中级前端开发者学习实践。通过该示例可快速掌握PDF文档加载、Canvas渲染、多页预览、缩放控制及进度提示等关键实现并了解Web Worker与流式加载等性能优化思路为构建完整的在线PDF预览系统打下基础。同时附带的测试用PDF文件也方便本地验证渲染效果。1. 从“发个PDF链接”到“页面里直接看”这个需求为什么绕不开JS在后台系统里点开一张电子发票、一份采购合同或一本设备手册如果浏览器直接弹出一个下载框用户体验会瞬间跳水。js在线查看pdf文件正是管理系统和SaaS产品里绕不开的常见需求不下载、不跳转页面内直接预览还能翻页、缩放、搜索文字。这个需求落到工程上先要解决的是技术选型。早些年大家习惯用iframe嵌入或服务端转图现在更主流的做法是用pdf.js这类浏览器端渲染库把PDF解析放进Worker线程再用canvas画出来。接下来会从方案对比讲起给出一份可以直接照抄的最小组件代码再拆翻页、缩放、文本层的实现细节最后把跨域、Worker加载、内存管理这些坑逐个拆开。想给自己的系统加PDF预览按这条路径走一遍基本能落地。2. 先把技术选型想明白四类预览方案对比与pdf.js的定位接到一个“在线查看PDF文件”的需求时我的第一反应不是打开编辑器写代码而是先问三个问题PDF文件放在哪个域名下、需要哪些交互能力、用户主要在什么设备上打开。这三个问题的答案基本能决定技术路线。难点在于表面上这几条路都能让PDF“显示出来”但上线后的体验差异非常大返工代价也完全不同。2.1 浏览器原生预览看似零成本实际把控制权交给了浏览器内核主流浏览器都内置了PDF解析器地址栏里直接打开一个.pdf链接就能在标签页里预览。如果只是内部系统传个文件给同事应急看一下原生预览是零成本方案——一个超链接就完事一个字符的代码都不用写。但这种方案的边界很快会浮出来。当PDF文件放在对象存储或独立文件服务的域名下浏览器会因为跨域策略或MIME类型判断不一致直接把预览变成下载行为。就算成功预览你也拿不到任何交互数据没法定制工具栏、没法统计用户看了多久、更没法限制缩放倍数。用户一旦进入浏览器全屏预览就和你的页面彻底脱节了。所以我一般把原生预览放在“降级兜底”位置而不是正式功能。比如在业务渲染流程意外失败时用window.open打开PDF保证“至少能看”但不把它作为交付能力。另外遇到带表单交互的PDF原生预览的渲染效果和桌面阅读器经常不一致这类文件不如直接交给有独立渲染器的库。2.2 iframe与embed标签把门槛压到最低但换不来交互在不了解pdf.js之前很多初版方案会选iframe嵌入代码只有一行iframe src/files/contract.pdf stylewidth:100%;height:600px;/iframe从“能看”的角度这确实成立了。但它的代价是把渲染行为完全交给浏览器内核对子框架的调度。我踩过的坑主要有三个方向一是部分浏览器内嵌PDF时会忽略iframe的尺寸约束内容溢出外层布局被顶乱二是文件地址一旦跨域iframe内部既无法正确显示也拿不到任何状态三是用户拖拽缩放时iframe内部滚动事件会和外层页面滚动互相干扰体验很不稳定。embed标签也是同理只是HTML元素换了个名字不少代码规范还会拦下embed。我的结论是iframe/embed适合做应急方案或内网试用不适合当正式产品功能交付。真正的可定制路径要从下面两个方案里挑。2.3 服务端转图片流效果稳定但每页一次请求另一种常见做法是服务端解析PDF把每一页渲染成PNG或JPEG前端按页加载图片。这样前端代码非常轻移动端也不会遇到字体缺失或字体模糊的问题因为最终呈现的就是一张渲染好的位图。代价集中在后半段。转码是CPU密集操作一个几十MB的PDF首次实时转换可能要花数秒到数十秒。如果选择离线预转换PDF更新后会出现新旧数据不一致的问题如果实时转换接口超时率很难压下去。前端翻页本质是请求下一张图片不做预加载就一直有网络等待。用户还不能选中文字、不能搜索、不能复制整个交互被砍掉一多半。我一般在PDF来源固定、且内容不允许被搜索的特殊场景里才选这条路比如某公司的发票查验功能版式是固定的几种服务端转图能省掉前端解析的不确定性可靠性更高。对于随时可能有新PDF上传的通用业务这条路不划算。两条路径都存在明显的取舍真正能担起“js在线查看pdf文件”这个主需求的是下一节这个。2.4 pdf.js的渲染管线解析在Worker绘制在Canvaspdf.js是浏览器端渲染PDF的主流方案核心思路是把二进制解析放到Worker线程主线程只接收解析好的页数据再用canvas绘制。你在页面上看到的每一个翻页、缩放、文字选择行为都是前端代码自己控制的不受浏览器内核默认行为的约束。它的调用链路是固定的三段。第一步getDocument()接收PDF的文件源可以是URL、ArrayBuffer或TypedArray文件校验、解压、字体加载都在Worker线程里完成。第二步解析成功后调用getPage(n)拿到某一页页码从1开始。第三步用getViewport({scale})计算页面输出尺寸再调render()把页绘制到指定canvas。pdf.js还有一层“文本层”设计把PDF内部的文字用透明的span叠在canvas上方坐标完全对齐从而支持选中、复制、搜索。这套能力是原生预览和转图方案都不具备的。四个方案横向对比大概是这样方案交互能力实现成本跨域适配典型场景浏览器原生预览只读、不可定制极低受限内网临时查看iframe/embed弱低受限快速兜底服务端转图片流弱无法搜索复制高中固定版式文件pdf.js完整可扩展中可控正式功能开发选择哪条路最终是在交互成本和服务端复杂度之间做权衡。pdf.js的优点是交互完整缺点是前端要管的细节变多渲染时机、内存、DPR补偿、失败清理全部要自己负责。下一章就直接把这套最小路径写出来目标是让你今天就能在本机跑通。3. 最小可运行方案一个组件把PDF画到canvas上方案理清了接下来进入落地。本章目标不依赖框架用一个普通函数把PDF第一页画到页面上的canvas里。整章代码都能直接复制到工程里试跑。3.1 环境准备模块化引入比CDN一把梭更适合业务如果你只是做一次性DemoCDN引入确实最快。但进了正式业务后我强烈推荐模块化引入三个原因其一CDN脚本不容易做版本锁定哪个节点缓存过期了预览功能就跟着失效其二pdf.js的Worker脚本需要单独指定路径全局CDN的写法很容易把路径维护漏掉线上出了问题还难排查其三工程里用import按需引入打包、缓存、降级都好控制。常规操作是先安装依赖npm install pdfjs-dist安装后在组件文件里import并手动指定Worker路径。新版pdfjs-dist把Worker脚本独立打包你得告诉浏览器去哪里找它import * as pdfjsLib from pdfjs-dist; // 显式指定Worker脚本避免浏览器猜测失败后走fake worker pdfjsLib.GlobalWorkerOptions.workerSrc new URL( pdfjs-dist/build/pdf.worker.min.mjs, import.meta.url ).toString();workerSrc这个全局变量直接决定解析任务跑在哪个线程。路径写错或版本和主包不一致时浏览器会退回主线程模拟解析小文件可能看不出问题大文件直接卡界面。这也是本章代码里唯一一个“不能不写”的全局配置。3.2 三段式渲染arrayBuffer → document → canvas有了Worker配置渲染第一页只需要三步取文件、取页、绘制。下面是在“模拟项目X”里沉淀过的渲染函数去掉业务装饰后保留核心逻辑export function renderPdfPage({ pdfUrl, // PDF文件直链或同源后端接口地址 canvas, // 页面上的目标canvas元素 pageNumber 1, scale 1.5, canvasWidth, // 可选期望显示宽度传入后自动反推scale }) { // 第一步解析PDF返回一个loadingTask对象 const loadingTask pdfjsLib.getDocument({ url: pdfUrl }); return loadingTask.promise.then(async (pdf) { // 第二步取指定页页码从1开始不是0 const page await pdf.getPage(pageNumber); // 第三步计算viewport并绘制到canvas let finalScale scale; if (canvasWidth) { const baseViewport page.getViewport({ scale: 1 }); finalScale canvasWidth / baseViewport.width; } const viewport page.getViewport({ scale: finalScale }); const ctx canvas.getContext(2d); canvas.width Math.floor(viewport.width); canvas.height Math.floor(viewport.height); await page.render({ canvasContext: ctx, viewport, }).promise; return { width: viewport.width, height: viewport.height, scale: finalScale, totalPages: pdf.numPages, }; }); }代码逻辑分三层说。第一步里getDocument返回的是loadingTask而不是Promise真正的解析进度和取消操作都挂在它身上这里用promise取解析结果。第二步的page对象是“关于这一页的描述”本身不占canvas内存翻页时可以反复调用。第三步最关键canvas.width和canvas.height决定画布物理像素总量页面发不发虚基本就是这里算出来的。参数方面pdfUrl要确保同域或服务端已开CORS否则直接翻车pageNumber从1开始和数组下标习惯不一样scale建议按用途分档canvasWidth是业务里很常用的自适应参数传入后函数会自动忽略scale按容器宽度反推倍率。各参数参考值如下参数作用建议值pdfUrlPDF文件地址同域或后端转发地址pageNumber页码从1开始翻页时由逻辑层重传scale渲染倍率多页列表1.25单页1.5打印2.0canvasWidth自适应容器宽度需要时传入自动覆盖scale这个函数把“显示一页”闭环了。但注意它没有处理DPR手机屏幕上会偏模糊下一小节补上。3.3 清晰度调整scale和devicePixelRatio两个参数决定模糊与否显示模糊是PDF预览里出现频率最高的“显性缺陷”。在Retina屏上浏览器CSS像素和物理像素是1:2或1:3的关系而canvas默认把CSS像素当成物理像素来画。画好的位图再被浏览器拉伸到物理像素位置文字边缘自然发虚放大后更明显。正确的做法是canvas物理尺寸乘以DPRCSS尺寸保持逻辑像素不变。给一段可以直接用的调整版const DPR window.devicePixelRatio || 1; const viewport page.getViewport({ scale: 1.5 * DPR }); canvas.width Math.floor(viewport.width); canvas.height Math.floor(viewport.height); canvas.style.width Math.floor(viewport.width / DPR) px; canvas.style.height Math.floor(viewport.height / DPR) px;这里参数分两组viewport基于1.5倍业务倍率再乘DPR决定画布内部有多少像素style.width和style.height回落为逻辑像素决定画布在页面上占多大位置。两者缺一不可只改CSS尺寸不会增加canvas内部像素该糊还是糊。这个DPR补偿要在每个页面渲染时都跑一遍。建议把它抽成一个公共函数避免翻页代码里到处重复同样的三行。做好这一步PDF在手机上放大后依然锐利也是后续缩放功能的清晰度基础。4. 从“能显示”到“能翻页缩放”交互层怎么叠能显示一页只是开始。真实的业务里用户要翻页、要放大看小字、要复制合同条款这一章把交互逐项叠上来。4.1 翻页与页码状态当前页、总页数、边界处理翻页最怕的是把渲染逻辑散落在按钮回调里每个按钮各画各的最后状态对不上。我习惯用一个轻量状态对象管住当前页、总页数和渲染互斥标记所有翻页动作只改状态再由统一的渲染函数响应。const state { pdf: null, currentPage: 1, totalPages: 1, rendering: false, }; function renderCurrentPage(canvas) { if (state.rendering) return; // 互斥上一页还没画完就不接新任务 state.rendering true; const DPR window.devicePixelRatio || 1; const pageNum state.currentPage; state.pdf.getPage(pageNum).then((page) { const viewport page.getViewport({ scale: state.scale * DPR }); const ctx canvas.getContext(2d); canvas.width Math.floor(viewport.width); canvas.height Math.floor(viewport.height); canvas.style.width Math.floor(viewport.width / DPR) px; canvas.style.height Math.floor(viewport.height / DPR) px; return page.render({ canvasContext: ctx, viewport, }).promise; }).finally(() { state.rendering false; }); } function goToPage(pageNumber, canvas) { if (!state.pdf) return; if (pageNumber 1 || pageNumber state.totalPages) return; state.currentPage pageNumber; renderCurrentPage(canvas); }这里三个细节值得说透。第一是rendering标记它保证同一时刻只有一个渲染任务在跑否则快速点下一页时canvas会被并发绘制白屏和闪烁都从这里来。第二是页码越界拦截第一页时上一页按钮置灰最后一页时下一页置灰键盘左右键也走同一个goToPage保证状态单一来源。第三是finally里释放rendering标记不管渲染成功还是失败锁都要解开。实际产品里goToPage比单纯“显示某页”多一层职责——预加载即将到达的页面。把getPage返回的Promise塞进一个Map缓存翻页就跳过解析等待体验差距非常明显。下一章的5.4节会从坑的角度再讲它为什么必要。4.2 缩放的三条路径工具栏档位、Ctrl滚轮、适配宽度第一招是工具栏档位缩放最直接把scale存进state每次点“放大”“缩小”就重新调renderCurrentPage。这种方式符合阅读习惯实现成本最低是把滚动条和按钮都收敛到同一个渲染入口的关键。第二种是滚轮缩放要留意的是和页面滚动冲突。我一般只在Ctrl按下时才触发缩放其他滚动行为原样保留canvas.addEventListener(wheel, (e) { if (!e.ctrlKey) return; // 没按Ctrl时不干扰页面滚动 e.preventDefault(); const ratio e.deltaY 0 ? 0.9 : 1.1; state.scale Math.min(4.0, Math.max(0.5, state.scale * ratio)); renderCurrentPage(canvas); // 复用渲染入口刷新当前页 });这个监听器有两个参数需要调ratio决定每次缩放的步幅0.9/1.1是稳的上下限0.5到4.0按业务文件类型改图纸类可能需要放到6.0。preventDefault要写在判断之后避免误伤常规滚动。第三种适配宽度用户不关心具体倍率只希望整页刚好落在可视区。做法是先量容器宽度拿scale1的viewport宽度反推倍率function fitToWidth(page, containerWidth) { const baseViewport page.getViewport({ scale: 1 }); state.scale containerWidth / baseViewport.width; renderCurrentPage(canvas); }注意容器宽度要先量后画不要在渲染后再读否则会因为布局抖动反复重算。4.3 文本层的价值搜索、复制与无障碍canvas画的PDF天生没有文字信息用户选不了、搜不了读起来像扫描件。pdf.js的文本层把这层补回来了它读取getTextContent()把每个字符变成一个带绝对定位的透明span叠在canvas正上方坐标完全对齐。用户看到的还是原来的画面但鼠标已经能选中文字按键也能触发系统朗读。const textLayerDiv document.createElement(div); textLayerDiv.className textLayer; // 官方样式absolute定位、透明文字 container.appendChild(textLayerDiv); page.getTextContent().then((textContent) { pdfjsLib.renderTextLayer({ textContentSource: textContent, container: textLayerDiv, viewport, // 必须和canvas用同一个viewport否则选中位置错位 }); });用这段代码时有三个注意点。第一viewport必须与绘制canvas时完全一致任何一个值不同文本和画面就对不上。第二textLayer的CSS样式要用pdfjs官方提供的那份自己写定位很容易翻车。第三翻页或缩放后旧的textLayer要清掉重建否则新页绘制完还能看到上一页的残留文字。有了文本层全文搜索就顺理成章把textContent按页拼接成字符串用indexOf找命中位置再给对应span加高亮背景色。这个能力是原生预览和转图方案都给不了的做进系统里用户的体验会有一个明显提升。5. 避坑PDF在线预览最常见的五个翻车现场断断续续看过不少pdf.js相关的报错也踩过不少坑把它们整理成五条现象按“现象、原因、解决”讲清楚。你遇到问题时直接对号入座。5.1 跨域白屏服务端没开CORS现象开发环境一切正常换到联调环境后PDF接口能直接打开canvas却一直是空的控制台出现跨域报错。原因PDF文件放在独立文件服务或对象存储上这些域名和前端域名不一致响应头里也没有Access-Control-Allow-Origin。pdf.js内部用fetch拉取文件浏览器直接拦截了响应解析器拿不到数据。“接口能打开”和“能被fetch读取”是两回事后者严格受CORS约束。解决文件服务补上CORS响应头如果文件服务不受控就由后端转发PDF前端请求同源接口再转成ArrayBuffer交给getDocument。后端转发时记得保持Content-Type: application/pdf有些网关会把这个头改成八进制流同样会触发解析失败。我习惯在后端加一个专门的/pdf/proxy接口顺带做权限校验一举两得。5.2 worker没加载控制台冒出“Setting up fake worker”现象本地开发一切正常部署到线上后文件能显示但页面明显卡顿控制台出现黄色警告Setting up fake worker。原因workerSrc配置的路径在打包后失效了。构建目录静态资源的hash变了而worker.src还指向旧的相对路径或者路径在生产环境下被拦截。pdf.js找不到worker脚本只能退回主线程模拟worker解析PDF小文件感觉不出来大文件直接卡成PPT。解决把worker脚本纳入构建产物管理用模块系统生成准确路径。这也是第3章开头那段配置的意义所在。验证方式很简单打开浏览器网络面板筛worker请求200就是对的404或0那就是路径挂了。还有一个血泪经验主包和worker包必须同版本版本不一致也会触发fake worker排查时先看版本号是否对齐。提示排查worker问题时先看网络请求再比对版本号顺序不能反。5.3 渲染任务没清理快速翻页白屏反转圈现象用户连点五次下一页偶尔某一页白屏或者旧页内容闪一下再消失看起来很不稳定。原因上一次render()任务还没结束新的render()又对同一个canvas上下文发起了绘制。canvas的绘图上下文被多个未完成的绘图任务争夺后续任务拿到的是被中断的半成品状态自然画不出内容。解决在翻页逻辑里维护一个renderTask变量发起新渲染前先调用旧task.cancel()并配合前面说的rendering互斥标记。cancel要放在任务开始的入口处而不是渲染完成后再判断——完成后就没有清理的必要了。核心代码如下if (currentRenderTask) { currentRenderTask.cancel(); // 放弃上一页未完成的绘制 } const task page.render({ canvasContext: ctx, viewport }); currentRenderTask task; await task.promise;这段代码我建议直接抄进渲染函数里别等出问题再补。5.4 大PDF卡死渲染任务堆叠阻塞主线程现象一个几十MB的图纸类PDF首次打开整个页面冻结数秒快速翻页时界面几乎完全失去响应。原因PDF解析虽然跑在Worker线程但canvas绘制本身是主线程同步操作。一次性把所有页面都画出来或者单页内容过于复杂主线程长时间被绘制任务霸占轮询、滚动、点击全部被堵住。解决只渲染当前页和它的前后各一页其余页面只保留page对象不创建canvas。单页canvas的物理边长控制在4096px以内超出就降低scale。配合第4章的预读缓存大文件翻页速度能有一个肉眼可见的提升。如果文件实在太大加一个“仅渲染前50页”的开关给用户一个快速浏览入口而不是硬扛。5.5 移动端放大发虚DPR补偿和容器尺寸没对齐现象手机上预览正常用户双指缩放到200%后文字边缘发虚部分安卓机型上快速滑动时页面出现大片白块。原因渲染时没补偿DPRcanvas物理像素不足放大后由浏览器强行拉伸补像素自然糊。白块的来源则是canvas高度和容器高度没有同步页面滚动时浏览器认为这个区域没有绘制内容主动做了裁剪。解决统一按“scale × DPR”设置canvas物理尺寸CSS尺寸回落为逻辑像素页面容器强制overflow:hidden滚动由canvas内部去接管不要依赖外层页面滚动。再提供一个“高清档”按钮按业务需要强制以更高倍率重渲染。移动端的验证不能靠桌面浏览器模拟老老实实用真机测两遍。以上五条前两条是环境配置问题后三条是渲染管理问题。项目上线前把这个清单过一遍能躲开大部分线上事故。6. 能不能上线用三份数据和一套预读缓存说话PDF预览做到“能显示”离上线还差一步我习惯把这一步拆成三份验证数据首屏耗时、翻页连续性、内存回收。首屏耗时要控制在800毫秒内打开Performance面板记录从点击到canvas第一帧的时间。翻页连续性要求连续快速翻20页以上不能出现白屏同时观察Memory面板里canvas的retained size——它只增不减说明有渲染对象没释放需要回头查renderTask的cancel逻辑。第三份数据是弱网用网络面板模拟slow 3G加载失败必须给出明确提示而不是无限转圈。这三项全过这个功能才算真的能交付。如果首屏本身性能不达标最实用的一招是做页面预读缓存。把已经getPage过的Promise放进Map翻页时不再重复解析const pageCache new Map(); function prefetchPage(pdf, pageNumber) { if (!pdf || pageNumber 1 || pageNumber pdf.numPages) return; if (!pageCache.has(pageNumber)) { pageCache.set(pageNumber, pdf.getPage(pageNumber)); } }翻页时先从缓存里试取取到了直接走渲染取不到再调getPage。这里有个细节缓存的是Promise而不是page对象如果getPage还没返回就发起翻页两次请求会复用同一个Promise不会产生重复解析。等到页面被翻过之后缓存自然淘汰或按需清理。这个习惯是我在一次真实上线后被迫养成的。当时只盯着首屏做优化忽略了“快速翻页”这个最频繁的操作上线第二天就被反馈翻页卡顿。回头排查发现每个page对象都被重复解析加一层缓存后延迟直接降了一个档次。如今每接一个PDF预览需求我都会先确认三点文件从哪里来、首屏要多快、翻页会不会连击。把这几个点提前钉死再走完整套渲染流程基本不会再有大意外。调试时留一手任何一次渲染失败都把error对象完整打印出来不要只打一行“渲染失败”错误码和堆栈才是定位的关键。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。