☰
JS在线查看PDF:基于pdf.js与canvas实现页面内预览与交互
2026/10/10 6:43:25 网站建设 项目流程

简介:面向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" style="width: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,绘制在Canvas

pdf.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一把梭更适合业务

如果你只是做一次性Demo,CDN引入确实最快。但进了正式业务后我强烈推荐模块化引入,三个原因:其一,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.0
canvasWidth自适应容器宽度需要时传入,自动覆盖scale

这个函数把“显示一页”闭环了。但注意,它没有处理DPR,手机屏幕上会偏模糊,下一小节补上。

3.3 清晰度调整:scale和devicePixelRatio两个参数决定模糊与否

显示模糊是PDF预览里出现频率最高的“显性缺陷”。在Retina屏上,浏览器CSS像素和物理像素是1:2或1:3的关系,而canvas默认把CSS像素当成物理像素来画。画好的位图再被浏览器拉伸到物理像素位置,文字边缘自然发虚,放大后更明显。

正确的做法是:canvas物理尺寸乘以DPR,CSS尺寸保持逻辑像素不变。给一段可以直接用的调整版:

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要写在判断之后,避免误伤常规滚动。

第三种适配宽度,用户不关心具体倍率,只希望整页刚好落在可视区。做法是先量容器宽度,拿scale=1的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%后文字边缘发虚;部分安卓机型上,快速滑动时页面出现大片白块。

原因:渲染时没补偿DPR,canvas物理像素不足,放大后由浏览器强行拉伸补像素,自然糊。白块的来源则是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对象完整打印出来,不要只打一行“渲染失败”,错误码和堆栈才是定位的关键。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询