ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PDF.js构建Web PDF在线阅读器:渲染、标注与移动端适配实战

PDF.js构建Web PDF在线阅读器:渲染、标注与移动端适配实战

简介:PDF在线阅读器制作源码是一套面向Web前端与Java后端开发者的实战项目,旨在演示如何把PDF解析、渲染、在线编辑、移动端适配整合进网页应用,适合希望快速搭建在线文档浏览或批注功能的开发者学习。压缩包共8个文件,含3个HTML页面、2个PDF样例文档、2个PDF.js插件脚本及1个工程描述文件,大小约490KB,结构紧凑,便于对照运行。已有1751人学习下载,可作快速上手的参考。项目结合PDFBox、iText与PDF.js等开源方案,剖析从服务端解析到浏览器Canvas渲染、在线注释编辑、移动响应式设计及安全过滤等关键环节,既有前端页面调用示例,也能帮助理解底层实现原理。

1. 为什么 PDF 在线阅读器不能直接依赖浏览器自带插件:PDF.js 选型与最小可运行示例

接到“PDF 在线阅读器制作源码”这个需求时,我第一反应是先把浏览器默认的 PDF 插件调出来试一遍——结果在跨平台、前端嵌入和移动端这三个场景上全部碰壁。浏览器自带的 PDF 查看器虽然零成本,但你在 Web 项目里没法控制它的工具栏、没法监听翻页事件、更没法在 PDF 之上叠加批注层或者把当前阅读进度回传给后端,移动端上它的渲染性能和缩放体验也不可控。所以能嵌进自己前端工程的方案,几乎只剩下两条路:要么用 PDF.js 这类纯前端渲染引擎,要么走服务端转图片再切片下发。考虑到在线编辑、文本选择和批注这些需求,直接用 PDF.js 把 PDF 绘制到 Canvas 上,再由你接管手势和交互,是在功能完整度和实现成本之间最平衡的一条路。

本文从零搭建一个可嵌入的 PDF 在线阅读器,覆盖渲染管线、在线编辑边界、移动端适配和避坑排查。下面先给出最小可运行示例,再逐层展开参数和实现细节。

# 使用 npm 初始化工程并安装 pdfjs-dist npm init -y npm install pdfjs-dist@3.11.174

这里固定用 3.x 是因为 4.x 的 Worker 加载方式有变化,后续会专门说。安装完成后,先准备一个本地 PDF 文件放在 public/sample.pdf,然后写最简渲染逻辑。

// 最简渲染逻辑:把 PDF 第一页画到 Canvas 上 import * as pdfjsLib from 'pdfjs-dist'; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.js', import.meta.url).toString(); const loadingTask = pdfjsLib.getDocument({ url: '/sample.pdf' }); loadingTask.promise.then(pdf => { return pdf.getPage(1); }).then(page => { const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.getElementById('pdfCanvas'); const ctx = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; const renderContext = { canvasContext: ctx, viewport }; return page.render(renderContext).promise; }).then(() => { console.log('第一页渲染完成'); });

逻辑说明:pdfjs-dist 导出的getDocument会创建一个加载任务,getPage(1)是按页码取页对象,getViewport({ scale })用来计算当前缩放倍数下页面渲染尺寸。渲染动作本身是异步的,page.render()返回一个带promise的对象,只有 promise 落定后 Canvas 上才是完整画面。参数方面,scale习惯上用1.5作为基准,因为它在 Retina 屏上正好对应 3 倍物理像素,而普通屏幕上略放大也不会显得模糊。

这里有个隐藏点:PDF.js 的默认渲染是单页模式,意味着每次翻页都要重新执行getPage和render。后续的缓存策略、预加载和虚拟列表都建立在这两个调用之上。

注意:workerSrc 的写法在 3.x 和 4.x 之间不兼容。3.x 用new URL(...).toString()能得到正确的 Worker 路径,4.x 需要额外设置workerPort,而且打包工具对 Worker 的处理方式也不一样。如果你拿到的是 4.x 版本源码,先确认它是用的哪种加载方式,再去改业务代码。

2. PDF.js 渲染管线:Canvas 绘制、缩放策略与翻页性能参数

渲染管线是整个阅读器的地基。PDF.js 的工作机制是:PDF 文件解析在 Worker 线程完成,解析结果传给主线程,主线程再用render接口把页面内容绘制到 Canvas 上。这个设计避开了主线程卡顿,但也带来了两个直接的性能问题:Canvas 内存占用过高,以及每次缩放都触发重新渲染的抖动。这两点处理不好,阅读器的体验会卡到没法用。

