陌上人如玉
公子世无双

1.6 事件系统

1.6 事件系统

一句话:React 自己实现了一套合成事件(SyntheticEvent)系统,事件不绑在元素上而是委托到根容器统一处理;这带来了跨浏览器一致性与性能优势,也带来几个「与原生不一致」的细节(最典型的是 onChange 的行为)。

一、合成事件是什么

function Button() {
  const handleClick = (e: React.MouseEvent) => {
    console.log(e.type);            // 'click'
    console.log(e.target);          // 真实触发的 DOM 元素
    console.log(e.currentTarget);   // 绑定监听器的元素(这里就是 button)
    console.log(e.nativeEvent);     // ⭐ 原生事件对象
  };

  return ;
}
合成事件对象的本质:
  它是对【原生事件对象】的一层跨浏览器包装(不是简单的 Proxy),
  提供了统一的属性与方法(preventDefault、stopPropagation、target、currentTarget…)
  并做掉了历史浏览器之间的差异(如 IE 的 attachEvent、event.target 兼容)

原生事件对象可以通过 e.nativeEvent 拿到。

二、为什么要用合成事件

原因 说明
跨浏览器一致性 统一属性名与行为,屏蔽历史浏览器差异(如 stopPropagation 的实现差异)
事件委托 所有事件都挂在根容器上统一分发,减少监听器数量(性能 + 内存)
动态元素自动生效 列表项增删无需重新绑定(因为监听在根上)
与 React 的更新机制协同 事件处理里的 setState 能被正确批处理(React 知道「这次更新发生在事件里」)
明确的事件类型体系 TypeScript 里提供 React.MouseEvent / ChangeEvent 等精确类型
// 1000 行的列表,每个都有 onClick:
// ❌ 原生方式:1000 个监听器
{list.map((item) => (
   handle(item)}>
))}

// ✅ React 方式:内部只挂一个(在根容器上),点击时按 event.target 找到对应组件的处理函数
// → 对开发者来说写法相同,但 React 内部做了优化

三、事件委托到根容器(React 17 的变化)

React 16 及之前:所有事件监听挂在 document 上
React 17+      :挂在【根容器】(ReactDOM.createRoot 的容器元素)上

ReactDOM.createRoot(document.getElementById('root')).render();
                                    ↑ 事件委托到这里

为什么 React 17 要改:

原因 说明
多版本 React 共存 微前端/多根渲染时,都挂在 document 上会互相干扰;挂在各自根上就互不影响
渐进式升级 页面不同区域可以用不同版本的 React(升级期间)
更贴近原生语义 「事件不应冒泡出根容器」变成现实(因为监听器就在根上)
// 这个变化带来的一个可观察差异:
// 如果子应用(React 16)和外层应用(React 17+)混用,
// 外层【原生】监听在 document 上的事件会先于子应用的 React 事件触发。

四、事件的绑定方式

// ① 内联箭头函数(最常用)

// 优点:能传参、写法简洁
// 缺点:每次渲染创建新函数(若子组件是 memo 的,会导致它重渲染)

// ② 传函数引用(推荐用于纯展示子组件)


// ③ 绑定参数:用 data-* 属性代替闭包
;
function handleClick(e: React.MouseEvent) {
  const id = e.currentTarget.dataset.id;
}
// 优点:函数引用稳定(不随渲染变化)

// ④ useCallback 包裹(需要稳定引用时)
const handleClick = useCallback((id: string) => deleteItem(id), [deleteItem]);
;
// ⑤ 类组件的三种绑定方式
class A extends React.Component {
  // 方式一:构造函数里 bind
  constructor(props) {
    super(props);
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() { console.log(this); }

  // 方式二:类字段箭头函数(推荐)
  handleClick2 = () => { console.log(this); };

  // 方式三:渲染时 bind(⚠️ 每次渲染都创建新函数)
  render() {
    return (
      <>
        
        
        
      
    );
  }
}

函数组件没有 this 问题——这也是函数组件更简单的原因之一。

五、事件对象:target 与 currentTarget

function List() {
  const handleClick = (e: React.MouseEvent) => {
    console.log(e.target);          // ⭐ 实际被点击的最深元素(可能是 )
    console.log(e.currentTarget);   // ⭐ 绑定监听器的元素(
    ) }; return (
    • 项目一
    ); }
