简介:面向网页开发与Java技术爱好者,这是一份用于构建PDF在线阅读器的项目源码,解决网页端直接查看、编辑PDF文档的技术实现问题。资源共8个文件,类型包括3个HTML页面、2个JavaScript脚本、2个PDF测试文档及1个项目配置文件,压缩包约490KB;HTML与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 的getDocument,pdf.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 关键配置项说明
有几个配置直接影响最终效果,建议按项目情况调整:
| 配置项 | 位置 | 作用 | 我的建议 |
|---|---|---|---|
workerSrc | main.js | Worker 脚本路径 | 优先本地引用,别用远程 CDN |
cMaps | PdfLoader.js | 中文/日语等 CJK 字符映射 | 中文文档多时必配,否则部分字符乱码 |
standardFontDataUrl | PdfLoader.js | 标准字体数据路径 | 需要高质量文本层时配置 |
maxCanvasPixels | renderer.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 框架都能接入。从那以后,我做文档预览类项目都会先把数据模型和渲染层严格分离,哪怕是单机版也要留出接口边界。希望这篇拆解能帮你少走点弯路,拿到源码后建议先按第五章跑通,再动第六章的改造。
本文还有配套的精品资源,点击获取