简介面向Web前端开发者的PDF在线预览实践资料以PDF.js为核心覆盖从库引入、文档加载、Canvas渲染到多页浏览、缩放控制、进度提示、文本选择等关键能力并涉及Web Worker后台解析与性能优化思路适合有一定JavaScript基础的读者快速上手浏览器内PDF查看场景。压缩包共402个文件、整体约3.06MB完整示例代码与静态资源均整理在内包含可直接运行的HTML演示页、配套JavaScript脚本、CSS样式以及SVG/PNG/GIF界面元素和支撑PDF字体映射的bcmap、properties文件其中bcmap与properties数量较多是PDF.js正确渲染字体所必需目录层级规整便于逐项查阅。已有3142人学习下载。除基础用法外还能了解分块加载、服务端预解码等进阶方案实际开发时可借助演示页面快速验证API调用顺序与Canvas渲染细节并根据业务需要调整导航、缩放与加载策略对从零构建或改造在线PDF预览功能均有直接参考价值。1. 在线看 PDF用 JavaScript 把文件渲染进网页用 JavaScript 实现在线查看 PDF 文件听起来是个常规需求真正做起来坑不少。浏览器自带的预览在不同内核下表现差异很大——有的不支持、有的样式完全不受控制、有的直接白屏。这个项目做的事情是把 PDF 渲染收拢进一套可控的前端方案里通过开源渲染内核把 PDF 解析成 Canvas 像素输出支持翻页、缩放、旋转、全屏等交互能直接嵌入管理后台、文档系统或移动端 H5。适合谁一类是给后台系统加在线预览 PDF功能的开发者另一类是正在调研前端 PDF 渲染方案、想看清完整实现链路的技术选型者。读完你能搞懂项目怎么跑、核心链路长什么样还能拿到一份真实业务里趟出来的踩坑清单。2. 技术选型与项目结构渲染内核加业务壳的两层拆法2.1 为什么不自研解析器也别用浏览器原生预览PDF 格式远看是个文档近看是一套复杂的对象系统交叉引用表、对象流、字体子集、图像压缩、内容流指令……想从零解析再渲染工程量不低于写一个轻量级排版引擎。商业项目里做这种自研基本是给自己挖坑所以我拿到这个需求的第一反应就是找现成的渲染内核把所有精力花在业务交互上。业界成熟的做法是直接用开源的 PDF 渲染内核它的标准 API 覆盖了解析、渲染、文本提取全链路而且持续在维护字体兼容性这是自研很难追上的。浏览器原生预览是另一个极端。它确实零成本但不同内核的表现差异很大有的直接把 PDF 当作附件下载有的虽然能在新标签页打开却完全没有交互控制能力你既拿不到渲染进度也改不了工具栏样式更是完全随缘。对产品而言这种黑匣子式体验无法接受——你没法告诉用户上次保存的批注存在哪也没法统计多少人翻到了第几页。换句话说原生预览适合内部快速看一眼文件不适合作为产品的正式功能交付。这个项目采用了两层拆法底层是一个开源 PDF 渲染内核负责把 PDF 字节流解析成页面对象再绘制到 Canvas 上上层是一个业务壳负责工具栏、翻页、缩放、懒加载这些交互逻辑。两层之间只通过几个简单函数通信好处是内核升级不影响业务代码业务加功能也不用去碰渲染逻辑。我拆过的多个前端项目里这种内核与业务解耦的组织方式是最容易维护的。我整理过一个方案对比表方便你做决定方案渲染质量交互可控性集成成本典型问题浏览器原生预览高低最低各内核行为不统一样式不可控第三方预览服务 iframe中低中存在上传隐私风险受服务商限制开源渲染内核 Canvas高高较高需要处理 Worker、内存、跨域结论很直接如果你的产品对交互有要求或者要嵌在自家后台里统一体验第三档是唯一合理的选择。选型定下来之后剩下的问题就变成了怎么把内核的能力包装成好用的业务功能。2.2 整体架构Worker 线程、Canvas 渲染层、业务交互层拿我拆过的这个项目来说架构可以分成三层往下看。第一层是文档解析层。PDF 解析是大计算量活如果跑在主线程上用户会明显感到页面卡顿尤其是有几十页、每页都是高清扫描图的那种文件。因此项目把解析工作全部放进了 Worker 线程主线程只拿到解析好的页面对象和元数据翻页时再按需向 Worker 请求数据。这样做的直接收益是用户拖动滚动条时主线程始终是空闲的界面不会出现假死状态。这个设计是整个项目能流畅跑大文件的前提。第二层是渲染层。每个 PDF 页面最终通过 Canvas 绘制出来。渲染层维护一个页面对象池记录哪些页面已经渲染过、哪些还在渲染中、哪些因为离开视口需要释放资源。这里有个细节Canvas 的宽高是按设备像素比换算过的而不是直接用 PDF 的逻辑尺寸否则在 Retina 屏幕上会明显模糊。这个细节做不做体验差距是肉眼可辨的——做过的人再看标准屏会觉得怎么那么糊。第三层是交互层。工具栏的缩放、旋转、翻页按钮都在这层它不直接操作内核 API而是通过一个状态对象当前页码、当前缩放比、旋转角度下发指令渲染层根据状态变化决定重绘哪些页面。三层之间是单向依赖交互层 → 渲染层 → 解析层。反向调用是被禁止的这保证了内核替换时只需要重写解析层和渲染层的对接部分业务壳一行都不用动。数据流大致是这样的用户点击下一页 → 交互层更新状态对象 → 渲染层读取新页码向解析层要页面对象 → 解析层从 Worker 拉取数据 → 渲染层绘制 Canvas → 交互层更新页码显示。整个链路清晰出问题的时候顺着链路一层层查比在一个巨型文件里找要快得多。维护这份依赖关系的时候记住一个原则状态只往下传事件只往上抛。2.3 目录结构逐层拆解项目解压后第一眼看到的目录是这样的pdf-viewer/ ├── index.html # 应用入口页承载画布和工具栏 ├── package.json # 依赖声明与脚本命令 ├── vite.config.js # 构建配置含 dev 代理和 worker 配置 ├── public/ │ └── pdf.worker.min.js # 渲染内核的 Worker 文件必须静态可访问 ├── src/ │ ├── main.js # 入口逻辑挂载应用、读取 PDF 地址 │ ├── viewer.js # 业务壳状态管理与工具栏绑定 │ ├── render.js # 渲染层页面绘制、缩放、旋转 │ ├── lazyLoad.js # 懒加载触发与页面对象池管理 │ ├── toolbar.js # 工具栏 UI 与事件绑定 │ └── style.css # 布局样式 └── samples/ └── demo.pdf # 本地测试用示例文件每个文件的职责很单一。main.js 负责启动它只做两件事读取 URL 里的 file 参数调用 openPdf然后把文档句柄交给 viewer.js。viewer.js 是全局状态的中枢所有页面共享的当前页码、缩放比例、旋转角度都存在这里它同时也负责把工具栏的按钮事件翻译成状态变更。render.js 是整条渲染链路的执行者所有 Canvas 绘制逻辑都在里面。lazyLoad.js 管性能用 IntersectionObserver 决定什么时候渲染、什么时候释放。toolbar.js 管交互按钮的 HTML 结构和点击绑定都在这里。你如果只想改按钮样式动 toolbar.js 和 style.css 就够了想改渲染清晰度去 render.js 里找 scale 相关逻辑想支持新的操作比如批注核心是给 viewer.js 加状态字段然后在 render.js 里加绘制分支。这种状态驱动渲染的设计让每个新功能都变成加状态 画一笔而不是推倒重来。这也是我推荐照着这个结构做二次开发的原因——文件少边界清晰新手看一遍源码就知道该改哪里。2.4 核心依赖与配置项目依赖非常克制真正跑起来必需的只有两个一个开源 PDF 渲染内核库提供 getDocument、getPage、render 等标准 API一个构建工具这里用的是 Vite。其余都是开发辅助。package.json 里核心部分大致是这样{ name: pdf-viewer, version: 1.0.0, scripts: { dev: vite, build: vite build, preview: vite preview }, dependencies: { pdfjs-dist: latest }, devDependencies: { vite: latest } }这里说明一下package.json 里写 latest 是为了不锁版本实际项目里你应该在 npm install 之后让它落一个具体版本号。为什么强调锁版本因为渲染内核的主库和 Worker 文件是配套发布的版本不一致会在运行时直接报错而且它升级频繁小版本之间行为可能变化不锁版本等于把自己的线上功能交给别人的发版节奏决定。vite.config.js 里通常要做两件事配 base 路径部署在子目录时需要配开发代理把 PDF 资源的请求转发到测试文件服务器。生产环境的跨域问题则靠 Nginx 的同源部署或后端网关的 CORS 配置来解决。提示Worker 文件的处理是整个项目最容易翻车的点。把 node_modules 里对应版本的 worker 文件复制到 public 目录是常见做法但别忘了每次升级内核后重新复制否则版本一 mismatch线上立刻白屏。3. 核心模块拆解一个 PDF 页面从字节流到屏幕像素的完整链路3.1 文档加载getDocument 与 Worker 线程整个项目的第一步是加载 PDF 文档。渲染内核提供的标准入口是 getDocument它接受一个 URL、一个 ArrayBuffer 或者一个 Uint8Array。项目里用的是 URL 方式代码长这样// src/main.js import * as pdfjsLib from pdfjs-dist; // 关键点Worker 路径必须在任何 getDocument 调用之前设置 pdfjsLib.GlobalWorkerOptions.workerSrc /pdf.worker.min.js; export async function openPdf(sourceUrl) { const loadingTask pdfjsLib.getDocument({ url: sourceUrl, // 禁用自动预取避免大文件把内存撑爆 disableAutoFetch: true, // 控制预取页数的分块大小配合懒加载使用 rangeChunkSize: 65536, }); try { const pdf await loadingTask.promise; console.log(文档加载成功共 ${pdf.numPages} 页); return pdf; } catch (err) { console.error(PDF 加载失败, err); throw err; } }这段代码里有几个值得展开说的点。第一workerSrc 必须是一个静态可访问的绝对或相对路径浏览器会用这个路径去创建 Worker 线程。如果路径 404整个加载会在第一步就失败而且报错信息比较抽象不熟悉的人容易在业务代码里瞎找。第二getDocument 返回的是一个 loadingTask 对象上面有 promise 属性也有 cancel 方法用于中断加载——如果用户打开一个上百兆的 PDF 后立即关掉你应该调用 cancel 而不是干等否则网络连接和解析线程会一直占着资源。第三disableAutoFetch 和 rangeChunkSize 是一对组合前者让内核不要一口气把整个文件拉下来后者控制在按需加载时每个范围块的大小。这个组合对网络体验影响很大局域网内可以调大块大小减少请求次数公网弱网环境则应该调小。参数说明参数默认值作用什么时候该改url无PDF 地址或数据源必填disableAutoFetchfalse关闭整文件预取大文件场景设为 truerangeChunkSize内核决定分块拉取的单块大小网络差时调小局域网内可调大password无加密 PDF 的密码处理带密码的文档时传入加载完成后拿到的 pdf 对象就是整个文档的句柄后面所有操作——取页、取元数据、取大纲——都从它出发。这个对象本身不直接渲染任何东西只是数据入口理解这一点对后面排查性能问题有帮助。3.2 Canvas 页面渲染像素级细节与清晰度控制拿到文档句柄后下一步是把某一页画到 Canvas 上。这是整条链路里最核心的函数// src/render.js let currentScale 1.5; // 全局缩放基准值1 100% export async function renderPage(pdf, pageNumber, canvas, rotation 0) { // 1. 从文档句柄中取页面对象 const page await pdf.getPage(pageNumber); // 2. 按缩放比和旋转角度计算视口 const baseViewport page.getViewport({ scale: currentScale, rotation }); // 3. 考虑设备像素比让 Canvas 物理尺寸匹配屏幕实际像素 const dpr window.devicePixelRatio || 1; const viewport { width: baseViewport.width * dpr, height: baseViewport.height * dpr, }; // 4. 设置 Canvas 尺寸并绘制 canvas.width viewport.width; canvas.height viewport.height; canvas.style.width ${baseViewport.width}px; canvas.style.height ${baseViewport.height}px; const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const renderTask page.render({ canvasContext: ctx, viewport: baseViewport, }); await renderTask.promise; return page; }这段代码是整个项目里最容易被改坏的地方我拆开说。第一步 getPage 是异步的因为内核需要去 Worker 线程请求页面的内容流网络慢的时候这一步可能会等一两秒。第二步 getViewport 返回一个描述该页面在屏幕上占多大区域的对象scale 参数直接决定渲染尺寸1 就是 100%2 就是 200%页面变模糊还是变清晰全看它。第三步是高分屏适配的关键Canvas 的物理尺寸要按 devicePixelRatio 放大否则 Retina 屏上文字边缘全是锯齿看着像没加载完。第四步的 setTransform 做了对应缩放保证绘制坐标不混乱——如果少了这一步绘制出来的内容位置会整体偏移尤其在高分屏上会错位得离谱。有件事要特别提醒page.render 返回的 renderTask 可以在中途 cancel。用户在快速翻页时前几页可能还没画完就离开视口了此时应该调 cancel 并释放这个页面的资源而不是等它慢慢画完。项目里对这个问题做了一个渲染任务表// src/render.js const renderTasks new Map(); // pageNumber - { task, page } export function cancelRendering(pageNumber) { const entry renderTasks.get(pageNumber); if (entry) { entry.task.cancel(); // 中断绘制 entry.page.cleanup(); // 释放页面对象持有的资源 renderTasks.delete(pageNumber); } }这张表的意义在于它把正在渲染的页面变成了可管理的集合而不是散落在各个回调里的匿名任务。排查问题的时候只要看这张表里有哪些 pageNumber就知道当前哪些页面还占着资源。这也是我建议所有用到 Canvas 渲染的组件都该有的基础设施。3.3 翻页与懒加载IntersectionObserver 的实战用法PDF 和普通网页不一样页面数量可能几百页一次性全渲染会把浏览器直接拖垮。项目里用的是近处渲染、远处释放的策略核心实现基于 IntersectionObserver// src/lazyLoad.js export function setupLazyRender(container, getPdf, getCanvasFor) { const observer new IntersectionObserver((entries) { entries.forEach(async (entry) { if (entry.isIntersecting) { const pageNum Number(entry.target.dataset.pageNum); const canvas getCanvasFor(pageNum); // 只渲染还没渲染过的页面 if (!canvas.dataset.rendered) { await renderPage(getPdf(), pageNum, canvas); canvas.dataset.rendered true; } observer.unobserve(entry.target); // 渲染完就停止观察 } }); }, { root: container, rootMargin: 200px 0px, // 提前 200px 预渲染减少滚动空白等待 threshold: 0.1, }); // 把所有页面容器都挂上观察 const placeholders container.querySelectorAll(.pdf-page); placeholders.forEach((el) observer.observe(el)); }这套做法的核心思想是用占位换时间每个 PDF 页面在 DOM 里先放一个占位容器容器上有>// src/toolbar.js render.js 的协作逻辑 export function updateViewerState(change) { const state getViewerState(); if (change.scaleDelta) { // 支持 50% 到 300% 的缩放范围 state.scale clamp(state.scale change.scaleDelta, 0.5, 3); } if (change.rotationDelta) { // 每次旋转 90 度取模保证在 0/90/180/270 之间循环 state.rotation (state.rotation change.rotationDelta 360) % 360; } // 重绘所有已渲染的可见页面 const visiblePages getVisiblePageNumbers(); visiblePages.forEach(async (pageNum) { const canvas getCanvasFor(pageNum); const page await getPdf().getPage(pageNum); await renderPageWithState(page, canvas, state); }); } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); }缩放这里有个体验细节渲染是异步的如果用户连续点击放大按钮会触发多次渲染前一次还没结束后一次就来了。项目里用了一个渲染中计数器如果前一次还在进行中后一次请求会强制 cancel 掉前一个 renderTask保证最终效果是最后点击的那次。这个永远以最后一次操作为准的策略在频繁交互场景下非常关键能避免页面闪烁和内存堆积。做交互开发的都知道连续点击下的竞态问题是最容易翻车的点之一。旋转的逻辑也不复杂每次旋转 90 度重新计算 viewport 后 Canvas 的宽高会自动变化。布局层需要监听这个变化来调整页面容器的高度否则容器高度不变就会出现上下留白。项目里用 ResizeObserver 监听每个 Canvas 容器的尺寸变化然后动态设置外层元素高度这样旋转后页面布局不会乱。如果你要支持连续旋转记得用取模运算把角度限定在 0/90/180/270 四个值里不然旋转十几次之后角度就累积成上千了。4. 跑起来从克隆到线上部署的完整操作4.1 环境准备与依赖安装这个项目跑起来的门槛不高你只需要满足三个条件Node.js 版本别太老建议 18 及以上这是构建工具的最低要求、一个包管理器npm、pnpm、yarn 都行、能联网装依赖。这是我通常会做的第一步检查node -v npm -v输出正常就直接装依赖。在项目根目录执行npm install装的过程如果出现网络超时多半是 registry 的问题换成国内镜像再装一次就行。等安装完成有个容易被忽略的操作确认 Worker 文件是否已经就位。检查一下 public 目录下有没有 pdf.worker.min.js 这个文件没有就手动从 node_modules 里复制cp node_modules/pdfjs-dist/build/pdf.worker.min.js public/这一步决定你后面会不会白屏别跳过。Windows 用户把命令换成 copy 即可效果一样。还有个小建议装完之后顺手看一眼 node_modules/pdfjs-dist/package.json 里的版本号记住它后面排查 worker 版本不匹配时要靠这个对照。4.2 启动开发服务器一切就绪后执行npm run devVite 会起一个本地开发服务器默认端口 5173。浏览器打开控制台提示的地址你会看到页面框架和一个工具栏。如果 URL 没带 PDF 地址参数页面会尝试加载 samples/demo.pdf 作为示例文件所以第一次启动一定能看到内容不用额外准备。如果你手上有一个现成的 PDF 想测试可以通过 URL 参数指定http://localhost:5173/?file/samples/demo.pdfmain.js 里有一段逻辑会读取 file 参数然后把它作为 getDocument 的输入源。开发阶段用这个方式换测试文件非常方便不用改代码。如果端口被占用Vite 会自动换一个端口并在终端提示直接用新地址即可也可以手动指定端口npm run dev -- --port 8080。开发服务器默认开了热更新改工具栏按钮的文字会即时刷新但改 render.js 里涉及 Worker 配置的部分可能需要手动刷新一次因为 Worker 的初始化不太参与热更新。这个区别知道就行别在热更新不生效的时候浪费时间反复改代码。4.3 生产构建与部署开发调试通过后构建生产版本npm run build构建产物会输出到 dist 目录。部署时有个关键点dist 里的 pdf.worker.min.js 必须和 index.html 在同一个静态服务下且路径匹配。如果你把应用部署在子路径下比如 /tools/pdf-viewer/需要同步修改 workerSrc 的路径为相对子路径的形式// vite.config.js 里配置 base同时 workerSrc 也跟着变 pdfjsLib.GlobalWorkerOptions.workerSrc ${import.meta.env.BASE_URL}pdf.worker.min.js;这段代码利用 Vite 注入的 BASE_URL 环境变量自动拼接正确的资源路径部署到任何子路径都不用手改。Nginx 的典型配置长这样注意把 PDF 文件的请求和页面请求指向正确的目录server { listen 80; server_name example.com; root /var/www/pdf-viewer/dist; index index.html; location /pdf/ { proxy_pass http://pdf-file-server/; } }location /pdf/ 这段是把 PDF 文件的请求转发到文件服务器同时不设 CORS 限制因为浏览器端是跨域请求。如果你的 PDF 和页面放在同一台机器上直接把 location 指向本地文件目录也行。部署完记得清一下浏览器缓存静态资源频繁迭代时最容易出现改了代码线上还是旧的这种玄学问题。4.4 验证清单启动完成后我习惯按下面这份清单过一遍确认核心功能都正常打开示例 PDF第一页完整渲染文字边缘清晰无锯齿。点击下一页第二页出现在视口中滚动时预渲染的页面没有白屏。连续点击放大到 200%页面重绘无闪烁Canvas 没有拉伸模糊。旋转 90 度后页面容器高度自适应没有巨大留白。打开一个 100 页以上的 PDF快速滚动整个文档浏览器内存没有持续暴涨。无痕窗口打开同样的 URL确认 Worker 文件加载成功Network 面板能看到 pdf.worker.min.js 返回 200。任何一项异常优先去查是不是 Worker 路径、跨域配置或者版本不匹配的问题这三者占了 80% 的故障原因。逐项排查的顺序建议是先看 Network 请求是否全部 200再看控制台有没有报错关键字worker、CORS、version最后才去看业务代码。注意验证时一定要开无痕窗口因为浏览器扩展尤其是翻译类、截图类插件会干扰 Canvas 渲染和 Worker 通信容易误判为项目 bug。这个教训我是在一次演示前十分钟才深刻领会的。5. 避坑指南五条血泪经验每一条都真实踩过5.1 白屏且控制台报 Worker 加载失败现象页面打开后一片空白控制台提示无法加载 worker 文件或者直接报 API version mismatch。原因十有八九是 workerSrc 路径不对。我见过把 worker 文件放到 src 目录下的也见过版本拷贝错的还有直接把 node_modules 里的路径当静态资源引用的。这个文件必须通过静态服务器以资源方式访问不能走模块导入——你把它当模块 import构建工具也不知道该往哪放。解决检查 public 目录下有没有 pdf.worker.min.js确认它与主库版本一致。然后打开浏览器 Network 面板搜 worker 文件名看实际请求 URL 返回 200 还是 404。返回 404 就修路径返回 200 还报错就核对版本号。最快的验证方式是在浏览器地址栏直接访问 worker 文件的 URL能显示一坨压缩 JS 代码就说明路径是对的。5.2 跨域报错导致 PDF 加载失败现象本地开发一切正常部署到线上后getDocument 加载远程 PDF 时报跨域错误控制台显示 CORS policy 相关报错。原因PDF 文件放在另一台服务器比如专门的文件服务该服务器没有返回 CORS 响应头。很多人以为 Worker 是后台线程就可以绕过浏览器安全模型实际上 Worker 里发起的请求同样受同源策略约束。解决两个办法。第一个是在文件服务器上配置 Access-Control-Allow-Origin: *内网文件服务可以这么做公网要按业务收紧。第二个是让前端先 fetch 该文件拿到 ArrayBuffer再交给 getDocument。第二种绕开了跨域限制但会多占一份内存超大文件不推荐。项目里默认用第一种配合 Nginx 配置最省事。如果两种都不行检查一下是不是部署时把 PDF 请求代理到了错误的后端地址。5.3 大 PDF 页面渲染卡顿滚动像幻灯片现象打开一个 50MB、每页都是高清图片的 PDF快速滚动时页面明显卡顿甚至浏览器长时间无响应。原因页面对象和 Canvas 纹理都没有及时释放。只做懒加载不做事后释放内存会随着滚动位置不断膨胀最后触发频繁 GC一 GC 就卡。还有可能是 disableAutoFetch 没开内核在后台把整个文件都预取了一遍流量和内存一起浪费。解决在渲染一个新页面之前主动回收所有离开视口很远的页面。项目里维护了一张页面使用时间表每次渲染标记当前时间戳定时检查并调用 page.cleanup() 释放超过阈值的页面。同时确认 disableAutoFetch 已开启让内核只加载临近范围的数据。如果单页尺寸特别大还可以考虑把 Canvas 的分辨率上限设一个值超过就不按 dpr 放大牺牲一点清晰度换流畅度。5.4 文字渲染正常但部分符号和中文变成乱码现象大部分 PDF 文字渲染正常但某些字符显示成方块或乱码尤其在标题和特殊符号上。原因PDF 内嵌字体子集不完整字体的字符映射表在解析时出了问题。这种情况常见于从某些办公软件导出的 PDF字体被裁剪得只剩部分字形。另一个可能性是渲染内核版本太老对某些字体格式支持不全。解决先升级渲染内核到最新版本字体支持是持续修复的版本升级能解决大多数问题。如果还不行打开系统原生预览对比测试原生也乱码说明文件本身有问题只能让业务方换导出方式原生正常而项目里乱码重点排查内核版本和 Worker 是否匹配再检查 PDF 里嵌入的字体子集是不是被某些压缩工具处理过。5.5 移动端双指缩放无响应页面滚动冲突现象手机上打开预览器双指缩放页面时触发的是页面滚动而不是 PDF 缩放内容也没有完整适配屏幕。原因浏览器的触摸默认行为把双指手势当作滚动Canvas 上绘制的内容无法直接接收触摸事件。移动端没有 hover 状态工具栏的可点击区域也变小了交互体验和桌面端完全不一样不能用同一套逻辑硬扛。解决在 Canvas 容器上监听 touchstart / touchmove / touchend当检测到两指触屏时调用 preventDefault 阻止默认滚动并把两指距离的变化换算成缩放比例交给缩放逻辑。同时给工具栏按钮设置至少 44px 的最小触控尺寸旋转和全屏按钮放到容易够到的拇指区域。测移动端用真机测别信模拟器模拟器里的触摸事件和真机差异很大很多问题只有真机才能复现。6. 二次开发把这个预览器改成你自己的 PDF 工具这套预览器最值钱的地方不是能看 PDF而是它把渲染流程完全暴露给了你所有交互都能定制。我改造时最先做的是加一个下载按钮让预览和下载在同一个工具栏里完成// 新增下载当前文档 function downloadPdf(pdfDoc, fileName) { pdfDoc.getData().then((data) { const blob new Blob([data], { type: application/pdf }); const link document.createElement(a); link.href URL.createObjectURL(blob); link.download fileName || document.pdf; link.click(); URL.revokeObjectURL(link.href); }); }再进一步可以给页面加上文本选择能力。渲染内核里其实已经解析出了文本内容通过 page.getTextContent() 可以拿到每页的文字和坐标信息把它们浮在 Canvas 上方做一个透明的文本层就能实现选中复制。这是做关键词高亮、批注功能的基础。我一般会把这个文本层的 z-index 设为 Canvas 之上pointer-events 设为 auto这样鼠标选中文字时不会影响到下面的画布。性能方面还有一个值得做的优化对经常打开的大文件在关闭时强制调用销毁逻辑而不是依赖浏览器 GC。我在项目里给 viewer 加了一个 destroy 方法统一清理 Worker、页面对象、Canvas 上下文和所有观察器。从那以后我每次接手新的前端项目都会先问一句这个组件有没有对应的销毁流程没有的话第一个任务就是补上它。这个小习惯帮我避开了不知道多少内存泄漏的隐形坑。改完这些你的预览器就已经不是看 PDF 的工具而是一个可以继续往上加批注、加签章、加文档对比的基座了。希望帮到你。本文还有配套的精品资源点击获取