Sanity 仓库中的 React 性能实践:用 Passive 事件监听器消除滚动延迟
【免费下载链接】sanitySanity Studio – Rapidly configure content workspaces powered by structured content项目地址: https://gitcode.com/GitHub_Trending/sa/sanity
本文聚焦 Sanity 仓库内收录的一条 Vercel React 性能规则——为触摸与滚轮事件监听器声明{ passive: true },从原理、正反代码示例到适用边界逐项讲透,并结合 CommandList、PreviewTooltip、ToolSVG 三处真实源码,展示该规则在大型 React 应用中的落地方式与反例。读完后你能掌握:为什么浏览器默认会“等一等”你的监听器再滚动、如何在useEffect中正确声明被动监听器、以及何时必须反过来使用{ passive: false }。
规则定位:Vercel React 性能规则集的一部分
client-passive-event-listeners.md 是 Sanity 仓库中「Vercel React 最佳实践」技能包(skill)中的一条规则文件,与仓库根目录下 skills/vercel-react-best-practices 中同名文件对应。该技能包收录了 57 条按影响级别排序的性能规则,覆盖异步瀑布、包体积、服务端、客户端数据获取、重渲染、渲染性能、JS 性能与高级模式八个类别,完整索引见 SKILL.md。
本规则在其中的定位如下(直接来自规则文件的 frontmatter):
| 属性 | 值 | 含义 |
|---|---|---|
title | Use Passive Event Listeners for Scrolling Performance | 为滚动性能使用被动事件监听器 |
impact | MEDIUM | 属于第 4 优先级「Client-Side Data Fetching」类别中的中等影响规则 |
impactDescription | eliminates scroll delay caused by event listeners | 消除由事件监听器引起的滚动延迟 |
tags | client, event-listeners, scrolling, performance, touch, wheel | 作用于客户端的 touch / wheel 事件处理 |
也就是说,这是一条客户端(client- 前缀)类别的性能规则,目标是消除触摸与滚轮事件监听器造成的滚动延迟。
原理:浏览器为什么要“等”你的监听器执行完
规则原文给出的核心解释是:浏览器默认会等待touchstart/wheel等事件的监听器执行完毕,以检查其中是否调用了preventDefault(),在此期间浏览器无法立即处理滚动,从而造成可感知的滚动延迟。
其背后的机制可以理解为:
- 触摸与滚轮类事件(
touchstart、touchmove、wheel)属于浏览器可以取消(cancelable)的事件——preventDefault()能阻止默认的页面滚动/缩放行为。 - 由于存在这种“可取消”语义,浏览器在派发事件后会阻塞滚动处理,直到所有(非被动的)监听器执行完,才能确定是否应该滚动。
- 监听器越重(比如做埋点上报、日志、状态计算),滚动卡顿就越明显。
{ passive: true }正是用来打破这一等待的:它向浏览器作出契约——“该监听器绝不会调用preventDefault()”,于是浏览器不再等待监听器完成,可以立即执行滚动。
正反示例:从document级监听看正确写法
以下两个示例完整继承自规则文件,展示典型的useEffect注册/注销模式。
反例:未声明 passive 的 touch / wheel 监听
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,浏览器仍会等待它们执行完毕再滚动。
正例:声明{ passive: true }
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) } }, [])两个版本唯一的差别就是第三个参数{ passive: true }。注意 cleanup 函数中removeEventListener的匹配逻辑:对于passive这类只影响浏览器调度语义的选项,移除监听时并不需要再次传入{ passive: true },按监听器函数与事件名匹配即可(这一点在 Sanity 源码中也是通行写法,见下文)。
判断标准:什么时候该用,什么时候不能
规则文件给出了简洁明确的判定口径,这也是本条规则最有实操价值的部分:
应当声明 passive 的场景:
- 埋点 / 行为分析(tracking / analytics)
- 日志记录(logging)
- 一切不会调用
preventDefault()的监听器
一句话口诀:监听器只做“读”不做“阻”,就声明 passive。
不要声明 passive(应保持默认或显式{ passive: false })的场景:
- 自定义滑动手势(custom swipe gestures)
- 自定义缩放手势(custom zoom controls)
- 一切需要调用
preventDefault()的监听器
从源码结构看,这个判定标准与浏览器行为完全吻合:passive: true的监听器中调用preventDefault()会被浏览器忽略,并在控制台产生告警。因此“是否会调用preventDefault()”是唯一的分水岭——声明了 passive 却需要阻止默认行为,等于同时丢失了性能收益和手势语义。
Sanity Studio 源码中的三个真实案例
以下案例均来自 Sanity Studio 核心包源码,可作为该规则在真实大型 React 应用中的落地参照。
案例一:CommandList 的滚轮监听——纯状态读取声明 passive
CommandList 是命令列表组件,需要在虚拟列表上监听鼠标/滚轮事件来重新启用子容器的指针事件:
useEffect(() => { function handleMouseEvent() { enableChildContainerPointerEvents(true) } virtualListElement?.addEventListener('mousemove', handleMouseEvent) virtualListElement?.addEventListener('wheel', handleMouseEvent, {passive: true}) return () => { virtualListElement?.removeEventListener('mousemove', handleMouseEvent) virtualListElement?.removeEventListener('wheel', handleMouseEvent) } }, [enableChildContainerPointerEvents, virtualListElement])该监听器仅调用enableChildContainerPointerEvents(true)切换一个布尔状态,不涉及任何preventDefault(),因此按规则口径正确声明了{ passive: true },保证滚轮滚动命令列表时不被监听器拖慢。
案例二:PreviewTooltip 的 capture 阶段滚动监听——passive 与 capture 可同时声明
PreviewTooltip 在悬浮预览提示框悬停期间监听滚动事件,用于在滚动时暂停(suspend)提示框:
const handleScroll = () => setSuspended(true) // Capture phase, since scroll events don't bubble from nested containers. window.addEventListener('scroll', handleScroll, {capture: true, passive: true})这个案例额外传递了两个信息:
- 监听器回调只更新一个布尔状态,无
preventDefault()需求,因此安全声明passive: true; passive与capture是相互正交的选项,可以同时声明。源码注释也解释了为何要用捕获阶段:滚动事件不会从嵌套容器冒泡,因此需在window上以捕获方式监听。
案例三:ToolSVG 的触摸拖动——必须用{ passive: false }的反例
ToolSVG 是图片工具的裁剪/热点 SVG 画布,其触摸移动监听刻意声明为非被动:
const handleTouchMove = (e: TouchEvent) => { // Prevent iOS scrolling page while dragging the element e.preventDefault() return undefined } svgElement.addEventListener('touchmove', handleTouchMove, {passive: false})注释说明了动机:拖动裁切框时若允许 iOS 页面随之滚动,交互会被打断,因此必须调用e.preventDefault()阻止默认滚动——这正是规则中“Don't use passive when”一栏列出的“自定义手势”场景。对照来看,同一个组件/同一个团队在不同监听器上对 passive 的取舍完全由“是否要阻止默认行为”决定,两条源码互相印证了规则的判断标准。
在 Sanity 仓库中查找这条规则的所有落点
如果想系统排查项目中是否存在“未声明 passive 的 touch / wheel 监听”,可以按下面的方式检索(本文引用的所有案例即由此发现):
用
addEventListener配合事件名定位注册点:addEventListener\(['"]wheel['"] addEventListener\(['"]touchmove['"]用
passive: true/passive: false反向核对已显式声明的监听器,确认每个 touch/wheel 监听都有明确的 passive 决策,而不是依赖浏览器默认值。
在 Sanity 核心包中,显式声明{ passive: true }的滚动类监听还包括 useScrollIndicatorVisibility(导航栏滚动指示)、scrollContainer(滚动容器)等组件;显式声明{ passive: false }的手势类监听则集中在图片工具与虚拟列表的拖拽逻辑中。可以推断,仓库中“滚动/触摸相关监听要么显式 passive、要么显式 non-passive”的写法是有意保持的约定。
补充说明:passive 与事件默认行为的细节
结合本规则的适用前提,还有三点值得注意:
- 适用前提:规则针对的是通过原生
addEventListener手动注册的 touch / wheel 类监听。React 合成事件的 passive 行为由 React 运行时统一管理,不在这条规则的直接管辖范围内;仓库中所有案例均为原生监听。 scroll事件:规则文本聚焦touchstart/touchmove/wheel,但 PreviewTooltip 对scroll同样显式声明了{ passive: true }。从源码结构看,这是一种防御性写法——无论浏览器对scroll事件的默认 passive 策略如何,显式声明能消除实现差异带来的不确定性。- 与去重规则配合使用:passive 解决的是“单个监听器拖慢滚动”的问题;当 N 个组件各自注册同一个全局监听器时,还需要配合同技能包中的 client-event-listeners 规则做监听器去重(例如用
useSWRSubscription将 N 个实例的监听收敛为 1 个)。两者分别优化监听器的“执行成本”与“数量”,可以叠加收益。
实践清单
在评审或编写 React 代码中的触摸/滚轮监听时,可按以下清单自检:
- 该监听器是否会调用
preventDefault()?- 会 → 不得声明 passive,必要时显式
{ passive: false }(如自定义滑动手势、缩放控制,参照 ToolSVG)。 - 不会(埋点、日志、状态读取等)→ 声明
{ passive: true }(参照 CommandList、PreviewTooltip)。
- 会 → 不得声明 passive,必要时显式
- 事件类型是否为
touchstart/touchmove/wheel(含scroll)等可被默认行为消费的事件? - 是否在
useEffect的 cleanup 中正确removeEventListener,避免组件卸载后监听器残留? - 多个组件是否共享同一全局事件?如是,结合 client-event-listeners 规则做去重,避免“N 个实例 = N 个监听器”。
掌握这条规则后,你在 Sanity 或任何 React 项目中处理触摸、滚轮与滚动性能问题时,都能做出正确的 passive 决策:既不因冗余的浏览器等待损失滚动流畅度,也不误伤依赖preventDefault()的自定义手势。
【免费下载链接】sanitySanity Studio – Rapidly configure content workspaces powered by structured content项目地址: https://gitcode.com/GitHub_Trending/sa/sanity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考