2.1 渲染管线的三层结构与职责划分

第一层是加载层,负责 PDF 文档的拉取和解析。getDocument可以传url也可以传二进制数据data,区别在于如果你拿到的已经是 ArrayBuffer,走data能省一次网络请求;如果走url,PDF.js 内部还会用disableAutoFetch和disableStream两个开关控制是否需要分片加载——这两点对超大 PDF 影响极大。

// 加载参数配置:对大文件走分片,不自动拉取全部数据 const loadingTask = pdfjsLib.getDocument({ url: fileUrl, disableAutoFetch: false, // 按需分片加载,需要翻到对应页时才拉取该页数据 disableStream: false, // 开启流式解析 cMapUrl: './cmaps/', cMapPacked: true, });

逻辑说明:disableAutoFetch设为false时,PDF.js 会优先拉取必要的数据来构建文档结构,之后按需拉取各页内容,适合体积大但单页内容不多的情况。cMapUrl是字符映射表路径,如果 PDF 里包含 CJK 字符而你没有配置这个路径,中文会渲染成乱码或豆腐块,这是最典型的一个中文阅读器坑。cMapPacked用压缩格式,能少传很多字节。

第二层是页面层,负责视图矩阵计算。getViewport返回的是渲染所需的视图矩阵,里面包含width、height和scale字段。必须强调的是,scale不是 CSS 缩放比例,而是文档坐标到屏幕坐标的换算基准。也就是说你要支持等比缩放,不能靠改 Canvas 的style.width,必须要改scale重渲染——这正是后面性能优化的核心矛盾。

2.2 缩放策略:是重渲染还是 CSS 缩放

从业者最容易犯的错误是:拖动滑块缩放的时候,直接改 Canvas 的 CSS 尺寸,想省掉重渲染的开销。这个做法在视觉上看似成功了,但页面里的文字会发虚,而且当你需要做文本选择或关键词高亮时,获取到的坐标和实际显示位置会对不上。

// 正确的缩放:更新 scale 后重渲染当前页 function zoomTo(page, baseScale, ratio) { const newScale = Math.min(5, Math.max(0.8, baseScale * ratio)); const viewport = page.getViewport({ scale: newScale }); const canvas = document.getElementById('pdfCanvas'); canvas.width = viewport.width; canvas.height = viewport.height; const renderContext = { canvasContext: canvas.getContext('2d'), viewport, }; return page.render(renderContext).promise; }

逻辑说明:缩放本质上是更新 viewport 然后调render重绘。这里Math.min(5, Math.max(0.8, ...))把缩放区间限制在 0.8 到 5 之间——低于 0.8 的时候 Canvas 要处理的像素数骤减,但文字已经小到失去阅读意义;高于 5 则可能触发部分 PDF 的绘制 bug,尤其是带大位图的扫描件。baseScale取 1.5 是前面的结论,ratio是从缩放滑条读到的百分比。如果你既要放大又要保证文字清晰度,建议在屏幕上做一层低清占位,然后等高清渲染完成再替换——这个“模糊优先更新”的思路能让缩放过程看起来更平滑。

2.3 翻页性能优化:预渲染、缓存与虚拟列表

如果每一页都在翻到那刻才render,用户每次翻页都要等几百毫秒,体验上是不可接受的。我的做法是维护一个“当前页 + 上一页 + 下一页”的三页缓存池,只有越过缓存池边界时才真正触发新渲染。

// 三页缓存池:核心是维护一个 Map,超出两页边界就淘汰 const pageCache = new Map(); function getPageWithCache(pdf, pageNumber) { if (pageCache.has(pageNumber)) { return Promise.resolve(pageCache.get(pageNumber)); } return pdf.getPage(pageNumber).then(page => { if (pageCache.size >= 3) { const oldestKey = pageCache.keys().next().value; pageCache.delete(oldestKey); } pageCache.set(pageNumber, page); return page; }); }

逻辑说明:pdf.getPage返回的是页对象,这个对象里既包含页面尺寸信息,也包含渲染所需的指令数据,缓存下来能省掉重复解析过程。Map 的.keys().next().value拿到的是最早插入的键,正好配合先进先出的淘汰策略。这里没有用 LRU,因为阅读器的访问模式是顺序翻页,单向淘汰更符合实际路径。参数上的取舍点是:缓存页数过大虽然能让快速滑动更顺,但每个页对象都持有独立的资源引用,内存占用会以页为单位线性增长——移动端的上限建议就是 3 页,桌面端可以开到 5 页。

