react-use 的 usePinchZoom 传感器 Hook 实战:用 Pointer Events 检测双指捏合缩放
【免费下载链接】react-useReact Hooks — 👍项目地址: https://gitcode.com/gh_mirrors/re/react-use
usePinchZoom是 react-use(当前仓库版本 17.6.1,见 package.json)提供的一个传感器(Sensor)类 Hook,它监听目标元素上的指针触摸事件,计算两根手指之间的水平距离变化,从而告知调用方用户当前正在"放大"(ZOOMING_IN)还是"缩小"(ZOOMING_OUT)。读完本文,你将掌握该 Hook 的完整 API、源码级实现原理,并能够直接写出基于它实现的可运行图片缩放组件。
一、usePinchZoom 是什么
react-use 将 Hooks 分为多个类别,其中传感器类 Hook 的定义在 docs/Sensors.md 中给出:它们监听某种接口的变化,并强制组件携带最新状态重新渲染。usePinchZoom正是这一类中的一员,它被列在 README.md 的 Sensors 分组下,其定位是"追踪 pointer 事件,检测捏合缩放是放大还是缩小"。
与基于传统touchstart/touchmove的写法不同,该 Hook 采用标准Pointer Events(pointerdown、pointermove、pointerup等)实现,因此对鼠标、触摸屏、触控笔等统一抽象。它在仓库中通过 src/index.ts 以export { default as usePinchZoom } from './usePinchZoom'对外导出,首次引入记录在 CHANGELOG.md。
二、安装与导入
从react-use包中导入即可:
import { usePinchZoom } from 'react-use';仓库内部完整实现位于 src/usePinchZoom.ts,类型声明与默认导出均来自该文件。如果需要在代码中引用方向枚举ZoomState,从仓库源码看它定义在 src/usePinchZoom.ts 中,且并未从 src/index.ts 入口导出(入口仅导出 Hook 本身),仓库内的演示 stories/usePinchZoom.story.tsx 是直接import { ZoomState } from '../src/usePinchZoom'引用的,你可以参照这一方式。
三、API 签名与返回值
从 src/usePinchZoom.ts 的源码可以归纳出完整的 API 形态:
| 项目 | 说明 |
|---|---|
| 函数签名 | usePinchZoom(ref: RefObject<HTMLElement>) |
参数ref | 绑定手势监听的目标 DOM 元素的 ref,必须是HTMLElement(如div、img的容器) |
| 返回值 | { zoomingState, pinchState } |
zoomingState | 缩放方向,值为ZoomState.ZOOMING_IN、ZoomState.ZOOMING_OUT或初始的null |
pinchState | 当前两根指针之间的水平距离(像素),无手势时为0 |
其中ZoomState枚举定义为(src/usePinchZoom.ts):
export enum ZoomState { 'ZOOMING_IN' = 'ZOOMING_IN', 'ZOOMING_OUT' = 'ZOOMING_OUT', } export type ZoomStateType = ZoomState.ZOOMING_IN | ZoomState.ZOOMING_OUT;返回值在无手势时由源码第 104-106 行保证为稳定结构:{ zoomingState: null, pinchState: 0 };一旦检测到方向变化,zoomingState即为枚举值,pinchState为对应pointermove事件时刻的两指水平间距(Math.abs(clientX1 - clientX2),单位为像素)。
四、基础用法(文档示例完整版)
原文档 docs/usePinchZoom.md 给出了最核心的用法——把缩放方向变化映射为图片的zoom样式。下面是对其完整继承并补充注释、修正依赖数组的版本:
import { useEffect, useRef, useState } from 'react'; import { usePinchZoom } from 'react-use'; const Demo = () => { const [scale, setState] = useState(1); const scaleRef = useRef(); const { zoomingState, pinchState } = usePinchZoom(scaleRef); useEffect(() => { if (zoomingState === 'ZOOMING_IN') { // 手指距离增大 → 放大 setState((s) => s + 0.1); } else if (zoomingState === 'ZOOMING_OUT') { // 手指距离减小 → 缩小 setState((s) => s - 0.1); } }, [zoomingState, pinchState]); return ( <div ref={scaleRef}> <img src="https://www.olympus-imaging.co.in/content/000107506.jpg" style={{ zoom: scale }} alt="scale img" /> </div> ); }; export default Demo;注意字符串值:原文档示例中写的是
"ZOOM_IN"/"ZOOM_OUT",而仓库源码实际产生的方向值是ZOOMING_IN/ZOOMING_OUT(见 src/usePinchZoom.ts)。直接复制原文档字符串将永远匹配不到分支,建议按上面代码使用枚举对应的字符串值,或像仓库演示那样导入ZoomState枚举进行比较。这是使用该 Hook 时最容易踩的坑。
上面的useEffect依赖同时包含zoomingState与pinchState,与仓库演示 stories/usePinchZoom.story.tsx 保持一致;用函数式更新setState((s) => s + 0.1)可避免连续缩放时基于过期 state 计算的问题。
五、工作原理:源码级解析
理解实现有助于你判断它在真实项目中的行为边界。核心逻辑全部在 src/usePinchZoom.ts 中,关键结构如下。
5.1 缓存结构与手势状态
export type CacheRef = { prevDiff: number; evCache: Array<PointerEvent>; };Hook 内部用useMemo维护一个cacheRef(src/usePinchZoom.ts),初始值为{ evCache: [], prevDiff: -1 },仅当ref.current引用变化时才重建:
evCache:当前仍处于按下状态的指针事件缓存,用于跟踪两根活动指针;prevDiff:上一次pointermove时两指的水平间距,初始为-1表示"尚无有效基准"。
5.2 三个核心事件处理函数
pointerdown_handler(src/usePinchZoom.ts):pointerdown时将事件对象推入evCache,表示"这根手指已按下"。
pointermove_handler(src/usePinchZoom.ts):这是判定缩放方向的核心:
- 先在缓存中找到与该
pointerId匹配的记录并更新为最新事件; - 当
evCache.length === 2(恰好两根手指按下)时,计算当前水平距离curDiff = Math.abs(clientX1 - clientX2); - 只有当
prevDiff > 0(即已有上一次基准)时才做比较:curDiff > prevDiff置为ZOOMING_IN,curDiff < prevDiff置为ZOOMING_OUT; - 无论是否触发状态更新,都会把
curDiff写入prevDiff作为下一次比较的基准。
pointerup_handler(src/usePinchZoom.ts):指针抬起(或cancel/out/leave)时调用remove_event把该指针从缓存中移除;当剩余指针不足两根时,把prevDiff重置为-1,等待下一次完整手势重新建立基准。
5.3 事件绑定
Hook 通过useEffect(src/usePinchZoom.ts)在ref.current上一次性挂载:
ref.current.onpointerdown = pointerdown_handler; ref.current.onpointermove = pointermove_handler; ref.current.onpointerup = pointerup_handler; ref.current.onpointercancel = pointerup_handler; ref.current.onpointerout = pointerup_handler; ref.current.onpointerleave = pointerup_handler;它直接赋值onpointer*属性(而非addEventListener),依赖数组为[ref?.current],因此 ref 变化时会重新绑定;组件卸载时由 React 一并清理 DOM 属性上的处理函数。
5.4 数据流小结
pointerdown(缓存指针)→ pointermove(两指时算水平距离并对比 prevDiff) → 距离增大 → setZoomingState([ZOOMING_IN, curDiff]) → 距离减小 → setZoomingState([ZOOMING_OUT, curDiff]) → pointerup/cancel/out/leave(移除指针,指针不足两根则重置基准)六、实现细节与使用注意事项
结合源码,使用该 Hook 时有以下几点值得注意(均为从源码结构可确认或可推断的事实):
只计算水平方向距离:实现中使用的是
Math.abs(evCache[0].clientX - evCache[1].clientX)(src/usePinchZoom.ts),即一维水平距离,而非两指间的真实欧氏距离。垂直方向的捏合、斜向捏合的距离变化不会被完整度量。需要全方向距离的场景(例如用Math.hypot(dx, dy)计算对角线长度)需要自行扩展。只有恰好两根指针才判定:
evCache.length === 2是进入判定分支的硬条件。若用户捏合过程中第三根手指落下,缓存长度变为 3,判定暂时失效,直到某根手指抬起回到 2 根才恢复。这不是缺陷,而是"双指手势"语义下的合理取舍。首次 move 不产生状态:由于
prevDiff初始为-1,双指按下后的第一次pointermove只建立基准、不触发zoomingState更新;从第二次 move 起才会比较并返回方向。pinchState在无手势时固定为0(src/usePinchZoom.ts)。不干预浏览器默认手势:从源码看,Hook 没有设置
touch-action: none,也未调用preventDefault()。在触屏浏览器中,双指捏合可能同时触发页面自身的缩放/滚动行为,两者叠加时体验可能受影响。如果你的目标场景需要独占手势,可在目标元素 CSS 上自行加上touch-action: none。状态与样式解耦:Hook 只"报告"方向与距离,不替你执行任何缩放变换。实际缩放多少(本例每步 0.1)、用什么样式(
zoom或transform: scale())完全由调用方决定,这保证了 Hook 的通用性。
七、进阶实战:带边界约束的图片缩放组件
把上面的思路整合成一个更完整的组件,加入缩放范围限制,避免无限放大或缩到看不见:
import { useEffect, useRef, useState } from 'react'; import { usePinchZoom } from 'react-use'; const MIN_SCALE = 0.5; const MAX_SCALE = 3; const STEP = 0.1; const PinchZoomImage = ({ src }) => { const [scale, setScale] = useState(1); const ref = useRef(null); const { zoomingState } = usePinchZoom(ref); useEffect(() => { setScale((prev) => { if (zoomingState === 'ZOOMING_IN') { return Math.min(prev + STEP, MAX_SCALE); } if (zoomingState === 'ZOOMING_OUT') { return Math.max(prev - STEP, MIN_SCALE); } return prev; }); }, [zoomingState]); return ( <div ref={ref} style={{ touchAction: 'none', overflow: 'hidden' }}> <img src={src} alt="pinch zoom target" style={{ zoom: scale, transition: 'zoom 80ms linear', }} /> </div> ); }; export default PinchZoomImage;要点说明:
touchAction: 'none'让浏览器把手势事件完全交给元素(对应上文注意事项 4);- 用
Math.min/Math.max把缩放值钳制在[0.5, 3],防止越界; - 加入轻微过渡动画可让缩放过程更平滑,但注意
zoom属性在现代浏览器中的过渡支持以 Chromium 系为主,跨浏览器时可改用transform: scale(scale)并设置transformOrigin: '0 0'。
八、本地运行与演示
仓库提供了该 Hook 的 Storybook 演示,位于 stories/usePinchZoom.story.tsx,其中Demo组件展示了与本文第四节相同的缩放逻辑,并挂载在Sensors/usePinchZoom分组下,包含Docs(渲染 docs/usePinchZoom.md 原文)和Default(可交互演示)两个 story。
在仓库根目录执行以下命令即可本地启动:
yarn install yarn storybookStorybook 默认运行在http://localhost:6008(端口由 package.json 中的"storybook": "start-storybook -p 6008"脚本指定),进入后导航到 Sensors → usePinchZoom 的 Default 页面,即可在支持触摸的屏幕上体验双指缩放,或使用浏览器开发者工具的触摸模拟进行调试。
九、小结
usePinchZoom是一个轻量、聚焦的传感器 Hook:输入一个元素 ref,输出"捏合方向 + 水平间距",把最繁琐的 pointer 事件缓存、距离比较与基准重置逻辑封装在约 100 行的实现内(src/usePinchZoom.ts)。它在图片查看器、地图缩放、幻灯片手势等"双指捏合"交互场景中即插即用;同时也要记住它的两个天然边界——只度量水平距离、只处理恰好两根指针,复杂手势需求需要在此基础上自行扩展。
【免费下载链接】react-useReact Hooks — 👍项目地址: https://gitcode.com/gh_mirrors/re/react-use
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考