TypeScript封装React组件:捕获事件、转发Ref与埋点实现
需求是给应用中所有MUI Button(原生HTML Button场景同理)统一添加分析埋点,计划通过Wrapper组件替代原Button,要求:
- 封装时注入埋点逻辑
- 完全保留原组件的事件监听与传播机制
- 正确转发Ref
当前实现遇到两个核心问题:
- Ref类型混淆:搞不清
ForwardRef、Ref、RefObject的差异 - 事件处理错误:尝试用
dispatchEvent传递React SyntheticEvent时出错,不确定当前实现路径是否正确,想了解React推荐的「隐形」封装方式,确保不阻断原有函数与事件调用
当前实现代码
CustomButton.tsx
import _ from 'lodash'; import { Button, ButtonProps } from '@mui/material'; import { analyticsTrack, mergeRefs } from 'utils/functions'; import { forwardRef, ForwardedRef, createRef } from 'react'; interface CustomButtonProps extends ButtonProps { trackingName?: string; } const CustomButton = forwardRef( ( { trackingName, children, ...rest }: CustomButtonProps, ref: ForwardedRef<any> ) => { const buttonRef = createRef(); function handleClick(...args: any) { analyticsTrack('Button Clicked', { props: rest, ariaLabel: rest['aria-label'], trackingName, buttonText: typeof children === 'string' ? children : '', }); if (rest.onClick) { rest.onClick(args); } } return ( // @ts-ignore // eslint-disable-next-line react/jsx-no-bind <Button {..._.omitBy(rest, ['onClick'])} onMouseDown={(event) => event.stopPropagation()} onTouchStart={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); event.preventDefault(); handleClick(); // @ts-ignore buttonRef.current && buttonRef.current.dispatchEvent(event); }} // @ts-ignore ref={mergeRefs(ref, buttonRef)} > {children} </Button> ); } ); export default CustomButton;
mergeRefs 工具函数
export const mergeRefs = (...refs: RefObject<Element | null>[]) => { return ( node: Element ) => { for (const ref of refs) { // @ts-ignore ref && !ref.current ? ref.current = node : null; } } }
解决方案
1. 先理清Ref相关类型差异
RefObject:通过createRef()创建的对象,包含current属性,用于存储DOM节点或组件实例,是可变引用ForwardedRef:forwardRef接收的第二个参数,类型为Ref<T> | undefined,可以是RefObject或者回调函数((node: T) => void)Ref:是RefObject<T>和回调Ref的联合类型,泛指所有可以用于React元素的引用类型
2. 修正后的「隐形」封装实现
React推荐的隐形封装核心是:不破坏原组件的API和行为,仅在原有逻辑上注入额外功能,不需要阻止事件传播或手动模拟事件派发。
首先修正mergeRefs函数(支持所有Ref类型)
import { Ref, RefObject } from 'react'; export const mergeRefs = <T>(...refs: Ref<T>[]) => { return (node: T | null) => { refs.forEach(ref => { if (!ref) return; // 处理回调类型的Ref if (typeof ref === 'function') { ref(node); } else { // 处理RefObject类型 (ref as RefObject<T>).current = node; } }); }; };
修正后的CustomButton组件
import { Button, ButtonProps } from '@mui/material'; import { analyticsTrack, mergeRefs } from 'utils/functions'; import { forwardRef, ForwardedRef } from 'react'; interface CustomButtonProps extends ButtonProps { trackingName?: string; } const CustomButton = forwardRef( ( { trackingName, children, onClick: originalOnClick, ...rest }: CustomButtonProps, ref: ForwardedRef<HTMLButtonElement> ) => { const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { // 1. 先执行埋点逻辑 analyticsTrack('Button Clicked', { props: rest, ariaLabel: rest['aria-label'], trackingName, buttonText: typeof children === 'string' ? children : '', }); // 2. 调用原组件的onClick事件,传递完整的事件对象 if (originalOnClick) { originalOnClick(event); } }; return ( <Button {...rest} onClick={handleClick} ref={mergeRefs(ref)} > {children} </Button> ); } ); export default CustomButton;
关键改进点说明
- 移除不必要的事件阻断:原代码中的
stopPropagation和preventDefault会破坏原组件的事件冒泡和默认行为(比如表单提交、按钮默认点击逻辑),完全不需要,React事件会正常冒泡,我们只需要在自定义的handleClick中先执行埋点再调用原事件即可 - 正确处理事件参数:直接将React的SyntheticEvent传递给原
onClick,不需要用dispatchEvent(dispatchEvent需要原生DOM事件,而React SyntheticEvent是封装后的对象,无法直接用于原生事件派发) - 类型明确化:指定
ForwardedRef的类型为HTMLButtonElement,符合MUI Button的Ref类型,消除TS报错 - 简化Ref处理:不需要额外创建内部
buttonRef,直接合并外部传入的Ref即可(如果需要获取内部按钮实例,再添加内部Ref并通过mergeRefs合并) - 避免lodash依赖:直接解构出
originalOnClick,不需要用_.omitBy移除onClick,代码更简洁
内容的提问来源于stack exchange,提问作者Caleb Jay
相关产品推荐
相关产品推荐

