ARTICLE DETAIL

资讯详情

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

基于pdf.js的移动端PDF阅读器手势缩放实现与优化

基于pdf.js的移动端PDF阅读器手势缩放实现与优化 最近在做移动端 PDF 阅读器核心需求里有一条就是必须支持双指捏合缩放、单指拖动翻页并且这些手势不能影响页面本身的滚动和 UI 操作。技术栈选了 pdf.js因为它的解析和渲染能力在开源库里确实成熟但问题也来了pdf.js 官方 demo 的 viewer 更多是桌面端思维在移动端浏览器里既重又不跟手尤其是手势缩放这块官方只提供了按钮和鼠标滚轮双指缩放几乎没有开箱即用的体验。于是我在 pdf.js v2.16.105 之上自己实现了一套移动端手势缩放并把触摸事件、滚轮事件、渲染逻辑全部串起来。这篇文章就把整个实现思路、踩坑过程、关键代码、性能优化和常见问题完整记录下来。适合那些在业务项目里用 pdf.js 做自定义阅读器、又不想直接套官方 viewer 的开发者参考。1. 为什么移动端手势缩放得自己写1.1 官方 viewer 在移动端不够“跟手”pdf.js 官方自带的 viewer 确实功能齐全工具栏、缩放按钮、缩略图、文本选择样样都有。但它天生的设计目标是桌面浏览器移动端适配最多算“能用”离“好用”差得远官方 viewer 的缩放按钮在屏幕上占地方用户必须双手操作不符合移动端单手浏览习惯。官方对触屏手势只做了基础的拖动和双击缩放在某些版本里双指捏合直接缩放并不同步要么没有要么被系统手势拦截。viewer 内部事件绑定和组件结构是整体耦合的你想从业务代码里触发一次缩放或监听缩放结束事件往往得 hack 内部 API版本一升级就崩。移动端最核心的流畅度问题官方 view 在缩放时经常整页重渲染体验卡顿内存占用还高。我最初也试过直接引入 viewer 的 CSS 和 JS然后把工具栏隐藏掉只留一个阅读区域。结果是页面结构被锁死和现有项目的路由、弹窗、手势冲突后来彻底放弃改为只使用 pdf.js 的底层 API自己写渲染层和手势层。1.2 确认技术路线transform 过渡加高清重渲染确定不用官方 viewer 之后面临的第一个抉择就是缩放方案怎么实现。我调研过的方案大概有三种第一种是纯 CSS transform 缩放。手势过程中和结束后都用transform: scale()控制 canvas优点是顺滑、GPU 加速、代码量少缺点是放大的倍数高了之后 canvas 里的文字会明显变虚锯齿重阅读体验差。第二种是纯重渲染。每次手势结束都重新计算 viewport scale然后用page.render()重新绘制 canvas。清晰度没问题但手势过程中如果实时重绘性能直接崩如果只在乎结果手势期间的实时反馈又很差。第三种是我最终采用的方案手势过程中用 CSS transform 做实时预览只缩放视觉不渲染手势结束后判断当前缩放比例和已渲染比例之间的差距超过阈值就重新用新比例渲染一次此时 canvas 的分辨率提高了再把 transform 重置掉。这个方案等于把两个方案的优点拼起来了手势过程顺滑不卡手势结束后的画面是真正的高清渲染。唯一需要注意的是重渲染时的时机控制和任务取消这个后面细讲。1.3 需要提前准备的依赖与版本项目里我用的 pdf.js 版本是 2.16.105build 号 172ccdbe5。这个版本在移动端的兼容性已经比较稳API 也稳定。再新一点的 3.x/4.x 改动较大getDocument返回的 Promise 行为和旧版有差异如果你用的是新版本代码里的部分 API 命名可能需要对一下文档。引入方式我推荐用 npm 依赖加打包而不是直接 CDN script 标签。因为移动端要处理 worker 的跨域和打包路径问题npm 方式可以统一交给构建工具处理npm install pdfjs-dist2.16.105主线程里引入import * as pdfjsLib from pdfjs-dist;注意 worker 文件需要单独配置pdfjsLib.GlobalWorkerOptions.workerSrc /pdfjs/pdf.worker.min.js;workerSrc 的路径建议用绝对路径避免在 vue-router 或 react-router 的 history 模式下相对路径解析错乱导致 worker 加载失败。2. 原生触摸事件与手势识别2.1 触摸事件的三要素与 passive 陷阱移动端手势的基础是原生触摸事件主要是touchstart、touchmove、touchend这三件套。每个事件对象里都有几个值得注意的数据结构e.touches当前屏幕上的所有触摸点。单指时长度 1双指时长度 2。e.targetTouches当前元素上的触摸点和touches在事件委托时会有差异一般用touches更符合手势判断直觉。e.changedTouches本次事件中发生变化的触摸点在touchend里特别有用因为手指抬起后touches里已经没它了。光知道事件还不够最坑的是 passive 机制。移动端浏览器为了提高滚动性能默认把touchstart和touchmove当成passive: true处理也就是说你在监听器里调e.preventDefault()会被浏览器直接忽略控制台还会打一条警告。这就导致了一个很常见的问题你在触屏上想拦截双指手势必须显式声明非 passivecontainer.addEventListener(touchmove, onTouchMove, { passive: false });如果你用的是 React 的合成事件onTouchMove默认也是 limit 为 passive 的吗不一定不同版本行为不一致。最保险的做法是我推荐用addEventListener直接绑定原生事件而不是走合成事件这样可以明确控制passive和preventDefault的行为。还有一个容易被忽略的地方是 CSS 的touch-action。即使 JS 里设了passive: false浏览器还是会根据touch-action决定是否接管手势。比如touch-action: pan-x pan-y时双指缩放会被浏览器自己处理掉。要保证 JS 手势不被干扰必须设置#pdf-container { touch-action: none; }这个属性设置了之后等于告诉浏览器“这块区域的手势交给我自己处理”它就不会再抢双指缩放了。2.2 双指捏合的距离与缩放比例计算双指捏合的核心是计算两个触摸点之间的距离变化。每次touchmove都计算当前距离和起始距离的比例用这个比例乘以上一次记录的缩放值就是当前手势的缩放结果。两个点的距离公式直接使用欧几里得距离function getTouchDistance(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里如果检测到有两个触摸点就进入捏合状态记录起始距离和起始缩放值let pinchStartDistance 0; let pinchStartScale 1; function onTouchStart(e) { if (e.touches.length 2) { pinchStartDistance getTouchDistance(e.touches); pinchStartScale currentScale; gestureState pinch; } }touchmove里实时算比例function onTouchMove(e) { if (gestureState pinch e.touches.length 2) { const currentDist getTouchDistance(e.touches); const ratio currentDist / pinchStartDistance; const nextScale clamp(pinchStartScale * ratio, MIN_SCALE, MAX_SCALE); applyTransform(nextScale); } }注意这里必须对缩放值做上下限限制不然用户可能把页面缩到看不见或者放大到 canvas 像素爆炸。我用的经验值是 0.5 到 5有的场景会放到 8但超过 5 之后渲染的 canvas 像素尺寸会非常大移动端内存容易吃不消。2.3 手势状态机与单指拖动手势除了双指缩放还有一个基础操作是单指拖动。拖动不应该直接拖 canvas 内容而应该修改外层滚动容器的scrollLeft和scrollTop这样才能和阅读器的滚动体系统一处理。我维护了一个简单的状态机idle、pan、pinch三种状态。单指按下时进入pan记录手指起始位置和滚动容器的起始滚动位置。双指按下时进入pinch记录起始距离和起始缩放。双指抬起后回到idle如果此前是捏合状态触发重渲染判断。单指抬起后回到idle不做额外操作。单指拖动的核心逻辑是“反向位移”function onTouchStart(e) { if (e.touches.length 1) { gestureState pan; startTouchX e.touches[0].clientX; startTouchY e.touches[0].clientY; startScrollLeft container.scrollLeft; startScrollTop container.scrollTop; } } function onTouchMove(e) { if (gestureState pan e.touches.length 1) { const dx e.touches[0].clientX - startTouchX; const dy e.touches[0].clientY - startTouchY; container.scrollLeft startScrollLeft - dx; container.scrollTop startScrollTop - dy; e.preventDefault(); } }注意这里的符号方向手指向右滑动时内容应该向右移动滚动容器的scrollLeft应该减小所以是减法。单指拖动在实际使用里还有一个细节如果页面可以上下滚动也可以左右拖动手指一滑就分不清到底是想平移还是想滚动。我在项目里的处理是在 PDF 页面宽度小于容器宽度时禁止横向滚动只保留纵向当页面放大超过容器宽度后再启用横向拖动。3. 缩放逻辑与 pdf.js 渲染层怎么联动3.1 渲染前的 viewport 计算pdf.js 渲染页面时page.getViewport({ scale })返回一个 viewport 对象它本质上是把 PDF 的原始尺寸乘以一个缩放系数同时包含了旋转、偏移量等信息。canvas 的绘制尺寸就来自 viewport 的width和height。我的思路是让初始缩放比例基于容器宽度自适应在拿到 PDF 第一页后先计算一个基础缩放比例baseScale让页面宽度刚好等于容器宽度保证首屏完整显示。const rawViewport pdfPage.getViewport({ scale: 1 }); const baseScale container.clientWidth / rawViewport.width;这里取 PDF 第一页的原始宽度来计算 baseScale。大部分 PDF 各页宽度一致多列扫描件可能有差异所以在渲染每一页时最好还能单独判断一下必要时用当前页重新计算 baseScale否则横版页在竖屏容器里会显得特别小。渲染时把 canvas 的 CSS 尺寸固定为 viewport 的宽高内部像素则乘以devicePixelRatio提升清晰度async function renderPage(pageNum, scale) { const page await pdfDoc.getPage(pageNum); const viewport page.getViewport({ scale }); const dpr window.devicePixelRatio || 1; canvas.width Math.floor(viewport.width * dpr); canvas.height Math.floor(viewport.height * dpr); canvas.style.width viewport.width px; canvas.style.height viewport.height px; const renderContext { canvasContext: ctx, viewport: viewport, }; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); await page.render(renderContext).promise; }这段代码是整个阅读器的渲染基础之后新增的缩放、旋转、翻页复用同一个函数通过传入不同的 scale 就能控制清晰度。3.2 CSS transform 做临时缩放在手势进行中我们不做真正的 re-render而是直接用canvas.style.transform scale()做视觉缩放。这样做的核心原因很简单page.render()的耗时是几十到几百毫秒频繁调用必然导致手势卡顿。而 CSS transform 走的是 GPU 合成层性能好得多。transform 的关键在于缩放基准点。如果使用默认的transform-origin元素中心双指缩放时视觉中心会跑到页面中心而不是两指夹住的中心体验会非常奇怪。所以要把基准点设置成双指的中心点function getTouchCenter(touches) { return { x: (touches[0].clientX touches[1].clientX) / 2, y: (touches[0].clientY touches[1].clientY) / 2, }; } function applyTransform(scale, center) { currentScale scale; const ratio scale / renderedScale; canvas.style.transformOrigin ${center.x}px ${center.y}px; canvas.style.transform scale(${ratio}); }这里要理解ratio的作用renderedScale是当前 canvas 内容实际渲染时使用的比例而手势里的currentScale是用户期望的最终显示比例。因为 canvas 本身已经是基于renderedScale渲染的所以显示层面的缩放只需要scale(currentScale / renderedScale)即可。3.3 手势结束后的高清重渲染与 cancel 机制手势结束后不能一直停留在 CSS transform 的状态否则放大之后画面会一直模糊。所以我在touchend里判断当前比例与渲染比例之间的差距超过阈值就触发重新渲染function onTouchEnd() { if (gestureState pinch) { const diff Math.abs(currentScale - renderedScale) / renderedScale; if (diff 0.15) { rerender(currentScale); } else { canvas.style.transform none; currentScale renderedScale; } } gestureState idle; }阈值 0.15 是我实际调出来的经验值。太小的话手指稍微动一下就重渲染太频繁会闪太大的话画面模糊时间过长。这里其实还可以继续优化成requestAnimationFrame节流但由于只有抬手才触发所以性能压力不大。重渲染函数要注意一个坑如果一个渲染任务还没完成又发起一个新的渲染任务老的渲染任务必须取消。否则两个page.render()同时绘制同一个 canvas 上下文画面会出现花屏甚至直接报错。取消方法是用render()返回的RenderTasklet renderTask null; async function rerender(scale) { if (renderTask) { renderTask.cancel(); } const page await pdfDoc.getPage(currentPage); const viewport page.getViewport({ scale }); const dpr window.devicePixelRatio || 1; canvas.width Math.floor(viewport.width * dpr); canvas.height Math.floor(viewport.height * dpr); canvas.style.width viewport.width px; canvas.style.height viewport.height px; canvas.style.transform none; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); renderTask page.render({ canvasContext: ctx, viewport }); try { await renderTask.promise; renderedScale scale; currentScale scale; } catch (err) { // 注意取消渲染时这里会抛 RenderingCancelledException要静默处理 if (err.name RenderingCancelledException) { return; } throw err; } }这段代码里有一个常见陷阱canvas.width ...一旦赋值canvas 的绘图上下文会被重置之前的绘制内容全部清空。所以在设置完宽高之后必须重新调用ctx.setTransform(dpr, 0, 0, dpr, 0, 0)否则绘制出来的画面会偏小。4. 实操代码移动端 PDF 手势缩放最小可用实现4.1 HTML 结构与初始化先看整个结构一个外层滚动容器里面放一个 canvasdiv idpdf-container canvas idpdf-canvas/canvas /div外层容器的样式很关键#pdf-container { position: relative; width: 100%; height: 100vh; overflow: auto; -webkit-overflow-scrolling: touch; touch-action: none; } canvas { display: block; transform-origin: 0 0; will-change: transform; }touch-action: none我在前面已经强调过少了它双指手势很容易被浏览器劫持。will-change: transform是为了提前告诉浏览器这个元素会频繁变换把它放到合成层单独处理可以减少手势期间的重绘卡顿。初始化逻辑let pdfDoc null; let currentPage 1; let renderedScale 1; let currentScale 1; let gestureState idle; const container document.getElementById(pdf-container); const canvas document.getElementById(pdf-canvas); const ctx canvas.getContext(2d); pdfjsLib.GlobalWorkerOptions.workerSrc /pdfjs/pdf.worker.min.js; async function initPdf(url) { const loadingTask pdfjsLib.getDocument(url); pdfDoc await loadingTask.promise; const page await pdfDoc.getPage(1); const viewport page.getViewport({ scale: 1 }); const baseScale container.clientWidth / viewport.width; await renderPage(1, baseScale); }如果 PDF 文件本身带授权信息需要在请求头里加 token可以用pdfjsLib.getDocument({ url, withCredentials: true, httpHeaders: { Authorization: Bearer xxx } })。但要注意这个方案在跨域场景下会触发预检请求后端服务如果不处理 OPTIONS就会一直 failed to fetch。4.2 触摸事件绑定与缩放核心逻辑把触摸事件用原生addEventListener绑到容器上container.addEventListener(touchstart, onTouchStart, { passive: false }); container.addEventListener(touchmove, onTouchMove, { passive: false }); container.addEventListener(touchend, onTouchEnd, { passive: true }); container.addEventListener(touchcancel, onTouchEnd, { passive: true });合并起来主要逻辑就是这样let pinchStartDistance 0; let pinchStartScale 1; let startTouchX 0; let startTouchY 0; let startScrollLeft 0; let startScrollTop 0; function onTouchStart(e) { if (e.touches.length 1) { gestureState pan; startTouchX e.touches[0].clientX; startTouchY e.touches[0].clientY; startScrollLeft container.scrollLeft; startScrollTop container.scrollTop; } else if (e.touches.length 2) { gestureState pinch; pinchStartDistance getTouchDistance(e.touches); pinchStartScale currentScale; } } function onTouchMove(e) { if (!gestureState || gestureState idle) return; if (gestureState pinch e.touches.length 2) { e.preventDefault(); const dist getTouchDistance(e.touches); const ratio dist / pinchStartDistance; const nextScale clamp(pinchStartScale * ratio, 0.5, 5); const center getTouchCenter(e.touches); applyTransform(nextScale, center); } else if (gestureState pan e.touches.length 1) { e.preventDefault(); const dx e.touches[0].clientX - startTouchX; const dy e.touches[0].clientY - startTouchY; container.scrollLeft startScrollLeft - dx; container.scrollTop startScrollTop - dy; } } function onTouchEnd(e) { if (gestureState pinch) { const diff Math.abs(currentScale - renderedScale) / renderedScale; if (diff 0.15) { rerender(currentScale); } else { canvas.style.transform none; currentScale renderedScale; } } gestureState idle; }这套代码已经是一个最小可用的移动端手势阅读器核心逻辑剩下的翻页、加载进度、多页渲染都是在这个基础上扩展的。4.3 移动端与桌面端的双事件兼容项目如果既要跑移动端又要跑桌面端触摸事件和鼠标事件必须同时支持。一种做法是分别监听然后共用同一套applyTransform逻辑但鼠标事件没有touches所以需要额外定义mouseDown/mouseMove/mouseUp三件套let isMouseDown false; let mouseStartX 0; let mouseStartY 0; let mouseStartScrollLeft 0; let mouseStartScrollTop 0; container.addEventListener(mousedown, (e) { isMouseDown true; mouseStartX e.clientX; mouseStartY e.clientY; mouseStartScrollLeft container.scrollLeft; mouseStartScrollTop container.scrollTop; }); window.addEventListener(mousemove, (e) { if (!isMouseDown) return; const dx e.clientX - mouseStartX; const dy e.clientY - mouseStartY; container.scrollLeft mouseStartScrollLeft - dx; container.scrollTop mouseStartScrollTop - dy; }); window.addEventListener(mouseup, () { isMouseDown false; });需要注意的是mousemove和mouseup绑在window上而不是容器上否则当鼠标移出容器时事件会丢失导致拖动卡住。桌面端缩放可以用鼠标滚轮加 Ctrl 键也可以直接绑定 wheel 事件。我这里提供一个基于wheel的缩放实现利用e.deltaY来判断方向以鼠标当前位置作为缩放中心container.addEventListener( wheel, (e) { e.preventDefault(); const delta e.deltaY 0 ? 1.1 : 0.9; const nextScale clamp(currentScale * delta, 0.5, 5); const rect canvas.getBoundingClientRect(); applyTransform(nextScale, { x: e.clientX - rect.left, y: e.clientY - rect.top, }); // 结束之后同样触发重渲染 const diff Math.abs(currentScale - renderedScale) / renderedScale; if (diff 0.15) { rerender(currentScale); } }, { passive: false } );4.4 在 uniapp、Vue 自定义组件中绑定原生事件很多人问在 uniapp 或 Vue 组件里怎么把这类手势事件绑上去这里单独说清楚。如果你是 uniapp 项目可以直接在 view 组件上绑定touchstart和touchmove但是要注意touchmove用模板绑定默认是 passive 还是非 passive 与小程序端不同有些平台的事件代理会吞掉preventDefault。我在 uniapp 里最稳的做法是给容器绑定 id然后在onReady之后用原生 API 挂监听// uniapp 页面内 onReady(() { const query uni.createSelectorQuery(); query.select(#pdf-container).node((res) { const node res.node; node.addEventListener(touchmove, handler, { passive: false }); }).exec(); });抖音小程序、微信小程序里如果手势不生效多半是touchmove的冒泡被catchtouchmove截断了处理手势的容器应该使用catchtouchmove而不是bindtouchmove这样事件才不会继续冒泡到 page。Vue 2 里面在自定义组件上监听原生事件需要加.native修饰符比如pdf-viewer touchstart.nativeonTouchStart touchmove.nativeonTouchMove /Vue 3 废弃了.native但自定义组件如果直接声明 emits并且根元素是原生元素事件监听器会自动绑定到根元素上所以直接写touchstart也能生效。前提是组件没有设置inheritAttrs: false否则就监听不到了。如果你封装的是一个多层的自定义组件最好的方式是不要在模板上绑直接给内部容器 ref然后在onMounted里addEventListener。这样最可控不受框架事件机制干扰。5. 性能优化移动端 pdf.js 渲染体验5.1 高 DPI 适配清晰度和性能的取舍移动端屏幕的devicePixelRatio普遍在 2 到 3 之间如果 canvas 的尺寸只用逻辑像素渲染画面会明显发虚。最直接的解决办法是让 canvas 内部的像素尺寸乘以 dpr再通过ctx.setTransform(dpr, 0, 0, dpr, 0, 0)让绘制坐标系保持逻辑像素这样一套渲染逻辑同时兼顾 Web 端和移动端。但高 DPI 是有代价的像素越多渲染耗时越长内存占用越大。尤其是 PDF 原本就是大尺寸页面比如工程图纸、海报再乘以 dpr 3canvas 的像素会非常恐怖。遇到这种情况我会设置一个 canvas 内部像素上限比如最大宽度不超过 4096 像素。超过限制后只能降低 dpr 系数画面会稍微损失一点清晰度但至少不会白屏或者崩溃。我实际用的一个简化方案是限制 dpr 上限const dpr Math.min(window.devicePixelRatio || 1, 2);这个 2 是一个平衡点在大多数手机上2 倍 dpr 的画面已经足够清晰内存占用也不会爆炸。5.2 渲染防抖与同页复用除了手势结束后的重渲染翻页时也需要避免频繁触发渲染。快速滑动页码时可能一秒钟触发了三次渲染任务如果每次都直接执行前两次的结果很快会被覆盖属于纯浪费。我的处理方式是在翻页方法里做一个 100ms 的防抖let renderTimer null; function goToPage(pageNum) { clearTimeout(renderTimer); renderTimer setTimeout(() { renderPage(pageNum, currentScale); }, 100); }同时还要记录当前正在渲染的页码如果目标页码没有变化就直接复用已渲染的 canvas不需要重新绘制let renderedPage 0; async function renderPage(pageNum, scale) { if (renderedPage pageNum Math.abs(renderedScale - scale) 0.01) { return; } renderedPage pageNum; currentPage pageNum; // ... 渲染逻辑 }这样在连续翻页时最多只会有一次无效渲染整体的 CPU 占用会明显下降。5.3 页码记录与恢复阅读进度这一点虽然不是手势缩放的核心但移动端阅读器基本都会遇到。用户看到一半退出下次应该回到同一页同一个缩放比例。实现方式很简单在renderPage成功之后把页码、缩放比例、容器的滚动位置存起来function saveReadingState() { try { localStorage.setItem( pdf_reading_state, JSON.stringify({ page: currentPage, scale: currentScale, scrollTop: container.scrollTop, scrollLeft: container.scrollLeft, }) ); } catch (e) { // 隐私模式或存储已满时静默处理 } }恢复时在initPdf里读取如果存在就往对应页跳const savedState localStorage.getItem(pdf_reading_state); async function initPdf(url) { pdfDoc await pdfjsLib.getDocument(url).promise; const page await pdfDoc.getPage(1); const viewport page.getViewport({ scale: 1 }); const baseScale container.clientWidth / viewport.width; if (savedState savedState.page 0 savedState.page pdfDoc.numPages) { await renderPage(savedState.page, savedState.scale || baseScale); container.scrollTop savedState.scrollTop || 0; container.scrollLeft savedState.scrollLeft || 0; } else { await renderPage(1, baseScale); } }如果项目里有后端接口需要记录用户阅读进度那就在saveReadingState里同时调接口把数据提交到数据库。注意移动端网络状态不稳定必要时要做重试机制。6. 常见问题与排查实录6.1 pdf.js 提示 failed to fetch 系列问题这个问题根据报错展示位置不同原因也不一样。我遇到最多的几种情况第一种是开发环境跨域。PDF 文件在测试服务器上前端在localhost:8080直接请求文件接口时被浏览器拦截。解决办法是在vue.config.js或vite.config.js里配置代理// vite 示例 server: { proxy: { /pdf: { target: https://your-api-server.com, changeOrigin: true, }, }, }第二种是 OSS 或云存储文件没有正确配置跨域头。在阿里云 OSS 这类场景里控制台可以配置跨域规则允许*的Access-Control-Allow-Origin即可。第三种是请求头带了 Authorization触发了 OPTIONS 预检。如果后端没有对 OPTIONS 请求返回 200 和明确的跨域头请求会直接失败。解决方向是后端统一放行 OPTIONS或者前端把鉴权参数放到 URL query 里避免预检。第四种是 worker 文件路径错误。报错信息里会出现类似Failed to load worker的信息不仔细看会和文件 fetch 失败混淆。检查一下GlobalWorkerOptions.workerSrc指向的路径是否存在打包后是否被正确处理。另外如果getDocument的 URL 是个需要重定向的地址比如短链、鉴权 URLpdf.js 可能在 fetch 过程中无法跟随重定向导致失败这种情况建议先把文件 URL 解析出来再交给 pdf.js。6.2 双指缩放时页面也跟着放大、手势不灵敏这个是移动端 webview 的经典问题。浏览器本身是有页面级缩放的双指捏合时如果页面也放大就会和我们的手势冲突。解决方式有三板斧第一页面 viewport meta 设置禁止用户缩放meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno /但注意 iOS 10 以后 Safari 已经忽略user-scalableno了普通浏览器里有效应用内嵌 webview 一般有效。第二在 touchmove 里调e.preventDefault()这个前面代码里已经做了。但预检条件必须是{ passive: false }否则不生效。第三设置 CSShtml, body { touch-action: none; overscroll-behavior: none; }这能有效阻止 iOS 橡皮筋效果和页面级手势劫持。手势不灵敏还有一个容易被忽略的原因容器或 canvas 的层级被其他元素盖住了。如果页面上有一个透明遮罩触摸事件都跑到遮罩上面手势自然不响应。排查时直接在浏览器 devtools 里选中元素看 pointer-events 的区域就行。6.3 缩放后 canvas 模糊模糊发生在两种场景一种是手势过程中本来就用 CSS transform 在做临时缩放模糊是正常的抬手后会高清重渲染另一种是抬手后依然模糊说明重渲染失败或者没有触发重渲染。可以先检查renderedScale和currentScale是否在重渲染后保持一致console.log(renderedScale, renderedScale, currentScale, currentScale);不一致大概率是rerender里的canvas.width没有正确更新或者ctx.setTransform(dpr, 0, 0, dpr, 0, 0)没写。还有一个很隐蔽的坑renderTask.cancel()之后旧的渲染 Promise 会抛出异常如果你在catch里直接throw err代码会中断导致新的渲染没有执行。我用的是判断err.name RenderingCancelledException后直接 return这样新任务才能正常跑完。6.4 其他容易踩的坑有的 PDF 页数特别多移动端一次性加载所有页面的元数据会比较慢。这种情况下可以延迟加载首屏只渲染当前页下一页的getPage调用放到前一个渲染完成之后再请求。pdfDoc.getPage()本身开销不大但大量连续调用也会卡 UI我的习惯是用requestIdleCallback做空闲加载。还有安卓低端机上的 GPU 内存问题。CSS transform 缩放时如果 canvas 本身已经很大will-change: transform会把整个 canvas 提升到独立图层内存占用翻倍。遇到低端机直接把will-change去掉或者只在手势开始时临时添加手势结束后移除。最后提一下scrollable区域的高度。canvas 尺寸变化时外层容器的高度要能自动撑开。由于 PDF 的宽高是由 viewport 决定的所以在重渲染后需要更新 canvas 的 style 尺寸保证滚动区高度正确。否则会出现页面只看到上半部分、滑不到底的情况。我的一些实际体会写了这么多最后分享几个小经验。第一手势交互的流畅度核心就是“过程走 transform、结果走重渲染”这个路子的体验上限很高但重渲染一定要做好取消和防抖否则高倍率放大后的连续操作很容易把 WebView 崩掉。第二不同手机厂商的 webview 对touch-action、passive、devicePixelRatio的处理不完全一致最好在真机上反复测Chrome 模拟器只能当参考。第三如果只是给内部工具用直接用 pdf.js 官方 viewer 也能凑合但只要是面向真实用户的移动端阅读器就别偷懒自己掌握渲染和手势层才是长期可控的方案。
返回列表