虚拟列表针对的是几百页的文档。常规 DOM 方案在滚动容器里同时创建 N 个 Canvas 会直接把内存打爆,虚拟化的核心是只渲染视口内可见的页,配合占位元素维持滚动条高度正确。

// 虚拟滚动核心逻辑:根据 scrollTop 计算当前需要渲染的页码范围 const estimatedPageHeight = 1200; // 单位是 px,A4 纸在 1.5 倍 scale 下的高度 let currentStartIndex = Math.max(0, Math.floor(scrollTop / estimatedPageHeight) - 1); let currentEndIndex = Math.min(pageCount - 1, Math.ceil((scrollTop + clientHeight) / estimatedPageHeight) + 1);

逻辑说明:用预估页高算出可视区间,再在这个区间内创建 Canvas 元素。estPageHeight是预估页高,如果 PDF 页面尺寸不统一,这套逻辑会存在偏差,此时需要在每页渲染完成后用实际高度修正占位块。这里我给的是一个最小化伪代码,真实工程里还要考虑滚动方向、快速滑动时的渲染节流和页面回收。千万不要在scroll事件里直接触发render,日常做法是加一层 150ms 左右的节流或交给requestAnimationFrame调度。

3. Web 版 PDF 在线编辑:文本批注与绘制的实现边界

在线编辑是阅读器从“能看”到“能用”的关键。先说清楚一个边界:PDF.js 定位是渲染引擎,不是编辑引擎——你想在已渲染的 PDF 上做文字级的直接修改(比如改掉 PDF 里的一个字),没有现成方案能简单做到,因为 PDF 的文本存储和排版模型本质上不是流式的。所以从业者口中的“PDF 在线编辑”,绝大多数场景指的是在 PDF 之上叠加自己的批注层、绘制层和高亮层,然后把原始 PDF 连同批注数据一起保存。这一点想明白,后续设计就不会走偏。

3.1 批注层的坐标换算:不落在 PDF.js 上,落在页面上

批注层最核心的问题是坐标对齐。批注不是悬浮在 Canvas 上的,而是附着在 PDF 页面上的,所以要把屏幕坐标转换成 PDF 页面坐标。

// 把批注坐标从屏幕坐标转为 PDF 页面坐标 function screenToPdfPoint(screenX, screenY, canvas, viewport) { const rect = canvas.getBoundingClientRect(); const scaleX = viewport.width / rect.width; const scaleY = viewport.height / rect.height; const canvasX = (screenX - rect.left) * scaleX; const canvasY = (screenY - rect.top) * scaleY; const pdfPoint = viewport.convertToPdfPoint(canvasX, canvasY); return { x: pdfPoint[0], y: pdfPoint[1] }; }

逻辑说明:convertToPdfPoint是 viewport 提供的基础方法,它把 Canvas 坐标变换回 PDF 的原始坐标空间。这里必须先乘上scaleX/scaleY修正 Canvas 内部尺寸和 CSS 显示尺寸的差异——如果你不给 Canvas 设置 CSS 宽度,或者你的 Canvas 在 Retina 屏上做了一倍的设备像素比适配,这两个值很可能不是 1,漏掉这一步的结果是批注位置在放大或缩小后全部偏移。批注数据建议用 JSON 存储,结构里至少要包含 pageNumber、类型、坐标数组、颜色和宽度。

3.2 高亮与手绘标注:实现方案和数据模型

高亮批注的实际做法不是把 PDF 重新渲染一遍,而是在覆盖层上用半透明颜色把目标区域画出来。如果你要做的是按文字选中的高亮,那么需要用 PDF.js 的page.getTextCOntent()拿到文本片段数组,遍历找出目标文字在哪个片段,再用片段提供的transform矩阵算坐标——这一步也叫文本定位,比鼠标拖拽画矩形要复杂得多。

// 通过文本内容定位并高亮关键词 page.getTextContent().then(textContent => { const target = 'PDF在线'; // 关键词 textContent.items.forEach(item => { if (!item.str) return; if (item.str.includes(target)) { const tx = pdfjsLib.Util.transform( viewport.transform, item.transform ); const x = tx[4]; const y = tx[5]; // 利用 item.width / item.height 绘制高亮矩形 highlightRect(x, y, item.width, item.height); } }); });

