PDF怎么在网页里展示?这个问题,做前端的迟早会遇到,尤其是文档管理、在线预览、内部资料查阅这类系统,几乎绕不开。我在一个内部资料系统里集成PDF预览功能时,最初试过iframe、试过后端转图片,最后才选定pdfjs作为渲染内核。整体跑下来体验很稳,但Worker路径配置、跨域文件、高清屏模糊这些细节也确实埋了不少坑。这篇就根据实际落地经验,把pdfjs的引入方式、核心API、进阶能力和问题排查一次讲透,适合正要接PDF预览、或者已经被细节坑过一轮的同学直接参考。
先说结论:pdfjs是目前纯前端渲染PDF最通用的方案。它把解析和渲染都放在浏览器里完成,不依赖任何插件,不仅能画页面,还能拿文本层支持选中、搜索,甚至可以做缩略图、高亮、打印联动。理解了它的工作流程,后面所有功能都是顺着这条主脉络长出来的。
1. 为什么是它:PDF前端渲染的方案对比与核心思路
1.1 传统加载方式的硬伤
很多人接到需求的第一反应是iframe直接怼PDF地址。这条路在浏览器本地环境正常时看着没问题,但本质上是把渲染工作交给浏览器自带的PDF插件,用户的插件环境决定一切。同一份PDF,有的浏览器能正常显示,有的只弹下载框,有的直接白屏。更要命的是交互完全不可控,你想做自定义工具栏、收起侧边栏、限制打印,基本没有操作空间。
后端转图片是另一个常见思路,服务端调用转换接口把每页PDF生成一张图片再发给前端。听起来简单,但PDF文件动辄几十上百页,一次转换生成几十张高清图,服务器内存和带宽都吃不消,转换速度也是按秒算的。而且PDF一旦转成图片,文字搜索、划词复制这些基础能力全没了,业务上一提“用户要能搜索合同内容”就彻底歇菜。
pdfjs走的是完全不同的路线:在浏览器里用JavaScript直接解析PDF的文件结构,然后通过Canvas把页面画出来。PDF里的文字、曲线、图片会被还原成可编程对象,页面渲染、缩放、搜索、选中复制都能在前端独立完成。对用户来说,看到的是和本地阅读器一样流畅的体验,对开发者来说,整个渲染链路都是可控制的。
1.2 解析与渲染分离的架构
pdfjs的设计可以拆成两条线:解析线和渲染线。解析线跑在Web Worker线程里,负责读取PDF字节流、还原页面结构、把字体和图像数据准备好。渲染线跑在主线程,只负责把解析好的页面内容画到Canvas上。这两条线分开的最大好处是,解析大文件时主线程不会被拖住,用户拖拽滚动条、点击按钮时页面依然跟手。
打个比方,PDF解析就像厨房的后厨,备菜切菜可能很慢,但前台不会因为后厨忙碌就把顾客晾在那。签到小厂的普通做法是把所有事都堆在主线程,文件稍大浏览器就卡到怀疑人生,pdfjs把重活挪进Worker之后才撑得起在线预览这种场景。
理解了这条主线,再看它的调用流程就非常清晰:
- 调用getDocument加载PDF,得到一个loadingTask
- 等待loadingTask.promise拿到PDFDocumentProxy文档对象
- 用文档对象的getPage(pageNum)获取指定页
- 通过getViewport计算当前缩放比例下的页面尺寸
- 调用page.render把页面画到Canvas上
后面所有高级玩法,比如文本选择、缩略图、搜索高亮、打印,都是在这个分层之上叠加出来的。只要把这条主链路吃透,其他功能都是锦上添花。
2. 环境准备与第一个可运行示例
2.1 引入pdfjs的方式与版本陷阱
pdfjs的引入方式无非三种:CDN直接引用、npm包引入、源码自构建。我实际项目里基本都用npm,配合打包工具处理后一道工序都不用管。
npm install pdfjs-dist装完之后在主入口设置Worker路径。这一步刚入门的人几乎都会踩坑,甚至有人直接漏掉这一行,然后被“workerSrc未指定”的报错折磨半天。
import * as pdfjsLib from 'pdfjs-dist'; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url ).toString();这里解释一下为什么必须设置:pdfjs在解析PDF时会启动一个Web Worker,这个Worker需要加载一个独立的脚本文件,也就是pdf.worker.min.mjs。如果不告诉它这个文件在哪,浏览器就不知道从哪拉Worker代码,解析自然进行不下去。用new URL的方式是为了让打包器把Worker文件作为资源正确处理,避免路径被哈希或拼接弄错。
版本问题必须重点提醒。pdfjs-dist的版本差异非常大,v2和v3/v4的API有明显变化,尤其是文本层的调用方式,v3之后从原有的renderTextLayer函数变成了TextLayer类。我项目里曾经锁定过主版本,因为升级之后旧的调用方式直接废弃,整个预览功能重构了一遍。建议在package.json里锁定主版本号,不要随手装latest,特别是在老项目里,升级pdfjs一定要先看迁移文档。
2.2 最小渲染逻辑与高清屏适配
下面是一段能跑通的最小渲染逻辑,只干一件事:把PDF第一页画到Canvas上。
async function renderPdf(url, canvas) { const loadingTask = pdfjsLib.getDocument(url); const pdf = await loadingTask.promise; const page = await pdf.getPage(1); const viewport = page.getViewport({ scale: 1 }); const context = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: context, viewport }).promise; }这段代码在普通笔记本上看没问题,但放到高清屏、Retina屏上就会露馅:渲染出来的文字明显发虚,边缘有模糊感。原因在于Canvas的物理像素设置。
Canvas的width和height属性决定内部实际像素数量,style.width和style.height决定它在页面上显示的CSS尺寸。设备像素比是2的屏幕上,一个CSS像素对应4个物理像素。如果不做处理,直接把Canvas宽高设成viewport.width,显示尺寸也是这么大,那系统只能用2x2个物理像素去对齐1个逻辑像素,周围的采样点全靠猜,图画出来自然发虚。
正确做法是把Canvas物理尺寸按devicePixelRatio放大,再用CSS尺寸撑回逻辑大小,渲染前用setTransform调整坐标系:
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.setTransform(dpr, 0, 0, dpr, 0, 0);这个细节对预览体验的提升非常直观,不改就是糊,改了一行代码就清楚了。顺便说一句,很多PDF预览库展示效果“看起来高级”,其实大半是靠这个细节撑起来的。
3. 核心API细读:把渲染链路彻底吃透
3.1 getDocument:三种数据形态与跨域处理
getDocument是pdfjs的唯一入口,它能接收的数据形态有三类:URL字符串、ArrayBuffer、TypedArray。URL字符串最直观,直接给文件地址即可,但有一个绕不开的坎:跨域。
PDF文件如果和前端页面不在同源,而且资源服务器没有返回CORS响应头,getDocument在读取数据阶段就会报错。这里的报错形式很多样,有时是CORS错误,有时是“Access to XMLHttpRequest”被拦截,总之拿不到文件内容。
如果服务端的跨域头没法改,另一种思路是用fetch先把文件抓下来,转成ArrayBuffer再传给getDocument:
const res = await fetch(url); const buffer = await res.arrayBuffer(); const loadingTask = pdfjsLib.getDocument({ data: buffer });这种方案的代价是文件会在内存里留下一份完整副本,大文件(比如几十MB的扫描件)会对内存产生压力。我的习惯是优先协调服务端加跨域头,实在不行才用ArrayBuffer方案,并且对大文件做大小上限判断。
3.2 PDFDocumentProxy与getPage的懒加载
getDocument返回的是一个loadingTask对象,要await它的promise才能拿到PDFDocumentProxy。这个Proxy可以理解成整个PDF文档的总管,通过它可以拿页数、元数据、目录结构等信息。要渲染某一页,再调用它的getPage方法。
getPage是懒加载机制,不会一次把几十页全部解析出来,而是用到哪页解析哪页。这个设计对性能意义重大:一个两百页的PDF,如果启动时全量解析,首屏要等好几秒,懒加载之后只需要等第一页出来就能展示。
页面的渲染是独立的。渲染完某页之后,如果短时间内不再需要它,可以调用page.cleanup()释放页面解析产生的临时资源。频繁翻页的场景下,不清理会持续累积内存占用,页数一多浏览器就开始毛刺甚至崩溃。
3.3 getViewport与正确的缩放姿势
getViewport是计算页面尺寸的核心方法,接收{ scale }参数,返回当前缩放比例下的页面尺寸和变换矩阵。要缩放页面时,不是去改Canvas的CSS宽度,而是用新的scale重新调用getViewport,再走一遍渲染流程。
我见过有人试图直接改canvas.style.width实现缩放,页面确实变大了,但文字全糊成一坨,原因就在这里:viewport的变换矩阵没跟着变,Canvas内部像素也没重新计算,只是拉伸了显示尺寸。正确写法是:
const newViewport = page.getViewport({ scale: newScale }); canvas.width = newViewport.width * dpr; canvas.height = newViewport.height * dpr; canvas.style.width = newViewport.width + 'px'; canvas.style.height = newViewport.height + 'px'; await page.render({ canvasContext: ctx, viewport: newViewport }).promise;缩放比例建议做范围限制,一般控制在0.25到5倍之间。用户把页面缩得太小或拉得过大,视觉体验都会失控,而且极端尺寸下渲染成本也直线上升。
3.4 render与RenderTask取消机制
page.render返回一个RenderTask对象,它的promise在渲染完成时resolve。RenderTask有一个很有用的能力:cancel,可以中断正在进行的渲染任务。
这个能力在快速翻页时是刚需。用户从第1页快速拖到第10页,如果第3页还在渲染,与其让它继续占用CPU,不如直接cancel掉,立刻去渲染第10页。很多PDF预览库翻页流畅、不卡顿,靠的就是这个取消机制。
我在每次渲染新页面之前,都会把上一个RenderTask先cancel掉。这里提醒一个细节:cancel之后,原来的promise会进入rejected状态,调用链上必须catch一下,否则控制台会冒未处理的promise rejection。这个报错不影响功能,但在排查其他问题时很容易干扰判断。
4. 进阶功能:从“能看”到“好用”
4.1 文本层:让PDF可以被选中和搜索
只把页面画出来,离“产品可用”还差很远。用户浏览PDF时,默认期待能划词、复制、搜索,而这些能力Canvas本身给不了——因为Canvas上只有像素,没有文字信息。
pdfjs提供文本层机制来补上这一块。它把PDF里解析出的文本内容用DOM元素渲染出来,覆盖在Canvas上方,文字是透明的,但位置和大小严格对齐,用户选中、复制、搜索时操作的都是这些DOM节点。
文本层容器必须和Canvas在同一个位置上,宽高一致,并做绝对定位。核心调用方式在不同版本有差异,v3之后是创建TextLayer实例:
const textContent = await page.getTextContent(); const textLayer = new pdfjsLib.TextLayer({ textContentSource: textContent, container: textLayerDiv, viewport: currentViewport, }); await textLayer.render();一个常见问题是文本已经渲染了但位置错乱、字对不上。这多半是文本层的viewport和Canvas用的viewport不一致,比如一个用了缩放前的,另一个用了缩放后的。两者必须用同一个viewport实例,而且缩放后文本层也要重新渲染,不能只改Canvas。
4.2 缩略图、搜索与导航的实践取舍
缩略图本质上是把每一页用更小的scale再渲染一次。为了性能,缩略图建议按需生成,不要一上来把几百页全部渲染完。我做过的一个方案是只维护当前页前后几页的缩略图,用户滚动时才补渲染,这样首屏速度和内存占用都可控。
搜索功能是文本层最典型的业务结合。关键词命中后,把命中的片段和页码记录下来,点击结果跳到对应页并高亮。高亮实现不复杂:遍历文本层里对应页的span元素,找到包含关键词的片段,给它加一个背景色class。这里有几个细节注意:搜索要区分大小写、支持多命中,并且要记录当前是第几个命中项,方便用户逐个跳转。
翻页导航建立在getPage之上,无非是维护当前页码、总页数,并在翻页时取消上一页的渲染任务。建议用一个统一的状态对象保存当前page和旧的renderTask,翻页时先cancel再渲染,避免并发乱序导致页面错乱。
4.3 打印与下载的实战取舍
打印需求同样绕不开。直接调window.print会把整个网页打出来,工具栏、侧边栏、搜索框全在纸上,非常难看。业界常见的做法是隐藏iframe加载原始PDF文件,再在iframe里调print。这样打印对话框是浏览器原生样式,用户体验最熟悉,前提是文件能直接访问。
如果文件因为权限控制不能单独访问,只能在Canvas上想办法:把当前页转成图片放进打印DOM。但多页打印要逐页生成图片,速度慢且清晰度有限,缩略图级别的图放大打印会糊。我一般优先推荐iframe方案,简单稳定,也不容易踩到边界情况。
5. 高频问题与排查技巧实录
5.1 Worker加载失败的三种报错
Worker问题占pdfjs报错的大头,常见三种现场:
- “No GlobalWorkerOptions.workerSrc specified” 或类似提示,说明Worker路径根本没设置
- 控制台404,说明指向的worker文件不存在
- “Failed to fetch dynamically imported module”,常见于打包工具没有正确处理worker资源
排查思路很固定:确认workerSrc指向的文件真实存在、路径可访问;打包环境下用new URL方式交给打包器处理;部署后注意子路径部署时worker路径仍要正确。
5.2 跨域文件加载失败
PDF在别的域名上,前端直接getDocument(url)报CORS错误,这是跨域限制。优先找资源方开放跨域头;如果做不到,临时方案是fetch转ArrayBuffer再传给getDocument。注意ArrayBuffer方案对大文件不友好,内存开销明显,建议做文件大小上限控制。
5.3 内存泄漏与渲染性能
大PDF频繁翻页,如果不释放资源,内存会持续上涨。一份两百页的扫描件,每页渲染完都留着一堆临时数据,翻五十页后浏览器就可能卡顿甚至崩溃。我这里有一个基础清理组合:
- 渲染完成后调用page.cleanup()释放页面临时资源
- 整个PDF不再需要时调用pdfDocument.destroy()
- 不要在同一页面保留多个渲染过的Canvas节点
- 用节流控制缩放、翻页时的渲染频率
另外中文PDF乱码也是个高频问题。某些旧式字体编码需要CMap文件,老版本pdfjs不配置cMapUrl时会提示相关错误或者中文变得七零八落。新版本兼容性好了很多,但遇到特殊编码的PDF依然要靠CMap目录来兜底。配置时注意cMapPacked参数要和文件实际格式匹配。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 启动就报Worker相关错误 | workerSrc未设置或路径不对 | 设置GlobalWorkerOptions.workerSrc并检查文件可访问 |
| 加载PDF时CORS报错 | 文件跨域且服务端未放行 | 服务端加跨域头,或fetch转ArrayBuffer |
| 文字边缘模糊 | Canvas未处理devicePixelRatio | width/height按dpr放大,style尺寸回逻辑值 |
| 文本选不中、复制无内容 | 文本层没渲染 | 创建TextLayer并渲染到对应容器 |
| 中文乱码或字体异常 | 缺少CMap配置 | 配置cMapUrl和cMapPacked |
| 翻页偶尔白屏且控制台有rejection | cancel未catch | 对renderTask.promise加catch处理 |
| 页面能显示但搜索无结果 | 文本层和canvas位置/尺寸不对齐 | 复用一个viewport,同步更新文本层 |
最后再分享几个实操经验
说句实话,千万别把官方示例直接搬上线。示例能跑通,但离“产品可用”差得远。真正吃时间和踩坑的,是Worker路径在打包环境下的正确写法、跨域文件的处理策略、高清屏的DPR适配、还有翻页时RenderTask的取消与清理。这些细节不做,看起来很正常的PDF预览功能,换台电脑、换个部署路径、换个大文件,就原形毕露。
还有一个小技巧:调试pdfjs时,把DevTools的Network面板开着,重点看worker文件是否加载成功、字体资源是不是404、PDF文件请求是否走了缓存。很多莫名其妙的问题,看一遍请求列表就能定位七八成。pdfjs本身是个非常成熟的库,一旦理解了加载和渲染两条链路,往里加功能其实是水到渠成的事。