首页 / 资讯中心 / 文章详情

自建PDF在线查看器:基于PDF.js实现渲染、翻页、缩放与搜索的完整方案

自建PDF在线查看器:基于PDF.js实现渲染、翻页、缩放与搜索的完整方案 ★ FEATURED ARTICLE
简介面向前端开发者此 RAR 压缩包聚焦 JavaScript 在浏览器中的在线 PDF 预览实现基于 Mozilla 开源的 PDF.js 方案覆盖库引入、文档加载、Canvas 渲染、分页浏览、缩放与进度提示等关键环节可直接迁移到 OA 文档管理、电子合同预览等真实业务。压缩包内共 402 个文件其中 168 个 bcmap 用于字体编码映射、124 个 properties 提供配置、86 个 png 与 9 个 svg 支撑界面展示并包含 PDF.js 核心 js、示例 html/css 与演示 pdf整体 3.06MB轻量易集成。该资料已有 3142 人学习或下载。借助配套示例代码可掌握 getDocument、page.render 等核心 API 的调用方式理解多页遍历、Canvas 视口尺寸计算与 Web Worker 性能优化等要点UniCNS、UniGB 系列 bcmap 文件用于解决中文 PDF 的字符映射问题。包内还涉及加载进度监听、缩放参数调整等可扩展点目录按依赖文件组织方便直接复制进前端工程适合需要快速搭建 PDF 预览模块的初中级开发者参考使用。1. 在线 PDF 查看器从“手机上一片白”到自建渲染流程某天产品反馈说合同预览页在电脑上好好的手机上一打开就是一片白。我下意识回了一句“浏览器自带查看器不管这事吗”结果发现 window.open 直接把 PDF 扔给浏览器桌面端 Chrome 确实没问题但到了微信内置浏览器和部分安卓 WebView既不能缩放也看不清小字甚至直接白屏。因为手头正好在折腾 JavaScript 在线查看 PDF 这件事我干脆把渲染、翻页、缩放、搜索全部收到自己的页面里来做。这套东西的核心是基于开源的 PDF 渲染库在浏览器端完成解码和 canvas 绘制UI 层完全由项目自己控制。跑起来之后桌面端和移动端行为一致文本还能选中、能搜索工具栏也能按业务需要随意加减。适合想把 PDF 阅读能力嵌进自家系统的前端团队也适合给产品里需要预览合同、报告、课程资料的场景打底。下面从选型、结构、核心代码到改造思路把整个项目拆开讲透。2. 技术选型与项目结构为什么放弃 iframe改用 PDF.js 自己渲染2.1 三个方案选型iframe、PDF.js、后端转图片动手之前我先把“在网页里看 PDF”的现成路子捋了一遍大致是下面三种。这个对比表后来也成了我在团队里说服大家重写查看器的依据。方案优点缺点适用场景iframe / embed 直出零代码浏览器原生支持移动端行为分裂样式不可控无法定制工具栏只在内网桌面浏览器用PDF.js 前端渲染交互完全可控文本可选、可搜索桌面/移动端一致需要写一些胶水代码依赖体积偏大产品内嵌阅读器、文档预览后端转图片实现简单兼容性最好后端资源开销大文本信息丢失放大模糊纯展示、不允许复制的场景我最终选了 PDF.js。判断依据很简单iframe 在手机上的表现太不可控后端转图片又丢失了文本层连搜索和复制都做不了。而 PDF.js 是专门做 PDF 解析渲染的库浏览器端就能完成解码和绘制UI 层完全由自己决定想加工具栏、缩略图、记忆进度都行。缺点是需要把渲染流程摸清楚包括 worker 配置、canvas 尺寸、渲染任务取消这些细节但它值得投入。2.2 目录结构与依赖清单pdfjs-dist 的正确引入姿势项目结构不复杂我用的是单页加工具模块的方式方便后面二次开发时单独改。整体目录长这样pdf-online-viewer/ ├─ index.html ├─ src/ │ ├─ main.js # 入口初始化查看器 │ ├─ viewer.js # 渲染、翻页、缩放的核心逻辑 │ └─ styles.css # 查看器样式 ├─ vendor/ │ └─ pdfjs/ # pdfjs-dist 的本地拷贝或 npm 引入 └─ package.json依赖其实只有一个核心包我在项目里用 npm 安装npm install pdfjs-distindex.html 里的查看器容器布局是后面所有渲染的前提我一般这样写div idviewer-container canvas idpdf-canvas/canvas div idtext-layer/div /divcanvas 画 PDF 内容text-layer 叠在 canvas 上方承载可选中的文字。这个 text-layer 必须和 canvas 在同一个相对定位的父容器里否则文字坐标和图像对不上。后面章节会重点讲文本层现在先记住这个结构。这里有个容易翻车的地方pdfjs-dist 的 worker 不能直接用默认路径必须显式指定 workerSrc否则渲染时会在控制台报 API 版本不匹配或者干脆不渲染。常见做法是在入口处这样注册import * as pdfjsLib from pdfjs-dist; import PdfWorker from pdfjs-dist/build/pdf.worker.min.js?url; pdfjsLib.GlobalWorkerOptions.workerSrc PdfWorker;第三行里的?url是打包工具Vite的写法会把 worker 文件作为独立资源导出。如果你用的是 Webpack可以用new URL(pdfjs-dist/build/pdf.worker.min.js, import.meta.url)拿地址。不设置 workerSrc 时pdfjs 会尝试从 CDN 加载默认 worker内网环境就直接挂掉。所以这一步不是玄学是必须做的。2.3 加载 PDF从 URL 到 PDFDocument 的初始化链路查看器的入口是拿一个 PDF 地址转成 PDFDocument 对象。下面是 main.js 里初始化加载的代码function loadPdf(pdfUrl) { const loadingTask pdfjsLib.getDocument({ url: pdfUrl, withCredentials: false, password: , }); loadingTask.promise.then((pdfDoc) { console.log(PDF 加载成功共 ${pdfDoc.numPages} 页); initViewer(pdfDoc); }).catch((err) { if (err.name PasswordException) { // 加密 PDF需要弹出密码框重新加载 askForPasswordAndRetry(pdfUrl); return; } if (err.name InvalidPDFException) { showError(文件不是有效的 PDF); return; } console.error(PDF 加载失败, err); showError(err.message); }); }有几个参数值得说明。url可以是绝对地址、相对地址也可以是 ArrayBuffer 或 Uint8Array这在后面做上传本地文件预览时很有用。withCredentials控制请求是否携带 cookie如果 PDF 接口需要登录态要把它设为 true。password用于加密 PDF传空字符串表示不处理密码。注意loadingTask是一次性对象加载失败后不能重新调用同一个 task 的 promise必须重新 getDocument 一次。这也是我见过很多人踩的坑第一次加载失败后以为再 then 一次就行结果一直卡在同一个 rejected promise 上。加载完成后拿到的 pdfDoc 就是后续所有操作的入口numPages是总页数getPage(pageNum)获取具体页面对象。从这一步开始项目进入核心渲染环节。3. 核心模块拆解渲染、翻页与缩放的关键实现3.1 渲染第一页viewport、canvas 尺寸与 render 的配合PDF.js 的渲染模型是先把页面解析成绘图指令再通过 canvas 的 2d context 执行绘制。所以最核心的函数是 renderPage负责把某一页画到 canvas 上。我第一次写的时候直接把 viewport 的宽高塞给 canvas结果就是手机上看永远发虚。正确的写法是区分 CSS 尺寸和绘图缓冲区尺寸async function renderPage(pdfDoc, pageNum, canvas, scale) { const page await pdfDoc.getPage(pageNum); const viewport page.getViewport({ scale }); const dpr window.devicePixelRatio || 1; canvas.width Math.floor(viewport.width * dpr); canvas.height Math.floor(viewport.height * dpr); canvas.style.width ${viewport.width}px; canvas.style.height ${viewport.height}px; const renderContext { canvasContext: canvas.getContext(2d), viewport: viewport, transform: dpr ! 1 ? [dpr, 0, 0, dpr, 0, 0] : null, }; return page.render(renderContext); }这里的逻辑是viewport 是 PDF.js 计算出来的 CSS 像素尺寸canvas.width 是绘图缓冲区的物理像素尺寸canvas.style.width 是元素在页面上的展示尺寸。dpr 不为 1 时如果只设置 canvas.width 而不乘 dpr在 2 倍屏上就会出现“100px 的内容被拉到 200px 宽”于是字体发虚、线条像被水泡过。transform 参数是让绘制时的坐标系统同时考虑缩放因子把内容在绘制阶段就放大到物理像素。page.render() 返回的是一个 RenderTask 对象它带一个 promise绘制完成或取消时会 resolve 或 reject。这个返回值在翻页场景里非常关键先留着。3.2 翻页逻辑渲染任务的取消与并发保护翻页看起来只是 pageNum 加一减一但如果你直接连着点下一页很容易看到上一页的残影或者内容错乱这是因为连续两次 render 在同一个 canvas 上互相覆盖。PDF.js 的 RenderTask 提供了 cancel() 方法可以在新渲染开始前取消上一次渲染。我一般会在查看器里维护一个渲染锁let currentPage 1; let renderTask null; let rendering false; async function gotoPage(pdfDoc, target, canvas, scale) { if (rendering) { renderTask?.cancel(); renderTask null; } currentPage target; rendering true; try { const task await renderPage(pdfDoc, currentPage, canvas, scale); renderTask task; await task.promise; } catch (err) { if (err?.name RenderingCancelledException) return; throw err; } finally { rendering false; renderTask null; } }这样做的关键是每次渲染开始前先对上一次的 renderTask 调用 cancel()。PDF.js 被取消的渲染任务会抛出一个 RenderingCancelledException这个异常是预期内的直接吞掉就好。rendering标志位用来防止同一个 canvas 被两段渲染逻辑同时写入如果用户在渲染过程中点了三下下一页最终只会保留最后一次渲染结果。我见过不少人省掉 cancel 这一步只用一个 loading 遮罩结果用户快速点翻页后页面偶尔出现上一页和当前页叠在一起的情况这正是没取消旧渲染的典型症状。翻页按钮的事件绑定也很简单监听 click把目标页码传给 gotoPage页码范围限制在 1 到 pdfDoc.numPages 之间。3.3 缩放与高清屏适配为什么不直接改 CSS缩放需求一般来自两个入口工具栏的 /- 按钮和手势缩放。最简单的实现是改 canvas 的 style.width但 PDF.js 的坐标体系是固定视口直接改 CSS 只会把画布拉伸文字会糊点击区域也会错位。正确做法是重新计算 scale然后重新走一遍 renderPagefunction zoomTo(pdfDoc, canvas, nextScale) { const baseViewport pdfDoc.getPage(currentPage).getViewport({ scale: 1 }); const containerWidth canvas.parentElement.clientWidth; // 以 100% 为基准限制最小 50%最大不超过容器宽度的 2 倍 const minScale 0.5; const maxScale Math.max(1, (containerWidth / baseViewport.width) * 2); const finalScale Math.min(Math.max(nextScale, minScale), maxScale); renderPage(pdfDoc, currentPage, canvas, finalScale); currentScale finalScale; }这里的 baseViewport 是 scale1 时的原始尺寸用来算当前容器能放下多少。限制缩放范围是为了避免用户把画面缩到 0.01 倍后找不回来或者放到 20 倍后把 canvas 撑爆。PDF.js 对超大 canvas 的内存占用很敏感超过一定物理尺寸可能直接白屏所以 maxScale 要设上限。关于手势缩放我一般会监听容器的 touchmove 事件用两个触点的距离变化来算 scale 增量然后做一层节流避免渲染频率太高。缩放过程中还要同步更新文本层的 viewport否则文字和图像会错位。为了减少高频缩放带来的卡顿我习惯在缩放结束后再重渲染而不是每次 touchmove 都渲染。3.4 文本层与搜索让 PDF 不只是图片canvas 渲染出来的 PDF 本质上是一张位图用户没法选中文字CtrlF 也没反应。想让 PDF 可搜索、可复制需要额外渲染一个文本层。PDF.js 的文本层会把每个文字的位置算出来然后输出成透明的 HTML 元素叠在 canvas 上。这个项目里用的是 pdfjs-dist 4.x 的写法const textLayerDiv document.getElementById(text-layer); textLayerDiv.innerHTML ; const textContent await page.getTextContent(); const textLayer new pdfjsLib.TextLayer({ textContentSource: textContent, container: textLayerDiv, viewport: viewport, }); await textLayer.render();文本层有几个细节要注意。第一text-layer 这个 div 必须和 canvas 位于同一个相对定位的容器里而且要设置 pointer-events: none否则用户点击会被透明层挡住翻页按钮就点不动了。第二viewport 必须和渲染 canvas 时用的 viewport 完全一致包括 scale否则文字位置和图像对不上。第三每次翻页或缩放后都要清空并重新渲染文本层复用上一次的 DOM 会导致文字重叠。搜索功能是文本层的自然延伸。拿到 textContent 后可以遍历 items 找到匹配关键词的片段在对应的 span 上加高亮样式。要注意的是PDF 里的文字可能被拆成多个 fragment所以匹配时最好对连续片段做合并再比对function searchKeyword(page, keyword) { return page.getTextContent().then((textContent) { const matches []; let buffer ; textContent.items.forEach((item, idx) { buffer item.str; if (buffer.toLowerCase().includes(keyword.toLowerCase())) { matches.push(idx); buffer ; } }); return matches; }); }这个思路做出来的搜索体验和浏览器 DevTools 里 CtrlF 的效果很接近。拿到匹配索引后在文本层对应 span 上加高亮 class再滚动到第一个匹配位置就算完成一个搜索交互了。4. 常见问题排查加载、白屏与渲染的五个踩坑记录这一章把我实际遇到过的问题按阶段拆成两类每条都是“现象 → 原因 → 解决”的结构照着排查比瞎试快得多。4.1 加载阶段跨域、白屏与本地文件第一个坑本地双击打开 index.htmlPDF 区域全白控制台报 CORS 相关错误。现象是文件没加载出来原因在于 PDF.js 默认用 fetch 去拉 PDF而 file:// 协议下浏览器不允许跨源读取本地文件。解决方法是本地起一个静态服务比如npx serve .或者把 PDF 请求转发到同域接口。如果生产环境 PDF 在 CDN 上记得给存储桶配 CORS 规则并把 getDocument 的 withCredentials 设为 false。第二个坑页面能加载、控制台也没报错但 canvas 是 0 高度。现象是画布区域完全没撑开原因一般是 canvas.width 和 canvas.style.width 只设了其中一个导致 CSS 尺寸为 0。解决方法是回到 renderPage 函数确认 canvas.width、canvas.height、style.width、style.height 四个值都设置到位再调用 render。检查顺序上先看元素高度再看 devicePixelRatio 是否把缓冲区尺寸撑得过大。4.2 渲染阶段画布残影、打包报错、字体模糊第三个坑快速翻页时canvas 上偶尔叠着前一页的内容。现象是上一页的残影留在当前页上原因是没有取消上一次的 RenderTask旧渲染的后半段覆盖了新一轮的绘制。解决方法是每次 gotoPage 前调用 renderTask.cancel()并在 catch 里忽略 RenderingCancelledException。我在 3.2 节的写法可以直接抄来用。第四个坑在 Webpack 项目里引入 pdfjs-dist 后打包报错 “Can not resolve module canvas”。现象是构建直接失败原因在于 pdfjs-dist 的 Node 版本依赖了一个可选的 canvas 模块打包工具错误地把它当成必需依赖。解决方法是把 canvas 标记为 external或者改用对应框架封装好的浏览器版入口。用 Vite 时通常不会碰到这个问题但 Webpack 老项目很容易踩到。第五个坑放大两倍后文字发虚手机端特别明显。现象是字体边缘有毛边原因是没有把 devicePixelRatio 乘进 scalecanvas 物理像素不足。解决方法是按 3.1 节的方式处理 dpr在 renderContext 里加 transform。这个坑最迷惑的地方在于桌面显示器上不明显一上手机就穿帮所以调试时最好直接用开发者工具的设备模拟模式。5. 二次开发把查看器改造成能上线的阅读器基础渲染稳定后大部分改造都围绕“交互”和“状态”展开。以我的习惯会先把工具栏、进度记忆和加载进度做掉这三件事直接决定用户体感。5.1 保存阅读进度和加载进度对于合同、报告这类长文档用户关掉页面再打开最好回到上次读到的位置。实现很直接在翻页时写入 localStorage初始化时读出来function saveProgress(pageNum) { localStorage.setItem(pdf:lastPage, pageNum); } function restoreProgress() { const saved Number(localStorage.getItem(pdf:lastPage)); return Number.isNaN(saved) ? 1 : saved; }注意这里要用 Number() 转换因为 localStorage 存进去的是字符串直接用|| 1在某些边界情况下会误判。恢复进度时先渲染目标页再渲染文本层顺序不能反。PDF 文件大的时候首屏空白会让人觉得卡死了。pdfjs-dist 的 loadingTask 有 onProgress 回调可以用来做进度条const loadingTask pdfjsLib.getDocument({ url: pdfUrl }); loadingTask.onProgress ({ loaded, total }) { const percent total ? Math.round((loaded / total) * 100) : 0; updateProgressBar(percent); };5.2 加搜索和全屏搜索按 3.4 节的思路做高亮全屏则用 Fullscreen API 把查看器容器挂到 documentElement 上。工具栏旋转时记得 viewport 要带 rotation 参数旋转后 canvas 宽高互换必须重新计算。const viewport page.getViewport({ scale: currentScale, rotation: 90 });最后说句经验之谈这个查看器我从第一版只显示一页改到后来支持缩放、搜索、进度记忆中间翻过好几次车有一次甚至是上线后才发现文本层遮住了翻页按钮。从那以后我每次给查看器加新功能都会强制走一遍“加载→翻页→缩放→搜索→恢复进度”这条完整链路确认没有交互遮挡和渲染残留再交付。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站