逻辑说明:viewport.transform是页面到视口的变换矩阵,item.transform是文本片段在页面坐标系里的变换矩阵,两者用Util.transform乘在一起得到屏幕坐标。注意item.transform里的 tx、ty 位于数组的第 4、5 位,也就是 x、y 平移量。这个方案在旋转过的 PDF 页面里会出现坐标没对齐的问题——旋转页面的矩阵不是简单平移,矩形高亮只能做到“位置接近”,而不是精准贴合文字,想把旋转场景做严谨还要用矩阵展开计算边界四边形。对大多数阅读器需求来说,不做旋转页面的精准高亮是完全可以接受的。

3.3 在线编辑的工程决策:是前端直写还是后端合并

前端负责收集批注数据并保存,后端负责把批注合并回 PDF——这是 Web 版 PDF“在线编辑”最稳妥的分工。因为前端如果用pdf-lib这类库直接改 PDF,虽然能合并文本和矩形,但对已有 PDF 里的中文字体处理经常有问题,字体会被替换成默认字体,页面布局就乱了。所以我的习惯是:前端只存增量 JSON,后端用 Python 的reportlab或 Java 的 PDFBox 做合并。

// 前端保存批注数据的标准格式 const annotationPayload = { fileId: 'file_12345', annotations: [ { pageNumber: 3, type: 'highlight', coords: [[100, 200, 150, 220], [160, 200, 180, 220]], color: 'rgba(255, 255, 0, 0.4)', width: 2, createdAt: '2024-05-20T10:00:00Z', }, { pageNumber: 4, type: 'draw', points: [[50, 80], [70, 100], [90, 95]], color: '#ff0000', width: 3, }, ], };

逻辑说明:coords里用 PDF 页面坐标系的绝对值,不要用相对比例,否则在后续不同尺寸设备打开时会出现漂移。type区分批注类型,后续渲染层根据type分发不同的绘制逻辑。这里的核心决策是:前端不做任何合并操作,让后端统一处理,既绕开字体问题,又能在服务端做权限校验。如果你一定要前端直写,测试时优先测试包含中文、表格和扫描图像的 PDF——这三类文档出问题的概率最高。

4. 移动版适配与加载优化:分片、内存与机型差异排查

移动端是 PDF 阅读器最容易暴露问题的地方。桌面浏览器内存充足,Canvas 开多大都不太慌,但手机浏览器的可用内存常在 200MB 到 500MB 这个量级,大一点的扫描版 PDF 渲染几页就会白屏。做移动版不能只把桌面端缩放一下,要重新审视整个渲染链路。

4.1 移动端 Canvas 内存模型:为什么大 PDF 会白屏

移动端白屏的根本原因是 Canvas 占用的内存不是由 JS 控制的,而是由底层图形栈控制的。一个 1280x1024 的 Canvas 大约占 5MB 显存,但 PDF 页面通常在 3000x2000 以上,按 1.5 倍 scale 渲染就是 4500x3000 像素,乘以 4 字节每像素——一个页面就吃掉 54MB。连续渲染 3 页就是 162MB,这在 Android 低端机上是致命的。

// 移动端 Canvas 尺寸上限控制:限制物理像素总量 const MAX_CANVAS_AREA = 4096 * 4096; function createCanvasWithLimit(width, height) { const scaleFactor = Math.min(1, Math.sqrt(MAX_CANVAS_AREA / (width * height))); const safeWidth = Math.floor(width * scaleFactor); const safeHeight = Math.floor(height * scaleFactor); return { safeWidth, safeHeight, scaleFactor }; }

逻辑说明:Math.sqrt用于计算等比缩放系数,把 Canvas 面积压到 4096x4096 以内。这个是页面渲染的兜底方案——当单页尺寸过大时,先按比例缩小 Canvas,保证渲染不崩溃,渲染完后再用 CSS 拉伸到视觉上合适的大小,代价是字会略模糊。scaleFactor可以缓存在页面对象里,后续获取文本坐标再做反向换算时要用到。实测中,四千平方像素的 Canvas 在主流中端机上能稳定渲染,超过这个值白屏概率明显上升。

4.2 移动端分片加载和延迟渲染

