
1. 初识PDF.js它解决的三个核心痛点我在前端业务中处理文档在线预览已经有几年时间最早的时候遇到一个很现实的问题产品经理在需求评审会上说这个发票列表点进去最好能直接看PDF原件当时我第一反应是让后端转图片但转图片方案有几个硬伤——图片放大了模糊、文本不能选中复制、文件量大的时候服务器压力巨大。后来换成浏览器自带插件方案又被Chrome禁用了自带PDF插件整个方案直接报废。直到接触了PDF.js才发现这个库比我预期中能解决的问题多得多。PDF.js是Mozilla开源维护的一套纯前端PDF解析和渲染引擎它的核心特点是不需要任何浏览器原生插件支持完全依靠JavaScript把PDF文件解析成Canvas、文本和图片数据。换句话说无论用户用的是Chrome、Edge、Firefox还是国产浏览器只要它支持HTML5 Canvas就能正常渲染PDF。这解决了我的第一个痛点兼容性和可控性。第二个痛点是定制能力。以前用浏览器内建PDF预览除了看和打印基本什么都做不了。用PDF.js之后页码跳转、缩放比例、文字选中、搜索高亮、进度记录、权限控制全都能自己掌控UI想怎么改就怎么改。这对做SaaS平台、在线教育系统、合同管理后台这类产品来说太关键了因为这类产品的PDF经常承载着业务逻辑——比如未签完的合同要禁止下载内部文件要在指定页加水印培训资料要记录学员看到第几页了。第三个痛点是性能与带宽。PDF.js可以按需渲染当前页面而不是一次性把整个文档所有页面都绘制出来。100页的PDF用户打开只渲染首页翻到第50页才渲染那一页这对移动端弱网环境非常友好。同时它支持Canvas渲染、SVG渲染两种模式页面清晰度也比服务端转图片高得多。适合用PDF.js的场景我总结下来有三类一是做在线文档管理系统的需要自定义化权限和样式二是做在线教育或培训系统的需要统计阅读进度和答题状态三是做前端富应用、不想依赖后端渲染服务的团队希望把解析工作彻底放在浏览器端。如果你只是偶尔需要在项目里快速预览一个PDF、不想引入太多依赖PDF.js依然是目前最成熟的方案比一些现成的付费预览组件透明度高也更容易排查问题。2. 跑通PDF.js的三种接入姿势2.1 最简方式CDN直接引入如果只是做个Demo或者公司内网工具CDN引入最快。去官方发布页拉最新稳定版然后直接声明两个资源一个是核心库pdf.min.js一个是Worker脚本pdf.worker.min.js。script srchttps://unpkg.com/pdfjs-dist3.11.174/build/pdf.min.js/script然后初始化全局变量并指定WorkerpdfjsLib.GlobalWorkerOptions.workerSrc https://unpkg.com/pdfjs-dist3.11.174/build/pdf.worker.min.js; const loadingTask pdfjsLib.getDocument(https://example.com/sample.pdf); loadingTask.promise.then(pdf { console.log(PDF加载成功总页数 pdf.numPages); }).catch(err { console.error(加载失败 err.message); });这中间有个容易忽略的点主线程和Worker的版本必须保持一致版本号不一致的时候解析行为会变得不可预测有时报错有时直接白屏可能是版本错配引起的。2.2 工程化集成Webpack/Vite下的正确姿势在React、Vue这类工程里接入PDF.js我建议直接用npm包管理而不是再手动引CDN因为脚手架项目里的模块解析、打包、静态资源拷贝有一套自己的逻辑混着来很容易出路径问题。npm install pdfjs-dist以Vite项目为例需要把Worker文件复制到公共目录否则构建后Worker路径会找不到// vite.config.js import { defineConfig } from vite; import path from path; export default defineConfig({ optimizeDeps: { exclude: [pdfjs-dist] }, build: { rollupOptions: { output: { manualChunks: { pdf: [pdfjs-dist] } } } } });同时把pdf.worker.min.js放到public目录初始化代码里指一下import * as pdfjsLib from pdfjs-dist; import workerUrl from pdfjs-dist/build/pdf.worker.min.js?url; pdfjsLib.GlobalWorkerOptions.workerSrc workerUrl;用Webpack反而更简单些因为pdfjs-dist官方和Webpack的配合比较成熟在module.rules里对.mjs文件开启type: javascript/autoworkerSrc直接new URL生成即可pdfjsLib.GlobalWorkerOptions.workerSrc new URL( pdfjs-dist/build/pdf.worker.min.js, import.meta.url ).toString();2.3 Node.js端服务端也能解析PDFPDF.js不是只能跑在浏览器里它的核心层在Node.js环境下也能运行适合做服务端的数据提取、PDF合并、元数据读取。注意Node端不需要也没法实例化Worker直接用主线程解析就行。const pdfjsLib require(pdfjs-dist/legacy/build/pdf.js); async function extractText(pdfPath) { const data new Uint8Array(require(fs).readFileSync(pdfPath)); const doc await pdfjsLib.getDocument({ data }).promise; const page await doc.getPage(1); const content await page.getTextContent(); const text content.items.map(item item.str).join( ); return text; } extractText(./合同.pdf).then(text { console.log(text.slice(0, 200)); });这个能力在处理大量PDF批处理时特别有用比如我要批量抓取几百份投标文件的供应商名称根本不需要前端直接Node脚本跑一圈就完了。3. 核心API拆解从加载到渲染再到内容提取3.1 getDocument理解LoadingTask的生命周期getDocument()这个API接收一个对象或一个URL字符串。它的返回值是PDFDocumentLoadingTask不是PDF文档本身很多人第一次接触时会在这犯迷糊明明打印出来了怎么是个Promise实际上loadingTask上有promise属性、onProgress回调、destroy()销毁方法。加载PDF时最好加上进度反馈因为用户看到白屏第一反应是页面坏了。onProgress里的loaded和total可以用来计算百分比对弱网环境体验提升非常明显const loadingTask pdfjsLib.getDocument({ url: /files/xxx.pdf, onProgress: (progressData) { if (progressData.total 0) { const percent Math.round(progressData.loaded / progressData.total * 100); console.log(加载进度${percent}%); } } });3.2 渲染到Canvas数字与像素的换算把PDF页渲染到Canvas是整个PDF.js使用里最核心、也最容易出错的环节。先说一个必须牢记的公式渲染尺寸等于PDF物理尺寸乘以设备像素比devicePixelRatio。PDF的默认单位是点pt1pt约等于1/72英寸。在屏幕显示时系统默认以96DPI来计算所以一段72pt的文字在屏幕上大约是96px。如果不乘以devicePixelRatio在高分屏比如MacBook Retina和大部分手机上渲染出来的文字会发虚这是我最初被用户反复吐槽字模糊才发现的。标准渲染流程如下async function renderPage(pdf, pageNumber, canvas) { const page await pdf.getPage(pageNumber); const viewport1 page.getViewport({ scale: 1 }); const containerWidth canvas.parentElement.clientWidth; const scale containerWidth / viewport1.width; const viewport page.getViewport({ scale }); const context canvas.getContext(2d); const pixelRatio window.devicePixelRatio || 1; canvas.width Math.floor(viewport.width * pixelRatio); canvas.height Math.floor(viewport.height * pixelRatio); canvas.style.width Math.floor(viewport.width) px; canvas.style.height Math.floor(viewport.height) px; context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); await page.render({ canvasContext: context, viewport: viewport }).promise; }这个scale计算逻辑是自适应的基础。我的经验是先把scale1的视口宽度拿来和容器宽度比较算出等比缩放系数再生成真正用于渲染的viewport这样能确保整页完整显示在容器内。渲染任务返回的是一个RenderTask它带一个promise属性可以通过cancel()取消。翻页特别快时一定要判断上一页的RenderTask是否还在执行如果没执行完就渲染新页会造成Canvas绘制资源竞争出现花屏或颜色错乱最稳妥的做法是维护一个renderTask变量每次渲染前检查并取消旧的if (currentRenderTask) { currentRenderTask.cancel(); } currentRenderTask page.render({ canvasContext: context, viewport }); await currentRenderTask.promise;3.3 getTextContent把PDF变成可搜索的文本层只渲染Canvas的PDF等于一张图片用户无法选中文字搜索引擎也收录不了内容这就需要一个不可见的文本层。PDF.js提供了一个getTextContent()方法返回页面所有文本项的位置和内容我们可以据此生成span元素放到Canvas上方实现真正的文本选中。实际项目中我通常把文本层和Canvas叠放Canvas在底层负责显示文本层是透明的、负责交互。文本层的每个span需要严格的绝对定位视口尺寸一变缩放文本层也要等比重新生成async function renderTextLayer(page, container, viewport) { const textContent await page.getTextContent(); const textLayerDiv container.querySelector(.text-layer); textLayerDiv.innerHTML ; textContent.items.forEach((item, index) { const tx pdfjsLib.Util.transform( viewport.transform, item.transform ); const fontHeight Math.sqrt(tx[2] * tx[2] tx[3] * tx[3]); const left tx[4]; const top tx[5]; const span document.createElement(span); span.textContent item.str; span.style.left left px; span.style.top top px; span.style.fontSize fontHeight px; span.style.transformOrigin 0% 0%; textLayerDiv.appendChild(span); }); }这里item.transform是PDF页面对文本对象的变换矩阵包含位移、缩放、旋转信息。多数情况下我们直接读transform[4]和transform[5]作为left和top就够用但严谨一点还是用Util.transform去乘视口变换矩阵这样在页面旋转或缩放时定位才不会偏。4. 真正有价值的扩展把阅读到第几页记进数据库这是很多在线文档系统都绕不开的需求——用户上次看到第36页下次打开直接跳回第36页。网上问这个问题的不少很多人上来就问PDF.js怎么保存页码其实严格说PDF.js只是个渲染工具它没有内置存储能力要做的是通过API拿到当前页码再由你用自己的后端接口去持久化。数据存储这件事没有悬念关键是什么时候获取页码怎么避免刷爆接口什么时候恢复进度这三个设计点。4.1 先设计数据表结构以一个最简单的阅读进度表为例CREATE TABLE pdf_read_progress ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL COMMENT 用户ID, file_id VARCHAR(128) NOT NULL COMMENT PDF文件唯一标识, page_number INT NOT NULL COMMENT 当前阅读到的页码, total_pages INT NOT NULL COMMENT 总页数可用于展示阅读百分比, read_percent DECIMAL(5,2) DEFAULT 0 COMMENT 阅读进度百分比, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 最后阅读时间, UNIQUE KEY uk_user_file (user_id, file_id) );user_id file_id设联合唯一键这样同一个用户对同一个文件的进度只会有一条记录更新时直接INSERT ... ON DUPLICATE KEY UPDATE不用先查再加再更新性能也稳妥。4.2 获取当前页码监听pagechange事件PDF.js的API本身没有当前页码这个公共getter所以要在自定义的翻页逻辑里维护一个变量。监听方式取决于你的翻页实现如果用的是pdf.currentPage来切换那每次设置之后立刻就能读到如果用的是滚动翻页就监听Scroll容器的scroll事件用Math.floor(scrollTop / pageHeight) 1计算。以按钮翻页为例统一的记录逻辑写成这样function updateCurrentPage(pdf, pageNumber, totalPages) { currentPage pageNumber; // 计算百分比保留两位小数 const percent totalPages 0 ? Math.round(pageNumber / totalPages * 10000) / 100 : 0; saveProgressToServer({ userId: getCurrentUserId(), fileId: getCurrentFileId(), pageNumber, totalPages, readPercent: percent }); }4.3 上报时机防抖与频率控制是必须的直接把翻页事件绑到接口上会出大问题。用户快速连翻10页就会有10个并发请求打到后端数据库压力大不说请求返回的顺序还是乱序的有可能先发的第6页请求最后才到结果把第10页的进度覆盖成第6页。这里我用的方案是防抖窗口卸载时强制提交。设置一个2秒的防抖用户停止翻页2秒后才真正上报。组件卸载、页面关闭前用navigator.sendBeacon或者fetch keepalive保证最后一次上报不丢let timer null; function scheduleSaveProgress(progressData) { clearTimeout(timer); timer setTimeout(() { saveProgressToServer(progressData); }, 2000); } function saveProgressToServer(progressData) { if (navigator.sendBeacon) { const blob new Blob([JSON.stringify(progressData)], { type: application/json }); navigator.sendBeacon(/api/pdf/progress, blob); } else { fetch(/api/pdf/progress, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(progressData), keepalive: true }).catch(() {}); } }sendBeacon的好处是即使用户直接关掉标签页浏览器也会尽力把这个数据发送出去不会被页面卸载打断。用户停留一个页面看了10分钟很短时间内的几次翻页只算一次上报这样后端接口压力很小数据也更准确。4.4 恢复进度打开PDF后自动跳转恢复逻辑在PDF文档加载成功之后、渲染第一页之前判断。先从后端拿进度再决定渲染第几页async function loadPdfWithProgress(fileId) { const loadingTask pdfjsLib.getDocument({ url: /files/${fileId} }); const pdf await loadingTask.promise; const totalPages pdf.numPages; let targetPage 1; try { const response await fetch(/api/pdf/progress?userId${uid}fileId${fileId}); const result await response.json(); if (result.data result.data.pageNumber) { targetPage Math.min(result.data.pageNumber, totalPages); } } catch (e) { // 接口异常时保持默认第一页 } await renderPage(pdf, targetPage); setCurrentPage(targetPage); }这里有个小陷阱是后端返回的页码可能已经超过当前文件的总页数比如文件被换成了新版本、页数变少了所以一定要做Math.min兜底不然渲染时会直接报错用户看到的是一片空白加控制台红色报错。4.5 服务端接口的实现要点后端接口只需要两个一个GET查询进度一个POST写入进度。以Node.js/Express为例app.get(/api/pdf/progress, async (req, res) { const { userId, fileId } req.query; const [rows] await db.query( SELECT page_number, total_pages, read_percent FROM pdf_read_progress WHERE user_id ? AND file_id ?, [userId, fileId] ); res.json({ code: 0, data: rows[0] || null }); }); app.post(/api/pdf/progress, async (req, res) { const { userId, fileId, pageNumber, totalPages, readPercent } req.body; await db.query( INSERT INTO pdf_read_progress (user_id, file_id, page_number, total_pages, read_percent) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE page_number VALUES(page_number), total_pages VALUES(total_pages), read_percent VALUES(read_percent), update_time CURRENT_TIMESTAMP, [userId, fileId, pageNumber, totalPages, readPercent] ); res.json({ code: 0, message: ok }); });整体流程串起来就是打开PDF → 查询上次进度 → 跳转 → 用户翻页 → 防抖上报 → 关闭页面时再次保底上报。这个模式在前后端分离、小程序WebView、桌面端内嵌浏览器里都能直接复用。5. 我踩过的坑从报错到白屏的完整排查链路5.1 Worker路径404导致的白屏第一次在React项目里打包上线后用户反馈PDF区域空白控制台打了一堆404。我打开Network面板才发现请求的Worker路径是/static/js/pdf.worker.min.js但打包后这个文件根本没有被拷贝到输出目录。原因就是Webpack/Vite并不天然识别workerSrc字符串它只是当普通字符串处理不会帮你打包资源。我当时的排查顺序是先在浏览器直接访问Worker地址确认404然后看打包产物目录确认文件缺失最终通过new URL()方式让构建工具把这个文件当成一个资源模块处理问题才解决。这类问题最常见的反应是去改GlobalWorkerOptions.workerSrc路径但改了再改都是治标不治本根本原因是打包工具的静态资源处理策略。记住一条在工程化项目里优先用import workerUrl或new URL(..., import.meta.url)让构建工具感知这个文件。5.2 跨域资源加载失败CORS是一道绕不过去的坎如果PDF文件不在当前域名下比如存在独立的OSS或另一个子域直接传给getDocument()会被浏览器拦截。这个错误在控制台显示为Failed to fetch看起来像网络问题实际是预检请求没过。解决办法有两个方向一是在服务端给PDF文件的响应头加上Access-Control-Allow-Origin: *或指定域名二是不直接跨域拉PDF而是由后端做一个代理接口前端请求同域接口后端去拉取文件流再返回。第二种方式我项目里用得更多因为很多文件存储的鉴权逻辑本来就在服务端客户不想开放公网访问。5.3 大PDF文件性能优化按需渲染只是第一步PDF.js虽然会按需渲染页面但当你把getDocument的URL传给它时它会把整个文件的字节流下载下来。一个200MB的PDF在弱网环境下加载要等到天荒地老用户看到进度条卡在5%大概率直接关页面。针对这个场景我采用的是分段加载Range请求。后端接口支持HTTP Range头前端用PDFDataRangeTransport来按需请求数据。pdfjs-dist有现成的PDFDataRangeTransport类配合一个能响应Range请求的接口就能实现只拉取当前页所需的数据块const transport new pdfjsLib.PDFDataRangeTransport(byteLength, loadedPromise); // 自定义数据请求逻辑按range范围向后端要数据 transport.requestDataRange function(range) { fetch(/files/xxx.pdf?range${range.start}-${range.end}) .then(res res.arrayBuffer()) .then(buffer transport.onDataProgress({ loaded: range.end, total: byteLength })); };这个方案对后端的要求是支持Range请求Node.js的sendFile基本原生支持Nginx也默认支持。实测下来一个300页的PDF用户打开后只需要下载前10页的数据量后面的区块等翻页的时候再拉性能提升非常明显。5.4 渲染长页面时的内存泄漏持续渲染大量页面后浏览器内存不断上涨这是Canvas模式的通病。原因主要是旧的Canvas画布没有释放以及渲染任务没有取消导致Canvas上下文堆积。我当时的排查方法是打开Chrome的Performance Monitor观察切换页面时JS Heap是否只涨不降发现确实存在明显阶梯上升。修复方法有两个浅层修法是每渲染新页面前把旧Canvas的width和height置0再清空画布function clearCanvas(canvas) { const context canvas.getContext(2d); context.clearRect(0, 0, canvas.width, canvas.height); canvas.width 0; canvas.height 0; }深层修法是页面离开时主动调用loadingTask.destroy()释放PDF文档和对应的渲染数据。如果在一个单页应用里反复打开关闭预览组件不destroy的话每一次打开都会有一个Document对象留在内存里时间一长必定OOM。5.5 字体渲染错误导入自定义字体文件后中文乱码有些PDF内嵌了特殊字体如果浏览器环境没有该字体且PDF.js解析字体文件失败页面上的文字会变成方框或乱码。这个问题的排查方向有两层第一层是检查控制台有没有FontError报错有的话说明解析字体对象失败第二层是检查PDF中相关字体的FontFile3或FontFile2数据是否正确。碰到某些受版权保护的字体文件无法复制的情况PDF.js会在控制台打印警告但通常不影响文本层内容只是Canvas显示会有问题。我遇到过一次是CFF字体表不完整最后通过升级版本解决所以如果字体渲染异常先看看你的版本是不是太老。6. 进阶玩法PDF.js还能做这些事6.1 表单填写与数据提取PDF.js支持读取PDF中的表单字段——输入框、单选框、下拉列表都能枚举出来。在线审批系统里员工上传的PDF表单可以自动提取关键字段回填到业务系统省掉人工输入的步骤。通过pdf.getFieldObjects()拿到字段结构再配合getTextContent()解析值基本能覆盖大多数阅读器表单需求。6.2 服务端批量切图之前做合同存档系统时需要把PDF首页生成缩略图我没有额外引入图片处理库直接复用PDF.js在Node端把第一页渲染到node-canvas上生成JPEG。代码流程就是把renderPage跑在Node环境canvas用canvasnpm包提供。const { createCanvas } require(canvas); const canvas createCanvas(width, height); const ctx canvas.getContext(2d); await page.render({ canvasContext: ctx, viewport }).promise; const buffer canvas.toBuffer(image/jpeg, { quality: 0.8 });这个方案的优点是不用再单独部署图片处理服务一套代码两种端都能跑。6.3 自定义水印与权限控制渲染到Canvas后Canvas本身就是一张图片所以在项目的业务层可以很方便地叠加业务水印——在renderPage完成后往Canvas上方绘制半透明文字或图片。权限控制就更直接了如果用户没有下载权限就不给下载按钮下载接口也做服务端权限校验如果用户没有打印权限脚注里的打印按钮直接隐藏。这个层面PDF.js本身不管但在它的渲染链路里做这些扩展非常顺手。6.4 文本高亮与全文搜索基于文本层可以给指定文字加高亮背景。思路是先遍历getTextContent()的返回数组找到目标字符串再计算它的坐标范围生成一个半透明背景的div盖在文字上。全文搜索就是跨页迭代文本内容把所有页的文本拼起来做正则匹配再跳转到匹配页。在线法律文书阅读器、PDF版产品手册经常需要这个能力。7. 版本选择与兼容性备忘录最后把我长期使用的一些版本经验和兼容性结论集中写出来方便大家选型。场景推荐版本说明老项目Webpack 42.x兼容最好API稳定但功能偏旧新工程Vite/Webpack 53.11.x或4.xAPI变化不大性能有明显优化Node.js批处理legacy build官方维护的传统CJS版本需要最新特性如加密、部分字体支持最新版留意API变动升级前读CHANGELOG兼容性上有三条是真测出来的经验一是IE系列直接放弃永远支持不了产品经理如果要IE兼容趁早摊牌二是移动端iOS Safari对Canvas内存有硬限制超大页面需要把Canvas尺寸控制在一定范围内否则白屏三是国产浏览器的兼容模式伪IE内核会出现莫名其妙的问题上线前要引导用户切到极速模式。版本升级要谨慎我在生产环境里从2.x升到3.x时发现getViewport的返回结构有细微变化原来直接读viewport.width没问题升级后移动端的缩放计算全偏了排查了整整半天才定位到是版本差异。所以升级后第一件事不是跑通主流程而是把缩放、文本层定位、Worker路径这些边缘逻辑全回归一遍。