React组件onFocus监听在Jest测试中无法触发的问题排查与求解
问题分析
你遇到的这个问题,本质是Jest+React Testing Library的DOM事件模拟逻辑,和React合成事件、forwardRef组件的事件传播特性共同作用导致的:
- 直接用
new Event('focus') + dispatchEvent模拟的是原生DOM事件,但React的onFocus是合成事件,依赖内部的事件委托机制。如果组件内有使用forwardRef的子组件(比如你提到的Icon),可能会干扰事件的冒泡路径,导致React的合成focus事件没被正确捕获。 onFocusCapture能触发是因为它属于事件捕获阶段的回调,会在冒泡前执行,绕开了路径干扰;userEvent.tab()则是完全模拟真实用户的聚焦行为,能正确触发React的合成事件链路。
推荐的解决方案
方案1:用userEvent模拟真实交互(最推荐)
React Testing Library的设计核心就是贴近真实用户操作,而非直接操作DOM事件。针对focus场景,推荐用userEvent.focus()或userEvent.tab()来模拟,既符合真实用户行为,又能完美触发原生onFocus,还不用修改组件代码:
import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import Button from './Button'; describe('Button interactions', () => { // 统一维护事件类型和对应的模拟操作 const interactions = [ { type: 'click', trigger: (el) => userEvent.click(el) }, { type: 'mouseenter', trigger: (el) => userEvent.hover(el) }, { type: 'focus', trigger: (el) => userEvent.focus(el) }, ]; for (const { type, trigger } of interactions) { it(`should invoke the ${type} listener callback`, async () => { const mockInteraction = jest.fn(); render(<Button onInteraction={mockInteraction} />); const button = screen.getByTestId('trigger'); await trigger(button); expect(mockInteraction).toHaveBeenCalled(); }); } });
这种方式能让所有事件的测试逻辑保持统一,完全符合React Testing Library的最佳实践。
方案2:修复DOM事件模拟逻辑(若坚持用dispatchEvent)
如果你一定要用dispatchEvent,需要创建可信且支持冒泡的FocusEvent——因为原生focus事件默认不冒泡,React的事件委托需要事件冒泡才能捕获到,同时React会过滤掉不可信的模拟事件:
it(`should invoke the focus listener callback`, async () => { const interaction = jest.fn(); const component = render(<Button onInteraction={interaction} />); const button = await component.findByTestId("trigger"); // 创建符合要求的focus事件 const event = new FocusEvent('focus', { bubbles: true, cancelable: true, isTrusted: true }); button.dispatchEvent(event); expect(interaction).toHaveBeenCalled(); });
注意这里要使用FocusEvent而非通用Event,并显式开启bubbles: true和isTrusted: true。
不推荐:使用onFocusCapture
虽然onFocusCapture能临时解决问题,但它属于事件捕获阶段的回调,触发时机和场景和onFocus有本质差异(捕获阶段先于冒泡阶段执行),长期来看会增加代码的理解成本,也不符合常规的事件绑定习惯,除非你确实需要在捕获阶段处理focus逻辑,否则不建议用这种hack方式。
关于forwardRef的影响
你发现去掉forwardRef的Icon后问题消失,是因为forwardRef组件如果内部处理了焦点相关逻辑(比如自身获取焦点),可能会中断或改变原始按钮的事件传播路径,导致React的合成onFocus事件没有被正确触发。而用userEvent模拟真实交互的方式,会完整复现焦点转移的全过程,自然能避免这个问题。
内容来源于stack exchange