关键差异:
  target        = 事件最初发生的元素(会随着冒泡保持不变)
  currentTarget = 当前正在处理该事件的元素(即绑定 onClick 的那个)

⚠️ 陷阱:在异步回调里 currentTarget 会变成 null
   setTimeout(() => console.log(e.currentTarget), 0);   // null
   ✅ 需要保存就先取出来:const el = e.currentTarget;
// 事件委托的典型写法(利用 target + closest)
    { const li = (e.target as HTMLElement).closest('li'); if (li) handleSelect(li.dataset.id!); }}> {list.map((it) => (
  • {it.name}
  • ))}

六、onChange 的特殊行为(高频考点)

⚠️ React 的 onChange 与原生 change **不是同一个事件**:

原生 DOM:
  input 事件  → 每次输入字符都触发(实时)
  change 事件 → 失焦后才触发(对文本框而言)

React:
  onChange → 等价于原生的 input 事件(实时触发)
  → 所以 React 里写 onChange 就能拿到「每次按键后的值」
// React
 console.log(e.target.value)} />   // 每敲一个字符都触发

// 等价的原生写法
element.addEventListener('input', (e) => console.log(e.target.value));
场景 React 写法 等价原生事件
文本框实时输入 onChange input
提交时/失焦时 onBlur blur
表单提交 onSubmit submit
选择变化(select) onChange change(select 的 input 与 change 都会触发)
// 如果要「失焦才更新」,用 onBlur
 setValue(e.target.value)}
/>

七、事件池(React 16 有,17 移除)

