Plate 性能规则实战:用 SWR 订阅去重全局事件监听器,让 N 个组件共享 1 个监听
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
本文讲解 Plate 仓库内置 Vercel React 最佳实践技能中的一条客户端性能规则——client-event-listeners(去重全局事件监听器)。它解决的问题是:当同一个自定义 Hook(如键盘快捷键、视口监听)被 N 个组件实例调用时,浏览器上会挂载 N 个全局事件监听器;读完本文,你将掌握"模块级回调注册表 +useSWRSubscription共享订阅"这一标准改造模式,并能对照本仓库中真实存在的全局监听 Hook(如视口、触摸设备检测、热键)判断哪些代码适合应用该规则。
规则来源:vercel-react-best-practices 技能的规则体系
这条规则位于仓库内的 client-event-listeners.md,属于vercel-react-best-practices技能的一部分。该技能由 SKILL.md 描述,是 Vercel Engineering 维护的一套 React/Next.js 性能优化指南,共包含 69 条规则、8 个类别,并按影响程度(impact)划分优先级:
| 优先级 | 类别 | 影响级别 | 文件前缀 |
|---|---|---|---|
| 1 | 消除请求瀑布流 | CRITICAL | async- |
| 2 | 包体积优化 | CRITICAL | bundle- |
| 3 | 服务端性能 | HIGH | server- |
| 4 | 客户端数据获取 | MEDIUM-HIGH | client- |
| 5 | 重渲染优化 | MEDIUM | rerender- |
| 6 | 渲染性能 | MEDIUM | rendering- |
| 7 | JavaScript 性能 | LOW-MEDIUM | js- |
| 8 | 高级模式 | LOW | advanced- |
本条规则的元数据(frontmatter)完整继承自原规则文件:
- title:Deduplicate Global Event Listeners(去重全局事件监听器)
- impact:LOW,
impactDescription为 "single listener for N components"(N 个组件只挂 1 个监听器) - tags:
client, swr, event-listeners, subscription
从client-前缀可以看到它归属第 4 类"客户端数据获取"。按 SKILL.md 的说法,每条规则文件都包含"为什么重要、错误示例、正确示例、补充上下文"四部分,这条规则的核心主张只有一句话:使用useSWRSubscription()在多个组件实例之间共享同一个全局事件监听器。
规则库本身还带一套构建机制:README.md 说明可通过pnpm build将rules/下的规则文件编译成AGENTS.md汇总文档、pnpm validate校验规则文件、pnpm extract-tests抽取 LLM 评测用例;文件名前缀决定所属章节,规则在章节内按标题字母序排序,编号自动生成为。impact 等级从CRITICAL到LOW共六级,本文这条被定为LOW——意思是它是渐进式优化,通常不是瓶颈主因,但在编辑器这类全局监听密集的长页面中值得规范。
问题模式:N 个实例 = N 个全局监听器
原规则文件给出的反例是一个典型的useKeyboardShortcutHook:
function useKeyboardShortcut(key: string, callback: () => void) { useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.metaKey && e.key === key) { callback() } } window.addEventListener('keydown', handler) return () => window.removeEventListener('handler') // 注意:原规则文件此处为 removeEventListener('keydown', handler) }, [key, callback]) }以上为原文件代码的忠实呈现(其清理函数写作window.removeEventListener('keydown', handler),本文不改动语义)。这段代码的问题在于:useEffect在每个组件实例挂载时都会执行一次window.addEventListener('keydown', handler)。如果 10 个组件各自调用useKeyboardShortcut,window上就会同时挂着 10 个keydown监听器,每次按键都要逐一触发、逐一做键名比较。
这种"每实例一个监听器"的模式在本仓库中是真实存在的。从源码结构看,几处现有实现都属于该模式:
- use-viewport.ts:
useViewportHook 在useEffect中对window.addEventListener('resize', handleResize)注册监听,每个使用该 Hook 的组件实例都会独立挂一个resize监听; - use-is-touch-device.ts:同样在 effect 中注册
window.addEventListener('resize', onResize)并调用一次初始化,多个消费方即多个监听器; - useHotkeys.ts:Plate 的热键底层实现,每次
useHotkeys调用都会对domNode(默认为document)执行addEventListener('keydown', handleKeyDown)和addEventListener('keyup', handleKeyUp)(见该文件第 210-213 行),并在卸载时成对移除。
这类监听器单个开销很小,这正是该规则 impact 被标为LOW的原因;但当编辑器应用里成百上千个节点组件(如 Plate 的表格单元格、列表项)各自挂监听时,事件触发路径会被拉长,去重就有意义了。
解决方案:模块级回调注册表 + useSWRSubscription 共享订阅
原规则文件给出的正确写法(完整继承):
import useSWRSubscription from 'swr/subscription' // Module-level Map to track callbacks per key const keyCallbacks = new Map<string, 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', () => { /* ... */ }) // ... }这个实现由三层组成,逐层拆解如下:
1. 模块级回调注册表。const keyCallbacks = new Map<string, Set<() => void>>()声明在组件和 Hook 之外,属于模块作用域的单例。它以"按键"为 key,value 是订阅了该按键的回调集合(用Set保证同一回调重复注册时不会重复触发)。组件挂载时把自己的callback加入集合,卸载时从集合中删除;当某个按键的集合清空时,顺手keyCallbacks.delete(key)把空桶移除,避免注册表随会话无限膨胀——这是注册表式架构里防止内存泄漏的关键清理细节。
2. 共享订阅键'global-keydown'。useSWRSubscription(key, setupFn)来自 SWR 的订阅 API:setupFn返回一个清理函数,SWR 保证同一key的订阅在"首个订阅者挂载时"执行一次 setup、在"最后一个订阅者卸载时"执行一次 cleanup。因此无论多少组件调用useKeyboardShortcut,window.addEventListener('keydown', handler)只会真正执行一次——这就是"N instances = 1 listener"的机制来源。对比反例中"每次挂载都 addEventListener",共享订阅把监听器数量从 N 收敛到 1。
3. 单一 handler 内部分发。唯一的监听器收到按键事件后,先做廉价的e.metaKey && keyCallbacks.has(e.key)判断(Map.has是 O(1) 查找,这与同技能库中 js-set-map-lookups.md 提倡的"用 Set/Map 做 O(1) 查找"思想一致),再遍历对应Set逐个调用回调。事件触发的路径从"N 个监听器各自比较键名"变成"1 个监听器一次查表 + 批量分发"。
需要注意的适用前提:该方案依赖 SWR 库(swr/subscription是其官方子路径导出)。从当前仓库的主package.json、apps/www/package.json中检索不到swr依赖,即本仓库源码目前并未引入 SWR;这条规则在本仓库的定位是 Vercel 最佳实践技能库沉淀下来的改造准则,当项目引入 SWR、或在自建订阅基础设施时,可参照该模式落地。
与同规则族及现有实现的对照
与client-swr-dedup的关系。同目录下有姊妹规则 client-swr-dedup.md(impact: MEDIUM-HIGH),讲的是用useSWR让多个组件实例共享同一份数据请求,还给出了useSWRMutation的变更场景示例。两条规则的共同内核是"多个实例、单一信源":一个作用于网络请求的去重,一个作用于 DOM 事件订阅的去重。理解了useSWR的请求去重语义,useSWRSubscription的订阅去重就是同一机制在"长连接/监听器"场景的延伸。
与 Plate 热键实现的对照。从源码结构看,useHotkeys.ts 代表了另一条工程路线:它不共享全局监听器,而是通过cbRef(见该文件第 84-86 行)让"回调引用"稳定——监听器只在keys/options/scopes变化时重新挂载,而回调本身通过 ref 保持最新,从而减少 effect 重跑。也就是说它优化的是"监听器重挂载的频率",而本文规则优化的是"监听器实例的总数"。两者并不冲突:一个编辑器应用可以既有稳定的每实例监听(热键需要按元素/作用域精确触发,如enableOnContentEditable、scopes等选项都依赖逐实例判断),也可以对真正全局的监听(如"Cmd+K 打开命令面板")采用共享订阅去重。
实践要点与限制
- 回调身份要稳定。正确示例中注册 effect 的依赖是
[key, callback]。若调用方传入内联匿名函数(如useKeyboardShortcut('p', () => doSomething())),每次渲染都会产生新回调引用,导致注册/注销 effect 反复执行——此时应配合useCallback或把最新回调存入 ref,注册表里只登记一个稳定句柄。 - SSR 边界。
window只在浏览器存在,注册表读写与addEventListener都必须发生在 effect/订阅 setup 内(客户端)。useSWRSubscription的 setup 函数天然只在客户端执行,符合 Next.js SSR/CSR 混合场景;这也是该规则 tags 中带client的原因。 - 共享键要按语义划分。示例用
'global-keydown'一个键共享所有快捷键。如果未来需要区分 keydown/keyup 或 modifier 组合通道,可以用不同订阅键(如'global-keyup')分别共享,而不是退回每实例监听。 - 不要过度套用。该规则 impact 为
LOW:对于只有一两个消费方的监听(比如单例的 use-viewport.ts),注册表 + 订阅的间接层可能比收益更重;它最适合"大量组件实例重复订阅同一全局事件"的场景,如列表/表格中每个子项都要响应全局快捷键或窗口事件。 - 注册表必须成对清理。卸载时
set.delete(callback)且空集合整体删除,是示例中容易被省略却决定长期稳定性的部分;只做 add 不做 delete 会让模块级 Map 成为跨路由的内存泄漏点。
延伸阅读路径
- 规则原文(含完整错误/正确示例):client-event-listeners.md
- 技能总览与 8 类规则优先级:SKILL.md
- 规则库结构、构建与校验命令:README.md
- 姊妹规则(SWR 请求去重):client-swr-dedup.md
- 客户端其他规则:client-passive-event-listeners.md(滚动等事件使用 passive 监听)、client-localstorage-schema.md
- 仓库内可对照的每实例监听实现:use-viewport.ts、use-is-touch-device.ts、useHotkeys.ts
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考