ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

移动端PDF预览实战:pdf.js渲染、翻页与避坑指南

移动端PDF预览实战:pdf.js渲染、翻页与避坑指南

简介:这份资源面向需要在H5页面中嵌入PDF预览功能的Web前端开发者,基于Mozilla开源的PDF.js库,解决浏览器原生无法直接展示PDF、必须依赖插件或跳转下载的痛点。压缩包共402个文件,约3.67MB,以168个bcmap字符映射文件、124个properties语言配置、86个png与9个svg界面素材为主,另含4个js核心脚本、3个map、1个css样式表及1个html示例页,覆盖字体编码、多语言界面与渲染逻辑等模块,结构完整便于直接集成。资源已积累1224人学习下载,适合初中级前端快速上手。借助其中的库文件与示例,读者可掌握PDF加载、解析、Canvas分页渲染及缩放、平移、搜索等交互实现思路,并了解Web Worker后台解析的优化方式,为在线文档、合同预览等场景提供可复用的落地方案。

1. 移动端 PDF 预览的第三条路:为什么我最终选了 pdf.js

做过 H5 项目的人大概率都遇到过这个需求:后端返回一个 PDF 链接,要求用户在手机浏览器里直接看,不能下载、不能跳转第三方应用。第一反应通常是<iframe src="xxx.pdf">或者<embed>,桌面端跑得好好的,一到移动端就翻车——iOS Safari 只显示第一页,Android 各家浏览器行为不一致,微信内置浏览器更是玄学,有时候白屏,有时候直接触发下载。

也试过让后端把 PDF 转成图片返回,但文件体积翻倍、文字不能选中、放大就糊,产品经理第一个不答应。绕了一圈,最后还是回到 pdf.js 这条路上。它把 PDF 解析成 Canvas 渲染,浏览器只要能跑 JS 就能用,不依赖任何插件,移动端兼容性是目前最稳的方案。这篇笔记就把我在几个 H5 项目里用 pdf.js 的完整流程拆开讲——怎么引入、怎么渲染、怎么翻页、怎么避坑,以及那些官方文档不会告诉你的参数细节。适合正在做移动端文档预览的前端,也适合被 iframe 方案折磨过的同行。

2. 引入与初始化:从 CDN 到本地构建的选型对比

pdf.js 的引入方式直接决定了后续的维护成本和加载性能。我见过不少项目直接一行<script src="https://cdn.../pdf.min.js">就上了,开发阶段确实省事,但上线后一旦 CDN 被墙或者版本更新导致 API 变动,排查起来就是黑匣子。所以这一章先把引入方式讲清楚,再落到初始化参数。

2.1 三种引入方式的取舍

目前主流有三种方式:CDN 直引、npm 安装、本地构建。我一般会根据项目阶段来选。

CDN 直引适合快速验证和 Demo,代码量最少:

<!-- 引入 pdf.js 主库和 worker --> <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script> <script> // 设置 worker 路径,否则 pdf.js 会降级到主线程解析,大文件直接卡死 pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; </script>

这里有个关键点:workerSrc必须设置,而且路径要和主库版本严格一致。版本不一致时,worker 加载会静默失败,pdf.js 回退到主线程解析,表现就是页面卡顿几秒然后才渲染出来。这个坑我在一个活动页上踩过,当时以为是文件太大,查了半天才发现是 worker 没生效。

npm 安装适合工程化项目:

npm install pdfjs-dist
import * as pdfjsLib from 'pdfjs-dist'; // 注意:Vite/Webpack 下 worker 的引入方式不同 import workerSrc from 'pdfjs-dist/build/pdf.worker.min.js?url'; pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc;

本地构建则是把 pdf.js 的源码 clone 下来,改配置后打包,适合需要深度定制渲染逻辑的场景,比如要改文字层的选择行为或者加自定义水印。大多数业务项目用不到这一步。

2.2 初始化参数怎么设

拿到 PDF 文件后,第一步是调用getDocument加载文档:

