☰
H5自动展示PDF的pdf.js实践:渲染原理与移动端适配指南
2026/10/14 16:00:39 网站建设 项目流程

简介:这是一份基于 Mozilla 开源库 PDF.js 的 H5 在线 PDF 展示资源包,面向需要在网页中直接渲染、缩放和浏览 PDF 文档的 Web 前端开发者,也适用于移动端 H5 项目集成,解决 HTML5 原生不支持解析 PDF 的问题。PDF.js 通过解析器将 PDF 内容转换为可绘图数据,再利用 Canvas 画布分页渲染,并提供文本选择、缩放、平移、搜索等交互能力,无需安装任何浏览器插件。压缩包内含四百零二个文件,体积约三点六七兆字节。文件类型以字体映射表、配置属性、位图与矢量图标为主,其中一百六十八份字体映射表负责字符定位,一百二十四份配置属性用于渲染参数,八十六个位图图标与九个矢量图标提供界面支持;同时包含脚本、样式、示例页面和 PDF 样例,完整覆盖 PDF.js 运行所需资源。已有 1224 人学习/下载。借助该压缩包可快速理解网页渲染 PDF 的完整流程,也可直接用作项目基础库,按需定制界面主题与功能,实现在 H5 页面中流畅地在线阅读 PDF 文档。

1. H5 里自动展示 PDF:为什么绕不开 pdf.js

做 H5 时,“自动展示”PDF 是一个很容易被低估的需求。合同、电子发票、产品说明书,页面加载完就要整整齐齐出现在用户面前,不要用户点击,也不要引导下载。最省事的方案是丢一个 iframe 或新开标签页,让浏览器自己处理,但真放到线上会发现:iOS 上还好,Android WebView 多数直接变成下载,微信内置浏览器还会拦截;就算能打开,不同内核版本表现也不一样,等于把不确定的黑匣子交给用户。

pdf.js 能把这种不一致兜住。它负责解析 PDF 并用 canvas 画出来,加载进度、缩放、失败兜底全在业务代码掌握里。下面从选型原理讲到最小 Demo,再给出移动端参数调优和常见坑,适合要把 PDF 自动展示做进 H5、又不想被浏览器内核折腾的开发者。

2. pdf.js 选型与渲染原理:为何 canvas 渲染比 iframe 可靠

2.1 只靠 iframe 或 embed 在移动端为什么容易翻车

H5 里想要“自动展示” PDF,最容易想到的是嵌套一个 iframe,把后端返回的 PDF 地址丢进去,或者用 embed、object 标签。在带原生预览能力的桌面浏览器上这通常没问题,但移动端是另一个世界。

先看 iOS:Safari 对 application/pdf 的支持相对好,但展示的是系统级预览控件,页面里顶着一个陌生工具栏,视觉和交互跟你的 H5 完全割裂,导航按钮、缩放逻辑都是系统定的。想要“自动展示”并自定义头尾内容,几乎做不到。

Android 分裂更明显。不同厂商的 WebView 对 PDF 的响应要么交给下载管理器,要么套一个简版预览,有的内核直接不识别。到了微信内置浏览器和各类 App 的 webview,iframe 嵌套 PDF 大概率会白屏或弹下载框。你无法预知用户会被引导到哪个流程。

还有个隐藏问题:接口鉴权。很多 PDF 地址需要带自定义请求头或 Cookie 才能访问,iframe 加载一个 URL 只能发普通 GET,不能设置 Header;如果后端要求 token,前端只能把 token 拼到 query 里,一旦日志泄露,PDF 地址也就暴露了。

结论很直接:浏览器原生行为差异太大,想稳定地把 PDF 当内容展示,就得自己掌握渲染层。pdf.js 把 PDF 文件解析成页面对象,再通过 canvas 绘制,展示过程完全受页面代码控制,UI 一致,进度可做,失败可兜底。对 H5 来说,它是最常规的落地方案。

2.2 引入 pdf.js 的两种常见姿势:CDN 与 npm 本地打包