移动端网络环境不确定,大 PDF 直接通过url拉起会让用户等很久。正确做法是先拉取文件元信息,展示页数和文件大小,等用户翻到某页时再触发真正的内容拉取。

// 移动端延迟加载:先拿元信息,再按需取数据 const loadingTask = pdfjsLib.getDocument({ url: fileUrl, disableAutoFetch: true, // 不自动预取后续数据 disableStream: false, stopAtErrors: true, // 遇到错误立刻停止解析,避免白屏死循环 maxImageSize: 1024 * 1024, // 超过 1MB 的嵌入图片降采样 });

逻辑说明:disableAutoFetch: true会把拉取行为改成按需模式,用户不翻页就不会拿下一页的数据,适合网络请求耗时长的用户场景。maxImageSize限制嵌入图片的最大尺寸,超过阈值的图片会被降采样——这是移动端扫描版 PDF 的关键调节参数,因为扫描版的图片经常是 3000x4000 级别,直接解码会把内存打爆。stopAtErrors是兜底开关,如果 PDF 里有损数据且恰好被解析到,它会让加载失败而不是继续渲染一个坏页面。

4.3 移动端交互细节:双击缩放和横屏适配

移动端的交互适应重点有两个:双击缩放的手势坐标和横屏的自动重排。双击缩放的核心是在手势触发点重新计算视口中心并重渲染。

// 双击缩放:以点击点为锚点重新渲染 canvas.addEventListener('dblclick', (e) => { const rect = canvas.getBoundingClientRect(); const offsetX = e.clientX - rect.left; const offsetY = e.clientY - rect.top; const ratio = currentScale < 2 ? 2 : 1; const newScale = currentScale * ratio; const viewport = page.getViewport({ scale: newScale, offsetX, offsetY, }); // 用新 viewport 执行 render renderPageWithViewport(page, viewport); });

逻辑说明:getViewport的参数里,offsetX和offsetY用于控制缩放后的“锚点”,默认是无偏移的。不传参的话,双击放大后页面会以左上角为焦点,内容跑到屏幕外,体验非常差。这里我设置了双击在 2 倍以内时放大,超过 2 倍则恢复原始比例——移动端 4 倍缩放对阅读正文已经无意义,反而会频繁触发重绘和内存抖动。横屏适配时,要侦听orientationchange事件,并在回调里重新计算 viewport 和 Canvas 宽高。

5. 四处最容易翻车的实现坑:从白屏到内存溢出的排查记录

在线阅读器的坑往往不在业务逻辑,而在浏览器底层行为和后端配置上。下面挑出四个高频问题,按“现象 -> 原因 -> 解决”写清楚,这些基本覆盖了 PDF.js 项目上线初期的大部分故障来源。

5.1 Worker 加载失败导致整个页面无法渲染

现象:页面初始化后 Canvas 区域一直空白,控制台报错Failed to fetch dynamically imported module或者The Worker was unable to load.

原因:GlobalWorkerOptions.workerSrc路径配置错误。最常见的是路径用了相对路径或者 CDN 地址写错,导致浏览器无法正确加载pdf.worker.min.js。这个文件是 PDF.js 的解析引擎,它加载失败时主线程负责渲染,Worker 负责解析——没有 Worker,整个管道直接断裂。

解决:确认 Worker 文件路径与你的部署环境一致。如果是 Webpack/Vite 工程,用new URL('pdfjs-dist/build/pdf.worker.min.js', import.meta.url)这种方式让打包工具正确处理。如果是纯静态页面,用绝对路径/pdf.worker.min.js并确认文件确实放在服务器根目录。调试技巧是在 Network 面板里搜 worker 这个词,确认请求的状态码是 200 而不是 404。

注意:Worker 文件版本必须和 pdfjs-dist 主版本完全一致。混用 3.x 主库和 4.x 的 worker 文件,会在解析到某些 PDF 时产生奇怪的异常,这类问题不查版本很难发现。

5.2 中文内容渲染成豆腐块或空白

现象:PDF 页面可以翻页,英文内容显示正常,但中文区域全是方框或者直接空白。

原因:PDF 文件中文字的 ToUnicode 映射缺失或损坏。PDF.js 在渲染这类文字时需要使用cMap文件来还原字符编码,如果工程里没有配置cMapUrl,或者映射文件路径错误,对应字符就无法绘制。