const loadingTask = pdfjsLib.getDocument({ url: '/api/file/xxx.pdf', // PDF 地址,支持跨域需服务端配 CORS cMapUrl: '/cmaps/', // 中文等 CJK 字符映射表路径 cMapPacked: true, // 使用压缩版 cmap,体积更小 httpHeaders: { // 需要鉴权时带上 token Authorization: 'Bearer ' + token } }); loadingTask.promise.then(function (pdf) { console.log('总页数:', pdf.numPages); }).catch(function (err) { // 这里必须捕获,否则加载失败时页面无任何反馈 console.error('PDF 加载失败:', err); });

参数逐个说:url可以是相对路径、绝对路径或者 ArrayBuffer,如果后端返回的是二进制流,就传{ data: arrayBuffer }。cMapUrl是中文 PDF 最容易出问题的地方——不配这个,中文可能显示成乱码或者空白方块,因为 pdf.js 默认不带 CJK 字体映射。cMapPacked设为 true 会加载.bcmap压缩文件,比未压缩的小很多。httpHeaders用于需要登录态的接口,但注意跨域时自定义 header 会触发预检请求,服务端要允许对应的 header。

提示:getDocument返回的是 loadingTask 而不是 promise,必须通过.promise拿结果。直接 await loadingTask 会报错,这个 API 设计确实反直觉。

3. 渲染与翻页:Canvas 绘制、缩放适配与性能控制

文档加载进来只是第一步,真正决定用户体验的是渲染环节。移动端屏幕小、内存有限,如果一上来就把所有页渲染出来,低端机直接崩。这一章讲清楚单页渲染的完整流程、缩放适配策略,以及怎么做按需渲染。

3.1 单页渲染的完整代码

先看最基础的单页渲染:

async function renderPage(pdf, pageNum, canvas, scale) { // 获取指定页 const page = await pdf.getPage(pageNum); // 根据缩放比例计算视口尺寸 const viewport = page.getViewport({ scale: scale }); // 处理高清屏:devicePixelRatio 让 canvas 实际像素翻倍,避免模糊 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'; const context = canvas.getContext('2d'); // 缩放 context 以匹配 dpr context.scale(dpr, dpr); const renderContext = { canvasContext: context, viewport: viewport }; // render 返回 renderTask,可用于取消渲染 const renderTask = page.render(renderContext); await renderTask.promise; }

这段代码有几个容易忽略的点。第一,canvas.width和canvas.style.width是两回事,前者是画布实际像素,后者是 CSS 显示尺寸。移动端 dpr 通常是 2 或 3,如果只设 style 不设 width,画面会糊;如果只设 width 不设 style,画面会超出容器。第二,context.scale(dpr, dpr)必须在每次重新设置 canvas 尺寸后调用,因为改变 canvas 宽高会重置 context 状态。第三,renderTask可以调用.cancel()取消,这在快速翻页时很有用,避免上一页还没渲染完就开始渲染下一页导致画面错乱。

3.2 缩放适配:fit-width 还是固定比例

移动端最常见的需求是「宽度撑满屏幕」。做法是先拿到容器宽度,再反推 scale:

function calcScale(page, containerWidth) { // 获取 PDF 原始尺寸(scale=1 时) const baseViewport = page.getViewport({ scale: 1 }); // 用容器宽度除以原始宽度得到适配比例 return containerWidth / baseViewport.width; }

但这里有个细节:PDF 的原始尺寸单位是 pt(1pt = 1/72 英寸),不同文档的原始宽度可能差很多。A4 纸大概是 595pt 宽,但有些扫描件可能是 1000pt 以上。所以不能写死 scale,必须动态计算。

如果产品要求支持双指缩放,那就需要监听 touch 事件,动态调整 scale 后重新渲染。但频繁重渲染会卡,常见做法是缩放过程中用 CSS transform 做临时缩放,手指松开后再按最终 scale 重新渲染一次。这个策略在低端安卓机上能明显减少卡顿。

3.3 按需渲染与页面回收

一个 50 页的 PDF,如果全部渲染,内存占用可能超过 500MB,移动端直接白屏。我一般用「可视区域 + 预加载一页」的策略:

// 用 IntersectionObserver 监听 canvas 是否进入视口 const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const pageNum = Number(entry.target.dataset.page); renderPage(pdf, pageNum, entry.target, currentScale); } else { // 离开视口超过一定距离,清空 canvas 释放内存 const ctx = entry.target.getContext('2d'); ctx.clearRect(0, 0, entry.target.width, entry.target.height); } }); }, { rootMargin: '200px 0px' }); // 上下各预加载 200px

rootMargin设为 200px 意味着提前 200px 开始渲染,用户滑动时不会看到空白。离开视口后清空 canvas 是关键,否则内存只增不减。这里要注意,清空后如果用户滑回来,需要重新渲染,所以最好用一个 Map 记录每页的渲染状态,避免重复渲染。

注意:IntersectionObserver在部分老版本安卓 WebView 上不支持,需要引入 polyfill 或者降级为 scroll 事件监听。降级方案虽然性能差一点,但兼容性更稳。

4. 避坑与排查:中文乱码、跨域、内存泄漏的实战记录

pdf.js 用起来不难,难的是各种边界情况。这一章记录几个我在真实项目里踩过的坑,每条按「现象 → 原因 → 解决」写,方便对照排查。

4.1 中文显示为方块或乱码

现象:PDF 里的中文全部显示成空白方块,英文和数字正常。

原因:pdf.js 默认不加载 CJK 字符映射表,遇到嵌入字体缺失或者使用标准 CJK 字体的 PDF 就无法正确渲染。

解决:在getDocument时配置cMapUrl和cMapPacked,并把 cmaps 目录放到静态资源服务器上。cmaps 文件在 pdfjs-dist 包的cmaps/目录下,直接拷贝到 public 目录即可。配置后中文正常显示。

4.2 跨域请求被拦截

现象:控制台报Access-Control-Allow-Origin错误,PDF 加载失败。

原因:PDF 文件和 H5 页面不在同一个域名下,浏览器同源策略拦截。

解决:服务端配置 CORS 允许对应域名,同时注意如果带了httpHeaders,预检请求(OPTIONS)也要返回正确的 header。另一个方案是后端提供接口转发 PDF 流,前端用 ArrayBuffer 方式加载,这样就不涉及跨域。我一般优先让后端配 CORS,实在不行才走转发。

4.3 快速翻页导致画面错乱

现象:连续快速滑动时,某一页显示的是上一页的内容,或者画面撕裂。

原因:多个renderTask并发执行,后发起的渲染先完成,覆盖了前面的结果。

解决:在渲染新页之前,先取消上一页的renderTask:

let currentRenderTask = null; async function safeRender(pdf, pageNum, canvas, scale) { // 取消上一次未完成的渲染 if (currentRenderTask) { currentRenderTask.cancel(); currentRenderTask = null; } const page = await pdf.getPage(pageNum); const viewport = page.getViewport({ scale }); // ... 设置 canvas 尺寸 currentRenderTask = page.render({ canvasContext: ctx, viewport }); try { await currentRenderTask.promise; } catch (e) { // cancel 会抛出 RenderingCancelledException,忽略即可 if (e.name !== 'RenderingCancelledException') throw e; } }

4.4 长时间浏览后页面卡顿甚至崩溃

现象:翻了几十页后,滑动明显变卡,最后白屏。

原因:每页的 canvas 都保留在 DOM 里,内存持续增长,移动端浏览器触发 OOM。

解决:结合 3.3 的 IntersectionObserver,离开视口的 canvas 及时清空并释放引用。另外,pdf对象本身也持有资源,如果用户退出预览页,要调用pdf.destroy()释放。这个在单页应用里尤其重要,否则路由切换后内存不会回收。

4.5 worker 加载失败导致主线程阻塞

现象:PDF 能显示,但加载过程中页面完全卡死,按钮点不动。

原因:workerSrc路径错误或跨域,pdf.js 回退到主线程解析,大文件解析时阻塞 UI。

解决:检查workerSrc是否可访问,版本是否与主库一致。如果用了 CDN,确认 CDN 支持跨域。本地构建时,worker 文件要单独打包并正确配置路径。一个简单的验证方法是在 Network 面板看pdf.worker.min.js是否成功加载(状态码 200)。

5. 进阶技巧:文本层叠加、懒加载与移动端手势优化

基础功能跑通后,如果产品要求「文字可选中复制」或者「支持双指缩放」,就需要用到 pdf.js 的文本层和更细的手势控制。这一章讲两个进阶用法,都是实际项目里验证过的。

5.1 文本层叠加实现可选中

pdf.js 渲染的 canvas 本身是图片,文字不能选中。要实现选中,需要在 canvas 上方叠加一层透明的文本层:

async function renderTextLayer(page, container, viewport) { // 获取文本内容 const textContent = await page.getTextContent(); const textLayerDiv = document.createElement('div'); textLayerDiv.className = 'textLayer'; // 设置文本层尺寸与 canvas 一致 textLayerDiv.style.width = viewport.width + 'px'; textLayerDiv.style.height = viewport.height + 'px'; textLayerDiv.style.position = 'absolute'; textLayerDiv.style.top = '0'; textLayerDiv.style.left = '0'; // 关键:文本层本身透明,但文字可选中 textLayerDiv.style.opacity = '1'; textLayerDiv.style.color = 'transparent'; container.appendChild(textLayerDiv); // 使用 pdf.js 提供的 renderTextLayer 方法 const textLayer = new pdfjsLib.TextLayer({ textContentSource: textContent, container: textLayerDiv, viewport: viewport }); await textLayer.render(); }

文本层的原理是把 PDF 里的文字按位置绝对定位到透明 div 上,用户选中时看到的是高亮,复制出来的是真实文字。这里要注意 CSS 里.textLayer的样式需要配合 pdf.js 的官方样式表,否则文字位置会偏移。另外,文本层会增加 DOM 节点数量,页数多时建议只对当前页渲染文本层。

5.2 移动端手势缩放与双击放大

双指缩放的核心是监听touchstart、touchmove、touchend,计算两指距离变化:

let initialDistance = 0; let initialScale = 1; canvas.addEventListener('touchstart', (e) => { if (e.touches.length === 2) { // 计算两指初始距离 initialDistance = Math.hypot( e.touches[0].pageX - e.touches[1].pageX, e.touches[0].pageY - e.touches[1].pageY ); initialScale = currentScale; } }); canvas.addEventListener('touchmove', (e) => { if (e.touches.length === 2) { e.preventDefault(); // 阻止浏览器默认缩放 const distance = Math.hypot( e.touches[0].pageX - e.touches[1].pageX, e.touches[0].pageY - e.touches[1].pageY ); // 计算缩放比例,限制在 0.5 到 3 倍之间 const newScale = Math.min(3, Math.max(0.5, initialScale * (distance / initialDistance))); // 这里只更新 CSS transform,不重新渲染 canvas.style.transform = `scale(${newScale / currentScale})`; } }); canvas.addEventListener('touchend', () => { // 手指松开后,按最终 scale 重新渲染一次,保证清晰度 // 具体实现略,核心是调用 renderPage 并传入新 scale });

这个方案的关键是「缩放过程用 CSS transform,松手后再重渲染」。如果每次 touchmove 都重渲染,低端机直接卡死。e.preventDefault()必须加,否则浏览器会触发页面级别的缩放,和 canvas 缩放冲突。

提示:双击放大可以用dblclick事件,但移动端更常见的是监听两次touchend的时间间隔,小于 300ms 判定为双击。放大时以点击位置为中心,需要计算偏移量,否则会跳到左上角。

5.3 预加载与缓存策略

用户翻到第 5 页时,第 6 页应该已经在渲染了。我的做法是维护一个渲染队列,当前页渲染完成后,立即触发下一页的渲染,但优先级低于当前页。同时用一个 Map 缓存已渲染的页面,用户往回翻时直接显示缓存,不用重新渲染。缓存上限设为 10 页,超过后按 LRU 淘汰。这套策略在几个项目里跑下来,翻页体验基本和原生应用持平。

从那以后我每次接 PDF 预览需求,都会先把 worker 路径、cMapUrl、CORS 这三项检查一遍,再开始写渲染逻辑。这三个地方不出问题,后面基本就顺了。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进