pdf.js 的发布产物里有几个文件不能少:主库脚本、worker 脚本、可选的中文字体映射目录 cmaps 和标准字体目录 standard_fonts。引入方式直接影响 worker 路径和静态资源部署,这里分两种常见姿势。

CDN 方式适合快速验证和内网项目,HTML 里先引主库再设 worker 路径:

<script src="https://your-cdn.example.com/pdfjs/pdf.min.js"></script> <script> pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://your-cdn.example.com/pdfjs/pdf.worker.min.js'; </script>

注意两点:一是 workerSrc 必须指向能访问到的实际文件,且域名要与页面同源或已放开 CORS,否则 worker 加载失败,getDocument 一直转圈或白屏;二是 CDN 上通常只放了压缩后的主库和 worker,cmaps、standard_fonts 是单独目录,需要一并上传到静态服务,否则遇到特定字体编码的 PDF 照样乱码。

npm 方式适合工程化项目,主库通过 import 引入。常见做法是把 pdfjs-dist 作为依赖,然后在入口设置 workerSrc:

import * as pdfjsLib from 'pdfjs-dist'; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.js', import.meta.url ).toString();

这段代码依赖构建工具把 worker 文件当作静态资源拷到输出目录。不同版本构建输出的文件名略有差异,有的带 hash,有的不带,你应当以 node_modules 里实际文件名为准。npm 方式的优点是路径跟着构建走,不容易写错,配合哈希文件名也能更好地处理缓存。

怎么选?我的判断标准很简单:POC 阶段用 CDN,五分钟能跑出效果;要上线的项目一律 npm 打包,离线场景和内网环境更可控。无论哪种,都不要只引主库忘了 worker,这是后面白屏高发原因之一。

2.3 渲染链路拆解:从 getDocument 到 canvas 绘制

pdf.js 的渲染链路可以拆成四步:解析文档、获取页面、计算视口、绘制画布。先看一段核心代码:

const loadingTask = pdfjsLib.getDocument({ url: pdfUrl }); const pdf = await loadingTask.promise; const page = await pdf.getPage(1); const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.getElementById('pdf-canvas'); canvas.width = viewport.width; canvas.height = viewport.height; const context = canvas.getContext('2d'); const renderTask = page.render({ canvasContext: context, viewport }); await renderTask.promise;

第一步 getDocument 返回的是一个 loadingTask,不只接受 url,也接受 data(ArrayBuffer 或 Uint8Array)、range 等。url 方式最省事,但没法自定义请求头,鉴权受限制;data 方式适合先 fetch 再渲染。loadingTask 上可以挂 onProgress 拿加载进度,也可以调用 cancel 中断,这一点在展示大文件时很有用。

第二步 getPage(n) 的页码从 1 开始,不是数组下标,初学容易在这里拿到 undefined 或报错。拿到 page 对象后,页面宽度、高度、字体信息都已解析完成,真正绘制要靠 viewport。

第三步 getViewport({ scale }) 是理解 pdf.js 布局的关键。PDF 内部坐标单位是 point,1 point 约等于 1/72 英寸。scale=1 时,一个 595×842 的 A4 页渲染出来就是 595×842 CSS 像素。要占满容器宽度,需要按容器宽度反推比例;要适配高清屏,还要再乘以 devicePixelRatio,这部分下一章展开。

第四步 page.render 接受 canvasContext 和 viewport,返回一个 RenderTask。不要忽略 promise,渲染是异步的,中途页面切换时最好保存 renderTask 并在下次渲染前 cancel,否则可能出现旧页面画完覆盖新页面的诡异现象,这也是常见“白屏/错页”的原因之一。

理清这四个环节后,去看报错会更有头绪:文档解析挂掉,多半是 worker 或跨域;取页报错,多半是页码边界;画出来模糊,多半是 canvas 尺寸与 viewport 不匹配。后面几章会顺着这些线索逐个展开。

