H5 PDF在线阅读:PDF.js渲染、懒加载与移动端适配
发布时间:2026/10/24 4:20:43 锦皓数字建站

简介这份压缩包围绕“H5自动展示PDF文件”场景提供基于Mozilla开源库PDF.js的网页端PDF渲染方案适合需要在HTML5页面内嵌预览PDF的前端开发者与项目实践者。包内共402个文件约3.67MB以168个bcmap、124个properties字体映射与配置资源为主配合86个png、9个svg界面素材以及js、css、html示例文件和license等说明覆盖PDF.js运行所需的脚本、样式、字体与展示资源便于快速集成与二次调整。已有1224人学习下载说明其在H5 PDF预览方向具有一定参考价值。读者可据此理解PDF.js将文档解析并绘制到Canvas的机制参考现成的页面示例、字体资源与UI素材完成嵌入展示、缩放翻页等基础功能并为自定义工具栏、渲染质量与性能优化提供可复用的目录结构。1. 从「点开就下载」到页内阅读H5 里为什么要自己渲染 PDF在 H5 页面里挂一个 PDF 链接用户点开要么跳去下载要么只看到第一页就没了下文。绝大多数移动端浏览器的内置 PDF 能力就是一次性渲染翻页、缩放、选中文字全无安卓 WebView 更干脆iframe srcx.pdf往往直接触发下载。所以这条路绕不开 PDF.js用纯 JS 把 PDF 解析出来画到 Canvas 上翻页节奏、缩放级别、内存回收全部由页面自己说了算。它适合合同预览、发票查看、课程讲义、设备说明书这类「必须在页面里读完、不能让用户跳走」的场景。这份资源能解决的核心问题只有一个——让 PDF 在 H5 里像普通长页面一样滚动阅读。2. 接入 pdf.js从构建产物到第一屏 Canvas 的完整链路2.1 三种接入姿势先算清楚体积账H5 项目引 PDF.js 通常三条路CDN 全局脚本、npm 包配打包器、自托管静态目录。选哪种不看喜好看你的构建链和部署环境。方式适用工程落地要点主要风险全局脚本legacy / UMD 构建无构建的老页面、模板渲染主库脚本 worker 脚本两个文件版本靠手写锁定容易漂移npm 打包器ESMVue / React / Vite / webpack 工程worker 路径交给打包器解析多份依赖共存导致版本错配自托管静态目录内网、离线包、私有部署主库、worker、cmaps、标准字体一起拷目录漏拷导致中文缺字体积上心里要有数主库压缩后几百 KBworker 一兆上下再加 cMaps 目录按需加载不用全下。这意味着首屏不要指望它跟一张图片一样秒开尤其是弱网。我的习惯是主库用preload或者进入阅读页前先静默拉一次用户真正点开时命中缓存。版本必须锁死。package.json里写死小版本号不要用^因为这个库的主库和 worker 之间存在严格的版本校验差一个版本就直接拒绝工作。打包类工程还要检查是不是有两份副本npm ls pdfjs-dist输出里出现两行以上基本就是依赖树里被别的包带进来的旧版本得用overridesnpm或resolutionsyarn强行统一。2.2 最小可运行把第一页画到 canvas不用任何框架先跑通一页确认 worker 和字体资源都到位再往上叠功能。下面这段是可以直接存成 HTML 打开的版本。!DOCTYPE html html langzh-CN head meta charsetutf-8 / meta nameviewport contentwidthdevice-width, initial-scale1, viewport-fitcover / style html, body { margin: 0; height: 100%; background: #525659; } #viewer { height: 100%; overflow-y: auto; -webkit-overflow-scrolling: touch; } .pdf-page { display: block; margin: 8px auto; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.35); } /style /head body div idviewer/div script typemodule import * as pdfjsLib from ./vendor/pdfjs/pdf.mjs; // worker 必须和主库同版本、同源。路径写错不会报致命错误 // 而是退化成“假 worker”整个 PDF 在主线程解析长文档直接卡死 pdfjsLib.GlobalWorkerOptions.workerSrc ./vendor/pdfjs/pdf.worker.mjs; const viewer document.getElementById(viewer); async function boot(pdfUrl) { const loadingTask pdfjsLib.getDocument({ url: pdfUrl, cMapUrl: ./vendor/pdfjs/cmaps/, // 中日韩字体映射中文 PDF 必需 cMapPacked: true, standardFontDataUrl: ./vendor/pdfjs/standard_fonts/ // 内置 14 种标准字体 }); const pdf await loadingTask.promise; const page await pdf.getPage(1); // scale:1 得到的是 PDF 原生点尺寸A4 大约是 595 x 842 const base page.getViewport({ scale: 1 }); const cssWidth Math.min(viewer.clientWidth - 16, 900); const cssScale cssWidth / base.width; // 位图密度上限压到 2是给移动端留的余量 const dpr Math.min(window.devicePixelRatio || 1, 2); const viewport page.getViewport({ scale: cssScale * dpr }); const canvas document.createElement(canvas); canvas.className pdf-page; // 位图尺寸 物理像素CSS 尺寸 逻辑像素两者必须分开算 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; viewer.appendChild(canvas); await page.render({ canvasContext: canvas.getContext(2d, { alpha: false }), viewport }).promise; } boot(/static/manual.pdf).catch((err) { // 线上不能只 console.log用户看到的是白屏 viewer.textContent 加载失败${err err.message ? err.message : err}; }); /script /body /html这段代码的关键不在 API 调用在三个容易被忽略的地方。第一scale和dpr是乘在一起的但用途完全不同。cssScale决定内容在页面里占多宽dpr决定这块位图的清晰度。一次性算进 viewport 是最省事的写法代价是后面做文本层时要单独再算一份逻辑坐标的 viewport这点在第 6 章会细说。第二getContext(2d, { alpha: false })让 Canvas 不带透明通道合成更快PDF 页面本来就不需要背景透明。第三page.render()返回的是一个RenderTask而不是 Promise要取.promise。它同时带一个cancel()方法这是后面做按需渲染和防重复渲染的核心。2.3 参数拆解调错一个就出玄学问题getDocument的参数不算多但每一个调错都有明确的外部表现。参数作用常用取值调错时的表现url/data文档来源二选一URL 字符串或 Uint8Array两个都给会以 data 优先cMapUrlcMapPacked中日韩字符映射表自托管目录末尾必须带斜杠中文变方框或整片空白standardFontDataUrl内置标准字体数据同上部分 PDF 排版错位、行距异常withCredentials跨域请求是否带凭证需要登录态时 true接口 401 或拿不到文件disableAutoFetch关闭自动预取后续分片移动端建议 true开着会一路预下载整份文档disableRange关闭 Range 分片请求服务端不支持 206 时 true不关会卡在加载中disableStream关闭流式解析与上一条配合使用大文件首屏更慢stopAtErrors遇到坏页是否中断默认 false容错更友好设 true 时一个坏页整份打不开cMapUrl这个斜杠是血泪经验。少了斜杠路径拼接会变成cmapsAdobe-GB1-UCS2.bcmap浏览器请求到的是一个畸形 URL控制台看到的是一堆 404但页面上只是中文变成方框非常容易误判成字体问题。我一般会在部署后抽查一次 network 面板确认.bcmap文件返回 200。disableAutoFetch在移动端值得开。默认行为是拿到文件长度后主动拉取后续片段PC 上体验很好但在按流量计费的场景下用户只看了第一页却下了整份 30MB 的文档。开启后改成跟随滚动按需请求代价是快速拖动时会有短暂空白。3. 百页文档不卡占位、懒渲染与回收的三层结构3.1 先读元数据再排页numPages 与占位高度一上来就把 100 页全 render 是新手最常见的写法结果就是页面卡死几秒、内存飙到几百兆、低端机直接崩溃。正确顺序是拿到文档对象先读numPages和第一页的原始尺寸用它们生成占位结构一页都不渲染。async function buildSlots(pdf, container, cssWidth) { // 用第一页的比例作为所有页的初始占位比例A4 大约是 1.414 const first await pdf.getPage(1); const vp first.getViewport({ scale: 1 }); const ratio vp.height / vp.width; const frag document.createDocumentFragment(); const slots []; for (let i 1; i pdf.numPages; i) { const el document.createElement(div); el.className pdf-slot; el.dataset.page String(i); el.style.width ${cssWidth}px; el.style.height ${Math.round(cssWidth * ratio)}px; el.style.margin 8px auto; el.style.background #fff; frag.appendChild(el); slots.push({ pageNo: i, el, canvas: null, task: null, done: false, visible: false }); } container.appendChild(frag); return slots; }占位高度决定了滚动条的总长度。这里用第一页比例是有取舍的混合尺寸的文档比如扫描件和文字页混排会出现后面的页高度不准滚动条轻微跳动。想更准可以在空闲时用requestIdleCallback逐页getPage拿真实尺寸修正但不要放在首屏路径上getPage每页都要走一次 worker 通信100 页就是 100 次往返。getPage返回的 page 对象也要注意它内部持有资源页数多了不能无脑缓存。常见做法是只缓存当前视口附近前后各 3 页的 page 对象其余用完即弃下次重新取——重新取的代价比内存吃满小得多。3.2 可视区触发IntersectionObserver 配单通道渲染队列有了占位接下来要解决「什么时候渲染哪一页」。滚动事件监听是能用的但要自己做节流、自己算每页相对视口的位置代码量不小。我一般直接上IntersectionObserver把视口当成 root上下各留 300px 的提前量。const pending new Map(); // pageNo - slot用 Map 天然去重 let pumping false; const io new IntersectionObserver((entries) { for (const entry of entries) { const slot entry.target.__slot; if (!slot) continue; slot.visible entry.isIntersecting; if (entry.isIntersecting) { enqueue(slot); } else { pending.delete(slot.pageNo); // 划走了就别排了 } } }, { root: viewer, rootMargin: 300px 0px, // 上下提前一屏左右开始准备 threshold: 0.01 }); function enqueue(slot) { if (slot.done || pending.has(slot.pageNo)) return; pending.set(slot.pageNo, slot); pump(); } // 单通道同一时刻只有一个页面在渲染避免多个 render 抢主线程 async function pump() { if (pumping) return; pumping true; while (pending.size) { const [pageNo, slot] pending.entries().next().value; pending.delete(pageNo); if (!slot.visible) continue; try { await renderSlot(slot); } catch (err) { // 单页失败不能中断整个队列否则后面的页永远不渲染 slot.el.dataset.error 1; } } pumping false; }单通道队列是这套结构里最关键的一环。Canvas 渲染虽然是异步的但解码和绘制仍然占主线程时间片同时跑三个 render滚动立刻掉帧。串行化之后用户快速滑动时中间页会被pending.delete掉实际只渲染停下时视野里的那几页反而更快。rootMargin的值要按页面高度调。100px 在慢网下不够用户滑到底部时下面还是白的800px 又会在快速滚动时排进太多页。300px 是我在多数文档类页面上的默认值图文混排、单页较高的场景可以加到 500px。3.3 释放与取消renderTask.cancel 与 canvas 归零渲染函数本身要处理三件事取消上一次未完成的任务、按面积限制 DPR、失败时能重试。const MAX_CANVAS_AREA 16 * 1024 * 1024; // 移动端内核的位图面积上限约 16M 像素 async function renderSlot(slot) { // 同一块 canvas 上有未完成任务时直接再 render 会抛异常必须先取消 if (slot.task) { try { slot.task.cancel(); } catch (e) { /* 已结束的任务取消会抛忽略 */ } slot.task null; } const page await pdf.getPage(slot.pageNo); const base page.getViewport({ scale: 1 }); const cssWidth slot.el.clientWidth || 360; const cssScale cssWidth / base.width; let dpr Math.min(window.devicePixelRatio || 1, 2); const area (base.width * cssScale * dpr) * (base.height * cssScale * dpr); if (area MAX_CANVAS_AREA) { // 超限就按比例往回压压完再留 5% 余量避免边界上仍然超 dpr * Math.sqrt(MAX_CANVAS_AREA / area) * 0.95; } const viewport page.getViewport({ scale: cssScale * dpr }); let canvas slot.canvas; if (!canvas) { canvas document.createElement(canvas); canvas.className pdf-page; slot.el.appendChild(canvas); slot.canvas canvas; } 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; const task page.render({ canvasContext: canvas.getContext(2d, { alpha: false }), viewport }); slot.task task; try { await task.promise; slot.done true; } catch (err) { if (err err.name RenderingCancelledException) return; // 主动取消不算错误 throw err; } finally { slot.task null; } }取消之后task.promise会以RenderingCancelledException拒绝这个异常必须接住否则控制台一片红还会被错误监控平台当成真实故障上报半夜被叫起来看告警。滚动距离远了之后要主动回收位图。Canvas 的位图内存不受 JS 垃圾回收直接控制width 0是最直接的释放手段比remove()更彻底——只移除 DOM 节点位图要等浏览器自己回收中间那段时间内存是双份的。function recycle(slot) { if (slot.task) { try { slot.task.cancel(); } catch (e) {} slot.task null; } if (slot.canvas) { slot.canvas.width 0; slot.canvas.height 0; slot.canvas.remove(); slot.canvas null; } slot.done false; // 允许下次进入视口时重新渲染 }触发时机建议再挂一个 observerrootMargin设成负的两三屏高度页面进入这个「远区」就回收。回收后占位高度不变滚动条不会跳用户滑回去时重新渲染视觉上就是一次短暂的白块比整页崩溃好得多。4. 移动端适配双指缩放、横竖屏与四种文件来源4.1 用 CSS transform 做手势缩放抬手再重绘移动端 H5 看 PDF用户第一动作几乎都是双指放大。有两条路改 viewport 的 scale 重新 render清晰度最好但每动一次手指就渲染一次卡到不能用或者对整层做 CSStransform: scale()是纯合成操作跟手代价是放大后模糊。实际可用的方案是两者结合手势过程中只动 transform抬手后按新的逻辑宽度重排并重绘一次。const layer document.getElementById(pdf-layer); const pinch { startDist: 0, startScale: 1, scale: 1 }; const dist (a, b) Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); const clamp (v, min, max) Math.min(Math.max(v, min), max); layer.addEventListener(touchstart, (e) { if (e.touches.length ! 2) return; pinch.startDist dist(e.touches[0], e.touches[1]); pinch.startScale pinch.scale; }, { passive: true }); layer.addEventListener(touchmove, (e) { if (e.touches.length ! 2) return; e.preventDefault(); // 拦掉内核自身的整页缩放否则两层缩放会打架 const d dist(e.touches[0], e.touches[1]); pinch.scale clamp(pinch.startScale * (d / pinch.startDist), 1, 3); layer.style.transformOrigin center top; layer.style.transform scale(${pinch.scale}); }, { passive: false }); layer.addEventListener(touchend, () { if (Math.abs(pinch.scale - pinch.startScale) 0.01) return; scheduleRerender(); // 抬手后按新宽度重绘把清晰度找回来 }, { passive: true });配套的 CSS 里要写touch-action: pan-y把纵向滚动交给内核横向和缩放自己接管。只写preventDefault不写touch-action部分安卓 WebView 上会出现「拦了缩放但纵向也滚不动」的僵死状态。scale上限设 3 倍是经验值。再大PDF 内容本身的分辨率也撑不住只是在放大马赛克而且重绘后的位图面积很容易触到下一节要说的面积上限。4.2 横竖屏切换的防抖重排横竖屏切换是整个适配里最容易翻车的一环。orientationchange事件触发的那一刻window.innerWidth在很多移动端内核里还是旋转前的旧值直接拿来算宽度重排出来是错的。正确做法是监听matchMedia((orientation: portrait))的change再叠一层延时。这个延时不是玄学是因为布局要在旋转动画结束后才稳定250ms 是能覆盖大多数机型的值。let resizeTimer null; const mq window.matchMedia((orientation: portrait)); function applyLayout() { const cssWidth Math.min(viewer.clientWidth - 16, 900); // 1) 改所有占位 div 的宽高 for (const slot of slots) { slot.el.style.width ${cssWidth}px; slot.el.style.height ${Math.round(cssWidth * ratio)}px; } // 2) 已渲染的页全部作废让可视区的页按新宽度重来 for (const slot of slots) { if (slot.done) recycle(slot); } pending.clear(); for (const slot of slots) { if (isInViewport(slot.el)) enqueue(slot); } } mq.addEventListener(change, () { clearTimeout(resizeTimer); resizeTimer setTimeout(applyLayout, 250); }); // 部分内核只发 resize 不发 orientationchange两条都要挂靠防抖合并 window.addEventListener(resize, () { clearTimeout(resizeTimer); resizeTimer setTimeout(applyLayout, 250); });这里有个取舍要说明横竖屏切换时把所有已渲染页面作废是「重」的做法但也是唯一保证不留半张错位页面的做法。轻量的做法是只重排不重绘靠 CSS 拉伸 canvas 的显示尺寸——清屏模糊用户一眼能看出来。4.3 四种文件来源url、ArrayBuffer、base64、Blob真实项目里 PDF 不一定是一个静态 URL常见四种来源参数写法各不相同。import * as pdfjsLib from pdfjs-dist; // 1) 远程 URL最省事支持 Range 分片适合大文件 const t1 pdfjsLib.getDocument({ url: https://static.example.com/manual.pdf, withCredentials: false, disableAutoFetch: true }); // 2) ArrayBuffer接口返回二进制、或者用户本地选文件 const buf await fetch(/api/file/123).then(r r.arrayBuffer()); // data 会被 transfer 给 worker传完之后原 buffer 变成 detached // 后面再想用 buf.byteLength 会得到 0。要复用就必须传副本 const t2 pdfjsLib.getDocument({ data: buf.slice(0) }); // 3) base64常见于后端把文件塞进 JSON 里返回 function base64ToBytes(b64) { const raw b64.includes(,) ? b64.split(,)[1] : b64; // 兼容 dataURL 前缀 const bin atob(raw); const out new Uint8Array(bin.length); for (let i 0; i bin.length; i) out[i] bin.charCodeAt(i); return out; } const t3 pdfjsLib.getDocument({ data: base64ToBytes(b64Str) }); // 4) Blob上传组件的产物或者接口返回的 blob const blob await fetch(/api/file/123).then(r r.blob()); const t4 pdfjsLib.getDocument({ data: await blob.arrayBuffer() });四种来源的差别不只是写法。URL 方式支持 Range 分片30MB 的文档可以只下前几 MB 就出第一页另外三种全是整包进内存文件大了内存直接翻倍——Uint8Array一份、worker 里的解析结果一份、还有 Canvas 位图。base64 更狠字符串本身比原始二进制大约三分之一传之前最好在后端就把 base64 换掉。Blob 还有一条岔路URL.createObjectURL(blob)拿到一个伪 URL 再传给url参数。好处是能享受 Range 逻辑虽然是内存里的 blob坏处是必须记得revokeObjectURL否则只要页面不关这块内存就一直占着反复打开几次就崩了。5. 五个高频翻车点worker、版本、跨域与白屏的排查顺序5.1 加载阶段文件还没开始解析就失败现象一控制台出现Setting up fake worker failed页面能出图但长文档卡死。原因是 worker 脚本没加载成功。可能是路径写错可能是被 CDN 改了文件名带 hash也可能是打包后被挪到了assets目录而代码里还按源码路径找。退化成假 worker 后整个 PDF 的解析在主线程跑10 页以内感觉不出来100 页就是明显的白屏加卡顿。解决把 worker 放到public目录用固定路径引用或者用new URL(pdfjs-dist/build/pdf.worker.min.mjs, import.meta.url)让打包器接管路径解析。改完在 network 面板确认 worker 请求返回 200且响应内容不是 HTML返回 HTML 说明命中了前端路由的兜底页。现象二The API version x does not match the Worker version y。主库和 worker 版本不一致。npm 工程里最常见的原因是依赖树里被别的包带进了第二份pdfjs-dist打包器挑了一个版本的主库你的workerSrc指向另一个版本。解决方法前面提过npm ls pdfjs-dist查重复用overrides锁死CDN 方式则是把主库和 worker 的地址都写死同一个版本号不要一个用latest一个写具体版本。现象三Failed to fetch或者进度条卡在 0 不动。跨域问题但有两种不同的成因。第一种是 PDF 和页面不同源、服务端没给Access-Control-Allow-Origin。第二种更隐蔽服务端返回了 CORS 头但没暴露Content-Range和Accept-RangesPDF.js 拿不到文件长度Range 分片逻辑走不下去。解决服务端补上Access-Control-Expose-Headers: Content-Range, Accept-Ranges, Content-Length并确保分片请求返回 206 而不是 200。改不了服务端的场景客户端加disableRange: true, disableStream: true整包下载牺牲首屏速度换可用性。5.2 渲染阶段文件解析成功但画面不对现象四Cannot use the same canvas during multiple render() operations。同一块 canvas 上有两个未结束的 render 任务。触发场景很典型用户快速滑动同一页在短时间内被enqueue了两次或者改窗口大小触发了重排旧任务还在跑新任务就上来了。解决就是第 3 章那套renderSlot开头先cancel()掉旧任务队列用 Map 去重。注意cancel()本身会抛异常任务已经结束时要包 try/catch。现象五iOS 上大页面整块或半块空白控制台没有任何报错。这个最坑因为没有任何线索。根因是 Canvas 位图面积超过了移动端内核的限制普遍在 1600 万像素量级超限后getContext和render都不报错就是画不出来。触发条件是三个因素叠加页面本身很大大幅面图纸、横向排版的表格、容器宽度大平板或横屏、DPR 高3 倍屏。解决DPR 上限压到 2再按面积二次 clamp就是第 3 章renderSlot里那段。如果你的文档单页确实超过这个面积只能上分块渲染——把一页切成上下若干块各画到独立 canvas 上拼接复杂度陡增多数业务场景用 clamp 就够了。排查顺序建议固定成先看 network 里 worker 和 pdf 文件的请求状态再看控制台有没有版本或 canvas 冲突的明确报错最后才怀疑渲染参数。前两步能覆盖八成的线上问题直接跳到调参数会在错误的方向上折腾很久。6. 做到「好用」文本层、加密兜底与三个验收指标6.1 文本层让用户能选中、能搜索只有 Canvas 的 PDF 是「图片」用户没法复制合同编号页内搜索也失效。文本层要单独叠一层绝对定位的div或span坐标来自getTextContent()。async function buildTextLayer(page, container, cssWidth) { // 注意这里必须用不含 dpr 的 viewport // 文本层是 DOM坐标系是逻辑像素和位图坐标系差一个 dpr 倍数 const base page.getViewport({ scale: 1 }); const viewport page.getViewport({ scale: cssWidth / base.width }); const content await page.getTextContent(); const layer document.createElement(div); layer.className text-layer; // position:absolute; inset:0; 且 pointer-events 按需开启 for (const item of content.items) { if (!item.str) continue; const tx pdfjsLib.Util.transform(viewport.transform, item.transform); const fontHeight Math.hypot(tx[2], tx[3]); // 变换矩阵里的纵向缩放即字号 const span document.createElement(span); span.textContent item.str; span.style.left ${tx[4]}px; span.style.top ${ p a hrefhttps://download.csdn.net/download/qiuzhihzy123/20978413 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。