前阵子公司移动端合同预览模块提了个需求:H5里用pdf.js渲染的PDF,在手机上必须支持双指捏合缩放,而且要和现有自定义组件里的原生事件无缝配合。听起来好像就是个"给canvas加组touch事件"的活,实际上手才发现,pdf.js本身在移动端根本没内置手势缩放,桌面端的缩放机制完全依赖滚轮和按钮,到了触摸设备上,你不动手它就是一张死图。再加上Android上的浏览器默认手势、iOS的GestureEvent、React组件事件绑定、渲染性能这些乱七八糟的问题堆在一起,硬是踩了两天坑。这篇文章把我最后能跑通的方案拆开讲清楚,重点是原生touch事件如何与pdf.js的scale渲染层做集成,以及移动端跑起来不卡的几个关键做法。适合正在做移动端PDF预览、H5内嵌阅读器或者WebView混合应用的前端同学。
1. 为什么pdf.js在移动端没有"现成的手势缩放"
1.1 桌面端缩放路径在触摸设备上彻底失效
pdf.js的PDFViewer和PDFPageView默认监听的是wheel滚轮事件和UI层上的缩放按钮。桌面端用滚轮缩放时,viewer会通过scale参数触发page重新render,但这是桌面交互模型下的设计,移动端既没有滚轮也没有物理右键,开箱就是完全没有双指缩放。很多人第一次上手会下意识去pdf.js配置项里找"enablePinchZoom"之类的开关,很遗憾,直到v2.16.105这种常用版本里也没有这种现成配置。所以"给pdf.js加手势缩放"这个需求,本质上不是改配置项,而是自己接管触摸输入,再把缩放结果同步给pdf.js的渲染层。
移动端还有个更大的麻烦:浏览器把触摸手势当成系统级交互。iOS Safari和Android Chrome默认会响应双指手势做页面缩放,我们必须在适当的节点阻止掉这种默认行为,否则你刚识别到两个手指,浏览器先把整个页面缩小了,canvas里的PDF纹丝不动,用户看到的就是两只手指在打架,体验非常割裂。
1.2 要知道被放大的到底是什么:视觉缩放与渲染缩放
桌面端伸缩PDF时,pdf.js做的是什么?它把PDF经过解析、绘制成一帧位图,放到canvas元素上;滚轮放大时,它用更大的scale重新计算viewport,然后把canvas清掉再画一遍,也就是说缩放是"重新渲染"的结果。这个成本高,但清晰度是真的高。
移动端手势缩放要是也每帧重绘,基本是灾难。比如A4纸默认72dpi下约595x842逻辑像素,想要显示清晰还得乘上devicePixelRatio(普遍是2或3),再乘逻辑scale 2或3,一个canvas的物理尺寸很容易超过4000px甚至6000px。这种尺寸下,60fps连续重绘是不可能的。所以工程上的惯用做法是拆成两层:手势过程中用CSS transform scale做视觉预览,体验上跟手,成本极低;手势结束那一帧,让pdf.js按最终scale重新渲染,把清晰度补回来。这就是"视觉缩放"与"渲染缩放"的区分,下面所有实现都基于这两条腿走路。
2. 手势识别方案选型:GestureEvent、hammer.js还是原生touch
2.1 GestureEvent的"看起来很美"
iOS Safari很早就支持gesturestart、gesturechange、gestureend这一组手势事件,事件对象上直接带scale、rotation,代码写起来非常短。我第一次实现时也想用这个,因为它在iOS上可以用一行代码拿到捏合比例,尤其是rotation属性做旋转场景确实很省事。可实际一跑就发现问题:Android端无论是Chrome还是国产WebView,根本不支持GestureEvent;如果产品只碰iOS还好,一旦要双端统一,就必须为Android另写一套touch事件逻辑。两套逻辑维护起来很别扭,尤其还要混在同一个组件事件流里,最后我整套删掉重写了。
这段经历给的结论是:GestureEvent只适合做iOS独享的原生Demo,商业项目里尽量别作为主方案。它还有个隐含问题,gesture事件和touch事件是两条事件流,如果页面其他元素已经绑了touch事件,两套手势在边界情况下会互相干扰,排查起来比单纯touch方案更难受。
2.2 第三方库也是"额外负担"
hammer.js能识别pinch手势,API封装得也成熟,很多开发者会选它。我在这个项目里没用,原因是两个:一是它默认的preventDefault策略有时候会把页面滚动一起吃掉,导致PDF预览区域外的列表滑动也变卡;二是我们组件里已经绑了一批原生touch事件,再引入一个手势库,事件流里就会存在两套兄弟逻辑,排查起来既难也不符合"原生事件集成"这个硬性要求。
如果只是图快,hammer.js可以上,但想要可控性和代码体积,尤其是在自定义组件里既要处理底层触摸又要同步pdf.js状态,我更推荐原生touch。原生方案代码量很小,但每个行为都在自己手里:什么时候进入捏合、什么时候阻止默认、什么时候通知页面滚动,全部显式控制,不会出现"第三方库悄悄改了我事件行为"这种问题。
2.3 用两个数学公式自己识别捏合
原生touch事件识别捏合,核心只要两个公式:两指距离和两指中点。
function getDistance(touches) { const [t1, t2] = touches; const dx = t1.pageX - t2.pageX; const dy = t1.pageY - t2.pageY; return Math.hypot(dx, dy); } function getMidpoint(touches) { const [t1, t2] = touches; return { x: (t1.pageX + t2.pageX) / 2, y: (t1.pageY + t2.pageY) / 2, }; }判断逻辑是:touchstart时touches.length === 2,进入捏合态,记录startDistance;touchmove里重新算distance,用distance / startDistance得到相对缩放比;最后在当前渲染scale上乘这个比,就是目标scale。中点用来做锚点,保证缩放视觉上围绕两指之间进行,而不是绕着canvas左上角转。这一段逻辑不依赖任何库,几十行代码,后续也方便扩展双击放大、单指平移。
3. 原生事件集成:touch-action、passive和组件生命周期
3.1 第一步是给浏览器让路:touch-action怎么设
在自己写touch监听之前,必须先在样式层面告知浏览器"这块区域你别插手"。默认情况下,触摸滑动会滚动页面,双指会缩放视口。对于全屏PDF预览页面,我直接把预览容器设为:
.pdf-preview-canvas { touch-action: none; }注意触屏区域和页面滚动区域冲突时需要单独切割。例如PDF阅读器只占据屏幕上半屏,下半屏还需要滑动列表,此时touch-action:none设在整个阅读器容器上只禁用了阅读器区域的滚动,并不会影响外部列表。真正要注意的是不要把touch-action:none写在body或html上,否则整页滚动和缩放全废。如果既想保留单指上下滚动,又需要双指缩放,可以设:
touch-action: pan-y pinch-zoom;但这需要你的手势代码能兼容页面在缩放过程中仍然滚动,逻辑会复杂不少。我的方案是预览区全屏,直接none,简单且行为一致。
3.2 为什么必须给touchmove传{ passive: false }
很多人写移动端触摸事件时踩过这个坑:在touchmove监听器里调用e.preventDefault(),控制台立刻打出一句类似"Unable to preventDefault inside passive event listener invocation"的警告,preventDefault完全无效,浏览器继续缩放页面。原因是Chrome从56版本开始默认把touchmove注册为passive,也就是"保证不取消默认行为",以优化滚动性能。我们不能改变浏览器对addEventListener的默认值,但可以在注册时明确覆盖:
const opts = { passive: false }; container.addEventListener('touchmove', onTouchMove, opts);需要说明的是,只有确定要阻止浏览器默认缩放的触摸事件才用passive:false。如果是一般的滑动增强,保持默认passive会更好,避免影响滚动手感。我这里也是只对touchmove用了passive:false,touchstart和touchend保持默认,把性能损耗降到最低。
3.3 绑定到容器而不是canvas,并处理好组件卸载
如果pdf.js把canvas渲染在容器内,事件最好绑定在容器节点上,不要绑到canvas本身。原因有两点:pdf.js在页面切换、缩放重绘时可能替换canvas DOM,绑在canvas上的监听会随着节点销毁丢失;另外自定义组件(比如React或Vue封装)的所有权和生命周期很严格,绑在canvas上还容易出现重复渲染后旧监听残留。我习惯用useEffect统一注册,再在清理函数里removeEventListener:
useEffect(() => { const container = containerRef.current; const opts = { passive: false }; const onTouchStart = (e) => { handleTouchStart(e); }; const onTouchMove = (e) => { if (pinchState.active) { e.preventDefault(); handleTouchMove(e); } }; const onTouchEnd = (e) => { handleTouchEnd(e); }; container.addEventListener('touchstart', onTouchStart, opts); container.addEventListener('touchmove', onTouchMove, opts); container.addEventListener('touchend', onTouchEnd, { passive: true }); return () => { container.removeEventListener('touchstart', onTouchStart); container.removeEventListener('touchmove', onTouchMove); container.removeEventListener('touchend', onTouchEnd); }; }, [currentPage]);这里的handleTouchMove里e.preventDefault()是有条件的,不处于捏合态时不要阻止默认,这样单指滑动可以继续滚动页面。把这个"条件阻止"做好,就是"自定义组件绑定原生事件"和页面原生行为共存的根本。
4. 捏合过程如何映射到pdf.js的scale和位移
4.1 一个最小的捏合状态机
我用一个对象保存捏合开始时的快照,整套状态机就三个字段:
let renderedScale = 1.5; const pinchState = { active: false, startDistance: 0, startScale: 1, currentScale: 1, };- touchstart检测到两根手指时,active设为true,记录startDistance和当前pdf.js渲染使用的renderedScale。
- touchmove里算出currentDistance,ratio = currentDistance / startDistance,那么当前整体缩放应该是startScale * ratio,用clamp限制在0.5到4之间。
- touchend检测到手指少于两根时,active复位,用最终的currentScale触发一次真正的pdf.js渲染。
clamp函数很简单:
function clamp(v, low, high) { return Math.min(Math.max(v, low), high); }这样设计的好处是,不管用户手指在捏合过程中怎么抖动,缩放比例始终相对于touchstart那一刻,不会累积误差。如果你用"当前距离 / 上一次距离"去叠乘,手稍微一抖scale就会漂移,我在初版实现里就犯过这个错。
4.2 手势过程中只做CSS变换,松手后才重绘
在touchmove里直接执行pdf.js的render是不现实的。我用手势move阶段只更新预览层的CSS样式:
function updatePreviewTransform(targetScale, origin) { const box = containerRef.current; box.style.transformOrigin = `${origin.x}px ${origin.y}px`; box.style.transform = `scale(${targetScale})`; }这里要求pdf.js渲染出的canvas外面包一层Box,transform加在Box上而不是canvas上。transformOrigin取双指中点,这样视觉上PDF会围绕两个手指的中心放大缩小,非常跟手。GPU会接管这层变换,一帧的消耗极低。等到touchend时,才拿最终scale调pdf.js重新渲染:
async function finishPinchToRender() { const targetScale = pinchState.currentScale; containerRef.current.style.transform = ''; containerRef.current.style.transformOrigin = ''; await renderCurrentPageWithScale(targetScale); }注意个小细节:清掉CSS transform那一刻,画面会短暂回到原尺寸,随后pdf.js重新渲染完成后又变清晰。为了避免"闪一下",可以把清理动作延后到render即将完成的时机,或者给容器在重绘期间加一个opacity过渡。移动端性能允许时,更简单的做法是:先在新canvas上渲染,渲染成功后再替换DOM,同时清transform;不过这会导致短时间内双份canvas并存,内存占用翻倍,需要根据PDF大小取舍。
4.3 中点锚定计算:别让画面"飘"了
如果你只做scale而不写位移,双指缩放会固定围绕transform-origin展开;而移动端用户的本能是围绕手指中点缩放,这就需要把origin动态设置成两指实时中点。完整逻辑是:
function handleTouchMove(e) { const origin = getMidpoint(e.touches); const distance = getDistance(e.touches); const ratio = distance / pinchState.startDistance; pinchState.currentScale = clamp( pinchState.startScale * ratio, 0.5, 4 ); updatePreviewTransform(pinchState.currentScale, origin); }有些方案喜欢维护translateX/translateY,我试下来在pdf.js场景里不如直接改transformOrigin干净。因为pdf.js的页面本身有布局坐标体系,如果再用translate去补偿,一方面要跟pdf.js内部的滚动位移换算,另一方面render完成后transform清零时容易跳位置。如果后续还要接单指拖动平移,那建议把transform和translate都放进去,并且用requestAnimationFrame统一更新,避免多个样式字段交错写入。
5. 移动端渲染与性能优化:不重绘、不超限、不卡顿
5.1 一次渲染到底有多贵
算一笔账:一张A4页面,逻辑尺寸约595x842,移动端通常需要scale=1.5到2才能看得清文字,再加上devicePixelRatio=2或3,物理像素大约是595×2×2≈2380,842×2×2≈3368,这一张画布就接近2400万像素了。如果再放大到scale=4,物理尺寸甚至会逼近5000×7000。在大部分低端Android WebView里,这么大的canvas位图足以触发内存紧张甚至崩溃。所以移动端pdf.js优化的第一原则是:能用CSS预览就绝不用渲染缩放去追手势,能只渲染当前页就绝不多渲染下一页。
5.2 控制缩放上限与渲染节流
我给这套方案设置了两个硬性限制:一是scale上限设为4,不允许用户无限放大;二是渲染上限,在重绘前判断目标scale对应的canvas物理尺寸,超过4096像素时自动下调dpr或封顶scale:
function getRenderScale(targetScale) { const viewport = pdfPage.getViewport({ scale: 1 }); const baseWidth = viewport.width; const maxWidth = 4096; const maxScaleByWidth = maxWidth / (baseWidth * (window.devicePixelRatio || 1)); return Math.min(targetScale, maxScaleByWidth); }这样内存有保障,画面也不会因为canvas尺寸超过系统限制而变成空白。同时每个page的render会返回一个RenderTask,如果页面在渲染过程中又发起了新的渲染,旧任务必须取消:
let activeRenderTask = null; async function renderCurrentPageWithScale(scale) { const safeScale = getRenderScale(scale); const page = await pdfDocument.getPage(currentPageNum); const viewport = page.getViewport({ scale: safeScale * (window.devicePixelRatio || 1), }); const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (activeRenderTask) { activeRenderTask.cancel(); } canvas.width = viewport.width; canvas.height = viewport.height; activeRenderTask = page.render({ canvasContext: ctx, viewport, }); await activeRenderTask.promise; }不取消旧任务的后果是,多个render任务并发写同一个canvas,会出现白屏、闪烁、部分区域渲染错乱。这个坑很隐蔽,我第一次踩到时以为是代码逻辑错了,后来才意识到是旧任务还没死,新任务已经抢占了canvas。
5.3 requestAnimationFrame合并高频touchmove
touchmove事件在手机上触发频率远高于屏幕刷新率,如果在事件回调里直接更新style.transform和React state,主线程会不断layout和重算样式,表现就是浏览器卡成PPT。我通常先把目标数据存到变量,再用rAF节流:
let rafId = 0; let pendingScale = 0; let pendingOrigin = { x: 0, y: 0 }; function onTouchMoveForPinch(e) { if (pinchState.active) { e.preventDefault(); pendingScale = ...; pendingOrigin = ...; if (rafId) return; rafId = requestAnimationFrame(() => { rafId = 0; updatePreviewTransform(pendingScale, pendingOrigin); }); } }requestAnimationFrame保证每帧最多提交一次样式更新,和屏幕刷新同步,又不会丢失最后的变换目标。需要注意的是,如果在React里直接把state放进rAF回调,频繁setState同样可能引发额外渲染,最好让transform操作不经过React状态管理,直接操作DOM style,保持这条路径的独立性。
5.4 其他值得留意的移动端差异
内核之间行为差异很大。iOS WKWebView对canvas内存限制更宽松,但也别滥用;部分Android X5内核对于超大canvas会直接画不出内容,返回的canvas宽度可能是0。我排查过一例,就是等比放大到scale=6后在某个国产机型上白屏,把scale上限调到4就正常了。另外,不要在一进入页面时就渲染全部页面,pdf.js默认懒加载可视区附近几页,手势缩放时也只需要重绘当前页;等用户翻页后再渲染相邻页。移动端上可以让渲染队列只保留一个并发任务,宁可翻页时等几十毫秒,也不让WebView卡顿两三秒。
6. 集成中的两个高频坑:Failed to fetch与阅读进度落库
6.1 pdf.js v2.16.105 报"Failed to fetch"到底哪里挂了
很多人在移动端项目里引入pdf.js之后,控制台会出现类似:
pdf.js v2.16.105 (build: 172ccdbe5) 信息:failed to fetch这个提示看着像是PDF文件请求失败,实际上绝大多数情况是pdf.worker.min.js加载失败。pdf.js的运行依赖Web Worker,Worker文件需要通过HTTP请求拉取,而pdf.js自身只有在worker加载失败时才抛出这行奇怪的"信息:failed to fetch"。我在自己项目里定位过几种诱因:
- GlobalWorkerOptions.workerSrc路径写错,比如写成相对路径,在某些构建工具下没有正确拷贝文件。
- 服务器或本地静态服务没把worker文件按application/javascript的MIME类型返回,部分环境直接拒绝执行worker脚本。
- 通过file协议打开HTML时会触发跨域限制,worker无法加载。
- 部分网络环境对CDN资源访问不稳定,导致worker文件下载失败。
建议的解法是把workerSrc写成一个完整的可访问URL,并确保该URL返回的Content-Type正确:
import { GlobalWorkerOptions } from 'pdfjs-dist'; import workerUrl from 'pdfjs-dist/build/pdf.worker.min.js?url'; GlobalWorkerOptions.workerSrc = workerUrl;如果使用CDN部署,可以显式给出CDN链接。配套地,PDF文件的加载建议使用同源或后端临时签名URL,尽量不要依赖本地file读取,省得移动端壳子里一堆白名单和跨域问题。遇到fail to fetch先看Network面板里pdf.worker.min.js的状态码,通常立刻就能找到是404还是MIME类型问题。
6.2 阅读到第几页怎么记录进数据库
还有一个高频问题:pdf.js如何把阅读到哪一页记录到数据库里?实现不难,难在时机。pdf.js的PDFViewer有eventBus,可以监听pagechange事件:
viewer.eventBus.on('pagechange', (e) => { const pageNumber = e.pageNumber; saveReadingProgress(pageNumber); }); function saveReadingProgress(pageNumber) { localStorage.setItem('reading_progress', String(pageNumber)); if (isUnloading) { // 通过 axios 上报到后端,或通过 bridge 交给原生侧 } }移动端上报不能每次翻页都发请求,否则一页页快速滑动会产生大量接口调用。我采用的是本地缓存加退出时提交:pagechange只写localStorage,应用onPause或组件卸载时把当前pageNumber通过接口或WebView bridge提交到原生侧,原生侧再落库。注意pdf.js有些文档带有自定义标签,e.pageNumber是数字页码,不要混用currentPageLabel,后者可能是字符串。集成手势缩放的那一版里,还要考虑放大状态下翻页与缩放重绘结束后的页码一致性,重绘页面时不要重复上报,简单加个判断即可。
let savedPage = 0; viewer.eventBus.on('pagechange', (e) => { if (e.pageNumber === savedPage) return; savedPage = e.pageNumber; localStorage.setItem('reading_progress', String(savedPage)); });6.3 我这套方案在真实移动端的表现
实际把整套方案放到X5内核和WKWebView里做过测试。一份4MB、40页A4的测试PDF,冷启动首屏大约600ms,捏合过程用CSS预览,松手后重绘一页耗时约200到400ms,期间会有一次轻微"闪顿",但整体可用。峰值内存增量稳定在几十MB以内,没有出现canvas崩溃。对于中轻度的合同预览、报表查看场景,这套"原生touch识别手势 + CSS预览 + 结束时重绘"的架构完全够用;配套了touch-action、passive:false、渲染任务取消和阅读进度落库之后,移动端上属于能直接交付的水平。
根据我的实际体验,还有一个额外建议:如果你们的PDF经常是大尺寸图纸或扫描件,不要只依赖手势缩放这一层,最好在服务端做切片或预生成多分辨率图片,把pdf.js当作兜底渲染方案。手势缩放解决的是交互层的问题,渲染层的性能始终得靠合理的资源规划来解决。