对比项iframe / embedpdf.js
加载逻辑浏览器内核决定页面代码自控
自定义 UI基本不可控完全可控
带 Header 鉴权不支持通过 fetch + data
移动端一致性差好
实现成本低中等,需配置参数

3. 用 pdf.js 在 H5 跑通最小 Demo:从零到自动展示第一页

3.1 基础页面结构与最小渲染代码

先做一个能跑的最小页面:页面加载完成就自动解析指定 PDF,并把第一页画到 canvas 上。下面这段代码可以直接保存成 HTML 文件,替换 PDF 地址后打开看效果。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>H5 自动展示 PDF</title> <style> #pdf-container { width: 100%; box-sizing: border-box; padding: 12px; background: #f5f5f5; } #pdf-container canvas { width: 100%; height: auto; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.15); } </style> </head> <body> <div id="pdf-container"> <canvas id="pdf-canvas"></canvas> </div> <script src="https://your-cdn.example.com/pdfjs/pdf.min.js"></script> <script> pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://your-cdn.example.com/pdfjs/pdf.worker.min.js'; const pdfUrl = '/static/example.pdf'; async function renderPdf(url) { try { const loadingTask = pdfjsLib.getDocument(url); const pdf = await loadingTask.promise; const page = await pdf.getPage(1); const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.getElementById('pdf-canvas'); canvas.width = viewport.width; canvas.height = viewport.height; const context = canvas.getContext('2d'); await page.render({ canvasContext: context, viewport }).promise; } catch (err) { console.error('PDF 渲染失败', err); } } renderPdf(pdfUrl); </script> </body> </html>

逻辑说明:renderPdf 在页面加载后立即调用,这就是“自动展示”的起点。代码里先创建 loadingTask,拿到 pdf 文档对象,再取第 1 页。注意 getPage 的入参是页码,从 1 开始,很多初次接手的开发者在这一步传 0 导致报错。viewport 决定渲染尺寸,scale 设成 1.5 是一个临时粗值,真正的自适应算法在 3.2 节说明。

参数说明:GlobalWorkerOptions.workerSrc 是全局配置,必须在使用 getDocument 之前赋值。canvas.width 和 canvas.height 是画布物理像素尺寸,viewport.width 是 PDF 在当前 scale 下的像素宽度。这里刻意没有设置 style 宽高,是为了让 canvas 按实际尺寸撑开容器,移动端首次验证最容易跑通的形态就是这种。

3.2 让 PDF 自动铺满一屏:scale 与容器宽度换算

固定 scale 最大的问题是:不同手机屏宽不同,375px 的 iPhone 和 414px 的安卓机渲染出来可能一屏放不下,或者留白太多。要做自动展示,第一步就是让 PDF 宽度跟随容器宽度。

