ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PDF.js实战:从渲染原理到阅读进度记录的完整指南

PDF.js实战:从渲染原理到阅读进度记录的完整指南 1. 为什么前端要自己处理PDF先说个很常见的场景你在做后台管理系统、电子合同平台或者在线文档产品客户发过来一个PDF合同或者产品说明书要求直接在网页里预览不给下载。你可能有几种下意识的选择。第一种是用浏览器自带的预览能力直接在iframe或者a标签里塞一个PDF地址。但这里有个致命问题不同浏览器对PDF的原生支持完全不一样。Chrome和Firefox自带PDF查看器Edge也还行但到了Safari尤其是移动端的Safari页面表现经常让人血压升高。更重要的是原生预览无法和你的业务系统做数据交互比如你要控制谁能看、看到第几页就触发生成水印、统计用户阅读了哪些章节这些需求原生查看器一概满足不了。第二种是转成图片把PDF的每一页都渲染成PNG然后展示PDF.js之前确实有些做法是这样后端用Ghostscript或者ImageMagick转图片再给前端。这个方案确实所有浏览器都能看但问题更多文件体积大、加载慢、文字选不了、清晰度受限于图片分辨率、还要额外占用服务端资源。第三种就是今天要聊的PDF.js。它是由Mozilla团队开发和维护的开源项目也是Firefox浏览器内置PDF阅读器的底层引擎。它不依赖任何浏览器原生能力全部基于HTML5标准用Canvas来实现渲染。这意味着只要你的浏览器支持Canvas就能统一获得一致的PDF展示体验。它的核心价值就一句话让PDF在网页里变成一个可以被完全控制、可以编程操作的对象。你可以控制它在什么时候加载、怎么渲染、显示哪一页、用户翻页时触发什么事件、把阅读进度记录到数据库里。这些能力恰恰是开发真实业务系统时最需要的。这篇文章的内容我分为五个部分先看PDF.js的整体架构和运行原理搞清楚它内部干了什么然后直接上手用CDN方式写出第一个能用的PDF阅读器接着是重点把“阅读到哪一页记录到数据库”这个需求完整实现一遍之后是几个高频扩展场景比如目录定位和文本提取最后是实际开发中容易踩的坑。无论你是刚刚接触PDF.js的新手还是已经在项目里引入但被各种细节困扰的同学这篇文章都可以直接作为一份参考笔记来用。2. 先搞懂PDF.js的整体架构和工作原理2.1 PDF.js在浏览器里到底是怎么渲染PDF的在你写第一行代码之前最好先理解一下PDF.js在你浏览器里做了什么。这样后面遇到性能问题或者白屏问题的时候你不会一头雾水。PDF.js的渲染流程可以概括为三个步骤。第一步是下载PDF文件的数据它可以通过URL获取也可以接收ArrayBuffer或者Base64字符串。第二步是解析PDF的二进制结构这一步在Worker线程里完成。PDF格式看起来很复杂内部有对象树、字体子集、压缩流但在PDF.js内部这些会被解析成一个PDFDocumentProxy对象这个对象并不是直接对应每一页的内容而是一个索引和元数据的管理层。当你请求某一页的时候它才懒加载地去解析这一页的具体数据。第三步是绘制页面这是主线程的活通过getViewport计算页面的尺寸和缩放比例拿到一个PageViewport对象然后调用render方法把这一页绘制到Canvas上。这里要特别说明一下第二步和第三步为什么分开。PDF.js解析PDF结构是非常耗CPU的操作如果和主线程的UI渲染抢资源页面就会卡成PPT。所以它默认把解析工作交给Web Worker去执行主线程只负责拿到解析好的数据然后往Canvas上画。但这里也带来一个点要注意本地直接打开HTML文件file://协议或者跨域读取PDF文件经常会有Worker加载失败的问题这就是后面我们排查问题时一个高频考点。2.2 核心API的使用逻辑从Document到Page再到RenderPDF.js设计的API层级非常清晰从顶层到底层依次是getDocument()最顶层的入口传入PDF文件的URL或者数据返回一个PDFDocumentLoadingTask对象当解析完成后通过promise拿到PDFDocumentProxy实例。pdfDocument.numPages获取总页数。pdfDocument.getPage(pageNumber)获取指定页的PDFPageProxy对象这个对象代表PDF中的单个页面。page.getViewport({ scale })获取视口对象它包含了这一页在给定缩放比例下的宽度、高度和变换矩阵。这个viewport不是渲染的最终像素而是“页面坐标系”到“屏幕Canvas坐标系”的映射关系。page.render(renderContext)真正触发绘制的接口传入的参数包括canvas上下文、viewport以及可选的渲染任务配置。整个调用链可以用这样一段伪代码来概括// 加载PDF文件返回一个task对象 const loadingTask pdfjsLib.getDocument(url); const pdf await loadingTask.promise; // 获取第1页并计算符合屏幕比例的viewport const page await pdf.getPage(1); const viewport page.getViewport({ scale: 1.5 }); // 准备Canvas设置尺寸 const canvas document.getElementById(pdfCanvas); const context canvas.getContext(2d); canvas.width viewport.width; canvas.height viewport.height; // 渲染 const renderTask page.render({ canvasContext: context, viewport }); await renderTask.promise;你可能会问为什么要有getViewport这一步直接用一个固定尺寸Canvas画不就行了吗。因为PDF内部的坐标系单位和屏幕像素不是一回事PDF页面默认的坐标系单位是点point1点约等于1/72英寸。普通屏幕的DPI通常是96所以要想在屏幕上得到一个不模糊的渲染效果你需要根据DPR和显示尺寸计算一个scale。最常见的做法是scale1.5或者根据window.devicePixelRatio动态调整。不理解这一步你后面会遇到“渲染出来的PDF特别模糊”的问题以为是PDF.js的锅其实是你没有正确设置viewport。3. 先跑起来CDN方式快速实现一个基础PDF阅读器3.1 选对引入方式少踩很多坑PDF.js有几种使用方式。如果你是打包构建的项目Webpack、Vite、Rollup可以直接npm install pdfjs-dist然后在代码里import。这种方式最大的好处是可以按需引入和参与构建配合现代框架开发和维护成本更低。但要注意的是一定要保证Worker文件的版本和主文件一致否则各种诡异的报错会折磨你一晚上。如果你只是做个Demo验证功能或者项目本身没有复杂的前端工程体系我建议直接用CDN引入。好处是零配置、开箱即用几行代码就能看到效果。但CDN方式有一点要提醒不要在网上随便找教程里的老版本CDN链接。PDF.js更新频率不低老版本API有过几次明显的调整网上一堆教程用的还是2.x的写法在3.x甚至4.x下直接跑不起来。建议直接到官方或可靠的CDN资源站查看最新版本然后锁定版本号引入。你拿到的是哪个版本就要查对应版本的文档。3.2 完整Demo几分钟实现可翻页和缩放这里我选用一个比较新的CDN版本以3.11.174为例实际使用时你可以换成更新的版本完整代码逻辑如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titlePDF.js 快速上手/title style #pdfContainer { max-width: 800px; margin: 0 auto; text-align: center; } canvas { width: 100%; border: 1px solid #ddd; box-shadow: 0 2px 8px rgba(0,0,0,.1); } #controls { margin: 20px 0; } /style /head body div idpdfContainer div idcontrols button idprevBtn上一页/button span第 span idcurrentPage1/span / span idtotalPages0/span 页/span button idnextBtn下一页/button button idzoomInBtn放大/button button idzoomOutBtn缩小/button /div canvas idpdfCanvas/canvas /div script srchttps://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js/script script // 如果CDN上的worker文件可以使用同源策略设置workerSrc pdfjsLib.GlobalWorkerOptions.workerSrc https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js; const canvas document.getElementById(pdfCanvas); const context canvas.getContext(2d); const currentPageSpan document.getElementById(currentPage); const totalPagesSpan document.getElementById(totalPages); let pdfDoc null; let pageNum 1; let scale 1.5; async function loadPDF(url) { try { const loadingTask pdfjsLib.getDocument(url); pdfDoc await loadingTask.promise; totalPagesSpan.textContent pdfDoc.numPages; renderPage(pageNum); } catch (error) { console.error(PDF加载失败, error); } } async function renderPage(num) { if (!pdfDoc) return; const page await pdfDoc.getPage(num); const viewport page.getViewport({ scale }); canvas.width viewport.width; canvas.height viewport.height; await page.render({ canvasContext: context, viewport }).promise; currentPageSpan.textContent num; } document.getElementById(prevBtn).addEventListener(click, () { if (pageNum 1) return; pageNum--; renderPage(pageNum); }); document.getElementById(nextBtn).addEventListener(click, () { if (pageNum pdfDoc.numPages) return; pageNum; renderPage(pageNum); }); document.getElementById(zoomInBtn).addEventListener(click, () { scale 0.25; renderPage(pageNum); }); document.getElementById(zoomOutBtn).addEventListener(click, () { if (scale 0.5) return; scale - 0.25; renderPage(pageNum); }); // 替换成你要预览的PDF路径或URL loadPDF(/path/to/your/file.pdf); /script /body /html这段代码的运行逻辑很简单页面加载完成后调用loadPDF去请求PDF文件等解析完成拿到PDFDocumentProxy记录总页数然后渲染当前页码对应的页面。点击按钮时对pageNum做增减或对scale做增减然后重新调用renderPage。有一个很容易被忽视的细节在设置canvas.width和canvas.height之后一定要重新调用page.render。如果只修改了scale不重新设置canvas的像素尺寸画布还是老的分辨率放大缩小只是撑大了图像清晰度完全不对。这也是新手最容易遇到的“放大之后变模糊”的原因。3.3 初版扩展版把渲染逻辑抽象成可复用的模块demo跑通之后很多同学想直接用到自己的Vue或者React项目里但又不想整套引入重型组件库这时候最好的办法是把PDF渲染逻辑封装成独立的模块。以Vue3为例一个简单可复用的封装思路大概是这样的// usePdfViewer.js import { ref, onUnmounted } from vue; import * as pdfjsLib from pdfjs-dist; export function usePdfViewer(url) { const canvasRef ref(null); const currentPage ref(1); const totalPages ref(0); const scale ref(1.5); let pdfDoc null; async function loadDocument() { const loadingTask pdfjsLib.getDocument(url); pdfDoc await loadingTask.promise; totalPages.value pdfDoc.numPages; await renderPage(currentPage.value); } async function renderPage(page) { const pageData await pdfDoc.getPage(page); const viewport pageData.getViewport({ scale: scale.value }); const canvas canvasRef.value; const context canvas.getContext(2d); canvas.width viewport.width; canvas.height viewport.height; await pageData.render({ canvasContext: context, viewport }).promise; } function nextPage() { if (currentPage.value totalPages.value) { currentPage.value; renderPage(currentPage.value); } } function prevPage() { if (currentPage.value 1) { currentPage.value--; renderPage(currentPage.value); } } onUnmounted(() { if (pdfDoc) { pdfDoc.destroy(); } }); return { canvasRef, currentPage, totalPages, nextPage, prevPage, loadDocument }; }封装的过程有几个细节值得说道。第一pdfDoc在组件卸载的时候一定要调用destroy方法不然会有内存泄漏的隐患尤其是一个页面里反复加载和销毁PDF的场景。第二渲染过程中如果用户连续点击下一页上一页的render任务还没完成就开始新的一页渲染会产生竞态问题。严谨的做法是通过renderTask.cancel()取消旧任务或者用一个renderId标记判断当前渲染结果是否已经过期后面排查问题会细说。第三Vue3的响应式系统在处理canvas这种原生DOM对象时尽量用ref而不是reactive避免深层次代理带来的性能损耗。4. 重点来了怎么把“阅读到哪一页”记录到数据库4.1 这个需求的真实业务场景拆解在文章开头提到的热搜词里有一个很具体的问题“pdf.js如何把阅读到哪一页记录到数据库里”。这说明很多人做PDF阅读器不只是为了展示而是在做一个真正的业务闭环。想想哪些场景会需要记录阅读进度在线学习平台学生看课件读到第几页了、电子合同浏览用户是否仔细看完了合同的关键页、法律文书审查审查员审查到哪一页了、企业内部的制度文档强制学习文件有没有看完。这些场景都有一个共同点你在关注用户的阅读行为而不只是提供一个查看工具。把阅读页数记录到数据库看起来很简单——不就是用户翻页的时候往后端发个请求吗——但真要做得可靠、不打扰用户、不拖垮后端里面还是有不少门道的。4.2 前端监听翻页的时机和技巧记录阅读进度的第一步是确定“什么时候算一次阅读行为”。这个时机选错了记录的数据要么冗余要么不准确。先说最容易想到的方案在用户点击“下一页”或者滚动翻页的时候记录当前页码。但这里有个问题——如果你的阅读器支持鼠标滚轮滚动用户可能在一页内容上停留很久滚动幅度很小页面并没有跳转。如果只在“页码变化”时记录就会遗漏这部分阅读行为。而另一方面如果用户从第1页快速翻到第50页只是为了查阅你是不是也把50页记成“阅读进度”了这在某些严格需要“学完”的业务场景里会变成漏洞。我建议根据场景采用不同的策略。如果你做的是电子书或合同阅读用户在每一页的停留时间通常较长可以用定时上报每15秒或者30秒上报一次“当前页”和“停留秒数”这样后端拿到的不只是一个断点还有阅读时长这个非常有价值的辅助数据。如果你做的是轻量级的文档预览用户翻页速度很快可以用页码变化时上报配合一次进入页面时的初始上报。另外千万不要在每一次render完成之后就立刻调用接口。比如用户用触控板连续滑动翻页你的页码可能在几秒内从1变到10如果每页都上报后端会收到10个请求而真正有价值的记录只有最后一页。正确做法是做一层节流throttle或者等到页面稳定3-5秒后再上报下面的示例代码我会演示具体实现。4.3 完整的“记录页码到数据库”实现方案下面给出一个前端完整实现。这里我假设你的项目有自己的后端接口POST请求格式是JSON。为了演示效果代码里做了注释并加入了节流逻辑。class ProgressTracker { constructor({ pdfDoc, apiEndpoint, docId, userId, throttleTime 3000 }) { this.pdfDoc pdfDoc; this.apiEndpoint apiEndpoint; // 例如 /api/read/progress this.docId docId; // 文档唯一ID this.userId userId; // 用户ID this.throttleTime throttleTime; // 上报节流时间 this.currentPage 1; this._lastReportTime 0; this._timer null; this._pageLoadStartTime null; } // 页面渲染成功后调用更新当前页码 onPageChange(pageNumber) { this.currentPage pageNumber; this._pageLoadStartTime Date.now(); // 如果离上次上报时间超过阈值立即上报 if (Date.now() - this._lastReportTime this.throttleTime) { this._reportProgress(); } else { // 否则延迟到阈值时间后统一上报 clearTimeout(this._timer); this._timer setTimeout(() { this._reportProgress(); }, this.throttleTime); } } // 上报阅读进度 async _reportProgress() { if (!this.apiEndpoint || !this._pageLoadStartTime) return; try { const response await fetch(this.apiEndpoint, { method: POST, headers: { Content-Type: application/json, }, body: JSON.stringify({ docId: this.docId, userId: this.userId, page: this.currentPage, totalPages: this.pdfDoc.numPages, readTimestamp: Date.now(), duration: Math.round((Date.now() - this._pageLoadStartTime) / 1000) }) }); if (response.ok) { this._lastReportTime Date.now(); } } catch (error) { console.error(进度上报失败, error); } } // 用户离开页面或者关闭阅读器时调用确保最后的进度也能保存 async flush() { clearTimeout(this._timer); await this._reportProgress(); } }这个类的用法是在PDF加载完成之后实例化一个ProgressTracker在每次renderPage成功之后调用tracker.onPageChange(pageNum)在组件销毁或者用户关闭阅读器的时候调用tracker.flush()。这里有两个细节要强调。第一个是duration字段的计算方式。我在示例里把duration定义成“从进入这一页到上报时的时间差”。但实际上更精确的做法是上报“离开上一页的时间间隔”你可以在页面翻页事件里拿当前时间减去进入上一页的时间。因为用户若在某一页停留了10分钟这个数据对产品分析太重要了。不过为了示例简单我在代码里用了当前页面渲染时间到上报时间的差你在生产环境可以按需调整。第二个是后端接口的幂等性设计。同一个用户、同一份文档的进度后端收到请求后应该做的是更新操作而不是插入新记录。用户每次翻页都是一次更新最终只保留一条用户对某文档的阅读进度记录。你可以把docIduserId作为唯一键做更新或者UPSERT。4.4 补充刷新页面后如何恢复到上次的阅读位置记录进度到数据库的最终价值是用户下次打开文档时能恢复到上次读到的位置。这个功能在在线文档里已经非常常见了。实现上分为两步。第一步是根据用户ID和文档ID请求后端接口拿到最后的进度页码。第二步是在PDF加载完成后直接跳到指定页码并渲染。代码逻辑可以这样写async function loadWithProgress(url, docId, userId) { const loadingTask pdfjsLib.getDocument(url); pdfDoc await loadingTask.promise; totalPagesSpan.textContent pdfDoc.numPages; // 请求后端获取最后阅读页码 const res await fetch(/api/read/progress?docId${docId}userId${userId}); const data await res.json(); const lastReadPage data.lastPage || 1; // 跳到上次阅读的页码 pageNum Math.min(Math.max(lastReadPage, 1), pdfDoc.numPages); await renderPage(pageNum); }这里有个边界条件一定要处理后端返回的页码可能超过PDF的总页数。比如合同在两次阅读之间被更新过原来50页的文档变成了45页。如果默认跳转到50页就会渲染失败。所以我在代码里用Math.min和Math.max做了边界钳制这一个小小的防御性处理在生产环境能省掉很多工单。4.5 前端持久化还是后端保存上面说的是保存到数据库的后端方案。但如果你还没有后端或者只是做一个纯前端的工具型Demo还有另一个方案localStorage。它的优点是零后端成本、实时性高、无网络延迟缺点是换设备、清浏览器缓存就丢失数据而且没法在多端之间同步。我的建议是区分场景。如果一个阅读器只是给自己用或者团队内部临时用一下localStorage完全够用。具体做法很简单function saveProgressToLocal(docId, page) { const key pdf_progress_${docId}; localStorage.setItem(key, JSON.stringify({ page, timestamp: Date.now() })); } function loadProgressFromLocal(docId) { const key pdf_progress_${docId}; const data localStorage.getItem(key); return data ? JSON.parse(data).page : 1; }但如果你做的是正经的SaaS产品用户数据必须落到服务端。两种方案还能结合用前端先保存到localStorage让用户刷新页面时立即恢复进度在合适的时间再把进度同步到后端保证多端一致。这种“本地优先云端兜底”的思路在很多协作产品里都能看到。5. 扩展玩法的核心目录跳转、文本提取与移动端适配5.1 用PDF.js实现点目录跳转到对应页很多业务文档都有PDF书签Outline也就是文档的目录结构。用户在阅读器里点击左侧目录的某一项内容要能跳到对应页。PDF.js的API支持读取文档的Outline实现起来比想象中简单。核心代码是这样的async function loadOutline(pdfDoc) { const outline await pdfDoc.getOutline(); if (!outline || outline.length 0) { console.log(该PDF没有目录); return; } buildOutlineTree(outline, document.getElementById(outlineContainer)); } function buildOutlineTree(outline, container) { outline.forEach(item { const link document.createElement(div); link.className outline-item; link.textContent item.title; link.addEventListener(click, () { if (item.dest) { goToDest(item.dest); } }); container.appendChild(link); // 如果有子目录递归渲染 if (item.items item.items.length 0) { const childContainer document.createElement(div); childContainer.style.marginLeft 20px; buildOutlineTree(item.items, childContainer); container.appendChild(childContainer); } }); } async function goToDest(dest) { // dest可以是数组或字符串需要统一处理 const destArray Array.isArray(dest) ? dest : await pdfDoc.getDestination(dest); if (!destArray) return; const pageIndex await pdfDoc.getPageIndex(destArray[0]); // 注意getPageIndex返回的是0开始的索引显示给用户需要1 pageNum pageIndex 1; renderPage(pageNum); }实际开发中这个功能有一个避坑点PDF中目录项的目标地址dest不是直接存页码而是一个内部对象引用。你需要通过getDestination去解析然后再通过getPageIndex拿到它在PDF中的索引位置。而且getPageIndex返回的是从0开始的页码你在UI上显示页码时要加1。不处理这两层转换目录点击经常跳错位置。5.2 提取PDF文本内容能做什么PDF.js不仅能画页面还能把页面里的文字提取出来。这个能力在搜索引擎式内容检索、合同关键条款定位等场景非常有用。例如用户输入合同里的某个条款关键词你要定位到包含该条款的页面。提取文本的API是page.getTextContent()它会返回一个对象里面包含页面中的所有文本项以及它们在页面上的位置坐标。你可以把所有文本项拼接起来得到某一页完整的文本内容。async function extractTextFromPage(page) { const textContent await page.getTextContent(); return textContent.items .map(item item.str) .join( ); }这里我要说一个容易踩的坑getTextContent提取出来的文本顺序并不一定绝对等于视觉上的阅读顺序。因为PDF本身是一种排版格式文字块的位置完全取决于原始文档的制作方式。如果某个PDF是从PPT转换过来的文本块的先后顺序可能会按照“文本框层级”排列而不是从左到右、从上到下。如果你的关键词检索功能特别依赖文本顺序建议在提取后增加一个按y坐标排序的步骤或者用viewer组件里更成熟的文本层机制来处理。后面常见问题部分我会再提这一点。5.3 移动端适配的几个问题移动端用PDF.js有一个绕不开的问题Canvas渲染非常吃内存和CPU在低端安卓机上加载几十页的PDF经常会出现卡顿甚至Chrome崩溃。做移动端适配我的经验是这三点。第一canvas尺寸不要直接满宽渲染。先计算viewport宽度等比缩放canvas的真实像素尺寸也就是把DPR换算进去。第二不要一次渲染所有页面采用“当前页前后各一页”的策略翻页时再按需渲染避免Canvas数量激增。第三如果PDF页数特别多建议做缓存抽帧的机制把用户看过的页的canvas截图存到内存离屏Canvas下次回头翻页时直接拉起缓存减少重新渲染。这也是PDF.js官方Viewer的做法之一。6. 高频踩坑实录从白屏到模糊到疯狂报错的排查办法6.1 workerSrc设置不当导致的白屏或加载失败这是PDF.js最经典的新手问题。现象是页面没有任何反应控制台报错内容类似“Failed to fetch dynamically imported module”或者“The API version does not match the Worker version”。问题根源在于PDF.js默认会把解析工作委托给Worker线程但Worker文件的地址需要你主动告诉它也就是GlobalWorkerOptions.workerSrc这个配置。如果你用的是打包工具很多人会遇到Worker文件被打包工具改了文件名或者没被正确拷贝到静态资源目录的情况。如果你用的是CDN一定要确保主JS文件和你指定的workerSrc是同一版本。我见过有人的页面引入了PDF.js 3.11的主文件workerSrc却指向2.x的worker文件结果报错持续了半个下午才被发现。解决方法是在引入pdf.js之后、调用getDocument之前立刻设置workerSrcpdfjsLib.GlobalWorkerOptions.workerSrc https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js;如果你用npm打包有更推荐的方式import * as pdfjsLib from pdfjs-dist; import workerUrl from pdfjs-dist/build/pdf.worker.min.js?url; pdfjsLib.GlobalWorkerOptions.workerSrc workerUrl;6.2 PDF跨域读取和字体文件加载的CORS问题如果PDF文件放在CDN或者另一个文件服务器的域名下直接传给getDocument大概率会因为跨域请求被拦截而失败。处理方式有三种。第一种是让后端配置CORS响应头这是最根本的解决办法。第二种是把PDF请求改为通过后端代理前端请求自己的同源接口由后端转发PDF数据流。第三种是前端自己用fetch请求PDF文件拿到ArrayBuffer之后传给getDocument。第三种方法的缺点是PDF会先被完整下载到内存如果你的PDF有几百兆这个方案会让页面很吃紧。更隐蔽的一个问题是字体资源跨域。PDF内嵌了一些特殊字体渲染时需要解析字体文件。如果这个字体文件被浏览器拦截就会导致某个字符渲染不出来或者变成空白块。排查这种问题时除了看网络请求里有没有CORS报错还要留意console里的字体警告。这种情况很多时候不是前端代码的问题而是服务器对字体资源的响应头配置有误。6.3 渲染任务竞态和内存泄漏问题连续性翻页场景下“渲染任务竞态”是我最常在真实项目里遇到的性能问题。用户在快速翻页时pages的render操作还没执行完下一页的render又开始了。此时旧页面渲染的Canvas会被新页面覆盖但旧渲染任务可能还在继续占用CPU甚至在渲染完成后把新的Canvas内容覆盖掉导致页面内容错乱。解决思路是保留当前渲染任务的引用在新的渲染开始前cancel掉旧任务let currentRenderTask null; async function renderPage(num) { if (currentRenderTask) { currentRenderTask.cancel(); } const page await pdfDoc.getPage(num); const viewport page.getViewport({ scale }); canvas.width viewport.width; canvas.height viewport.height; currentRenderTask page.render({ canvasContext: context, viewport }); try { await currentRenderTask.promise; currentRenderTask null; } catch (error) { if (error.name RenderingCancelledException) { // 主动取消的任务不需要当异常处理 return; } throw error; } }6.4 PDF表面字迹模糊的终极解法很多同学跑通PDF.js之后第一反应是“这渲染出来的PDF怎么有点模糊”。这不一定是你代码有bug而是viewport的scale设置没有考虑设备的像素比。普通屏幕一个CSS像素对应一个物理像素但当你的页面运行在Retina屏或高DPI的移动设备上时一个CSS像素可能对应2-3个物理像素。如果你只用scale1去渲染canvas的绘制分辨率就低于屏幕物理分辨率视觉上自然是模糊的。解决办法很简单把scale乘以window.devicePixelRatio。function calculateScale(baseScale 1) { const dpr window.devicePixelRatio || 1; return baseScale * dpr; }有一点要注意用DPR调整scale后canvas的物理尺寸变大容器尺寸不变的话会导致页面内容比预期大。你需要同时设置canvas.style.width为理想的CSS像素宽保证显示比例正确。同时要考虑Canvas的最大尺寸限制有些移动端浏览器对canvas的最大边长有限制超大尺寸的canvas会被绘制成空白。处理方案是限制最大scale或者采取分块渲染策略后一种展开讲太复杂一般业务用不到。用我上面给的代码手动改一遍把模糊问题修好你会对viewport的理解上一个台阶。7. 从简到生产如何把PDF.js用得更顺手PDF.js的能力远不止打开文件和翻页。围绕它社区里衍生了很多封装好的阅读器方案比如pdfjs-dist自带的PDF Viewer功能完整但样式重、定制成本高还有基于PDF.js二次开发的react-pdf、vue-pdf等组件库。我的建议是如果你只需要一个能用的查看器直接基于官方Viewer改或者用现成封装库但如果你要做的是深度业务集成比如阅读进度、权限水印、文本检索还是自己基于PDF.js封装一层更划算——因为你每多做一步和业务系统的对接都能多掌握一份对PDF.js运行机制的理解。PDF.js在Github上常年保持活跃Mozilla团队的维护力度也很稳定这是前端处理PDF领域最可靠的开源方案。它的学习曲线其实不算陡峭核心API就那么几个难点在于如何把它和你的业务场景结合得足够自然。回到开头提到的那个热搜问题把“阅读到哪一页”记录到数据库本质上是前端事件上报、后端存储、恢复续读三个环节的配合。前端要关心的事件时机、节流策略、异常处理后端要关心的幂等更新、字段设计、接口兼容缺一个都会在真正上线之后暴露问题。我见过不少项目前端进度上报接口做成了每次翻页都插入一条新记录结果数据库几万条废数据用户阅读页数还不准。所以建议动手之前先照着上面的思路把整个链路理一遍再写代码。如果这篇文章里的内容能帮你少走两步弯路那一晚上的调试时间就省得值了。
返回列表