FEATURED · 精选文章

PDF.js前端PDF预览与阅读进度记录实战指南

发布时间 / 2026/9/18 16:15:12
来源 / 创域科博编辑部
栏目 / 资讯中心
PDF.js前端PDF预览与阅读进度记录实战指南 1. PDF.js能做什么先搞清楚这个库的定位搞前端的人应该都有过这种经历产品经理丢过来一个PDF文件说“把这个在网页里显示出来”你觉得简单随手一个iframe塞进去结果在Chrome里好好的换到Firefox或者移动端就变了个样有的直接变成下载链接有的渲染得歪七扭八。这时候你就知道原生浏览器对PDF的支持其实参差不齐根本没法做到一套代码各处一致。PDF.js就是来解决这个问题的。它是Mozilla团队开源的一个JavaScript库核心作用是用纯前端的方式把PDF文件解析并渲染到网页上。不依赖浏览器原生插件不依赖Flash这玩意儿早就淘汰了也不需要后端去转图片全部在浏览器里搞定。GitHub上星标接近5万是目前前端处理PDF的事实标准。我想重点说的是PDF.js不只是一个“PDF预览器”这么简单。它能拆解PDF页面结构、提取文本、获取元数据、处理表单甚至是像“把用户阅读到第几页记录下来”这种需求也完全可以在PDF.js的基础上做出来。这篇文章我打算从一个实际项目的角度把PDF.js的基础使用、原理、以及带阅读进度记录的完整方案都梳理一遍希望能帮你少走弯路。适合谁来读如果你是前端开发正准备在业务里集成PDF预览或者你在做一个在线阅读器、电子合同平台、在线题库系统这篇内容基本能覆盖你80%以上的需求。如果你只是偶尔碰一下PDF只想快速预览也能直接抄作业。2. 为什么PDF.js是首选原理与优势拆解2.1 从PDF二进制的角度看PDF.js的原理PDF格式本身是一堆对象和数据流的组合包含文本、字体、图片、矢量绘图操作等内容。它不像HTML那样天生适合浏览器解析浏览器内核即使有原生的PDF查看器比如Chrome的PDF Viewer那也是浏览器厂商自己做了一层深度适配的结果不同内核行为不一致。PDF.js做的事情是从零开始去解析这个二进制格式。它内部有一套完整的PDF解析器负责读取PDF的文件结构、解析页面对象、提取字体和图片资源。拿到这些信息之后再把每个页面绘制成Canvas。这意味着只要你给它一个PDF文件的ArrayBuffer或者二进制流它就能把内容可视化地渲染出来整个流程完全可控。为什么这个方案比“后端转图片”要好我们做项目时经常会对比取舍。后端转图片的方案比如用Ghostscript或者ImageMagick把PDF逐页转成PNG思路简单但要等后端处理完才能看到结果而且图片格式没法搜索、没法复制文本、存储和带宽成本也高。PDF.js是在用户浏览器里实时解析渲染的加载快、交互性强还能做到跨平台行为一致这是它能成为主流方案的根本原因。2.2 PDF.js的模块化架构不是只有一个大文件PDF.js的源码结构是模块化的主要分成几个核心部分pdf.js核心解析模块负责读取PDF文件返回PDFDocumentProxy对象。pdf.worker.js一个独立的Web Worker线程负责执行大部分解析工作避免阻塞页面主线程。canvas.js负责把解析出来的页面内容绘制到Canvas上。text_layer负责生成文本层用于文本选择和搜索。annotation_layer负责渲染表单、链接等注释对象。下载官方发布的压缩包后你会看到这些文件。引入的时候需要特别注意主文件引用的是legacy/build/pdf.min.js而worker文件需要用pdfjsLib.GlobalWorkerOptions.workerSrc去指定路径。忘掉这一步是初学者最常踩的坑——页面白屏控制台报一堆关于worker加载失败的错误。2.3 和iframe、插件、第三方组件的横向对比我把主流的几种方案在项目里都试验过这里直接做个对比方案跨浏览器一致性性能可定制性文本选择开发成本iframe直接嵌入差中等极低依赖浏览器最低后端转图片好受网络影响低不支持中等PDF.js原生开发好好高支持偏高基于PDF.js的UI组件如vue-pdf、react-pdf好好一般支持低如果你只是临时给后台管理系统塞一个PDF预览用vue-pdf这种二次封装的组件确实省事。但一旦你想自定义工具栏、记录阅读进度、做批注、控制权限这些UI组件的约束就会成为阻碍最终你还是得回头去直接操作PDF.js的API。所以这篇文章后面都以原生PDF.js为主讲掌握了底层API用不用二次封装组件完全是你的自由。3. 从零开始集成PDF.js两个实际可用的方案3.1 CDN方式开发调试最快速如果你是在写一个测试页面或者给传统项目做一个快速功能CDN引入是最直接的。选一个稳定的版本比如2.16.105我个人比较偏好这个版本API完整且稳定网上资料也好查。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titlePDF.js 快速预览/title style #pdf-canvas { border: 1px solid #ddd; margin: 0 auto; display: block; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .toolbar { text-align: center; padding: 12px; background: #f5f5f5; } .toolbar button { padding: 6px 16px; margin: 0 4px; } /style /head body div classtoolbar button idprev上一页/button span第 span idpageNum1/span / span idpageCount0/span 页/span button idnext下一页/button /div canvas idpdf-canvas/canvas script srchttps://unpkg.com/pdfjs-dist2.16.105/build/pdf.min.js/script script const url ./sample.pdf; const canvas document.getElementById(pdf-canvas); const ctx canvas.getContext(2d); let pdfDoc null; let pageNum 1; // 关键步骤指定worker路径 pdfjsLib.GlobalWorkerOptions.workerSrc https://unpkg.com/pdfjs-dist2.16.105/build/pdf.worker.min.js; // 加载PDF文档 pdfjsLib.getDocument(url).promise.then(doc { pdfDoc doc; document.getElementById(pageCount).textContent doc.numPages; renderPage(pageNum); }); // 渲染指定页码 function renderPage(num) { pdfDoc.getPage(num).then(page { const viewport page.getViewport({ scale: 1.5 }); canvas.width viewport.width; canvas.height viewport.height; const renderContext { canvasContext: ctx, viewport: viewport }; return page.render(renderContext).promise; }); } document.getElementById(prev).addEventListener(click, () { if (pageNum 1) return; pageNum--; document.getElementById(pageNum).textContent pageNum; renderPage(pageNum); }); document.getElementById(next).addEventListener(click, () { if (pageNum pdfDoc.numPages) return; pageNum; document.getElementById(pageNum).textContent pageNum; renderPage(pageNum); }); /script /body /html这段代码跑通之后你就有了一页能翻页的PDF预览器。注意几个细节getDocument可以接收URL字符串也可以接收ArrayBuffer、TypedArray。如果PDF有密码可以传password参数。getViewport里的scale是缩放比例用于控制渲染的清晰度。设备像素比dpr高的屏幕建议用window.devicePixelRatio不然文字会发虚。每次换页都重新渲染canvas但长页面渲染耗时明显最好加loading状态。3.2 npm方式工程化项目的标准玩法在Vue或者React项目里我们肯定用npm包管理。安装很简单npm install pdfjs-dist2.16.105注意一个容易踩的坑在新版本3.x及以后中worker的引入方式发生了变化。3.x版本的worker文件放在pdfjs-dist/build/pdf.worker.min.js有些版本还需要你使用?url这样的导入方式。如果没有特殊需求我个人建议固定用2.16.105版本API文档和社区答案都是针对这个版本的遇到问题也不愁排查。以Vue 3项目为例import * as pdfjsLib from pdfjs-dist; import workerUrl from pdfjs-dist/build/pdf.worker.min.js?url; pdfjsLib.GlobalWorkerOptions.workerSrc workerUrl;在Vite构建工具里?url后缀会把文件作为资源URL导入这样worker文件能被正确加载。如果你是用Vue CLIWebpack写法略有不同import worker from pdfjs-dist/build/pdf.worker.min.js; pdfjsLib.GlobalWorkerOptions.workerSrc worker;为什么会有这种差异因为pdf.worker本身是一个独立的JS文件构建工具处理worker文件的方式不同。如果你打包后发现worker加载404那基本就是这个路径写法的问题。3.3 渲染清晰度优化别让文字看起来像马赛克初学PDF.js的人很容易忽略清晰度问题。默认的getViewport({ scale: 1 })在普通屏幕上还好一旦放到高分屏比如MacBook的Retina屏文字边缘明显发虚。原因很简单CSS像素和物理像素不是1:1的。正确的做法是把canvas的实际尺寸按设备像素比放大然后用CSS把显示尺寸缩回去function renderPage(num) { pdfDoc.getPage(num).then(page { const dpr window.devicePixelRatio || 1; const viewport page.getViewport({ scale: 1.5 }); canvas.width viewport.width * dpr; canvas.height viewport.height * dpr; canvas.style.width ${viewport.width}px; canvas.style.height ${viewport.height}px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); const renderContext { canvasContext: ctx, viewport: viewport }; return page.render(renderContext).promise; }); }注意ctx.scale(dpr, dpr)这行必须写否则你只是放大了一个模糊的位图。实际操作下来2x的Retina屏用这个方案清晰度肉眼可见地提升而且性能开销也没那么夸张。4. 进阶实战把阅读进度记录到数据库现在来聊一个很多人问过的问题PDF阅读器怎么记录用户看到第几页下次打开直接跳转。这个功能在在线课程平台、电子合同签署、长文档阅读场景里非常常见。先说清楚思路无非三件事监听当前页码变化。把页码传到后端数据库保存。用户再次打开文档时从数据库取回页码并跳转到对应位置。但这里面有几个细节值得展开讲讲比如多久保存一次、用户信息怎么关联、多设备同步怎么处理。4.1 技术选型localStorage还是后端数据库先说结论如果是单机、单浏览器的需求直接用localStorage最省事如果要跨设备同步、需要用户在不同浏览器上都能恢复进度就必须走后端数据库。localStorage方案代码极简一个setItem就搞定localStorage.setItem(pdf_progress_ fileId, currentPage);但它的限制也很明显换台电脑就丢了换个浏览器也没了而且用户清缓存就一切归零。后端方案我没有用特别复杂的架构一个简单的Node.js服务就够了。核心表结构也很直接记录用户ID、文档ID、页码、更新时间这样一个用户读多份文档互不影响。如果你做的是一个平台的PDF阅读功能两张表搞定也不是问题。4.2 前端实现页码监听与上报策略先说页码怎么拿。上一节的代码里每翻一页就会调用renderPage所以我们有两种做法在renderPage函数里把当前的页码值上报用发布订阅模式在翻页时触发上报事件。第一种最省事但如果用户连续快速翻页就会造成大量请求。我的做法是加一个“节流”机制比如每3秒上报一次或者只在页面切换超过1次时才上报。let lastReportPage 0; let reportTimer null; function reportReadingProgress(fileId, page) { // 如果页数没变就不上报 if (lastReportPage page) return; lastReportPage page; // 简单节流3秒内的多次变化只上报最后一次 if (reportTimer) clearTimeout(reportTimer); reportTimer setTimeout(() { // 实际请求发送到后端 saveProgress(fileId, page); }, 3000); } function saveProgress(fileId, page) { fetch(/api/pdf-progress, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileId, page }) }); }节流的好处很明显用户快速从第5页翻到第20页其实只需要上报第20页一次中途的14次请求完全没有必要。后端也能减轻压力。还有一个容易被忽略的细节PDF加载完成前的上一次进度恢复。用户打开文档时先请求接口拿进度然后渲染对应页码而不是默认从第1页开始。这段逻辑需要等getDocument返回后再执行。pdfjsLib.getDocument(url).promise.then(doc { pdfDoc doc; // 先请求上次阅读进度 return fetch(/api/pdf-progress?fileId${fileId}) .then(res res.json()) .then(data { const savedPage data.page || 1; currentPage Math.min(savedPage, pdfDoc.numPages); document.getElementById(pageNum).textContent currentPage; return renderPage(currentPage); }); });注意那个Math.min(savedPage, pdfDoc.numPages)这是必须的保护逻辑。万一文档更新后页数变少了直接跳转到一个不存在的页码就会报错。另外还要处理currentPage小于1的情况统一用Math.max(1, ...)包一下保证页码永远在合法区间。4.3 后端接口设计与实现前端调接口后端总得有东西接得住。我用Node.js Express写了一个最简单的接口数据库用SQLite不需要额外安装数据库服务开发测试很方便。const express require(express); const sqlite3 require(sqlite3).verbose(); const app express(); app.use(express.json()); const db new sqlite3.Database(./pdf_reader.db); db.run(CREATE TABLE IF NOT EXISTS pdf_progress ( user_id TEXT NOT NULL, file_id TEXT NOT NULL, page INTEGER NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, file_id) )); // 保存进度 app.post(/api/pdf-progress, (req, res) { const { userId, fileId, page } req.body; if (!userId || !fileId || !page) { return res.status(400).json({ error: 缺少必要参数 }); } db.run( INSERT INTO pdf_progress (user_id, file_id, page, updated_at) VALUES (?, ?, ?, datetime(now)) ON CONFLICT(user_id, file_id) DO UPDATE SET page excluded.page, updated_at datetime(now), [userId, fileId, page], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ success: true }); } ); }); // 获取进度 app.get(/api/pdf-progress, (req, res) { const { userId, fileId } req.query; db.get( SELECT page FROM pdf_progress WHERE user_id ? AND file_id ?, [userId, fileId], (err, row) { if (err) return res.status(500).json({ error: err.message }); res.json({ page: row ? row.page : 1 }); } ); }); app.listen(3000, () { console.log(Server running on http://localhost:3000); });这个方案的数据库脚本用到了SQLite的ON CONFLICT ... DO UPDATE语法也就是典型的“有则更新无则插入”。如果同一用户看同一份文档他每翻一次页就是更新同一条记录不会产生大量垃圾数据。如果后续要支持“阅读时长”“最后阅读时间”等维度的展示在这个表上扩展字段就行。4.4 多设备同步怎么搞很多人觉得跨设备同步很复杂其实就是加一个时间判断的问题。因为两个设备同时在读的话后上报的那次会覆盖先上报的进度。怎么处理我在项目中用的是简单策略只保存更新量最大的记录不做冲突合并。对阅读进度这个业务来说通常是用户今天在公司电脑看了一半回家接着看这种场景不存在同时高频读写的情况覆盖写入的体验是够用的。如果你要做得更细可以再加一个字段记录文档总页数或者保存多个版本的reading list。但一般情况下一个主键、一个页码、一个更新时间就是性价比最高的方案了。5. 项目落地中的常见问题与排查技巧5.1 本地文件打开报错跨域问题避坑PDF.js在浏览器里解析文件时会有跨域限制。直接用file://协议打开本地HTML然后让PDF.js加载本地PDF会在控制台报CORS错误或者Worker加载失败。解决办法有几种本地开发用http-server起一个服务访问页面而不是双击打开HTML文件把PDF文件通过接口以二进制流形式返回前端拿ArrayBuffer再传给PDF.js如果后端有权限校验就通过fetch先获取PDF的ArrayBuffer再传给getDocument。const response await fetch(/api/pdf/123, { headers: { Authorization: Bearer token } }); const buffer await response.arrayBuffer(); const pdfDoc await pdfjsLib.getDocument({ data: buffer }).promise;这样既解决了跨域又能给PDF加载添加权限控制是生产环境比较推荐的方案。5.2 页面渲染不全或者文字丢失我记得有一次遇到一个PDF在正常浏览器里看没问题但用PDF.js渲染时页面上的某些文字段落消失了查了半天最后发现是字体解析问题。这个PDF用了嵌入的子集字体但字体子集信息不完整PDF.js无法正确映射字形。这种问题不太好从代码层面完美解决。我的处理方案是升级到最新版PDF.js每个版本都有不少字体解析相关的修复确认PDF是否由比较老的工具生成尽量用正规的PDF转换工具重新生成生产环境加一个兜底方案PDF.js渲染失败时提示用户下载原PDF查看。这类坑在PDF.js的项目里不可避免合理的预期管理反而更重要。5.3 大文件性能优化思路一段300页的PDF每页都是高清扫描图直接渲染能让人崩溃。我从实际项目中整理出几个优化手段按需渲染只渲染当前页和前后一页不要预加载全部页面。延迟渲染滚动停下来之后再用requestAnimationFrame避免滚动时频繁渲染。降低初始scale首屏用scale1渲染等用户放大再提高分辨率。清理canvas资源翻到很远后释放前面页面的canvas避免内存占用过高。使用visible窗口只渲染当前视口内的页面这需要配合自定义滚动容器。如果文档是扫描版的PDF优化空间更大后端可以做一次OCR和图层分离把文字层提取出来前端就能实现搜索和文本选择体验会好很多。5.4 移动端适配的几个坑做移动端H5时PDF.js有几个实际问题需要注意。一个是手势缩放。PDF.js本身不负责触摸手势的识别canvas绘制完成后它就是一张静态图你得自己绑定touch事件实现双指缩放。如果项目里已经有手势组件库直接套用就好。另一个是布局问题。桌面端宽度够PDF一页一页竖着排没问题。移动端建议做成单页模式让canvas宽度自适应屏幕宽度同时保持宽高比。const viewport page.getViewport({ scale: 1 }); const containerWidth document.getElementById(pdf-container).clientWidth; const scale containerWidth / viewport.width; const scaledViewport page.getViewport({ scale }); canvas.width scaledViewport.width; canvas.height scaledViewport.height;5.5 Worker加载失败排查的正确姿势Worker加载失败是PDF.js最常见的问题之一表现是控制台报类似Failed to fetch dynamically imported module或者workerSrc not set的错误。排查顺序我梳理一下检查GlobalWorkerOptions.workerSrc是否正确设置。检查路径文件是否存在网络面板里看请求状态码。检查是否被CSPContent Security Policy拦截公司环境常见。检查构建工具是否正确处理了worker文件路径。确认没有重复加载多个版本的pdf.js文件。如果以上都没问题但依然失败还有一个简单的降级策略不设置workerSrcPDF.js会退化到主线程去解析文件。代价是页面可能卡顿但功能可以正常使用。5.6 常见问题速查表我把实战中的高频问题整理成一个速查表方便你和团队排查现象可能原因解决思路页面空白控制台无报错canvas未设宽高检查viewport赋值是否完成报错“Worker was destroyed”worker路径错误或跨域检查workerSrc和文件路径中文显示为乱码/方块字体解析异常升级版本内嵌字体不规范大文件渲染卡死一次性渲染过多页面按需渲染懒加载加载有密码的PDF失败未传password参数getDocument时传入密码移动端显示太小未做自适应缩放按容器宽度计算scale保存的页码越界文档替换后页数变化Math.minMath.max保护6. 个人经验总结做了几个PDF.js相关的项目后我最大的感受是这个库的上手门槛比想象中低但要做到生产可用、体验流畅坑还是不少。版本锁定是第一要务千万不要在项目里用“最新版”随意升级PDF.js的API在不同版本之间变动比较大升级往往意味着连带改代码。其次是进度保存功能别过度设计先用localStorage跑通流程验证产品逻辑后再上后端数据库很多团队一开始就设计了一大套同步方案结果用户根本不跨设备用白白增加复杂度。最后分享一个小技巧调试PDF.js页面时在控制台执行pdfJsLib.getDocument(url).promise.then(doc console.log(doc))你可以直接在浏览器里查看PDF解析后的完整数据结构和元信息排查问题的效率会高很多。这个习惯我一直保留着遇到解析类问题先用它定位。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