const container = document.getElementById('pdf-container'); const canvas = document.getElementById('pdf-canvas'); const dpr = window.devicePixelRatio || 1; async function fillPage(page) { // 先以 scale=1 拿到 PDF 原始宽度 const baseViewport = page.getViewport({ scale: 1 }); // 为了让 canvas 物理像素足够,要乘 dpr const scale = (container.clientWidth / baseViewport.width) * dpr; const viewport = page.getViewport({ scale }); canvas.width = viewport.width; canvas.height = viewport.height; // style 宽度保持 CSS 像素,避免被容器拉伸 canvas.style.width = `${container.clientWidth}px`; canvas.style.height = `${(viewport.height / viewport.width) * container.clientWidth}px`; const context = canvas.getContext('2d'); await page.render({ canvasContext: context, viewport }).promise; }

逻辑说明:先取 scale=1 的 viewport,PDF 宽度除以容器宽度得到一个基础比例,再乘 dpr,最终渲染出来的 canvas 物理像素够清晰。canvas.style.width 必须用容器宽度,不能写成 100%,否则某些 WebView 里 canvas 会被 CSS 拉伸,视觉比例和实际渲染尺寸脱节。

参数说明:container.clientWidth 在页面刚加载时可能为 0,如果 canvas 渲染发生在图片、字体或异步数据之后,要在实际拿到宽度后再调用。另一个细节是 dpr 在部分 Android WebView 上可能被厂商放大,导致页面整体模糊,此时可以用一个上限保护,比如Math.min(dpr, 2.5),避免低端机渲染超大 canvas 直接卡死。

3.3 加载进度与失败兜底:让用户能看到状态

自动展示不代表闷头加载。PDF 文件可能几 MB 甚至几十 MB,用户盯着空白页容易流失。体验上至少要有一条进度条和一个失败重试入口。

<div id="progress-wrap"> <div id="progress-bar"></div> </div> <div id="fallback" style="display:none;"> 加载失败,请检查网络后 <button id="retry-btn">重新加载</button> </div>
const loadingTask = pdfjsLib.getDocument({ url: pdfUrl }); loadingTask.onProgress = (progress) => { if (progress.total > 0) { const percent = Math.min(100, Math.round((progress.loaded / progress.total) * 100)); progressBar.style.width = `${percent}%`; } }; try { const pdf = await loadingTask.promise; // 拿到 pdf 后再执行 3.2 里的 fillPage await fillPage(await pdf.getPage(1)); } catch (err) { fallback.style.display = 'block'; retryBtn.addEventListener('click', () => { fallback.style.display = 'none'; renderPdf(pdfUrl); }); }

逻辑说明:loadingTask.onProgress 会在下载过程中多次回调,progress.loaded 是已加载字节数,progress.total 是总量。total 可能为 0,比如服务端没返回 Content-Length,所以要加一层判断,否则进度条会变成 NaN。失败兜底放到 catch 里,用 display 控制显隐,点击重试时重新执行整条加载链路。

参数说明:进度条宽度用百分比更新时,注意要给进度条容器设置固定宽度和 overflow hidden,否则内层 div 变宽后页面会出现横向滚动条。实际项目中我一般还会加一个超时控制,用 Promise.race 包住 loadingTask.promise,15 秒没完成就主动 cancel,避免用户一直挂在转圈界面。

4. 移动端适配与参数调优:把 scale、worker、cMap 一次调对

4.1 scale 与 devicePixelRatio:为什么 canvas 老是发虚

移动端 canvas 发虚是最高频的观感问题,根源在于 canvas 的物理像素和 CSS 像素不一致。很多实现只写canvas.style.width = '100%',画布内部实际尺寸可能只有 375px,但屏幕物理像素是 750px,浏览器放大后文字边缘发虚。

正确关系是:canvas.width 和 canvas.height 决定绘图缓冲区大小,style.width 和 style.height 决定浏览器绘制大小。想要清晰,缓冲区至少要等于 CSS 尺寸乘以设备像素比。

function buildViewport(page, containerWidth) { const dpr = window.devicePixelRatio || 1; const baseViewport = page.getViewport({ scale: 1 }); const scale = (containerWidth / baseViewport.width) * dpr; const maxScale = 4 * dpr; // 防止超大页面把 canvas 面积撑爆 return page.getViewport({ scale: Math.min(scale, maxScale) }); }

参数说明:maxScale 的取值和生产环境相关,4 倍 dpr 大约对应最大 8 倍缩放,足够应付绝大多数合同和说明书。scale 过大会让 canvas 面积变成原来的平方倍,低端 Android 机渲染一整页 A4 可能要好几秒,这就是要限制上限的原因。

另一个容易忽略的点是:canvas 会受 CSS transform 影响。如果外层容器做了缩放动画,canvas 内部尺寸不变,但 UI 上看起来会发虚或截断。自动展示场景里尽量减少对 canvas 父容器的 scale 动画,用重新计算 scale 代替。

4.2 worker、cMap 与标准字体:中文 PDF 的隐性依赖

worker 是 pdf.js 的解析线程。如果 worker 加载失败,解析会退回主线程,小文件还能忍受,大文件直接卡掉交互。常见的部署错误是主库和 worker 文件放在不同 CDN 域名且未配置 CORS,浏览器拦截 worker 脚本后 getDocument 一直 pending。

worker 的配置必须在使用 getDocument 前完成:

pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.js';

如果页面运行在文件协议下,或业务域名和静态资源域名跨域,worker 同样可能失败。解决办法是把 worker 文件放到同域,或通过 fetch 获取文件内容后用 Blob URL 创建 worker。后者虽然麻烦,但内网系统里经常用到。

cMap 和 standardFontDataUrl 是更隐蔽的问题。部分 PDF 使用 CID 字体,渲染时需要在 cmaps 目录里查字体映射。如果缺失,现象不是直接报错,而是文字变成方块、乱码,或者某些字符空白。

const loadingTask = pdfjsLib.getDocument({ url: pdfUrl, cMapUrl: '/pdfjs/cmaps/', cMapPacked: true, standardFontDataUrl: '/pdfjs/standard_fonts/' });

参数说明:cMapUrl 路径末尾必须带斜杠,cMapPacked 对应压缩过的 .bcmap 文件,standardFontDataUrl 指向标准字体数据目录。这三者与主库、worker 一样,都是构建产物的一部分。我的习惯是无论 PDF 内容是否涉及生僻字体,上线时都配齐这些目录,否则遇到某类字体就只有后悔药没有后悔机会。

4.3 分页加载与渲染任务取消:内存与渲染卡顿的平衡

一个 PDF 二十页,如果全部渲染成 canvas 并保留在 DOM 里,内存占用会非常可观。移动端 WebView 的内存上限通常不如桌面端,页面一旦被系统杀掉,表现为白屏或者直接退出。

比较好的策略是:只渲染当前可见的一页或两页,滚动翻页时先取消旧的渲染任务,再渲染新页。这样内存里最多保留几个 canvas。

let currentRenderTask = null; async function renderPage(pdf, pageNumber, containerWidth) { if (currentRenderTask) { currentRenderTask.cancel(); } const page = await pdf.getPage(pageNumber); const viewport = buildViewport(page, containerWidth); canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.width = `${containerWidth}px`; canvas.style.height = `${(viewport.height / viewport.width) * containerWidth}px`; currentRenderTask = page.render({ canvasContext: canvas.getContext('2d'), viewport }); await currentRenderTask.promise; }

逻辑说明:每次渲染前先 cancel 旧任务,避免两次渲染结果错乱。RenderTask.cancel() 之后,旧的 promise 会 reject,所以调用处要 catch 住这个错误,不能在控制台留下红色的 Uncaught Error。页面销毁时调用 pdf.destroy(),释放解析后的字体和图片缓存。

参数说明:如果做缩略图列表,不要让每个缩略图都持有独立 canvas。常见做法是复用固定数量的 canvas 节点,超出部分直接移除或用离屏 canvas 先绘制再转成图片。缩略图不追求高清,scale 可以降到 0.3 左右,能大大降低低端机的压力。

5. pdf.js 在 H5 里的踩坑与排查清单

5.1 白屏不渲染:worker 文件没加载到位

现象:页面打开后容器区域一片空白,控制台没有明显的语法错误,getDocument 的 promise 一直 pending 或报 “Setting up fake worker failed”。

原因:GlobalWorkerOptions.workerSrc 配置的路径不对,或者 worker 文件与页面跨域且服务端没有放行 CORS。最常见的是 CDN 只放了主库 pdf.min.js,忘了上传 pdf.worker.min.js,路径 404 后 pdf.js 尝试用主线程 fake worker,渲染不稳定。

解决:先打开网络面板确认 pdf.worker.min.js 的请求状态。404 就把 worker 文件放到同域静态目录,路径写成绝对路径;如果是跨域,让静态服务在响应头加上 Access-Control-Allow-Origin。配置完成后强制刷新页面再试。

5.2 中文乱码或空白字形:cMap 与 standardFontDataUrl 缺失

现象:页面能渲染,但某些文字变成方框、乱码,或者一段文字中部分字符消失。更奇怪的是同一个 PDF 在一台手机上正常,在另一台手机上字体错位。

原因:PDF 里的 CID 字体需要 cMap 数据做字符映射,缺少映射文件时 pdf.js 只能猜测字形;另一个相关因素是标准字体数据所在目录没有部署,导致某些内嵌字体回退失败。

解决:确认 PDF 文件里字体是嵌入的还是非嵌入的,如果非嵌入标准字体,必须配置 standardFontDataUrl。按 4.2 节的方式,把 cMap 和 standard_fonts 两个目录部署到静态服务,并在 getDocument 参数里写明路径。注意路径末尾斜杠,不要拼成/cmaps少一个斜杠。

5.3 画出来明显发虚:canvas 尺寸没跟上物理像素

现象:文字边缘发虚,用手机截图放大后模糊,但在 PC 浏览器开发者工具里看很清晰。

原因:开发者工具默认模拟 dpr=1,纯 CSS 横向上看不出问题。真机 dpr 是 2 或 3,canvas.width 如果只按 CSS 像素设置,缓冲区像素不足,设备缩放后就虚了。

解决:统一按下述公式处理:scale = (容器宽度 / PDF 基础宽度) * dpr,canvas.width = viewport.width,canvas.height = viewport.height,同时 style.width 用容器 CSS 宽度。手机上看不到的 PC 模拟问题,建议直接用真机预览,或者打开浏览器移动端模拟器并设置 devicePixelRatio 为 3。

5.4 大 PDF 内存飞升:渲染任务没取消、文档没释放

现象:连续翻页后页面越来越卡,最后 WebView 崩溃。查看任务管理器,浏览器进程内存持续上涨,不会回落。

原因:代码里每次翻页都创建新的 RenderTask,旧的还没有完成就执行新的渲染,多个渲染任务叠加;或者每页都创建新 canvas 并追加到 DOM,页面切换时没有移除,canvas 占用的内存不会自动释放。

解决:翻页前保存并 cancel 当前 RenderTask,已经渲染完成的 canvas 在不可见时移除或复用。整个 PDF 不再需要时调用 pdf.destroy()。这里还有一个小坑:renderPage 里await currentRenderTask.promise被 cancel 后会抛错,调用处必须加 try/catch,否则路由切换后控制台会残留未捕获错误。

5.5 带鉴权 PDF 加载 403:url 方式带不了自定义 Header

现象:页面请求 PDF 返回 403,but 后端接口用 Postman 或浏览器直接访问是正常的,因为登录态在 Cookie 或 Header 里。

原因:pdf.js 的 getDocument({ url }) 底层是普通 fetch,它的请求头由浏览器决定。如果后端要求 Authorization 或其他自定义 header,url 方式无法传入。

解决:先自己 fetch 拿到 ArrayBuffer,再走 data 模式:

const response = await fetch(pdfUrl, { headers: { Authorization: `Bearer ${token}` } }); const arrayBuffer = await response.arrayBuffer(); const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });

注意:这种方案会把整个 PDF 先下载到内存,大文件建议展示一个加载状态。后端如果支持 Range 请求,可以用 pdfjsLib 的 range 模式分片加载,但实现成本更高,项目早期先整体下载更可靠。

6. 进阶玩法:按需渲染与上线前的验证清单

6.1 用 IntersectionObserver 实现按需渲染

多页 PDF 的自动展示,不一定要一次把所有页面都画出来。常见做法是每个页面放一个占位节点,用 IntersectionObserver 监听是否进入视口,进入后再调用渲染逻辑。

const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const pageNum = Number(entry.target.dataset.page); renderPage(pdf, pageNum, containerWidth); observer.unobserve(entry.target); } }); }, { root: scrollContainer, threshold: 0.1 });

页面结构里给每个占位 div 加上 style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />

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

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

立即咨询