☰
Papermark 滚动性能优化指南:为 React 触摸与滚轮事件正确使用 Passive Event Listeners
2026/10/3 21:04:31 网站建设 项目流程
  • 后端
  • 前端
  • 企业应用

【免费下载链接】papermark

Papermark is the open-source DocSend alternative and secure data rooms with built-in analytics and custom domains.

项目地址:https://gitcode.com/GitHub_Trending/pa/papermark
点击查看免费下载

在 Papermark 这类以文档预览、数据室浏览为核心体验的开源项目中,滚动流畅度直接影响用户的阅读观感。本文以 Vercel React 最佳实践规则client-passive-event-listeners为骨架,讲解为何touchstart/wheel等监听器会造成滚动延迟、如何通过{ passive: true }恢复即时滚动,并结合 Papermark 仓库内实际的 viewer 组件源码(如触摸缩放、滑动翻页、面板定位等),给出可直接复用的正反示例与判定标准。

为什么滚动会被事件监听器"拖慢":passive 的浏览器机制

浏览器在处理触摸(touch)和滚轮(wheel)事件时,默认处于"不确定该监听器是否会调用preventDefault()"的状态。为了保住取消默认行为的可能性,主线程必须先完整执行完所有监听器回调,才能决定是否执行滚动。当页面上挂着多个 touch/wheel 监听器、且回调内部存在日志、埋点上报等耗时操作时,每次滚动都会被强制排队等待,表现为明显的"卡顿"与"延迟起滚"。

addEventListener的第三个参数传入{ passive: true }后,浏览器会明确收到"该监听器绝不会调用preventDefault()"的承诺,从而跳过等待、立刻开始滚动。这就是 passive listener 消除滚动延迟的核心原理。规则文件 client-passive-event-listeners.md 将此类问题的 impact 标注为 MEDIUM,因为它只影响滚动相关的交互路径,但一旦中招,体感非常明显。

需要特别指出的是:React 17 之后的合成事件(synthetic events)默认就是 passive 的,所以上述问题主要出现在通过原生document/window/ DOM 元素直接addEventListener挂载的监听器上——这正是 Papermark 的 viewer 组件大量采用原生监听的原因,也是本文讨论的场景。

错误写法与正确写法对照

规则文档给出了同一段滚动位置上报代码的正反两种实现。

错误写法(未声明 passive,浏览器被迫等待每个监听器执行完毕):

useEffect(() => { const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX) const handleWheel = (e: WheelEvent) => console.log(e.deltaY) document.addEventListener('touchstart', handleTouch) document.addEventListener('wheel', handleWheel) return () => { document.removeEventListener('touchstart', handleTouch) document.removeEventListener('wheel', handleWheel) } }, [])

正确写法(声明 passive,滚动立即生效):

useEffect(() => { const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX) const handleWheel = (e: WheelEvent) => console.log(e.deltaY) document.addEventListener('touchstart', handleTouch, { passive: true }) document.addEventListener('wheel', handleWheel, { passive: true }) return () => { document.removeEventListener('touchstart', handleTouch) document.removeEventListener('wheel', handleWheel) } }, [])

两点实践细节值得注意:

  • removeEventListener时无需重复传入passive选项——浏览器根据注册时的 capture 标志匹配监听器,因此清理函数写法不变(见上例)。
  • 回调中如果确实只需要touches[0].clientX、deltaY这类只读数据(埋点、日志、统计分析),就完全符合 passive 的使用前提;一旦回调可能调用preventDefault(),则必须改回非 passive 并承担相应开销。

何时使用 passive:tracking 与日志类监听器

规则文档明确了适用边界:

应当使用{ passive: true }的场景——所有不调用preventDefault()的监听器:

  • 访问/浏览埋点(tracking / analytics)
  • 日志记录(logging)
  • 纯状态同步与 UI 联动(如滚动位置驱动的面板定位)

严禁使用 passive 的场景——所有必须调用preventDefault()才能工作的监听器:

  • 自定义滑动(swipe)手势
  • 自定义缩放(zoom)控件
  • 需要拦截浏览器默认触摸/滚轮行为的任何逻辑

判定方法很简单:回调体内出现e.preventDefault(),就不能 passive;没有,就应该 passive。

仓库实测一:面板定位监听器(passive 的正确应用)

use-viewer-panel-top.ts 是 Papermark 中典型的"只读监听":它需要跟随window滚动,实时计算右侧 AI 问答面板的top值,使其贴合顶栏底部。该监听器全程不调用preventDefault(),因此同时满足两个优化点:

