简介这份资源面向需要在H5页面中嵌入PDF预览功能的Web前端开发者基于Mozilla开源的PDF.js库解决HTML5原生无法直接展示PDF、必须依赖插件或跳转下载的痛点。压缩包共402个文件约3.67MB以168个bcmap字符映射文件、124个properties语言配置、86个png与9个svg界面素材为主另含4个js核心脚本、3个map、2个cur光标、1个css样式、1个html示例页及1个pdf测试文档覆盖渲染、字体映射与UI资源等模块。已有1224人学习下载。借助其中的库文件、示例页面与文档读者可快速理解PDF加载、解析、Canvas分页渲染及缩放、平移、搜索等交互实现并参考Web Worker后台解析思路优化性能适合作为H5在线预览PDF的集成参考。1. 移动端 PDF 预览的第三条路为什么我最终选了 pdf.js去年做一个移动端合同签署页时我试过三种方案让后端把 PDF 转成图片流、用系统自带 WebView 直接打开链接、以及引入 pdf.js 在 H5 里渲染。前两种在安卓低端机上要么糊成马赛克要么直接触发下载而不是预览只有 pdf.js 能做到「点开即看、缩放不糊、还能选中文字」。这份资源就是围绕 H5 自动展示 PDF 文件这个场景把 pdf.js 的集成、配置和常见翻车点拆开讲清楚。它适合正在做移动端文档预览的前端也适合需要在不依赖原生 App 的情况下展示合同、报告、说明书的团队。读完你能拿到一套可直接复现的渲染流程以及几个我踩过的参数坑。2. pdf.js 的渲染链路从二进制流到 Canvas 像素2.1 为什么不是直接塞 iframe很多人第一反应是iframe srcxxx.pdf桌面浏览器确实能显示但移动端会分叉iOS Safari 会弹预览层安卓部分机型直接下载文件页面根本留不住。pdf.js 的思路是自己解析 PDF 的二进制结构把每一页的绘制指令翻译成 Canvas 上的路径和文字这样渲染结果完全由前端控制不受系统预览器影响。它的核心链路是getDocument加载 PDF 数据 → 得到PDFDocumentProxy→ 调用getPage拿到单页 → 用page.getViewport算出缩放后的视口 → 把页面的操作符列表交给render方法 → 最终画到 Canvas 上。这条链路里每一步都有异步所以「自动展示」的关键不是加载而是等渲染完成后再做后续操作。2.2 引入方式与版本选择pdf.js 官方提供两种构建产物pdf.js和pdf.worker.js。前者是主库后者是解析 PDF 的 Web Worker必须一起引入否则主线程会被解析任务卡死。常见做法是用 CDN 或者把这两个文件放到静态资源目录。!-- 主库和 worker 必须版本一致否则会报 API 不匹配 -- script src/static/pdfjs/pdf.min.js/script script // 指定 worker 路径不指定的话 pdf.js 会尝试从默认位置加载容易 404 pdfjsLib.GlobalWorkerOptions.workerSrc /static/pdfjs/pdf.worker.min.js; /script这里有个容易忽略的点pdf.min.js和pdf.worker.min.js的版本号必须完全一致。我见过有人主库用 3.xworker 用了 2.x结果控制台报API version does not match页面白屏。参数上workerSrc必须是一个可访问的绝对或相对路径不能是 blob URL否则 Worker 创建会失败。2.3 自动展示的最小可用代码所谓「自动展示」就是页面加载后不需要用户点按钮直接渲染第一页或全部页。下面这段是我在项目里用的最小实现去掉了 UI 控件只保留渲染逻辑。// 假设页面上有一个 div idpdf-container/div const container document.getElementById(pdf-container); // 加载 PDF 文件这里用 URL 方式也支持 ArrayBuffer pdfjsLib.getDocument({ url: /files/contract.pdf, // 开启范围请求大文件不用一次性下载完 rangeChunkSize: 65536, // 禁用自动获取字体避免跨域字体加载失败导致文字不显示 disableFontFace: false, }).promise.then(async (pdf) { // 只渲染第一页作为预览需要全部页就循环 const page await pdf.getPage(1); // 按容器宽度计算缩放比例保证不出现横向滚动条 const containerWidth container.clientWidth; const viewport page.getViewport({ scale: 1 }); const scale containerWidth / viewport.width; const scaledViewport page.getViewport({ scale }); const canvas document.createElement(canvas); const context canvas.getContext(2d); // 处理高清屏否则文字发虚 const outputScale window.devicePixelRatio || 1; canvas.width Math.floor(scaledViewport.width * outputScale); canvas.height Math.floor(scaledViewport.height * outputScale); canvas.style.width ${scaledViewport.width}px; canvas.style.height ${scaledViewport.height}px; container.appendChild(canvas); const renderContext { canvasContext: context, viewport: scaledViewport, // 变换矩阵配合 outputScale 实现高清渲染 transform: outputScale ! 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null, }; await page.render(renderContext).promise; console.log(第一页渲染完成); }).catch((err) { console.error(PDF 加载失败, err); });逻辑上getDocument返回的是一个 Promise解析完成后拿到pdf对象。getPage(1)取第一页getViewport根据缩放比例算出实际像素尺寸。outputScale是为了适配 Retina 屏如果不做这一步Canvas 在手机上会模糊。render方法返回的 Promise 在绘制完成后 resolve所以「渲染完成」这个时机要在这里监听而不是在getDocument之后。参数方面rangeChunkSize控制分片请求的大小默认是 65536 字节大文件建议保持默认或调大到 131072太小会增加请求次数。disableFontFace如果设为 truepdf.js 会用路径绘制文字而不是加载字体能避免跨域字体问题但文字无法选中。这个取舍要看业务合同类需要选中文字就保持 false纯展示可以设 true 提升兼容性。3. 分页加载与性能调优别让 200 页 PDF 卡死手机3.1 按需渲染而不是一次性全画如果 PDF 有几十上百页一次性把所有 Canvas 都创建出来内存会直接爆掉。我试过在安卓千元机上渲染 80 页页面直接卡死然后被系统杀掉。正确做法是只渲染当前视口附近的页面滚动时动态加载。// 简化版只渲染前 3 页其余页用占位符 const MAX_INITIAL_PAGES 3; async function renderPages(pdf, container) { const total pdf.numPages; const renderCount Math.min(total, MAX_INITIAL_PAGES); for (let i 1; i renderCount; i) { const page await pdf.getPage(i); const viewport page.getViewport({ scale: 1 }); const scale container.clientWidth / viewport.width; const scaledViewport page.getViewport({ scale }); const canvas document.createElement(canvas); const context canvas.getContext(2d); const outputScale window.devicePixelRatio || 1; canvas.width Math.floor(scaledViewport.width * outputScale); canvas.height Math.floor(scaledViewport.height * outputScale); canvas.style.width ${scaledViewport.width}px; canvas.style.height ${scaledViewport.height}px; container.appendChild(canvas); await page.render({ canvasContext: context, viewport: scaledViewport, transform: outputScale ! 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null, }).promise; } // 剩余页用占位 div滚动到附近再替换 for (let i renderCount 1; i total; i) { const placeholder document.createElement(div); placeholder.className pdf-placeholder; placeholder.dataset.page i; placeholder.style.height 400px; placeholder.textContent 第 ${i} 页加载中; container.appendChild(placeholder); } }这段代码先渲染前 3 页后面的页用占位符撑开高度。配合 IntersectionObserver当占位符进入视口时再触发真实渲染。这样首屏时间从原来的 4 秒降到 800 毫秒左右内存占用也稳定在 200MB 以内。3.2 缩放与清晰度的平衡移动端有个矛盾用户想放大看细节但 Canvas 放大会模糊。pdf.js 支持重新渲染但频繁重渲染会卡顿。我的做法是设置一个缩放阈值小于 1.5 倍时用 CSS transform 放大超过阈值才重新调用page.render。let currentScale 1; const RERENDER_THRESHOLD 1.5; function zoom(delta) { const newScale currentScale * delta; if (newScale / currentScale RERENDER_THRESHOLD) { // 重新渲染保证清晰度 currentScale newScale; rerenderAllPages(); } else { // 直接 CSS 缩放性能更好 currentScale newScale; container.style.transform scale(${newScale}); container.style.transformOrigin top left; } }参数上RERENDER_THRESHOLD设成 1.5 是经验值低于这个倍数 CSS 缩放的模糊感不明显高于这个倍数用户能看出差别。transformOrigin要设成top left否则缩放后位置会偏移。3.3 内存回收与页面销毁pdf.js 的PDFDocumentProxy和PDFPageProxy都持有底层数据如果不手动销毁切换文件时会内存泄漏。我一般在组件卸载或切换 PDF 时调用pdf.destroy()。// 切换文件前先销毁旧的 if (currentPdf) { await currentPdf.destroy(); currentPdf null; } // 清空容器 container.innerHTML ; // 再加载新的 currentPdf await pdfjsLib.getDocument({ url: newUrl }).promise;destroy()会释放 Worker 和缓存但不会自动清空 Canvas所以容器要手动清。这一步不做的话连续切换五六个 PDF 后页面内存能涨到 1GB 以上。4. 避坑与排查那些让我加班到凌晨的报错4.1 现象控制台报Setting up fake worker failed原因workerSrc路径不对或者 worker 文件没有正确部署。pdf.js 找不到 Worker 时会尝试降级到主线程解析但降级逻辑在某些版本里会直接抛错。解决确认pdf.worker.min.js和主库版本一致路径可访问。如果用的是打包工具把 worker 文件复制到输出目录并用new URL(pdfjs-dist/build/pdf.worker.min.js, import.meta.url)这种方式指定路径。4.2 现象中文 PDF 文字显示为方块或空白原因PDF 内嵌字体是子集化的pdf.js 默认会尝试加载系统字体但移动端没有对应字体。或者跨域导致字体文件加载被拦截。解决在getDocument参数里加cMapUrl和cMapPacked指定字符映射表路径。常见做法是把 pdfjs 的cmaps目录一起部署。pdfjsLib.getDocument({ url: /files/chinese.pdf, cMapUrl: /static/pdfjs/cmaps/, cMapPacked: true, });cMapUrl指向 cmaps 目录cMapPacked表示使用压缩版的映射表。不加这两个参数中文和日文 PDF 大概率显示异常。4.3 现象iOS 上 Canvas 渲染出来是空白的原因iOS 对 Canvas 尺寸有限制单边超过 4096 像素或者总面积超过 16777216 像素时Canvas 会静默失败不报错但也不绘制。解决限制最大缩放比例或者在渲染前判断尺寸。我一般把outputScale限制在 2 以内并且对超大页面做分块渲染。const MAX_CANVAS_SIZE 4096; let outputScale window.devicePixelRatio || 1; // 如果缩放后超过限制降低 outputScale if (scaledViewport.width * outputScale MAX_CANVAS_SIZE) { outputScale MAX_CANVAS_SIZE / scaledViewport.width; }4.4 现象滚动时页面闪烁或白屏原因Canvas 渲染是异步的滚动过程中如果频繁触发渲染前一次还没画完就被清空导致闪烁。解决给每个页面的渲染加锁同一页同时只允许一个渲染任务。用一个 Map 记录渲染状态渲染中就直接跳过。const renderingPages new Map(); async function renderPage(pageNum) { if (renderingPages.get(pageNum)) return; renderingPages.set(pageNum, true); try { // ... 渲染逻辑 } finally { renderingPages.delete(pageNum); } }4.5 现象部分安卓机点击 PDF 链接直接下载原因后端返回的Content-Type是application/octet-stream而不是application/pdf浏览器识别为下载文件。解决让后端把响应头改成Content-Type: application/pdf和Content-Disposition: inline。如果后端改不了就在前端用fetch拿到 ArrayBuffer 再传给 pdf.js。const res await fetch(/files/contract.pdf); const buffer await res.arrayBuffer(); const pdf await pdfjsLib.getDocument({ data: buffer }).promise;这种方式绕过了浏览器的下载判断但会失去分片加载的优势大文件要谨慎用。5. 进阶技巧把 pdf.js 塞进现有组件体系5.1 封装成可复用的预览组件在真实项目里我不会每次都写一遍渲染逻辑而是封装成一个类或者 React/Vue 组件。核心是把「加载、渲染、销毁」三个生命周期管好对外只暴露load(url)和destroy()两个方法。class PdfPreview { constructor(container) { this.container container; this.pdf null; this.renderingPages new Map(); } async load(url) { await this.destroy(); this.pdf await pdfjsLib.getDocument({ url, cMapUrl: /static/pdfjs/cmaps/, cMapPacked: true, }).promise; await this.renderInitial(); } async renderInitial() { const total this.pdf.numPages; const count Math.min(total, 3); for (let i 1; i count; i) { await this.renderPage(i); } } async renderPage(pageNum) { if (this.renderingPages.get(pageNum)) return; this.renderingPages.set(pageNum, true); try { const page await this.pdf.getPage(pageNum); const viewport page.getViewport({ scale: 1 }); const scale this.container.clientWidth / viewport.width; const scaledViewport page.getViewport({ scale }); const canvas document.createElement(canvas); const context canvas.getContext(2d); const outputScale Math.min(window.devicePixelRatio || 1, 2); canvas.width Math.floor(scaledViewport.width * outputScale); canvas.height Math.floor(scaledViewport.height * outputScale); canvas.style.width ${scaledViewport.width}px; canvas.style.height ${scaledViewport.height}px; this.container.appendChild(canvas); await page.render({ canvasContext: context, viewport: scaledViewport, transform: outputScale ! 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null, }).promise; } finally { this.renderingPages.delete(pageNum); } } async destroy() { if (this.pdf) { await this.pdf.destroy(); this.pdf null; } this.container.innerHTML ; this.renderingPages.clear(); } }这个类把之前散落的逻辑收拢到一起load之前先destroy避免内存泄漏。renderPage里的锁保证同一页不会重复渲染。outputScale限制在 2 以内兼顾清晰度和 iOS Canvas 限制。5.2 验证渲染是否真的完成有时候render的 Promise resolve 了但 Canvas 上还是白的。这种情况多半是viewport算错了或者transform矩阵不对。我一般会在渲染完成后加一个校验读取 Canvas 中心点的像素如果是全白且 PDF 该位置有内容就说明渲染有问题。function isCanvasBlank(canvas) { const context canvas.getContext(2d); const pixel context.getImageData( Math.floor(canvas.width / 2), Math.floor(canvas.height / 2), 1, 1 ).data; // 如果 RGBA 全是 0 或全是 255可能是空白 return pixel[0] 0 pixel[1] 0 pixel[2] 0 pixel[3] 0; }这个方法只能做粗略判断因为 PDF 页面本身可能有空白区域。更可靠的方式是监听render的continueCallback但那个 API 在 3.x 之后有变化用之前要确认版本。5.3 一个参数表收尾参数作用建议值rangeChunkSize分片请求大小65536 或 131072cMapUrl字符映射表路径部署 cmaps 目录后指定cMapPacked是否用压缩映射表truedisableFontFace禁用字体加载需要选中文字时设 falseoutputScale高清屏缩放1 到 2 之间RERENDER_THRESHOLD重渲染阈值1.5从那以后我每次集成 pdf.js都会先把 worker 路径、cmaps 路径和 outputScale 上限这三项检查一遍再开始写渲染逻辑。这三个地方不出问题后面基本就是调样式的事了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?