解决:在getDocument时显式配置cMapUrl和cMapPacked两个参数,cMap 文件从 pdfjs-dist 的cmaps目录复制到你的静态资源目录。路径要确认与你的页面服务路径一致,尤其在部署到带子路径的服务器时,要多检查一次相对路径是否正确。实测中,很多号称“PDF 中文乱码”的问题,最终都是这两行配置缺失。

// 中文乱码修复:必须设置 cMapUrl,且路径要能访问到 cmaps 目录 const loadingTask = pdfjsLib.getDocument({ url: fileUrl, cMapUrl: './cmaps/', cMapPacked: true, standardFontDataUrl: './standard_fonts/', });

5.3 连续翻页后内存持续上涨最终页面卡死

现象:用户连续翻页十几页之后,页面操作开始卡顿,再往上翻几页浏览器直接崩溃或白屏。桌面端偶尔出现,移动端几乎每次都能复现。

原因:翻页时反复创建新的Page对象和Canvas,旧的没有销毁。尤其是Canvas,它的内存不归 JS 管,调用canvas.width = 0才能释放显存——单纯把元素从 DOM 里移除,显存占用依然在,这种泄漏叠加几次就会压垮移动设备。

解决:翻页逻辑里,当渲染新页时,把上一页 Canvas 的width和height都归零,再移除 DOM 节点。同时把页对象从缓存池里淘汰掉。这一条看着简单,但很多人漏掉canvas.width = 0这一步。

// 翻页时释放 Canvas 显存:先清空尺寸,再移除 DOM function disposeCanvas(canvas) { canvas.width = 0; canvas.height = 0; canvas.getContext('2d').clearRect(0, 0, 0, 0); if (canvas.parentNode) { canvas.parentNode.removeChild(canvas); } }

5.4 JSON 数据传值导致 PDF 文件损坏

现象:后端传给前端的 PDF 文件是正常的,但前端有时能打开,有时报错 “Invalid PDF structure”,且报错内容不稳定。

原因:PDF 二进制数据在传输链路上被转成了文本。很多国内开发者的后端框架默认启用了content-transfer-encoding或 JSON 序列化,把 PDF 的原始字节流当成了 UTF-8 文本处理。当 PDF 里恰好存在不完整的 UTF-8 序列时,字节流就被污染了。

解决:前端要求后端以二进制流方式返回数据,接口用arraybuffer作为responseType。如果后端确实只能返回 JSON,前端拿到 base64 字符串后需要做atob解码成二进制数组,再转成 Uint8Array 传给 PDF.js。

// 后端返回 base64 时前端正确解析 const base64 = response.data.base64; const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } const loadingTask = pdfjsLib.getDocument({ data: bytes });

6. 进阶:把阅读器做成带进度同步的 PDF 工作台

最后一个技巧,是把上面的单机阅读器升级成“带进度同步的 PDF 工作台”。核心思路是:不直接实时保存渲染状态,而是定期采样阅读进度,批量发送到后端。阅读进度包括页码、缩放比例、滚动偏移和批注版本号。移动端切换到后台或锁屏时,再触发一次最终确认。

// 进度同步:节流上报,而不是每次滚动就发请求 let lastSyncTime = 0; function syncProgress(pageNumber, scale) { const now = Date.now(); if (now - lastSyncTime < 5000) { return; // 5 秒内重复触发就跳过 } lastSyncTime = now; localStorage.setItem('pdf-progress', JSON.stringify({ pageNumber, scale, updatedAt: Date.now(), })); }

逻辑说明:同步频率控制在 5 秒一次,既保证进度丢失的误差在几页以内,又不会产生高频请求压力。pageNumber和scale分别取自当前渲染页和当前缩放值。这种方式的好处是,当后端暂时不可用或网络抖动时,用户的操作不会被打断。恢复时,前端从 localStorage 读取最近的进度,调用getPage和getViewport回到原来的阅读位置——这是所有阅读类应用都要有的“后悔药”,对 PDF 这种动辄上百页的文档尤其重要。

配合这个技巧,建议把同步接口设计成幂等的:服务端按fileId + userId + updatedAt去重。如果两个人同时阅读同一份文档,后提交的进度会覆盖旧的。若不想互相覆盖,可以在 payload 里加一个sessionId,每次打开文档时重新生成。

从那以后,我每次给阅读器加新功能都会强制走一遍“先验证边界条件、再写业务逻辑”的流程——尤其是 Canvas 释放、坐标换算和 Worker 版本这三项,宁可多写几行防御代码,也不愿上线后排查到凌晨。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进