const onScroll = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(measure); }; measure(); window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onScroll, { passive: true });

这里passive: true消除了滚动等待,requestAnimationFrame又将高频滚动回调合并到每一帧只执行一次measure(),避免了对getBoundingClientRect()的重复强制同步布局。同类的只读滚动监听在仓库中还有多处,例如:

  • use-at-bottom.ts 通过window.addEventListener("scroll", handleScroll, { passive: true })判断是否滚动到底部,用于触发"加载更多";
  • pages-vertical-viewer.tsx 在连续竖排文档阅读器中监听容器滚动,结合 150ms 防抖判断滚动是否结束,同样声明了passive: true;
  • fade-scroll-area.tsx 监听 Radix 滚动视口的scroll事件驱动顶部/底部渐隐遮罩的显隐,并配合ResizeObserver处理内容异步加载导致的尺寸变化。

仓库实测二:滑动翻页监听器(passive + capture 组合)

横向翻页阅读器 pages-horizontal-viewer.tsx 展示了另一个典型模式:通过触摸手势识别左右滑动翻页,但翻页动作并不依赖preventDefault()——它只是在结束时通过位移阈值(如Math.abs(dx) > 48、dx/dy 比例)判定方向,再调用goToNextPageRef.current():

const opts: AddEventListenerOptions = { passive: true, capture: true }; el.addEventListener("touchstart", onStart, opts); el.addEventListener("touchmove", onMove, opts); el.addEventListener("touchend", onEnd, opts); return () => { el.removeEventListener("touchstart", onStart, opts); el.removeEventListener("touchmove", onMove, opts); el.removeEventListener("touchend", onEnd, opts); };

三个值得学习的细节:

  1. { passive: true }让touchmove高频事件不再阻塞滚动;
  2. capture: true让监听器在捕获阶段提前介入,保证手势判定的时序;
  3. 清理函数直接复用同一个opts对象,确保 remove 时匹配到注册时的标志位,避免内存泄漏。

仓库实测三:捏合缩放监听器(必须非 passive 的反例)

与上文形成鲜明对比的是 use-touch-zoom.ts。Papermark 连续阅读器的双指捏合缩放必须拦截浏览器的原生捏合手势(否则原生缩放会与阅读器自身的滚动容器互相冲突),因此四个触摸事件全部以{ passive: false }注册,并在回调中显式调用e.preventDefault():

el.addEventListener("touchstart", onTouchStart, { passive: false }); el.addEventListener("touchmove", onTouchMove, { passive: false }); el.addEventListener("touchend", onTouchEnd, { passive: false }); el.addEventListener("touchcancel", onTouchEnd, { passive: false });

文件头部的注释(use-touch-zoom.ts)还揭示了一个 React 生态的重要背景:React 的合成触摸处理函数本身就是 passive 的,无法preventDefault()浏览器手势,因此这类需要拦截手势的场景只能绕开 React 合成事件、走原生addEventListener并主动声明passive: false。这正是"何时不能用 passive"的最佳实证。

落地检查清单

对照 Vercel 规则与本仓库实践,给出一份可直接执行的审查清单:

  1. 在document、window或 DOM 元素上注册touchstart/touchmove/wheel原生监听器时,默认优先考虑补上{ passive: true };
  2. 回调体内若存在preventDefault()调用,明确改为{ passive: false }并注释说明原因(参考 use-touch-zoom.ts 的注释习惯),便于后人审查;
  3. 埋点、日志、只读 UI 状态同步类监听器一律 passive,并优先配合requestAnimationFrame/ 防抖节流,避免在滚动回调中做重计算或强制同步布局;
  4. 若同时使用capture: true,将选项对象提取为常量复用,保证注册与清理参数一致;
  5. 始终在useEffect的清理函数中调用对应的removeEventListener,防止滚动监听器在组件卸载后继续累积。

遵循上述原则,即可在 Papermark 这类重度依赖滚动交互的文档/数据室产品中,既保留埋点分析与 UI 联动能力,又让触摸与滚轮滚动保持即时响应,不损失任何功能的前提下换来流畅的浏览体验。

  • 后端
  • 前端
  • 企业应用

【免费下载链接】papermark

Papermark is the open-source DocSend alternative and secure data rooms with built-in analytics and custom domains.

项目地址:https://gitcode.com/GitHub_Trending/pa/papermark
点击查看免费下载

相关推荐

上一篇:tiny11builder实战指南:轻松打造精简Windows 11镜像的完整解决方案
下一篇:AWS EKS Workshop 项目推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询