cal.diy 前端性能规则解读:用 useSWRSubscription 让 N 个组件共享 1 个全局事件监听器
cal.diy 前端性能规则解读用 useSWRSubscription 让 N 个组件共享 1 个全局事件监听器【免费下载链接】cal.diyScheduling infrastructure for absolutely everyone.项目地址: https://gitcode.com/GitHub_Trending/ca/cal.diy本文基于 cal.diycal.com仓库中 Agent 技能规则文档 client-event-listeners.md 展开讲解“全局事件监听器去重”这一客户端性能规则当同一个键盘/窗口 Hook 被多个组件实例使用时如何从“N 个实例注册 N 个 listener”重构为“N 个实例共享 1 个 listener”。读完后你可以掌握该模式的完整代码结构、useSWRSubscription的订阅语义、落地时的边界条件并知道该规则与仓库中真实键盘事件处理代码的对应关系。这条规则在仓库中的位置与定位该规则是仓库内 Vercel React 最佳实践技能包的一部分同一规则文件在仓库中有两份镜像位置需求指定的文档位置.opencode/skill/vercel-react-best-practices/rules/client-event-listeners.md技能包主目录位置agents/skills/vercel-react-best-practices/rules/client-event-listeners.md配套索引见 agents/skills/vercel-react-best-practices/AGENTS.md 与 agents/skills/vercel-react-best-practices/SKILL.md文档的 YAML 元数据明确了这条规则的定位字段取值含义titleDeduplicate Global Event Listeners规则名称去重全局事件监听器impactLOW单点影响等级为低但随组件实例数线性放大impactDescriptionsingle listener for N components目标收益N 个组件只保留 1 个监听器tagsclient, swr, event-listeners, subscription适用端与涉及的技术客户端、SWR、事件监听、订阅它属于“客户端client”侧的订阅型优化规则适用于监听window/document全局事件keydown、resize、visibilitychange、message等的自定义 Hook。问题模式每个组件实例各自注册全局监听原文档给出的反模式是一个键盘快捷键 Hookfunction useKeyboardShortcut(key: string, callback: () void) { useEffect(() { const handler (e: KeyboardEvent) { if (e.metaKey e.key key) { callback() } } window.addEventListener(keydown, handler) return () window.removeEventListener(keydown, handler) }, [key, callback]) }问题出在“监听器随组件实例数线性增长”每个调用useKeyboardShortcut的组件实例都会在useEffect里执行window.addEventListener(keydown, handler)挂载 N 个组件就有 N 个 keydown 监听器每次按键事件到达时浏览器会顺序执行全部 N 个 handler即使其中 N-1 个 handler 都会立即判空退出e.key ! key这次遍历和函数调用开销也是真实发生的监听器是全局的无法靠 React 组件树的局部卸载自动“共享”组件 A 卸载并不会替组件 B 省掉任何工作。对单次按键而言这是毫秒级以下的问题这也解释了元数据中 impact 仅为 LOW但在命令面板、快捷键密集的配置页这类场景中监听器数量会随路由切换、组件复用不断累积且每个监听器都是一个潜在的闭包持有者值得在架构层面统一治理。解决方案模块级回调表 useSWRSubscription原文档给出的正确写法由两部分组成一个模块级回调登记表和一个全局唯一的 SWR 订阅。完整代码原样继承自文档import useSWRSubscription from swr/subscription // Module-level Map to track callbacks per key const keyCallbacks new Mapstring, Set() void() function useKeyboardShortcut(key: string, callback: () void) { // Register this callback in the Map useEffect(() { if (!keyCallbacks.has(key)) { keyCallbacks.set(key, new Set()) } keyCallbacks.get(key)!.add(callback) return () { const set keyCallbacks.get(key) if (set) { set.delete(callback) if (set.size 0) { keyCallbacks.delete(key) } } } }, [key, callback]) useSWRSubscription(global-keydown, () { const handler (e: KeyboardEvent) { if (e.metaKey keyCallbacks.has(e.key)) { keyCallbacks.get(e.key)!.forEach(cb cb()) } } window.addEventListener(keydown, handler) return () window.removeEventListener(keydown, handler) }) } function Profile() { // Multiple shortcuts will share the same listener useKeyboardShortcut(p, () { /* ... */ }) useKeyboardShortcut(k, () { /* ... */ }) // ... }注册与清理每个实例只管登记回调第一段useEffect与事件系统完全无关它只负责维护模块级module-level的登记表keyCallbacks: Mapstring, Set() voidkey作为一级键Set() void作为二级容器保证同一个快捷键被多个组件同时监听时各回调互不覆盖组件挂载时把自己的callback加入对应Set清理函数在组件卸载时执行set.delete(callback)并在set.size 0时删除整个 key——登记表随组件生命周期精确收缩不残留死回调避免“组件已卸载但仍被 Map 强引用”的内存泄漏登记表必须定义在模块作用域而非组件内部这样所有组件实例才能读写同一份状态这也是该模式成立的前提。唯一监听器由 SWR 订阅统一持有第二段useSWRSubscription(global-keydown, ...)是整个模式的核心每个组件实例仍然调用它但 SWR 的订阅机制保证同一 key 在任意时刻只有一个活跃订阅详见下一节活跃订阅的 setup 函数内部只做一次window.addEventListener(keydown, handler)组件卸载、订阅失活时通过返回的清理函数执行removeEventListener唯一 handler 的职责变成分发按键到达时先查keyCallbacks.has(e.key)命中后再forEach调用该 key 下所有登记的回调。最终效果就是文档元数据里那句话single listener for N components——无论Profile页面里挂了多少个useKeyboardShortcutwindow上始终只有 1 个 keydown 监听器。useSWRSubscription 的关键语义同一 key 只有一个活跃订阅要理解为什么上面代码里“每个实例都调用useSWRSubscription”却不会产生 N 个监听器需要明确 SWR 订阅的语义单活跃实例同一 key 的订阅在任意时刻只有一个处于激活状态第一个挂载的实例的 setup 函数执行后其余实例的 setup 均被挂起不会重复注册监听器失活接力当处于活跃状态的实例卸载或订阅被取消时其 setup 返回的清理函数会执行移除window上的 keydown 监听器随后 SWR 会把订阅接力给下一个仍挂载的实例执行它的 setup 重新注册监听器。因此只要“至少有一个使用方挂载”监听器就恰好存在一个与数据请求的差别普通useSWR是“共享请求结果N 个实例可并发存在”useSWRSubscription面向的是“只有一个消费者有资格持有副作用资源事件监听、WebSocket、定时器”的场景语义上更接近互斥锁而非缓存。这正好匹配全局事件的诉求监听器本身是全局单例性质的资源天然不需要 N 份。落地细节与边界条件规则文档的代码是“可直接复制”的但工程落地时有几个值得显式处理的点callback 的引用稳定性注册/清理 effect 的依赖是[key, callback]。如果调用方每次渲染都传入内联箭头函数如useKeyboardShortcut(p, () doX())callback 引用每帧变化会导致“注销→重新登记”的抖动功能正确但产生多余的 Map 操作。调用方应使用useCallback固定引用或把最新回调存入 ref 中登记、仅以 ref 为依赖key 必须全局稳定订阅 key示例中的字符串字面量global-keydown要跨所有实例保持一致才能保证它们被 SWR 归并为同一个订阅不同事件类型resize、hashchange、message应使用不同的订阅 key只适用于客户端setup 函数会直接触碰window因此该模式只应在客户端组件中使用在 SSR 首屏阶段订阅不会被激活不会产生“服务端没有 window”的问题判据不变、分发收敛注意正确示例中 handler 的判据e.metaKey keyCallbacks.has(e.key)与反模式中的逐实例判据一致只是把“N 次各自判断”收敛为“1 次全局判断 命中后批量调用”。快捷键条件本身没有被简化或改变行为语义等价适用边界该模式适合“全局事件 多实例 Hook”的组合。如果某个组件是页面内唯一实例、监听器生命周期与组件严格一致如焦点管理、Escape 关闭直接用useEffect注册反而是更简单正确的写法没必要引入订阅层。在 cal.diy 仓库中的对应场景从源码结构看cal.diy 的 Web 应用目前尚未引入swr/subscription在apps/web下检索swr/subscription无源码引用该规则在仓库中作为 Agent 辅助开发的约束性最佳实践存在但仓库内存在多处与规则“反模式”形态一致的、按组件实例注册全局 keydown 监听的真实代码正是这条规则的目标改造对象apps/web/modules/shell/Kbar.tsx命令面板Kbar内部NoResultsFound组件在useEffect中向document注册keydown监听监听器随该组件实例挂载/卸载而注册/移除apps/web/modules/bookings/components/BookingDetailsSheet.tsx预订详情侧滑面板在useEffect中通过document.addEventListener(keydown, handleKeyDown, true)以捕获阶段注册快捷键处理左右翻页、聚焦入会链接等其 handler 由createBookingSheetKeydownHandler构造行为由 apps/web/modules/bookings/lib/bookingSheetKeyboardHandler.test.ts 单测覆盖该 effect 依赖了 6 个导航相关状态监听器会随这些依赖变化而重复拆装其他按实例注册监听器的位置还包括 apps/web/components/notification-sound-handler.tsx、apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx) 等。需要强调的是BookingDetailsSheet 这类监听器是“单实例 捕获阶段 与组件生命周期强绑定”的形态本身并不构成规则要消除的“N 实例 N 监听器”问题它们更多作为对照样本说明“何时该直接注册、何时该去重”的判断依据只有当同一监听逻辑会随组件实例数复制多份时模块级登记表 useSWRSubscription的收敛才产生收益。小结client-event-listeners.md 这条规则给出的是一套可复制的客户端重构模板识别“全局事件监听随组件实例数线性增长”的 Hook把每个实例的回调收敛到模块级Mapkey, Setcallback登记表用 effect 的注册/清理保证登记表与组件生命周期同步用useSWRSubscription以固定 key 持有全局唯一的监听器利用 SWR“同一 key 单活跃订阅 失活接力”的语义保证监听器恰好存在一份结合 callback 引用稳定性、订阅 key 一致性、仅客户端适用等边界条件落地并对照仓库内 Kbar 与 BookingDetailsSheet 等真实代码判断哪些监听器属于可收敛的重复注册、哪些应保持按实例直接注册。【免费下载链接】cal.diyScheduling infrastructure for absolutely everyone.项目地址: https://gitcode.com/GitHub_Trending/ca/cal.diy创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考