// React 16:事件对象会被「池化」复用,异步访问会拿到 null
function Comp() {
  const handleClick = (e) => {
    setTimeout(() => {
      console.log(e.type);       // ❌ React 16 里是 null
    }, 0);
  };
  return 

现在不需要关心事件池了,但面试可能问到「React 16 里为什么异步拿不到 event 对象」——答案是事件对象被池化复用。

八、常见事件速查

// 鼠标
onClick / onDoubleClick / onContextMenu
onMouseDown / onMouseUp / onMouseMove / onMouseEnter / onMouseLeave
// 注意 React 的 onMouseEnter / onMouseLeave 不冒泡(与原生一致语义)

// 键盘
onKeyDown / onKeyUp / onKeyPress(已废弃)
// 判断某个键:e.key === 'Enter' / e.code === 'Enter'
// ⚠️ 中文输入时判断 e.nativeEvent.isComposing

// 表单
onChange / onInput / onSubmit / onReset / onFocus / onBlur
onInvalid / onSelect / onScroll(表单元素也可滚)

// 触摸与指针
onTouchStart / onTouchMove / onTouchEnd
onPointerDown / onPointerMove / onPointerUp
onPointerCancel / onGotPointerCapture(指针捕获)

// 拖拽
onDragStart / onDragOver / onDrop / onDragEnd

// 焦点
onFocus / onBlur(React 里这两个会冒泡,是合成事件的差异之一 ⚠️)

// 图片/媒体
onLoad / onError / onPlay / onPause / onTimeUpdate / onEnded

// 动画与过渡
onAnimationStart / onAnimationEnd / onTransitionEnd

// 滚动
onScroll(⚠️ 在 React 中不冒泡,需要绑在具体滚动容器上)
// 一个实用细节:需要 passive 监听器时用原生事件
// React 没有直接暴露 passive 选项(React 17+ 对 touch/wheel 已默认 passive)
// 需要精细控制时用 ref + addEventListener
useEffect(() => {
  const el = ref.current;
  if (!el) return;
  const onWheel = (e: WheelEvent) => { /* ... */ };
  el.addEventListener('wheel', onWheel, { passive: false });
  return () => el.removeEventListener('wheel', onWheel);
}, []);

九、常见坑

// ① onChange 误以为是原生 change(失焦才触发)
       // React 里其实是实时触发(等价 input)

// ② 写成小写的事件名
// ⑩ 在 React 里做「点击外部关闭」
useEffect(() => {
  const onDocClick = (e: MouseEvent) => {
    if (!ref.current?.contains(e.target as Node)) close();
  };
  // ⚠️ 必须用原生监听(React 的合成事件在 root 上,且点击内部时也会触发)
  document.addEventListener('pointerdown', onDocClick);
  return () => document.removeEventListener('pointerdown', onDocClick);
}, [close]);

面试延伸

  1. 「什么是合成事件?为什么要用它?」

合成事件是 React 在原生事件之上封装的跨浏览器一致的事件对象。用它有三个理由:① 跨浏览器一致性(统一属性与行为,屏蔽历史差异);② 性能——React 把事件统一委托到根容器,而不是给每个元素挂监听器,因此 1000 个列表项的点击也只需一个监听器;③ 与更新机制协同——事件处理里的 setState 能被正确批处理。需要原生对象时用 e.nativeEvent。

  1. 「React 的事件绑在哪里?React 17 有什么变化?」

React 把事件委托到根容器上(createRoot 传入的那个 DOM 节点)。React 16 及之前挂在 document 上,React 17 改成挂在根容器上。改的三点原因:① 支持多版本 React 共存(微前端/渐进式升级时不会互相干扰);② 更贴近原生语义(事件不再冒泡出根容器);③ 多个根渲染时互不影响。

  1. 「React 的 onChange 和原生 change 是同一个事件吗?」

不是。React 的 onChange 等价于原生的 input 事件(每次输入都触发),而原生的 change 对文本框来说是失焦后才触发。这是 React 有意为之的设计(让受控组件能实时拿到值)。如果确实需要「失焦才更新」,应该用 onBlur。这个差异是高频考点。

  1. 「e.target 和 e.currentTarget 有什么区别?」

target 是实际触发事件的最深元素(比如点击的是 <li> 里的 <span>,target 就是 span),它在冒泡过程中保持不变;currentTarget 是当前正在处理事件的元素(即绑定 onClick 的那个元素)。重要陷阱:在异步回调中 currentTarget 会变成 null(因为 React 会重置它),需要提前保存。

  1. 「React 16 里为什么在 setTimeout 中拿不到 event 对象?」

因为 React 16 使用了事件池(event pooling):为了减少对象创建开销,事件对象在回调执行完后会被「回收复用」,属性被清空,所以异步访问会得到 null。当时的解法是调用 e.persist() 让该对象不被回收。React 17 起事件池已移除,异步访问完全正常(e.persist() 保留为空操作以兼容旧代码)。

  1. 「React 的 stopPropagation 和原生有什么区别?」

React 的 e.stopPropagation() 阻止的是合成事件体系内的传播(React 自己维护的虚拟冒泡路径)。它不会阻止「绑在 document / 根容器上的原生监听器」——因为那些监听器是在 React 的分发逻辑之外触发的。反过来,原生事件里的 stopPropagation 也会影响 React 的事件(因为 React 依赖原生冒泡)。理解这一点对排查「点击外部关闭」这类交互很关键。

  1. 「onFocus / onBlur 在 React 里会冒泡吗?」

会。原生的 focus / blur 不冒泡(所以原生里常用 focusin / focusout),但 React 特意让 onFocus / onBlur 冒泡(内部映射到 focusin / focusout 的语义),这样可以用事件委托统一处理表单内的焦点变化。这是一个容易被忽略的合成事件差异。

  1. 「怎么在 React 里处理『点击外部关闭』?」

因为 React 的事件委托在根容器上,所以不能直接用合成事件的冒泡来判断「是否点击到外部」。正确做法是用 useEffect + 原生 document.addEventListener('pointerdown', ...),并用 ref.current.contains(e.target) 排除内部点击;组件卸载时必须移除监听。另外用 pointerdown 比 click 更及时(能避免与拖拽、聚焦顺序的时序问题)。

一句话速记

React 用合成事件(跨浏览器一致 + 委托到根容器,React 17 起从 document 改到 root);e.nativeEvent 才是原生对象,target 是最深元素、currentTarget 是绑定元素(异步里会变 null);React 的 onChange 等价于原生 input(失焦才更新要用 onBlur);onFocus/onBlur 在 React 里会冒泡、onScroll 不冒泡;事件池在 React 17 已移除;中文输入要判 isComposing;「点击外部关闭」要用原生监听。

赞(0) 打赏
未经允许不得转载:陌上寒 » 1.6 事件系统

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