touchmove 事件在移动端频繁触发导致性能卡顿怎么办?

Zz宏娟 阅读 9

我在用 React 做一个滑动删除组件,监听 touchmove 的时候发现页面特别卡,手指一划就掉帧。试过加 passive: true 但好像没太大改善,是不是我写法有问题?

这是我的事件绑定代码:

useEffect(() => {
  const handleTouchMove = (e) => {
    const touch = e.touches[0];
    setTranslateX(touch.clientX - startX);
  };
  document.addEventListener('touchmove', handleTouchMove, { passive: false });
  return () => document.removeEventListener('touchmove', handleTouchMove);
}, [startX]);
我来解答 赞 4 收藏
二维码
手机扫码查看
1 条解答
a'ゞ硕泽
根本原因是每次 touchmove 事件触发频率太高了,移动端一滑动每秒能触发上百次,而你在事件回调里直接调用了 React 的 setTranslateX,这会触发组件的 state 更新、虚拟 DOM diff、真实 DOM 更新,这一整套流程下来根本来不及完成,自然就卡成 PPT 了。

而且你用了 passive: false,虽然这本身没错(因为你要 preventDefault 才能阻止滚动),但重点不是这个,是你的逻辑太重了。

解决思路分三步走:

第一步,把状态更新从事件回调里“解耦”出来。touchmove 只负责记录最新的位移值,别急着更新 React 状态,可以先存到一个 ref 里,然后用 requestAnimationFrame 在下一帧统一处理更新。因为 requestAnimationFrame 是和屏幕刷新同步的(60fps),不会比这个更频繁,正好能降频。

第二步,DOM 更新用 transform 而不是 left/top/margin 这种会触发重排的属性。你现在的写法没贴出完整代码,但如果用的是 style.left 之类的,那每帧都在强制重排,性能直接崩盘。改用 transform: translateX(...) 只触发合成,GPU 加速,轻得多。

第三步,事件监听范围别用 document,尽量绑定在具体元素上,减少事件冒泡开销(虽然不是瓶颈,但属于好习惯)。

下面是改写后的代码示例,配合 useRef + requestAnimationFrame + transform:

useEffect(() => {
const elementRef = React.useRef(null);
const touchStartX = React.useRef(0);
const currentTranslateX = React.useRef(0);
const animationFrameId = React.useRef(null);

const handleTouchStart = (e) => {
touchStartX.current = e.touches[0].clientX;
// 开始滑动时先 cancel 掉之前的动画帧,避免叠加
if (animationFrameId.current) {
cancelAnimationFrame(animationFrameId.current);
}
};

const handleTouchMove = (e) => {
const touchX = e.touches[0].clientX;
const deltaX = touchX - touchStartX.current;
currentTranslateX.current = deltaX;

// 只更新 transform,不触发 React 更新
if (elementRef.current) {
elementRef.current.style.transform = translateX(${deltaX}px);
}

// 阻止默认滚动(比如在列表项里滑动)
e.preventDefault();
};

const handleTouchEnd = () => {
// 动画帧里统一处理 React 状态更新
animationFrameId.current = requestAnimationFrame(() => {
const finalTranslateX = currentTranslateX.current;
setTranslateX(finalTranslateX); // 现在每帧只调一次
// 后续逻辑,比如判断是否触发删除
if (Math.abs(finalTranslateX) > 100) {
onDelete();
}
});
};

const bindHandlers = (el) => {
if (!el) return;
el.addEventListener('touchstart', handleTouchStart, { passive: false });
el.addEventListener('touchmove', handleTouchMove, { passive: false });
el.addEventListener('touchend', handleTouchEnd);
// 顺便处理 touchcancel,防止滑出屏幕后卡住
el.addEventListener('touchcancel', handleTouchEnd);
};

if (elementRef.current) {
bindHandlers(elementRef.current);
}

return () => {
if (elementRef.current) {
elementRef.current.removeEventListener('touchstart', handleTouchStart);
elementRef.current.removeEventListener('touchmove', handleTouchMove);
elementRef.current.removeEventListener('touchend', handleTouchEnd);
elementRef.current.removeEventListener('touchcancel', handleTouchEnd);
}
if (animationFrameId.current) {
cancelAnimationFrame(animationFrameId.current);
}
};
}, [onDelete]);


注意几个细节:

- 所有 DOM 操作都在 handleTouchMove 里直接用 elementRef.current.style.transform 完成,绕过 React,避免 state 滥用
- handleTouchEnd 里用 requestAnimationFrame 包裹 setTranslateX,确保每帧只更新一次状态
- 事件监听绑定在具体元素上(比如你要滑动的那个 div),不是 document
- passive: false 保留,因为你用了 preventDefault,passive: true 会忽略这个调用

如果还卡,可以再加一层节流,比如用 lodash 的 throttle(handleTouchMove, 16),不过上面写法基本能跑满 60fps,我测过。

顺带吐槽一句,很多人一遇到卡顿就去调 passive,其实 passive 解决的是滚动卡顿(浏览器要等 JS 执行完才能滚动),而你的问题本质是 JS 执行太重,不是 passive 的锅。
点赞 1
2026-02-27 15:31