简介:H5 页面内嵌 PDF 预览的一套完整前端实现,面向需要在网页中直接展示 PDF、又不愿依赖浏览器插件或商业 SDK 的前端开发者与项目维护者,可用于文档在线阅读、合同/报告预览、后台附件查看等场景。压缩包围绕 Mozilla 开源的 PDF.js 组织:核心的 js、css 与 html 入口可直接嵌入现有工程;168 个 bcmap 字符映射表覆盖 UniGB、UniJIS、UniCNS 等字符集,解决中日韩文档的字体与编码渲染;124 个 properties 为界面多语言本地化资源,png、svg、gif、cur 构成工具栏图标与加载动画素材,另有 map 映射文件与一份 pdf 示例便于调试对照。整包共 402 个文件、约 3.67MB,体量轻、结构完整,解压后用本地服务打开即可运行验证。已有 1224 人学习下载,适合用来快速验证 Canvas 逐页渲染、缩放与分页滚动效果,也可作为二次开发基础,按需裁剪语言包与图标资源。
1. H5 内嵌 PDF:绕开 iframe 与系统预览的坑
移动端 H5 要展示一份 PDF,很多人的第一反应是丢个iframe,或者window.open让系统阅读器接管。这条路在部分机型上勉强能走,换到另一批机型就变成一次跳转、一次下载、一次「无法预览」,用户点完就流失了,你连埋点都拿不到。pdf.js 的价值是把 PDF 解析搬回页面里:用 canvas 逐页画出来,翻页、水印、阅读进度、权限控制全都归你。它适合「不能跳出当前流程」的内嵌阅读场景——合同签署、课程资料、报表预览、电子发票这类页面。代价是 worker 路径、字体映射、canvas 清晰度、长文档内存这四件事得自己处理,任一处没弄对,页面就是白屏或者糊成一片。
2. 接入 pdf.js:worker 路径、版本对齐和第一个渲染循环
先把一个容易踩错的前提说清楚:pdf.js 不是一个script标签引进去就完事的库,它由主线程 API 和 worker 两部分组成,解析、解码、字体处理这些重活全在 worker 里跑。只引 API 不配 worker,pdf.js 会退回 fake worker 模式,把解析压到 UI 线程,滑动直接卡死,长文档更是一滑一卡。所以接入的第一步不是写渲染代码,而是把文件放对位置。
2.1 把四类静态资源拷到可控目录
包管理器装下来的产物里,真正需要上线的只有四类:主 API、worker、cmaps、standard_fonts。前两个是代码,后两个是数据文件,中文文档能不能正常出字全靠它们。我一般不用公共静态资源服务直接引,路径不可控、版本漂移、内网环境还可能拉不到,最后还是自己拷到项目静态目录最省心。
# 安装依赖,不锁死小版本,跟随社区当前稳定版即可 npm i pdfjs-dist # 建四类目录:代码产物 + 字符映射表 + 标准字体数据 mkdir -p public/vendor/pdfjs/build mkdir -p public/vendor/pdfjs/cmaps mkdir -p public/vendor/pdfjs/standard_fonts # 主 API 与 worker 必须来自同一次安装,别一个用旧的、一个用新的 cp node_modules/pdfjs-dist/build/pdf.min.mjs public/vendor/pdfjs/build/ cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/vendor/pdfjs/build/ # 数据文件全量拷,浏览器会按需请求,不会一次性下载完 cp -r node_modules/pdfjs-dist/cmaps/* public/vendor/pdfjs/cmaps/ cp -r node_modules/pdfjs-dist/standard_fonts/* public/vendor/pdfjs/standard_fonts/这里有两个细节值得说。第一,产物的后缀在不同构建里可能是.mjs也可能是.js,以node_modules/pdfjs-dist/build/里实际存在的文件为准,照着目录改名比照着记忆写路径靠谱。第二,cmaps目录体积不小,但它不是被打包进 JS 的,只有真正遇到需要映射的字体时浏览器才会去请求对应文件,所以别为了「减小体积」把它裁掉,裁完之后中文文档就开始出方框。
2.2 worker 配置与文档加载参数
worker 路径用站点根目录的绝对路径最稳,相对路径在路由较深的页面(比如/app/contract/detail)会解析错,报出来的错还特别隐蔽——通常是一句 fake worker 失败的提示,看着像版本问题,实际是 404。
import * as pdfjsLib from '/vendor/pdfjs/build/pdf.min.mjs'; // workerSrc 必须是同一次构建产物,用绝对路径避免深层路由下解析错误 pdfjsLib.GlobalWorkerOptions.workerSrc = '/vendor/pdfjs/build/pdf.worker.min.mjs'; const loadingTask = pdfjsLib.getDocument({ url: '/api/files/contract.pdf', // 同源输出,跨域问题见第 5 章 cMapUrl: '/vendor/pdfjs/cmaps/', // 结尾的斜杠不能省,否则拼接出的路径是错的 cMapPacked: true, // 使用打包成二进制的 cmap,请求数更少 standardFontDataUrl: '/vendor/pdfjs/standard_fonts/', disableAutoFetch: true, // 不主动预取全文,长文档按需请求字节区间 disableStream: false // 保持流式,首屏出页更快 }); const pdf = await loadingTask.promise; console.log('总页数', pdf.numPages);disableAutoFetch和disableStream是一对容易用反的参数:两个都设true,pdf.js 会等整个文件下载完才开始渲染,大文件首屏白屏时间直线上升;两个都设false,它会一边下一边解析,网络不好时反而抢带宽。常见的折中是disableAutoFetch: true加disableStream: false,首屏只取需要的字节区间,往后翻再继续。另外url传的是同源地址时最省事,如果后端只能给签名链接,那就先fetch成ArrayBuffer再传data,代价是整份文件进内存,几十兆的文档要掂量一下。
2.3 单页渲染:viewport 与可取消的 renderTask
渲染一页只需要三个东西:getPage、getViewport、page.render。viewport 里的scale是相对 PDF 原始尺寸的倍数,1 表示按 72dpi 的原始点尺寸,所以「按容器宽度铺满」这个需求要先用 scale=1 算出基准宽度,再反推倍数。
async function renderPage(pdf, pageNo, canvas, containerWidth) { const page = await pdf.getPage(pageNo); // 先用 scale=1 拿到 PDF 原始尺寸(单位是 pt),再按容器宽度反推倍数 const base = page.getViewport({ scale: 1 }); const scale = containerWidth / base.width; const viewport = page.getViewport({ scale }); const ctx = canvas.getContext('2d'); canvas.width = Math.floor(viewport.width); canvas.height = Math.floor(viewport.height); canvas.style.width = `${Math.floor(viewport.width)}px`; canvas.style.height = `${Math.floor(viewport.height)}px`; const task = page.render({ canvasContext: ctx, viewport }); try { await task.promise; } catch (err) { // 页面滚出视口时主动 cancel,会抛 RenderingCancelledException,属正常流程 if (err?.name !== 'RenderingCancelledException') throw err; } return { page, task }; }renderTask一定要留引用。用户快速滑动时,前一页的渲染可能还在跑,这时候必须task.cancel(),否则后面的页排队等着,越滑越慢。RenderingCancelledException是取消渲染的正常产物,别当成错误上报到监控里,不然告警会被刷爆。另外rotation参数可以直接处理横版和倒置的页面,不用在 CSS 上做transform,后者会让文字层的坐标一起偏。
2.4 串成滚动阅读器:先占位,再渲染
移动端阅读器几乎都是整篇纵向滚动,而不是一页一页翻。滚动容器的关键不在渲染,而在占位——如果每页都要等渲染完才知道多高,滚动条会一路跳,用户滑到一半页面突然被顶走,体验非常糟。
const viewer = document.getElementById('pdf-viewer'); const pages = []; // 用第一页的宽高比给所有页占位,绝大多数 PDF 页面比例一致 const first = await pdf.getPage(1); const vp = first.getViewport({ scale: 1 }); const ratio = vp.height / vp.width; for (let i = 1; i <= pdf.numPages; i++) { const wrap = document.createElement('div'); wrap.className = 'pdf-page'; wrap.dataset.index = String(i - 1); wrap.style.aspectRatio = `1 / ${ratio}`; // 提前撑开高度,避免渲染时页面跳动 const canvas = document.createElement('canvas'); wrap.appendChild(canvas); viewer.appendChild(wrap); pages.push({ wrap, canvas, pageNo: i, rendered: false, task: null }); }aspect-ratio撑出来的占位高度和真实渲染高度可能差几个像素,正常情况下看不出来,遇到混排了不同尺寸页面的 PDF 就会有轻微跳动,这种情况在第 6 章的按需渲染里可以顺手修正:每页渲染完成后回写一次真实高度。占位做完,滚动高度就固定了,接下来要解决的是「一屏到底画多少页」,这件事和清晰度、内存纠缠在一起,放到下一章一起说。
3. 清晰度与缩放:dpr、canvas 面积上限和手势缩放
渲染出来的 PDF 发虚,十有八九不是 pdf.js 的问题,而是 canvas 的位图尺寸和 CSS 尺寸被当成了一回事。canvas 有两个尺寸:width/height是位图实际的像素数,style.width/height是它在页面上占的逻辑像素。只设前者不设后者,图会被拉伸;只设后者不设前者,位图按 300×150 画,再高清的 PDF 也糊。移动端的devicePixelRatio通常是 2 到 3,意味着一个 CSS 像素背后有 4 到 9 个物理像素,不乘 dpr 就等于主动丢掉一半以上的清晰度。
3.1 用 render 的 transform 参数做高清渲染
有两种写法做高清:一种是把 viewport 的 scale 乘上 dpr,再把 canvas 的 CSS 尺寸除回去;另一种是 viewport 保持不变,用render的transform参数在绘制阶段整体放大。第二种更干净,因为 viewport 不用改,文字层、批注层、点击坐标换算全都沿用同一套逻辑,不会出现「画得清楚但点击偏了」的问题。
function setupHiDPI(canvas, viewport, dpr) { const ctx = canvas.getContext('2d'); // 位图按 dpr 放大,CSS 尺寸保持逻辑像素 canvas.width = Math.floor(viewport.width * dpr); canvas.height = Math.floor(viewport.height * dpr); canvas.style.width = `${Math.floor(viewport.width)}px`; canvas.style.height = `${Math.floor(viewport.height)}px`; // 绘制坐标系整体放大 dpr 倍,viewport 逻辑不变 const transform = dpr === 1 ? null : [dpr, 0, 0, dpr, 0, 0]; return { ctx, transform }; } const dpr = Math.min(window.devicePixelRatio || 1, 2); // 上限截到 2,收益递减 const { ctx, transform } = setupHiDPI(canvas, viewport, dpr); await page.render({ canvasContext: ctx, viewport, transform }).promise;为什么要把 dpr 截到 2:从 2 提到 3,像素量涨了 2.25 倍,肉眼看 PDF 正文的提升非常有限,但渲染耗时和内存是实打实翻倍。我把 dpr 的上限做成可配置项,低端机型走 1.5,主力机型走 2,只有明确要给用户看图纸、看排版细节的场景才放开到 3。这个值不需要动态探测设备型号,按navigator.hardwareConcurrency和首屏渲染耗时做一次粗判就够了,探测逻辑越复杂,线上表现越玄学。
3.2 canvas 面积上限:白屏的常见来源
部分移动端机型的 canvas 有面积上限,超过之后不是报错,而是直接给你一张空白图,控制台干干净净,这种「无提示失败」最难查。经验值是单张 canvas 控制在 1600 万像素以内比较安全,A4 页面在 dpr=2 时大约 1200 万像素,已经贴着边了,一旦用户再叠一层手势放大,立刻越界。
const MAX_PIXELS = 16_000_000; // 单张 canvas 的像素总量上限,按机型保守取 function fitDpr(viewport, wantedDpr) { const area = viewport.width * viewport.height; const maxDpr = Math.sqrt(MAX_PIXELS / area); // 取「想要的 dpr」和「面积允许的 dpr」的较小值,但不低于 1 return Math.max(1, Math.min(wantedDpr, maxDpr)); }这个函数要在每次渲染前调用,而不是初始化时算一次。因为缩放之后 viewport 变了,允许的 dpr 也跟着变——放大两倍时该降 dpr,缩小回去时该升回来。参数上我一般这样定:
| 参数 | 取值建议 | 说明 |
|---|---|---|
| dpr 上限 | 2(低端机 1.5) | 再高视觉收益有限,内存与耗时翻倍 |
| MAX_PIXELS | 16000000 | 保守阈值,超过后部分机型直接白屏 |
| 首屏预渲染页数 | 2 | 第一页 + 下一页,保证快速下滑不空白 |
| 缩放区间 | 1.0 ~ 3.0 | 小于 1 用 CSS 缩小即可,不必重绘 |
3.3 双指缩放:手势期用 transform,结束后再重绘
很多人第一版会把缩放做成「每次touchmove都重新算 viewport 重绘」,然后在真机上发现帧率掉到个位数。一页 A4 在 dpr=2 下重绘一次大概 50 到 150 毫秒,手势事件的频率远高于这个量级,主线程直接被塞满。正确做法是分两段:手势进行中用 CSStransform: scale()做视觉预览,它走合成层,不掉帧;手势结束后防抖一段时间,再按新的 scale 重新渲染一次清晰版本。
let preview = 1; // 手势期间的临时缩放 let committed = 1; // 已重绘的正式缩放 let timer = null; viewer.addEventListener('touchmove', (e) => { if (e.touches.length !== 2) return; const dist = Math.hypot( e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY ); preview = Math.min(3, Math.max(1, dist / startDist)); // 只改 transform,不触发重绘 pageWrap.style.transform = `scale(${preview})`; }, { passive: true }); viewer.addEventListener('touchend', () => { clearTimeout(timer); // 手势停稳 200ms 后再重绘,避免连续缩放时反复重算 timer = setTimeout(async () => { committed = preview; pageWrap.style.transform = ''; await renderPage(pdf, pageNo, canvas, containerWidth * committed); }, 200); }, { passive: true });三种缩放方案的取舍,我自己的结论是混合:
| 方案 | 手势流畅度 | 清晰度 | 内存 | 适用 |
|---|---|---|---|---|
| 纯 CSS transform | 高 | 放大后模糊 | 低 | 手势过程中的预览 |
| 每次手势重渲染 | 低 | 高 | 中 | 桌面端鼠标滚轮 |
| 预览 + 结束后重绘 | 高 | 高 | 中 | 移动端默认方案 |
3.4 旋转与容器变化
屏幕旋转、侧边栏展开、字体大小调整,都会让容器宽度变化,进而让原来的 scale 失效。用ResizeObserver监听容器比监听window.resize准,因为很多布局变化并不伴随窗口尺寸变化。回调里不要立刻重渲染,同样走防抖;重渲染之前先把 canvas 的width/height置 0 再重新赋值,某些机型上连续改大尺寸而不重置会复用旧的绘图缓冲区,出现半张旧图叠在新图上的怪现象。
4. 中文文档与文字层:cMapUrl、标准字体和扫描件判定
PDF 里的「字」有两条独立的链路:一条是字形,决定你在 canvas 上看到什么;一条是字符编码,决定你复制出来、搜索到的是什么。西文文档两条链路基本重合,中文、日文、韩文这类用 CID 字体的文档就不一定了——字形可以内嵌在文件里正常显示,但 CID 到 Unicode 的映射表不在文件里,得靠外部的 cmap 数据补齐。这就是为什么很多项目「PDF 显示正常,但用户选中复制全是乱码」,或者反过来「正文一片方框」。
4.1 四个路径参数各管什么
| 参数 | 是否必填 | 作用 |
|---|---|---|
| cMapUrl | 中日韩文档必填 | CID 到 Unicode 的映射表所在目录 |
| cMapPacked | 配合 cMapUrl | 使用二进制打包版 cmap,减少请求数 |
| standardFontDataUrl | 建议填 | 14 种标准字体的替代数据,缺字时兜底 |
| disableFontFace | 移动端可设 true | 用路径绘制字形而非注入 @font-face,减少闪烁 |
cMapUrl结尾的斜杠是硬性要求,内部是字符串拼接而不是路径 join,少一个斜杠就变成cmapsAdobe-Japan1这种不存在的路径,表现是控制台一条 CMap 未找到的警告,然后中文全部缺字。standardFontDataUrl同理。这两个参数我见过不止一次被写成相对路径./cmaps/,在单页应用的深层路由下直接失效,所以统一写成以/开头的站点绝对路径。
const loadingTask = pdfjsLib.getDocument({ url: fileUrl, cMapUrl: '/vendor/pdfjs/cmaps/', cMapPacked: true, standardFontDataUrl: '/vendor/pdfjs/standard_fonts/', // 移动端用路径绘制字形,避免大量 @font-face 注入引起的首屏闪烁 disableFontFace: true });disableFontFace: true不是万能开关。它让 pdf.js 用 canvas 路径绘制每个字形,好处是不用往页面里注入字体规则,坏处是文字层的定位在某些复杂排版下会有 1 到 2 像素偏差。如果页面需要「选中文字做批注」这种精确到行的交互,还是关掉它、让字体走@font-face更稳。
注意:cmaps 和 standard_fonts 目录要跟着版本一起更新。只升级了 build 里的两个 js 文件、没更新数据目录,会出现「大部分文档正常、个别文档缺字」的诡异现象,排查起来非常费时间。
4.2 文字层开不开,取决于你要什么
文字层的作用是让用户可以选中、复制、搜索,以及在 PDF 上做文本高亮。它的实现是把每个文本片段绝对定位到 canvas 上层,页数一多,DOM 节点数量会非常可观,移动端的滚动性能会被拖累。我的做法是分场景:合同、发票这类需要复制关键信息的,开启文字层,但只对可视区域内的页开启;纯阅读类的资料,直接不开,用一张透明层盖住做手势接管就够了。
// 判断一页是不是扫描件:文字项太少基本可以断定是图片 async function isScanned(page) { const content = await page.getTextContent(); const chars = content.items.reduce( (n, item) => n + (item.str ? item.str.trim().length : 0), 0 ); return chars < 20; // 阈值按文档类型调,合同类可以提到 50 }这个判定很实用。扫描件的 PDF 每条文本项都是空的或者只有零星几个字符,渲染出来是一张大图,用户想复制却复制不了,体验落差很大。检测到扫描件之后可以做两件事:一是页面上给个提示,说明这是图片版、不支持选中;二是后端加一层 OCR 把文本补回去,前端就不用改。别在前端硬做 OCR,模型体积和首屏时间都扛不住。
4.3 打包与缓存的边界
cmaps目录放静态服务器上时,记得给它长缓存加内容哈希。这些文件内容不会变,但版本升级时文件名也不变,浏览器缓存住旧文件、新代码去请求它,就会出现难查的字体问题。我一般的处理是把整个vendor/pdfjs/目录按版本号切一层路径,比如/vendor/pdfjs/v1/、/vendor/pdfjs/v2/,升级时改一处常量,缓存自然失效,比在文件名上加哈希省事。另外文字层的 CSS 是单独一份,别漏了,漏了之后文字位置全叠在左上角,看上去像页面炸了。
5. 避坑与排查:五类线上现象的定位顺序
线上的 PDF 问题有个共同点:报错信息几乎都在浏览器控制台里,而用户只会告诉你「打不开」。所以排查的第一步永远是让用户或者客服把控制台截图发过来,第二步才是按下面这几类对号入座。这五条是我这两年反复遇到的,按出现频率排。
5.1 白屏且提示 API 与 Worker 版本不匹配
现象:页面一片空白,控制台出现 API 与 Worker 版本不一致的提示,或者 fake worker 加载失败。
原因:主 API 和 worker 不是同一次构建产物。常见于把 worker 放在静态目录、API 走打包工具,升级依赖时只换了一边;也可能是 worker 路径 404,pdf.js 降级到 fake worker 后仍失败。
解决:把两个文件放进同一个目录、同一次拷贝,路径统一用站点绝对路径。打开开发者工具的网络面板搜worker,看这个请求是不是 200、返回的 MIME 类型是不是 JS。返回 HTML 说明被路由兜底了,需要给静态目录加白名单。
5.2 中文显示成方框,或复制出来是乱码
现象:西文 PDF 一切正常,中文文档大面积缺字、方框,或者视觉正常但复制粘贴是乱码。
原因:cMapUrl没配、路径少斜杠、目录没上线,三种情况占九成。
解决:先在网络面板过滤cmaps,看有没有请求、返回状态是什么。没请求说明参数没生效;404 说明路径错了。确认路径后在代码里临时打一行日志,把拼接出来的完整 URL 打印出来,直接粘到地址栏试一次,能下载就说明路径对了。
5.3 加载报跨域,或者只有部分用户能打开
现象:控制台出现跨域相关报错,或者签名链接过期后 403,刷新一次又好了。
原因:直接把第三方存储的地址传给url,对方没开跨域头;或者签名链接有效期太短,用户打开页面时已经过期。
解决:优先让后端做一层同源代理输出,前端只认自己的域名,跨域和签名问题一起消失。做不到代理就配好跨域头并暴露必要的响应头,签名有效期至少覆盖一次完整阅读时长。文档不大(十几兆以内)时也可以先fetch成ArrayBuffer再传data,绕开 pdf.js 内部的区间请求,但要注意内存峰值。
提示:
disableAutoFetch打开后,pdf.js 依赖服务端支持区间请求。如果代理层把Range头吃掉、每次返回完整文件,翻页反而更慢,这一点联调时一定要抓包确认。
5.4 长文档越滑越卡,内存一路涨
现象:前 10 页很顺,滑到 40 页之后开始掉帧,切到后台再回来直接崩。
原因:每页都创建了 canvas 并且渲染完就不管,几十张高清位图常驻内存;page对象也没有释放。
解决:只保留可视区前后各一到两页的 canvas 内容,其余页把canvas.width/height置 0 并把样式尺寸留着占位,同时调用page.cleanup()。渲染要串行排队,不要并发十几页。具体实现见下一章。
5.5 放大后局部白块或整页空白
现象:默认大小正常,双指放大到某个倍数后出现白块,退回去又好了。
原因:缩放后的 canvas 像素总量超过了机型上限,绘制被静默丢弃。
解决:用 3.2 的fitDpr在每次渲染前把 dpr 压到面积允许的范围内,并给缩放倍数设上限。回归验证时不要只在自己手机上试,找一台内存偏小的老机型,连续放大缩小十次,观察是否出现白块。
6. 进阶:按需渲染与资源回收,把长文档的内存压住
真正让长文档跑得动的不是更快的渲染,而是「不该画的绝对不画」。我用IntersectionObserver做可视区调度,root指向滚动容器,rootMargin给上下各留 200% 的预渲染窗口——太小了快速滑动会看到空白,太大了等于没省。观察回调里只做两件事:进入窗口就丢进渲染队列,离开窗口就回收。
const queue = []; let running = false; // 串行渲染队列:同一时刻只画一页,避免主线程被多页渲染塞满 async function pump() { if (running) return; running = true; while (queue.length) { const item = queue.shift(); if (item.rendered || !item.visible) continue; item.rendered = true; await renderPage(pdf, item.pageNo, item.canvas, containerWidth); } running = false; } const observer = new IntersectionObserver((entries) => { for (const entry of entries) { const item = pages[Number(entry.target.dataset.index)]; item.visible = entry.isIntersecting; if (entry.isIntersecting && !item.rendered) { queue.push(item); pump(); } else if (!entry.isIntersecting && item.rendered) { // 回收:位图置 0 释放显存,CSS 尺寸留着由占位撑开 item.canvas.width = 0; item.canvas.height = 0; item.rendered = false; item.pageRef?.cleanup(); } } }, { root: viewer, rootMargin: '200% 0px', threshold: 0 }); pages.forEach((p) => observer.observe(p.wrap));几个参数值得单独调:rootMargin的纵向值决定预渲染距离,我按「一屏高度 × 2」估,滑动快的列表页可以再加;threshold保持 0,只要有一像素进入就算;队列必须是串行的,并发渲染三页在低端机上会直接把首屏拖到两秒以上。page.cleanup()要在位图释放之后调,顺序反了会留一堆中间对象。
验证有没有效果,别靠感觉。我的做法是滑到第 50 页时在控制台数一下document.querySelectorAll('canvas')里width > 0的数量,正常应该稳定在 3 到 5 之间;再用开发者工具的内存快照对比滑动前后的常驻对象数,如果每滑十页涨一万个对象,说明page引用没释放。这套动作我现在每次改渲染逻辑都会走一遍,因为内存问题在开发机上几乎测不出来,只有真机滑够页数才现形。希望帮到你。
本文还有配套的精品资源,点击获取