
简介面向网页开发与Java技术爱好者这是一份用于构建PDF在线阅读器的项目源码解决网页端直接查看、编辑PDF文档的技术实现问题。资源共8个文件类型包括3个HTML页面、2个JavaScript脚本、2个PDF测试文档及1个项目配置文件压缩包约490KBHTML与JavaScript构成前端交互界面PDF文档用于测试解析与渲染项目配置方便导入开发环境。源码展示了结合PDFBox、iText等开源库进行PDF解析、页面渲染和在线编辑的思路也涉及移动端自适应与网页性能优化。针对在线编辑、页面缩放、移动端触摸操作等场景方案给出了基于HTML5画布的渲染与交互示例并讨论了PDF.js与Java后端协同的可行性。已有1751人学习适合需要快速掌握PDF在线应用开发原理的初中级开发者可作为从零搭建阅读器的实用模板与学习参考。1. 一个能直接在浏览器里读PDF的工程pdf 在线阅读的完整链路在生产环境里我接到最多的需求反而不是“做一个 PDF 编辑器”而是“网页里点开 PDF 别总是弹下载框给我一个能翻页、能缩放、能搜索的阅读页面”。这份源码就是围绕这个场景拆出来的 Web 版 PDF 在线阅读器工程以开源渲染内核 pdf.js 为底座自己实现了文档加载、页面渲染、工具栏、文本选择、书签记忆和移动端适配。它适合两类人——一类是想给 OA、网盘或者知识库快速补上文档预览能力的前端开发者另一类是打算基于 pdf.js 做二次深度定制比如批注、在线编辑的团队。我拆完这份源码最大的感受是把 PDF 画到 Canvas 上只是最基础的一步真正的复杂度全在文本层对齐和长文档的状态管理上。2. 架构与技术选型为什么是 pdf.js 而不是从零自绘2.1 渲染内核对比pdf.js 和 Canvas 自绘的边界先说结论不要自己写 PDF 渲染器。如果你的需求是“在网页里打开 PDF 就能读”自己从零渲染意味着要处理字体解析、字符编码映射、矢量路径还原、页面合成这一整条底层链路。光是让一份带嵌入字体的中文 PDF 在 Canvas 上不出现乱码和错位一个熟练的前端也得踩好几天坑。pdf.js 的价值恰好在这里——它把“输入 ArrayBuffer输出 Canvas 像素和文本坐标”封装成了黑匣子我们只需要关心文件从哪里来以及渲染结果怎么展示。我整理了一张选型对比表可以直接拿去当技术方案的材料方案渲染质量文本选择开发成本维护成本pdf.js 内核高还原度高自带文本层低低社区活跃Canvas 自绘取决于实现深度需另做 OCR 或坐标映射极高高字体问题难解浏览器内置预览高有极低受浏览器版本限制样式难定制这份源码选择的是第一行把 pdf.js 当作依赖所有业务代码都写在项目自己的模块里。这样做的直接好处是文档加载策略、缓存清理、工具栏交互这些“自己的逻辑”可以完全掌控而不需要去改渲染内核的源码。后面你会看到源码里几乎没有改动 pdf.js 的内部代码全部是在它的 API 之上做封装。2.2 项目目录结构与模块职责拿到源码后我先把目录结构过了一遍发现它是典型的前后端分离形态——后端只负责输出文件流所有渲染逻辑都在前端。这和我做文档预览项目的习惯一致PDF 解析是纯前端计算任务没必要占用后端 CPU。pdf-reader/ ├── public/ │ ├── index.html │ └── pdf.worker.min.mjs # pdf.js 的 Worker 线程脚本 ├── src/ │ ├── api/ │ │ └── fileApi.ts # 文件流请求与 URL 转 Blob │ ├── components/ │ │ ├── Toolbar.vue # 缩放、翻页、搜索工具栏 │ │ ├── PageCanvas.vue # 单页 Canvas 渲染组件 │ │ └── TextLayer.vue # 文本选择层与高亮层 │ ├── core/ │ │ ├── PdfLoader.js # 文档加载与生命周期管理 │ │ ├── renderer.js # 页面渲染调度器 │ │ ├── viewport.js # 缩放与滚动视口计算 │ │ └── search.js # 关键字搜索与高亮 │ ├── store/ │ │ └── viewerStore.js # Pinia 状态管理 │ └── main.js └── vite.config.js核心在core目录四个文件里。PdfLoader.js负责加载文档和销毁文档renderer.js调度哪个页面该渲染、哪个该回收viewport.js处理缩放比例和滚动位置search.js负责搜索和定位。组件层只做一件事把状态渲染成 DOM。这种分层的好处是将来如果要支持更多文档格式只需要新增一个DocLoader替换PdfLoader组件层基本不用动。2.3 渲染链路与 Worker 线程模型pdf.js 的渲染跑在一个独立的 Worker 线程里这是它不阻塞 UI 的关键。初始化时必须把 Worker 脚本路径指对新版 pdf.js 用的是 ES module 形式的 Worker 文件如果路径配错渲染会一路静默失败。// main.js 入口处初始化 import * as pdfjsLib from pdfjs-dist; pdfjsLib.GlobalWorkerOptions.workerSrc new URL( ../public/pdf.worker.min.mjs, import.meta.url ).toString();Worker 路径必须用new URL(..., import.meta.url)这种写法而不是相对字符串因为打包工具Vite/Webpack会对资源做指纹重命名写死的路径在部署后大概率 404。参数workerSrc接收的是浏览器可访问的绝对 URL指向pdf.worker.min.mjs文件。如果你用的是 CDN 分发也可以直接传线上地址但不推荐因为会多一次跨域请求。整个渲染链路是这样的前端从后端接口拿到 PDF 文件流转成ArrayBuffer传给 pdf.js 的getDocumentpdf.js 在 Worker 线程里解析出页面元数据然后主线程拿到PDFPageProxy对象调用page.render()把页面画到我们指定的 Canvas 上。这个过程每一步都有对应的状态回调源码里用loadingTask对象统一管理后面会细讲。3. 核心模块拆解从加载 PDF 到渲染出第一页再到批注回写3.1 文档加载与生命周期管理PdfLoader.js是这份源码里我最喜欢的一个模块它把 pdf.js 容易“泄漏”的地方都收拾干净了。pdf.js 的getDocument返回的是一个PDFDocumentLoadingTask它上面有promise、destroy方法很多新手只取promise就完事翻几页之后内存疯涨也不知道为什么。这个项目把加载、取消、销毁统一收口到一个类里。export class PdfLoader { constructor(url) { this.url url; // 文档地址 this.loadingTask null; // pdf.js 的加载任务对象 this.doc null; // PDFDocumentProxy 实例 this._destroyed false; } async load() { // 从接口拿文件流转成 ArrayBuffer const buffer await fetch(this.url).then(res res.arrayBuffer()); if (this._destroyed) return null; // 传给 pdf.js 解析 this.loadingTask pdfjsLib.getDocument({ data: buffer }); this.doc await this.loadingTask.promise; return this.doc; } async destroy() { this._destroyed true; if (this.doc) { // 释放 worker 中缓存的文档对象 await this.doc.destroy(); this.doc null; } } }这里有两个细节值得注意。第一getDocument传入的是{ data: buffer }而不是{ url }因为url方式会让 pdf.js 自己去发请求跨域场景而且我们无法控制请求头自己 fetch 则可以在加载前做鉴权、加 token。第二destroy()一定要调用它是 pdf.js 官方提供的清理入口不调用的话 Worker 线程里会残留文档对象连续打开多个 PDF 后标签页会越来越卡。再看一个容易忽略的点_destroyed标志位。如果用户在 PDF 还没加载完就关闭了阅读器load()里的await已经返回了一部分此时必须判断是否被销毁否则会对一个已经不存在的组件继续执行渲染。3.2 缩放计算与虚拟滚动页码渲染采用“虚拟列表”思路不是把几十页一次性全部渲染到 Canvas 上。实现上这个项目维护了一个可视区页面的数组只渲染当前视口内以及上下各一页的缓冲页。这样对于几百页的文档内存里始终只有几份 Canvas 位图。export function computeVisiblePages(scrollTop, viewportH, pageH, buffer 1) { const first Math.max(0, Math.floor(scrollTop / pageH) - buffer); const last Math.min( totalPages - 1, Math.ceil((scrollTop viewportH) / pageH) - 1 buffer ); const visible []; for (let i first; i last; i) { visible.push(i); } return visible; }scrollTop是滚动容器当前的滚动距离viewportH是可视区高度pageH是单页高度。通过整除算出当前落在第几页再往上下各扩一页作为缓冲这能避免快速滚动时出现白屏闪烁。如果此时刚好在连续翻页虚拟列表的buffer可以考虑调大但不要超过 3否则就失去了虚拟加载的意义。缩放的实现比想象中要简单。pdf.js 的getViewport({ scale })会返回一个PageViewport对象里面已经算好了缩放后的宽高。源码里维护了一个scale状态滚动条位置按比例换算保证缩放时视觉中心不变。控制台里那些 0.25 倍、0.5 倍、实际尺寸、适合宽度等选项本质都是对同一个scale变量赋值只是计算公式不同。3.3 文本层与关键字搜索高亮PDF 渲染到 Canvas 后只是一张图如果想选中文字、复制、做高亮就需要单独渲染一个透明文本层盖在 Canvas 上面。这层文本其实是由无数个被精确摆位的span组成的每个字符或词语的坐标都来自 pdf.js 的TextContent接口。async function renderTextLayer(page, viewport, container) { const textContent await page.getTextContent(); const textLayer new pdfjsLib.TextLayer({ textContentSource: textContent, container: container, // 承载文本层的 div viewport: viewport, // 与 Canvas 同一个 viewport }); await textLayer.render(); return textLayer; }TextLayer初始化时传的三个参数缺一不可textContentSource是文本内容源container是覆盖层容器viewport必须和渲染 Canvas 时用的是同一个实例否则文字会对不上位置。这也是新手最容易翻车的地方——页面 Canvas 和文本层使用了不同的viewport结果字体位置全部偏移看起来像“重影”。搜索高亮在文本层之上再做一层。核心思路是先遍历TextContent里的每个文本项用简单的字符串匹配找出包含关键字的段落再计算关键字在段落中占用的宽度比例生成一个定位用的矩形最后在这个位置插入高亮节点。这样实现的好处是不需要理解 PDF 内部的排版模型完全基于坐标计算几十行就能完成一个够用的搜索功能。3.4 在线批注实现Canvas 叠加层与坐标换算关键词里的“pdf 在线编辑”在这个项目里落地成了批注模块。它在页面之上再叠加一个透明的绘图层用户用鼠标拖拽时记录坐标松开后保存为一条批注记录。核心难点不在画而在坐标换算——绘图层是相对于屏幕的但保存时必须转换为相对 PDF 页面本身的坐标这样换一台设备、换一个缩放比例批注位置仍然正确。// 鼠标事件中换算坐标 function toPdfCoordinate(screenX, screenY, pageViewport) { const scale pageViewport.scale; // 相对页面的坐标 屏幕坐标 / 当前缩放比例 return { x: (screenX - pageViewport.x) / scale, y: (screenY - pageViewport.y) / scale }; }pageViewport.x和pageViewport.y是从 pdf.js 视口中拿到的页面偏移量通常在没有边距裁切时是 0。除以scale是为了把像素值还原成 PDF 的原始 PDF 坐标单位这样保存到后端的数据是稳定的。渲染批注时再做一次反向换算乘以当前scale即可。批注数据最终聚合成一个数组连同页码、作者、时间一起序列化这份源码里先落到了 localStorage二次开发时可以替换成后端接口。4. 避坑指南五个最容易让阅读器翻车的配置4.1 页面白屏但控制台没有任何报错现象启动项目后打开 PDF页面区域一片空白F12 控制台也干净得像什么都没发生。原因pdf.js 的 Worker 脚本加载失败时经常是静默的尤其是用 Vite 开发服务器时pdf.worker.min.mjs没被正确拷贝到输出目录或者路径配错。解决先打开 Network 面板看有没有 404 的请求确认pdfjsLib.GlobalWorkerOptions.workerSrc指向的文件存在开发环境可以直接在public目录放一份再用new URL(..., import.meta.url)引用。提示Worker 脚本请求失败最容易被误判为“PDF 数据问题”排查时永远先看 Worker 请求有没有返回 200。4.2 文字选不中Canvas 上像隔了一层玻璃现象页面渲染正常视觉上完全看不出问题但鼠标拖拽文字没有任何反应。原因pdf.js 的文本层默认是启用的但文本层容器的 CSS 没有正确覆盖 Canvas常见是两个盒子尺寸不一致或者文本层被某个兄弟节点遮挡导致pointer-events失效。解决检查文本层容器的position: absolute和尺寸是否与 Canvas 父容器严格对齐同时确认没有给文本层设置pointer-events: none。如果确实需要阻止选中比如某些版权文档再显式禁用。4.3 连续翻页 20 页后内存飙升、页面卡顿现象单页渲染正常但快速滚动后浏览器内存占用直线上升滚动开始掉帧。原因pdf.js 渲染完成的 Canvas 位图没有被及时释放旧页面对象虽然不可见了但canvas元素仍在 DOM 中浏览器无法回收它的绘图上下文。解决虚拟列表中离开可视区的页面要把 Canvas 的width和height重置为 0并调用canvas.getContext(2d).clearRect()清空绘制缓存。截图说明这里我吃过亏——只移除 DOM 节点不够Canvas 的 GPU 缓存不会立刻释放。4.4 移动端文字发虚像 720p 拉伸到 2K 屏幕现象桌面上显示清晰换成手机浏览器就模糊尤其是高分辨率屏幕。原因Canvas 的像素尺寸没有跟上设备的物理像素。CSS 上设置 375px 宽的 Canvas实际渲染分辨率也是 375px在 DPR 为 3 的屏幕上被拉伸到 1125px自然发虚。解决渲染前把 Canvas 的实际宽高乘以window.devicePixelRatio再把scale同步乘上去。这里我给一个常用的写法const dpr window.devicePixelRatio || 1; canvas.width width * dpr; canvas.height height * dpr; canvas.style.width width px; canvas.style.height height px; page.render({ canvasContext: ctx, viewport });特别注意viewport传给render时不应该把 DPR 乘进去否则文字和文本层的坐标又会错位正确做法是viewport.scale维持逻辑值只放大 Canvas 的画布尺寸让浏览器用更多物理像素去绘制同一个 PDF 页面区域。4.5 滚动拖拽和文本选择互相打架现象鼠标想在文本层上选中一段文字时页面跟着滚动或者开启了拖拽移动画布后文本永远选不中。原因默认的滚动容器捕获了鼠标事件批注绘图层又覆盖了文本层事件优先级没有理清。解决区分交互模式——阅读模式下文本层pointer-events: auto滚动交给默认行为批注模式下给绘图层设一个高优先级同时文本层切到pointer-events: none。用状态变量控制比单纯调 CSS 优先级更稳。5. 构建与部署本地跑通与线上部署的完整步骤5.1 环境准备与安装步骤这份源码本身就是一个完整的前端工程我已经在本地跑通。环境要求不高Node.js 版本 18 以上即可包管理器用 npm 或者 pnpm 都行。先把依赖装好npm install npm run dev开发服务器起来后打开命令行里提示的本地地址就能看到阅读器页面。如果你自己的 PDF 文件不在同源地址需要在fileApi.ts里把请求地址改成实际文件接口。项目默认没有配 mock 数据我一般会先在public目录放一个sample.pdf把PdfLoader的初始化地址临时指向它先把渲染链路跑通再说别的。5.2 关键配置项说明有几个配置直接影响最终效果建议按项目情况调整配置项位置作用我的建议workerSrcmain.jsWorker 脚本路径优先本地引用别用远程 CDNcMapsPdfLoader.js中文/日语等 CJK 字符映射中文文档多时必配否则部分字符乱码standardFontDataUrlPdfLoader.js标准字体数据路径需要高质量文本层时配置maxCanvasPixelsrenderer.js单页 Canvas 最大像素上限内存紧张时适当调低但会损失清晰度cMaps是中文 PDF 最容易踩的配置。很多 PDF 里的中文字符是用 CID 编码的没有对应的 CMap 表就解不出来。在新版 pdf.js 中设置方式是在getDocument里传入cMapUrl和cMapPacked两个参数。如果你发现中文文档渲染出来是方框第一反应就查这里。pdfjsLib.getDocument({ data: buffer, cMapUrl: /cmaps/, cMapPacked: true, // .bcmap 是压缩格式 });5.3 部署为静态服务与构建检查构建产物是纯静态文件部署很简单npm run build输出目录dist/里包含完整的index.html、JS 和 Worker 文件。我部署时习惯用 Nginx 做静态服务。需要注意跨域配置如果 PDF 文件存在 OSS 或另一台服务器Nginx 里得加反向代理或者允许跨域请求头。部署后首先检查三项Worker 脚本是否带上了哈希文件名且能够访问cMapUrl指向的目录是否被打包进dist控制台里有没有Failed to fetch之类的跨域报错。这三项没问题阅读器基本就能稳定运行。构建时如果发现 Worker 文件没被复制到产物里在vite.config.js中把它放进public目录即可。6. 二次开发思路把阅读器改造成带多人批注的文档协作工具如果只是要一个能读 PDF 的页面做到第五章就够了。真正有挑战的是往协作方向扩展。我建议从批注模块下手——把它从 localStorage 迁移到后端接口并加上“实时同步”的语义。具体拆三步批注模型加文档 ID 和版本号保存操作改为乐观更新接收远程变更时合并到当前视图。// 乐观更新先画上去再请求后端 async function saveAnnotation(annotation) { const tempId temp- Date.now(); annotation.id tempId; annotations.push(annotation); // 立即渲染 try { const saved await api.save(annotation); annotation.id saved.id; // 用后端 id 替换临时 id } catch (err) { // 失败回滚删除这条临时批注 annotations annotations.filter(a a.id ! tempId); showToast(批注保存失败); } }乐观更新是多人协作批注最基础的体验保障。用户画完一笔屏幕马上出现结果不需要等接口返回失败时才回滚并提示。这样在高延迟网络下体验仍然像本地应用。跟后端同步的方式可以选择 WebSocket 推送把批注的增删改事件广播给同一文档下的其他连接也可以退一步用轮询每 3 秒拉取一次变更数据量小时完全够用。一个实用的技巧是给批注加“撤销栈”。每次保存或删除操作前把当前批注数组浅拷贝一份压入栈里撤销时就弹出上一个状态重新渲染。因为批注数据本身就是坐标数组反序列化成本极低这是性价比最高的撤销方案不要尝试去记录每一次鼠标移动轨迹。这段逻辑我建议放在viewerStore中和渲染层解耦测试也好写。我把这段改造做完之后最大的收获是理解了“渲染只管展示状态只管数据”这句话的分量——当批注数据脱离阅读器组件独立存在时任何 UI 框架都能接入。从那以后我做文档预览类项目都会先把数据模型和渲染层严格分离哪怕是单机版也要留出接口边界。希望这篇拆解能帮你少走点弯路拿到源码后建议先按第五章跑通再动第六章的改造。本文还有配套的精品资源点击获取