首页
/ cal.diy 中的 Vercel React 性能规则实战:用 Ref 存储事件处理器,让 Effect 订阅保持稳定

cal.diy 中的 Vercel React 性能规则实战:用 Ref 存储事件处理器,让 Effect 订阅保持稳定

2026-09-05 12:29:30作者:胡易黎Nicole

本篇解读 cal.diy(Cal.com 自托管代码库)中内置的 Vercel React 最佳实践技能(skill)里的一条高级优化规则 advanced-event-handler-refs:当事件回调被放入 useEffect 依赖数组时会导致监听器反复卸载再挂载,规则给出的解法是把回调存入 ref、用稳定函数完成订阅。读完你可以掌握「稳定订阅 + 最新回调」这一 React Hooks 惯用模式的完整原理,并看到它在本仓库 Web 应用中对应的真实工具函数实现(useCallbackRef)。

规则定位:来自 Vercel Engineering 的高级模式(Advanced Patterns)

本文的规则文件位于 advanced-event-handler-refs.md,它属于仓库内 .opencode/skill/vercel-react-best-practices/SKILL.md 所描述的 Vercel 性能优化技能。该技能共收录 45 条规则、按影响程度分为 8 个优先级类别,其中 advanced- 前缀对应第 8 档「Advanced Patterns(高级模式)」,官方标注的 impact 为 LOW、impactDescription 为 “stable subscriptions”(稳定的订阅关系)。

从源码结构看,这一定位与规则内容吻合:把 handler 存入 ref 并不会带来量级上的性能飞跃,它解决的是订阅抖动——handler 引用每渲染变化一次,事件监听就被 remove 一次、add 一次——属于“代码正确性与可维护性层面的优化”,因此排在低优先级。

问题本质:handler 进入依赖数组导致反复重新订阅

规则文档给出的错误示例是一个 useWindowEvent 钩子,把 handler 直接放进 useEffect 的依赖数组:

function useWindowEvent(event: string, handler: () => void) {
  useEffect(() => {
    window.addEventListener(event, handler)
    return () => window.removeEventListener(event, handler)
  }, [event, handler])
}

问题在于:在 React 中,普通函数没有引用稳定性保证——如果调用方没有用 useCallback 包裹 handler(或者 useCallback 的依赖又频繁变化),那么每次渲染产生的 handler 都是新函数引用。[event, handler] 依赖数组中的 handler 因此每次都变化,effect 的 cleanup 与重建逻辑会被反复触发:每一次组件渲染(哪怕只是无关 state 变化)都会执行一次 removeEventListener + addEventListener

这类开销本身不致命,但会引入两类更隐蔽的麻烦:

  • 订阅/退订的频繁切换可能让监听器短暂“失联”,或在高频渲染场景下产生无谓的开销;
  • 依赖数组里塞入不稳定的函数引用,会诱导开发者在后续维护中错误地“精简”依赖,最终制造出闭包过期(stale closure)的 bug。

正确写法:ref 存最新 handler,订阅只依赖 event

规则给出的正确实现由两个 effect 分工协作:一个只负责“同步最新回调”,另一个只负责“建立稳定订阅”:

function useWindowEvent(event: string, handler: () => void) {
  const handlerRef = useRef(handler)
  useEffect(() => {
    handlerRef.current = handler
  }, [handler])

  useEffect(() => {
    const listener = () => handlerRef.current()
    window.addEventListener(event, listener)
    return () => window.removeEventListener(event, listener)
  }, [event])
}

逐层拆解这个模式:

  1. const handlerRef = useRef(handler):ref 的引用在整个组件生命周期内不变,为“稳定”提供载体。
  2. 第一个 effect 以 [handler] 为依赖:handler 每变化一次,就把最新值写入 handlerRef.current。它不做任何订阅操作,成本极低。
  3. 第二个 effect 只以 [event] 为依赖:注册的是一个闭包稳定的 listener,它每次触发时通过 handlerRef.current() 动态读取当前最新的回调。只有 event 字符串变化(如监听事件从 resize 换成 scroll)时才会真正重新订阅。

最终效果是:订阅关系只由“监听什么事件”决定,而回调永远调用最新版本——既避免了重复订阅,又规避了直接捕获首次 handler 造成的闭包过期问题。

替代方案:React 的 useEffectEvent

规则文档还给出了官方 API 替代写法,适用于最新版本的 React:

import { useEffectEvent } from 'react'

