ARTICLE DETAIL

资讯详情

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

移动端H5中pdf.js手势缩放实战:从touch事件到渲染优化

移动端H5中pdf.js手势缩放实战:从touch事件到渲染优化 先问一句移动端 H5 里用 pdf.js 看 PDF做到“双指一捏就能缩放”这一步卡住过多少人反正我踩坑踩得很实在。pdf.js 桌面端用起来很舒服viewer.html开箱即用但一旦换成自己的组件、自己的 UI还要在手机浏览器上支持手势缩放问题就全部浮出来了。双指捏合怎么识别缩放后 canvas 为什么发虚手势和页面滚动怎么共存renderTask频繁取消会不会崩这些问题不解决功能就永远差一口气。这篇文章我会围绕“pdf.js 移动端手势缩放 原生事件集成”这条线把方案选型、viewport 渲染原理、touch 手势识别、渲染调度、性能优化、常见坑点全部串起来讲。代码是基于实际项目抽出来的精简版去掉业务噪声核心逻辑保留。适合正在做移动端 H5 PDF 阅读器、或者想在自己项目里给 pdf.js 加手势能力的同学参考。1. 方案选型与整体设计思路先说结论我不建议直接改 pdf.js 源码去加手势。pdf.js 版本迭代快每次升级都带着大量内部 API 变化你要是把自己的手势逻辑写进它的web/目录里升级成本能让你怀疑人生。我这次的做法是把 pdf.js 当纯渲染引擎手势识别全部放在业务组件层用原生 touch 事件搞定pdf.js 只负责按指定的 scale 去渲染页面。1.1 不选 Hammer.js 的理由手势库最出名的是 Hammer.js确实成熟支持 pinch、pan、tap 等各种手势。我前期原型也用了它但实际集成时发现几个问题Hammer.js 对手势的判定是整体的比如它会把手势分成“识别阶段”和“触发阶段”在移动端可滚动区域里这套判定和原生滚动的抢占时机不好控制。我们自己的业务里单指往往是用来“翻页”或者“选择文本”的双指才是缩放。Hammer.js 默认行为需要大量配置去区分调试成本不低。只为了一个 pinch 手势引一个库实在没必要。原生 touch 事件的逻辑其实很简单自己封装反而更可控。所以最终方案就是原生事件touchstart、touchmove、touchend三个事件再加上requestAnimationFrame做渲染节流。这套组合在 Android 和 iOS 上我都实测过足够稳定。1.2 缩放模型的两次抉择实现缩放路径大致有三条我对比了一圈方案原理优点缺点纯 CSS transform 缩放只改 canvas 的 CSS 尺寸不重新渲染即时响应性能开销极小放大后文字和线条发虚截图锐度差每次手势实时重渲染每个 touchmove 都调用page.render缩放过程中始终保持清晰性能压力大低端安卓机掉帧严重混合模式手势过程中用 transform 预览手势结束时在最终 scale 下重渲染流畅度和清晰度兼顾逻辑量稍大需要处理中间状态第三条是我最终采用的。道理很简单手势进行中我们只关心“看到的画面是否跟手”手势结束、手指离开屏幕的那一瞬间才需要把画面“永久性”地换成目标分辨率。这样一来touchmove 期间只需要改一个 CSS transform成本几乎为零而重渲染只发生一次canvas 里的内容又始终是清晰的两头好处都占到了。这里需要重点提醒一个细节缩放完成后transform 必须归零canvas 的实际渲染尺寸要用最终的 scale 值去更新。否则后续的单指平移、翻页计算坐标系时会出现偏移。2. 核心原理解读pdf.js 的 viewport 究竟在管什么把方案定了接下来就得吃透 pdf.js 的渲染模型。很多人在手势缩放上栽跟头其实不是手势代码写错了而是没搞明白viewport、scale、render之间的关系。2.1 渲染管线先捋一遍pdf.js 的渲染链路大概是这样的读取 PDF 文件得到PDFDocumentProxy对象通过pdfDoc.getPage(pageNum)拿到PDFPageProxy调用page.getViewport({ scale: s })得到PageViewport把 viewport 的宽高赋值给 canvas 的width、height调用page.render({ canvasContext, viewport })执行渲染。这里面最关键的是viewport。PDF 文件内部用的是“PDF 坐标点”viewport 做的事情就是把 PDF 坐标转换成 canvas 需要的屏幕像素坐标。scale 参数越大渲染出来的 canvas 像素越多画面也就越精细。所以缩放的本质不是放大 canvas 的 CSS 尺寸而是提升渲染时的 scale 参数让 pdf.js 重新画一遍。这也是为什么“纯 CSS 缩放”会模糊——CSS 只是把低分辨率画布拉大了画布里的像素总量没变。2.2 scale、devicePixelRatio 与清晰度的关系这里有个大坑手机屏幕的物理像素密度很高iPhone 的 devicePixelRatio简称 DPR通常是 2 或 3安卓机也差不多。如果你直接把scale1的 viewport 画上去在普通屏上看着还行但在视网膜屏上几乎必糊。正确的做法是用一个基础缩放值把 DPR 考虑进去const dpr window.devicePixelRatio || 1; // baseScale 表示“在 CSS 尺寸下缩放为 1 倍”的渲染比例 // 真正传给 pdf.js 的渲染 scale baseScale * currentScale const baseScale dpr;在静止展示场景下这个公式通常已经够用了。但注意在手势缩放场景里如果你直接用dpr * currentScale做每一帧的重渲染计算出来的 canvas 会非常大。比如用户把页面放到 3 倍手机上dpr2那渲染 scale 就是 6一个 A4 页面渲染成 6000 多像素宽的 canvas内存很容易爆。我的处理方式是渲染时的实际像素总量仍然用baseScale * currentScale但把 scale 的取值范围限制住。比如最小 0.5最大 3。低于这个范围就不渲染了直接显示 fit 页面时的视图高于就阻止继续放大。这个限制不是偷懒是保命。2.3 renderTask 的取消机制每次page.render()调用后pdf.js 会返回一个RenderTask对象。它有两个方法renderTask.promise渲染完成后的 PromiserenderTask.cancel()取消当前渲染。这个设计初衷是好的——“我正在渲染旧画面你叫我渲染新的那我先把旧的停掉”。但在移动端快速缩放场景下cancel 会被频繁触发如果调用时机不对可能触发异常或者出现“上一次的渲染结果覆盖了这一次”的错乱。所以我对 renderTask 的管理统一封装在一个函数里核心逻辑是async function renderPage(page, scale) { if (renderTask) { renderTask.cancel(); renderTask null; } const viewport page.getViewport({ scale: scale * dpr }); canvas.width viewport.width; canvas.height viewport.height; renderTask page.render({ canvasContext: ctx, viewport }); try { await renderTask.promise; } catch (e) { if (e?.name ! RenderingCancelledException) { console.error(e); } } finally { renderTask null; } }注意异常处理里对RenderingCancelledException的单独判断。这是 pdf.js 自己抛的取消异常不应该当错误上报。很多同学在线监控里报了一堆这种错其实都是误报过滤掉就好。3. 手势识别的细节设计与原生事件集成这一节是功能实现的重点。原生 touch 事件虽然基础但要把 pinch、滚动、翻页这些行为理清楚还是有一套完整的状态机逻辑的。3.1 双指捏合识别从距离到缩放比双指缩放的核心是“两指之间的距离”。初始距离记下来当前距离除以初始距离就是缩放比例。let startDistance 0; let startScale 1; let currentScale 1; let pinchMode false; function distance(touches) { const dx touches[0].clientX - touches[1].clientX; const dy touches[0].clientY - touches[1].clientY; return Math.sqrt(dx * dx dy * dy); }touchstart里判断event.touches.length 2进入 pinch 模式记录初始距离和当前缩放值。touchmove里持续计算scale startScale * (currentDistance / startDistance)。touchend里如果event.touches.length 2退出 pinch 模式触发重渲染。这段逻辑写下来不难代码也短。真正难的是和周围环境的配合接下来逐个说。3.2 事件冲突滚动、缩放手势、翻页的三方博弈移动端浏览器的滚动是“原生手势”它默认会抢占touchmove。如果你在touchmove里不做任何处理双指滑动时页面也会跟着上下滚动体验非常分裂。处理方法是在识别到双指时主动阻止默认行为。container.addEventListener(touchstart, onTouchStart, { passive: false }); container.addEventListener(touchmove, onTouchMove, { passive: false }); function onTouchMove(e) { if (pinchMode) { e.preventDefault(); // 更新缩放... } }passive: false是关键。移动端浏览器默认把touchmove设置成 passive意味着你无法在里面调用preventDefault()。一定要显式传{ passive: false }否则preventDefault()会被浏览器忽略还会打警告。但这里又有一个矛盾如果双指也调用preventDefault()那单指上下滑动翻页时是不是也要阻止我这里的处理是分人群的单指滑动交给原生滚动让用户自由滑动长页面双指才进入缩放模式此时无视页面滚动。也就是说pinchMode为 true 时才阻止默认行为。除非你的产品需求是“整个页面不能滚动只能通过按钮翻页”那才需要全局preventDefault()但那是另一个场景了。3.3 手势状态机避免识别中途跳变手势识别不写状态机的话会出现一个很常见的 bug一根手指先放在屏幕上第二根手指再放上来中间的 touch 事件是“一触即动”距离从 0 跳到正常值缩放比例可能瞬间猛增。我引入了一个简单的状态机const GestureState { IDLE: idle, TOUCHING: touching, PINCH: pinch, };IDLE无手指触碰TOUCHING一根手指触碰可能是将来双指的第一根也可能是单指翻页PINCH检测到两指进入缩放模式。只有从TOUCHING状态且第二次 touchstart 时touches.length 2才进入PINCH同时记录初始距离。如果在两指之间反复抬起、落下状态不会一直跳变缩放比例也不会出现突变。另外注意部分安卓机在双指缩放时一两根手指的clientX/Y会失效或变得异常官方叫“幽灵触摸”所以我在 touchmove 里会实时校验 distance 是否为有效正数如果异常就忽略这一帧。4. 实操实现从初始化到缩放完成的完整代码理论说完直接上可运行的实现。我以单页 PDF 为例子多页、虚拟列表的场景只需要在此基础上扩展索引逻辑即可。4.1 初始化与基本渲染let pdfDoc null; let currentPage null; let renderTask null; let currentScale 1; async function loadPDF(url) { const loadingTask pdfjsLib.getDocument(url); pdfDoc await loadingTask.promise; currentPage await pdfDoc.getPage(1); await renderPage(currentPage, currentScale); } async function renderPage(page, scale) { if (renderTask) { renderTask.cancel(); renderTask null; } const viewport page.getViewport({ scale: scale * dpr }); canvas.width viewport.width; canvas.height viewport.height; canvas.style.width viewport.width / dpr px; canvas.style.height viewport.height / dpr px; renderTask page.render({ canvasContext: ctx, viewport }); try { await renderTask.promise; } catch (e) { if (e?.name ! RenderingCancelledException) { console.error(render failed, e); } } finally { renderTask null; } }注意canvas.style.width和canvas.height的区别。canvas 的实际像素尺寸是width/heightCSS 展示尺寸是style.width/style.height。因为我们的scale已经包含了 DPR所以 CSS 尺寸要用viewport.width / dpr换算回去否则画面会放得过大。4.2 手势缩放完整逻辑let startDistance 0; let startScale 1; let pinchMode false; let gestureState GestureState.IDLE; let transform { x: 0, y: 0 }; // 缩放期间的临时位移 let scaleX 1; // 缩放期间的临时 scale container.addEventListener(touchstart, handleTouchStart, { passive: false }); container.addEventListener(touchmove, handleTouchMove, { passive: false }); container.addEventListener(touchend, handleTouchEnd, { passive: false }); container.addEventListener(touchcancel, handleTouchEnd, { passive: false }); function handleTouchStart(e) { if (e.touches.length 1) { gestureState GestureState.TOUCHING; } else if (e.touches.length 2) { pinchMode true; gestureState GestureState.PINCH; startDistance getDistance(e.touches); startScale currentScale; } } function handleTouchMove(e) { if (gestureState GestureState.PINCH pinchMode e.touches.length 2) { e.preventDefault(); const dist getDistance(e.touches); if (!isFinite(dist) || dist 0) return; // 限制 scale 范围 const nextScale Math.min(3, Math.max(0.5, startScale * (dist / startDistance))); // 期间用 CSS transform 预览 scaleX nextScale; canvas.style.transform scale(${scaleX}); // 双指中心点偏移计算略过实际项目中需要配合 translate 保证缩放中心点不跳 } } function handleTouchEnd(e) { if (e.touches.length 2) return; // 还有手指继续 pinch pinchMode false; gestureState GestureState.IDLE; if (Math.abs(scaleX - currentScale) 0.001) { // 手势结束正式渲染目标 scale currentScale scaleX; canvas.style.transform none; renderPage(currentPage, currentScale); } }这里我刻意省略了双指中心点的位移计算否则代码会膨胀很多。但有一点必须在这里强调如果缩放中心点偏移不做用户会在双指缩放时感觉画面在“乱跑”。正确的处理思路是记录 touchmove 时双指中心点的相对位置每次更新 scale 前把画面绕中心点做逆变换。实现上可以在 canvas 的父容器上设置transform-origin让它跟随双指中心点这样 transform 才能以正确的锚点缩放。4.3 让缩放中心点不跳动的实现这个技巧值得单独讲。做法是在 pinch 进入时记录当时的双指中心相对 canvas 左上角的坐标然后把这个坐标设置为transformOriginfunction setTransformOrigin(container, rect, touchCenterX, touchCenterY) { const ox touchCenterX - rect.left; const oy touchCenterY - rect.top; container.style.transformOrigin ${ox}px ${oy}px; }同时计算 canvas 位移让缩放中心在屏幕上保持稳定container.style.transform translate(${offsetX}px, ${offsetY}px) scale(${nextScale});说起来简单实际调试时你会发现“中心点保持不动”和“缩放中心跟随手指”是两个不同的产品预期代码差异也只在这几个坐标计算上。我的建议是第一版先做最保守的“以屏幕中心为锚点”或“以双击点为中心”等基础流程跑通了再优化成跟随手指排查问题会容易很多。4.4 渲染触发与节流手势期间不重渲染这一点前面说过了。但有一个例外如果手势结束瞬间目标 scale 和当前 scale 相差很大渲染任务会比较大用户会看到几帧空白。为了平滑我会加一个“加载中的状态提示”同时用一个小的setTimeout延迟 30ms 再触发渲染避免 touchend 后又立刻有新的手势进来。实际操作中这个延迟极有用。因为 touchend 之后系统可能还会发出少量 touchmove 事件如果立即渲染再加上这些尾随事件里的中断反而容易产生竞态。延迟 30ms 再渲染竞态几乎消失。5. 常见问题与性能调优功能跑通不算完移动端的体验是“跑通之后的持久战”。我把这几个月实测遇到的典型问题和解决方案整理成了一份列表方便后来人直接对号入座。5.1 问题一单指滚动和双指缩放冲突这是最常见的局面。处理原则是单指完全交给系统滚动双指再用preventDefault()接管。如果单指也需要平移页面内容而不是滚动页面那么你必须自己维护一个 translate 坐标并在 touchmove 里preventDefault()掉原生滚动。这和双指缩放的逻辑并不冲突但更复杂需要额外的位移记账。我的建议是尽量让页面作为一个整体滚动最简单也最符合用户预期。只有当你需要在页面上叠加标注、文字选择时才有必要把所有手势都接管下来。5.2 问题二缩放后 canvas 发虚发虚的原因90% 是 DPR 没处理好。检查三件事renderPage里的 scale 是否乘了window.devicePixelRatiocanvas 的width/height是否重新赋值而不是只改style手势结束后是否把style.transform清成none。我见过不少人在手势期间一直用canvas.style.transform scale(...)结束之后忘记清掉结果页面继续渲染后CSS transform 又叠了一层双保险加倍模糊。排查的时候先看这个。5.3 问题三renderTask 频繁取消导致的报错如果你在控制台看到一堆RenderingCancelledException不用慌那是因为取消渲染是异步的取消时正在渲染的 worker 线程可能会把一个中间状态返回回来。处理方式是统一在 catch 里判断异常名非取消异常才上报。另外不要在renderTask.promise的 finally 里再去canvas.getContext(2d)或者重复操作 canvas因为取消之后 canvas 内容可能处于半绘制状态操作它会导致花屏。5.4 问题四低端机内存飙升缩放后的 canvas 尺寸非常大一页 A4 在 dpr2、scale3 的情况下宽高都接近 3000px像素总量接近 900 万普通手机内存直接拉满。优化手段手段做法效果限制缩放范围把 scale 上限设为 2.5 ~ 3直接限制最大内存占用不渲染非可视页只渲染当前页和相邻页其他页销毁 canvas减少常驻内存用 OffscreenCanvas 做中间层复杂场景下可以先渲染到离屏 canvas再绘制到主画布减少主线程卡顿缩放结束后释放旧 canvas把旧的 canvas 尺寸重置为 1×1 再回收让浏览器尽快回收显存页面索引的记录也很重要。如果项目需要记住用户阅读到第几页缩放完成后顺手把pageNum和currentScale存进 localStorage下次打开直接恢复这个功能的性价比极高。5.5 常见问题速查表现象可能原因解决方案双指缩放时页面跟着滚动touchmove 未调用 preventDefault或事件监听未设 passive: false显式传{ passive: false }缩放后画面变模糊未乘 DPR 或 transform 未清空检查 scale 计算重置 transform缩放中心点乱跳transform-origin 未跟随双指中心动态设置 transformOrigin快速缩放后画面空白renderTask 竞态旧渲染覆盖新渲染统一管理 renderTask取消旧任务低端机卡顿canvas 像素总量过大限制 scale启用离屏渲染减少重渲染次数渲染完成前画面闪烁无加载提示或渲染延迟过短增加 loading 样式延迟 30ms 渲染5.6 性能优化的最底层心法最后分享一个调优的小技巧尽量把渲染频率压到最低把单次渲染质量提上去。很多性能问题并不是因为 pdf.js 渲染慢而是因为你在非常频繁地让它渲染。我的经验是一次 pinch 手势中touchmove 触发频率可以达到每秒 60 次以上如果每次都跑page.render没有任何手机扛得住。所以宁可手势过程中看到的画面有点模糊、有点“纸张感”也不要牺牲流畅度。流畅的模糊好过卡顿的清晰。用户感知层面上“跟手”永远比“边缘锐利”重要得多。最后再分享两个小技巧第一个是关于测试。真机调试时打开 Chrome DevTools 的设备模拟器是不够的必须拿真机去测尤其是安卓低端机和老 iPhone。电脑模拟器的手势行为、DPR、滚动机制和真机差异挺大很多藏在 touch 事件边角里的 bug只有真机上才能复现。第二个是关于事件委托。如果你的 PDF 组件会被反复创建和销毁记得在组件销毁时移除所有 touch 事件监听并把内部状态全部重置。这个看起来很简单但实际项目里因为对象被复用导致手势状态没清干净的崩溃我见过不止一次。实现 pdf.js 移动端手势缩放本质上就是三件事吃透渲染模型、控制好 touch 事件、管理好渲染任务。把这三件事想清楚后面的一切都会顺很多。
返回列表