function useWindowEvent(event: string, handler: () => void) {
  const onEvent = useEffectEvent(handler)

  useEffect(() => {
    window.addEventListener(event, onEvent)
    return () => window.removeEventListener(event, onEvent)
  }, [event])
}

按文档原话,useEffectEvent 为同一模式提供了更干净的 API:它直接创建一个引用稳定的函数,该函数始终调用 handler 的最新版本。也就是说,上面手工维护 ref 的两个 effect 在这里被一个 Hook 调用取代,依赖数组天然只需 [event]

本仓库的落地实现:useCallbackRef

cal.diy 的 Web 应用侧已经内置了与上述模式等价(且对 SSR 更友好)的通用工具函数 useCallbackRef

import { useRef } from "react";

import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";

export const useCallbackRef = <C>(callback: C) => {
  const callbackRef = useRef(callback);
  useIsomorphicLayoutEffect(() => {
    callbackRef.current = callback;
  });

  return callbackRef;
};

对照规则文档的「Correct」示例可以看到两处工程化取舍:

  • 同步时机用 useIsomorphicLayoutEffect 而非 useEffect:该 Hook 无依赖数组,每次渲染后(浏览器端为 useLayoutEffect,SSR 环境降级为 useEffect,见 useIsomorphicLayoutEffect)都会把最新 callback 写入 ref。相比规则示例中 [handler] 依赖的写法,这里不依赖 React 的依赖比较,写入时机也更早(在 layout 阶段完成),代价只是每次渲染多一次赋值,对回调同步这种轻量操作完全可接受。
  • 直接返回 ref 而非函数:调用方拿到 callbackRef 后在 effect 内以 callbackRef.current(...) 调用,保留了与规则示例完全一致的「稳定订阅 + 最新回调」语义,同时方便复用给定时器、WebSocket 回调等场景。

该工具函数在仓库中的真实使用点包括 EnableTwoFactorModal(security)EnableTwoFactorModal(settings) 两个双因素认证弹窗组件——这类组件内部存在表单校验、倒计时等高频变化的 state,正是「回调引用不稳定、不应触发重新订阅」的典型场景。

与相邻规则的边界:什么时候该用 ref 模式,什么时候该用监听器去重

本规则解决的是单实例的订阅稳定性,仓库内还有两条相关规则覆盖互补场景,理解它们的边界可以避免误用:

  • advanced-use-latest.md:提供 useLatest 工具 Hook,适用于定时器/异步回调这类不需要注册 DOM 监听器的场景。其错误示例是把 onSearch 放进 debounce 定时器 effect 的依赖数组;正确写法与本文 ref 模式同构(onSearchRef.current(query)),只是消费方从事件监听换成了 setTimeout
  • client-event-listeners.md:解决的是多实例监听器爆炸问题——N 个组件各自注册全局 keydown 监听时,本文的 ref 模式仍会产生 N 个监听器;该规则给出基于 useSWRSubscription 的模块级 Map + Set 方案,让 N 个组件共享 1 个监听器。

两者可以组合使用:先用 SWR 订阅把监听器数量收敛到 1,再在分发回调时用 ref 保证每个组件调用的是自己最新的 handler。

实操清单

结合该规则与仓库实现,落地「稳定订阅」时可按以下顺序决策:

  1. 检查依赖数组:若 effect 的依赖中包含未稳定化的函数引用(普通函数、依赖会变化的 useCallback),且该 effect 负责的是注册监听/定时器/长连接,优先改造。
  2. 优先使用框架能力:React 版本支持 useEffectEvent 时直接用;否则使用本仓库现成的 useCallbackRef(服务端渲染场景下其 isomorphic 实现比裸 useLayoutEffect 更安全)。
  3. 只让“订阅目标”留在依赖数组里event 名称、URL、topic 等真正决定“订阅什么”的原始值保留为依赖;回调一律通过 ref 间接调用。
  4. 多实例场景追加去重:同一全局事件被多个组件实例监听时,参照 client-event-listeners.md 的共享订阅方案,ref 模式只解决“每个实例内部不抖动”,不解决“实例数 × 监听器数”的乘法问题。

需要说明的适用前提:该规则文件是仓库内置给 AI 编码代理(opencode skill)参考的规范文档,本身不产生运行时行为;本文对 useEffectEvent 的描述以 规则原文 为准,实际采用前请确认项目所用 React 版本是否已暴露该 API。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
504
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384