React 基础体系 · 第 24/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 事件系统:合成事件、传播、优先级、闭包和原生事件

React 中的事件处理,看起来只是给 JSX 传入一个函数:

<button onClick={handleClick}>保存</button>

但一次点击实际上会经过多个层次:

  1. 浏览器产生原生 DOM 事件;
  2. React 在客户端监听并识别该事件;
  3. React 创建事件对象,并按照组件树计算传播路径;
  4. React 调用捕获阶段和冒泡阶段的处理函数;
  5. 事件处理函数触发状态更新;
  6. React 根据更新优先级调度渲染;
  7. 渲染结果经过提交阶段写回 DOM;
  8. 事件处理函数执行时使用的是某一次渲染形成的闭包。

因此,“事件没触发”“阻止冒泡无效”“状态总是旧的”“原生事件和 React 事件顺序异常”等问题,通常不是单个 API 的问题,而是混淆了这几层机制。

本文以 React 19、现代 TypeScript 和客户端 React DOM 为基础。服务端渲染、Server Components 和原生事件的边界会单独说明。


一、先区分四个对象:DOM 事件、React 事件、事件处理函数和状态更新

1. 原生 DOM 事件

浏览器发生点击、键盘输入、指针移动等操作时,会创建原生事件对象,例如:

MouseEvent
KeyboardEvent
PointerEvent
InputEvent

它具有 DOM 标准规定的属性和方法:

event.target
event.currentTarget
event.preventDefault()
event.stopPropagation()

浏览器负责:

  • 产生事件;
  • 计算 DOM 传播路径;
  • 执行通过 addEventListener 注册的监听器;
  • 处理默认行为,例如链接跳转、表单提交。

2. React 合成事件

React 传给 JSX 处理函数的事件对象通常是 SyntheticEvent 或其具体类型:

function Button() {
  function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
    console.log(event.currentTarget);
  }

  return <button onClick={handleClick}>点击</button>;
}

“合成事件”不是说浏览器没有产生原生事件,而是说 React 在原生事件之上提供了一层统一事件接口和分发机制。它的目的包括:

  • 让不同浏览器的事件属性和方法具有统一形状;
  • 让 React 按组件树分发事件;
  • 让事件处理产生的更新能够进入 React 的调度系统;
  • 让 Portal 等不完全对应 DOM 层级的结构仍能按 React 树传播。

在现代 React 中,事件对象不再采用早期版本的事件对象池机制。事件处理函数返回后,不需要为了异步使用而调用已经废弃的 event.persist()。不过,事件对象本身代表的是那一次事件;不能把“事件对象还可访问”和“闭包里的状态会自动更新”混为一谈。

3. 事件处理函数

事件处理函数是组件在某次渲染时创建或取得的函数:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    console.log(count);
    setCount(count + 1);
  }

  return <button onClick={handleClick}>{count}</button>;
}

这个函数捕获了当前渲染中的 count。如果当前画面显示 0,那么这一轮渲染产生的 handleClick 看到的就是 0

4. 状态更新

调用:

setCount(count + 1);

并不意味着当前函数中的 count 立即改变。它只是向 React 提交了一个更新请求。当前事件函数继续执行时,仍然使用当前渲染的变量值。

下一次渲染会得到新的 count 和新的事件处理函数。

可以把一次交互抽象成:

DOM eventReact dispatchhandler closurestate updatescheduled rendercommit\text{DOM event} \rightarrow \text{React dispatch} \rightarrow \text{handler closure} \rightarrow \text{state update} \rightarrow \text{scheduled render} \rightarrow \text{commit}

这条链中的每一步都有自己的时机,不能把它们视为同步的单步赋值。


二、React 如何把原生事件转成合成事件

React DOM 不会为每一个按钮都无条件注册一组独立的浏览器监听器。现代 React 通常会在 React 根容器附近进行事件委托:原生事件发生后,React 从事件目标反推出对应的 React Fiber 和组件树路径,再执行 JSX 中声明的处理函数。

这是常见实现机制,而不是应用代码可以依赖的公开 DOM 结构契约。尤其不能把“React 一定在 document 上监听”当成稳定事实。

例如:

const root = createRoot(document.getElementById('root')!);

root.render(
  <div onClick={() => console.log('React')}>
    <button>点击</button>
  </div>
);

点击按钮时,浏览器先产生原生 click。React 监听到该事件后,会根据 React 树找到:

<div onClickCapture?> 
  <button onClick?>

然后按捕获和冒泡规则调用处理函数。

事件类型和 JSX 属性名

React 使用驼峰形式的属性名:

<button onClick={handleClick} />
<input onChange={handleChange} />
<div onPointerMove={handleMove} />

不是 HTML 字符串形式:

<!-- 这是错误的 React JSX 写法 -->
<button onclick="handleClick()">点击</button>

React 事件属性的值是函数,而不是字符串。函数只有在事件发生时才执行:

<button onClick={handleClick} />

不要写成:

<button onClick={handleClick()} />

后者会在渲染过程中执行 handleClick,并把返回值作为处理函数,通常会导致渲染期间副作用、无限更新或运行时报错。


三、事件传播:捕获、目标和冒泡

1. DOM 传播路径

假设 DOM 结构如下:

<div id="outer">
  <button id="button">点击</button>
</div>

点击按钮时,事件路径可以简化为:

document
  ↓ 捕获
html
  ↓
body
  ↓
#outer
  ↓
#button 目标
  ↑ 冒泡
#outer
  ↑
body
  ↑
html
  ↑
document

事件通常经历三个概念阶段:

  1. 捕获阶段:从外层祖先向目标前进;
  2. 目标阶段:到达真正触发事件的节点;
  3. 冒泡阶段:从目标向外层祖先返回。

React 对应提供:

<div onClickCapture={handleCapture}>
  <button onClick={handleBubble}>点击</button>
</div>

执行顺序通常是:

handleCapture
handleBubble

如果按钮和外层容器都有冒泡处理函数:

function Example() {
  return (
    <div onClick={() => console.log('outer')}>
      <button onClick={() => console.log('button')}>
        点击
      </button>
    </div>
  );
}

点击按钮时输出:

button
outer

因为事件先到达按钮,再沿 React 树向上冒泡。

2. 一个可运行的传播示例

import { useState } from 'react';

export default function PropagationDemo() {
  const [logs, setLogs] = useState<string[]>([]);

  function log(message: string) {
    setLogs((previous) => [...previous, message]);
  }

  return (
    <section
      onClickCapture={() => log('section capture')}
      onClick={() => log('section bubble')}
      style={{ border: '1px solid gray', padding: 16 }}
    >
      <button
        onClickCapture={() => log('button capture')}
        onClick={() => log('button bubble')}
      >
        点击
      </button>

      <button onClick={() => log('second button bubble')}>
        第二个按钮
      </button>

      <pre>{logs.join('\n')}</pre>
    </section>
  );
}

第一次点击第一个按钮时,典型输出为:

section capture
button capture
button bubble
section bubble

第二个按钮的点击不会经过第一个按钮,因为它们是兄弟节点;输出中的路径只包含被点击节点及其祖先。

3. targetcurrentTarget

这是事件诊断中最容易混淆的两个属性。

function Panel() {
  function handleClick(event: React.MouseEvent<HTMLDivElement>) {
    console.log(event.target);
    console.log(event.currentTarget);
  }

  return (
    <div onClick={handleClick}>
      <span>文字</span>
    </div>
  );
}

点击 span 时:

  • event.target 是实际最初触发事件的 span
  • event.currentTarget 是当前正在执行处理函数的 div

因此,事件委托通常使用 currentTarget 作为监听容器,用 target 判断具体命中的后代元素。

在 TypeScript 中,target 类型往往较宽,因为它可能是任意 EventTarget

function handleClick(event: React.MouseEvent<HTMLDivElement>) {
  const target = event.target;

  if (target instanceof HTMLElement) {
    console.log(target.dataset.id);
  }
}

currentTarget 的类型由处理函数的泛型参数关联到当前元素:

function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
  event.currentTarget.disabled = true;
}

4. stopPropagation 到底停止什么

function Card() {
  function handleCardClick() {
    console.log('card');
  }

  function handleButtonClick(event: React.MouseEvent<HTMLButtonElement>) {
    event.stopPropagation();
    console.log('button');
  }

  return (
    <div onClick={handleCardClick}>
      <button onClick={handleButtonClick}>删除</button>
    </div>
  );
}

点击按钮时只输出:

button

stopPropagation() 阻止事件继续从当前节点向祖先传播,因此卡片的 onClick 不执行。

它解决的是“不要继续传播到祖先”的问题,不等价于“取消默认行为”:

event.preventDefault();     // 取消浏览器默认行为
event.stopPropagation();    // 停止向祖先传播

例如:

<a
  href="/settings"
  onClick={(event) => {
    event.preventDefault();
    console.log('不跳转');
  }}
>
  设置
</a>

这里没有停止传播;如果外层还有点击处理函数,外层仍可能收到该事件。

还要区分 stopImmediatePropagation()。它是原生 DOM 事件 API,用于阻止同一个事件目标上后续原生监听器继续执行。React 的常规代码通常只需要 stopPropagation();在 React 事件和原生监听器混用时,如果确实需要控制同节点上的原生监听器,才可能接触:

event.nativeEvent.stopImmediatePropagation();

这会让行为依赖原生监听器注册位置和执行顺序,风险明显高于单纯停止 React 树上的冒泡。


四、React 树、DOM 树和 Portal

大多数情况下,React 树和 DOM 树层级一致,因此传播结果直观。但 Portal 会把子节点渲染到另一个 DOM 容器,同时保留其 React 父子关系。

import { createPortal } from 'react-dom';

function ModalButton() {
  return createPortal(
    <button onClick={() => console.log('modal button')}>
      模态框按钮
    </button>,
    document.body
  );
}

export default function App() {
  return (
    <div onClick={() => console.log('React parent')}>
      <ModalButton />
    </div>
  );
}

虽然按钮实际被放到了 document.body,但从 React 树看,它仍然是外层 div 的后代。因此点击按钮时,React 父节点的处理函数可能收到该事件。

这形成一个关键区别:

DOM 物理位置:button 在 body 下
React 逻辑位置:button 在 div 下

Portal 中的事件传播按 React 树传播,这是 React 对 Portal 的设计行为。可是,原生 addEventListener 只认识 DOM 树,不认识 React 树。于是同一事件在两套传播系统中的路径可能不同。

生产代码中,模态框、下拉菜单、浮层和遮罩层经常同时使用 Portal 与事件处理。诊断时必须分别问:

  1. 这个处理函数是 React JSX 处理函数吗?
  2. 这个监听器是通过 addEventListener 注册的吗?
  3. 事件目标在 DOM 树上的祖先是什么?
  4. 事件目标在 React 树上的祖先是什么?

五、状态更新、批处理和事件优先级

1. 为什么一次点击中的多次更新不会每次都立即渲染

React 会对同一事件处理过程中的状态更新进行批处理。例如:

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }

  return (
    <button onClick={handleClick}>
      {count}
    </button>
  );
}

当初始 count0 时,三个表达式实际上都是:

setCount(1);

因此结果通常是 1,而不是 3

原因不是 React 丢失了三次调用,而是这三个更新都读取了同一次渲染闭包中的 count = 0

如果更新依赖前一个更新的结果,应使用函数式更新:

function handleClick() {
  setCount((value) => value + 1);
  setCount((value) => value + 1);
  setCount((value) => value + 1);
}

此时 React 处理更新队列:

初始值:0
第一次:0 + 1 = 1
第二次:1 + 1 = 2
第三次:2 + 1 = 3
最终值:3

函数式更新的形式是:

sn+1=fn(sn)s_{n+1} = f_n(s_n)

其中:

  • sns_n 是更新队列当前状态;
  • fnf_n 是第 nn 个函数式更新;
  • 每个更新接收前一个更新产生的结果。

而直接值更新更接近:

sfinal=vlasts_{\text{final}} = v_{\text{last}}

多个直接值更新会互相覆盖,最后应用的值决定结果。

2. React 事件中的批处理范围

在现代 React 中,批处理不只发生在 JSX 事件里,也通常覆盖 Promise、定时器等由 React 管理的更新场景。但“批处理”只描述更新合并和调度方式,不表示所有更新都一定在同一时刻提交,也不表示代码可以立刻读取新状态。

function handleClick() {
  setCount((value) => value + 1);
  console.log(count);
}

这里 console.log 仍会打印当前闭包中的旧值。状态的新值会在后续渲染中提供:

useEffect(() => {
  console.log('已提交的新 count:', count);
}, [count]);

如果只是需要在同一个事件中计算派生值,可以使用局部变量:

function handleClick() {
  const nextCount = count + 1;

  setCount(nextCount);
  console.log(nextCount);
}

3. 事件优先级是什么

React 内部会根据更新来源区分紧急程度。事件优先级可以理解为:

当多个更新竞争执行时,哪些更新应更快被处理,以及哪些更新可以被延后或中断。

React 内部实现使用 lanes 等调度结构表示不同更新的优先级。具体 lane 编号、内部函数和分配方式不是应用层稳定 API,不应直接依赖。

从概念上,可以区分:

离散事件

离散事件具有明确的单次交互边界,例如:

  • click
  • keydown
  • submit

按钮点击后,用户通常期望按钮状态、表单反馈等尽快反映。

连续事件

连续事件在短时间内不断产生,例如:

  • pointermove
  • mousemove
  • scroll
  • drag

如果每一个事件都立即触发昂贵的同步工作,可能造成输入延迟。因此 React 和浏览器通常需要给这类更新更多调度空间。

默认或低优先级更新

由普通代码、异步结果或显式过渡产生的更新,可能不需要抢占当前交互。例如搜索结果列表、复杂图表或筛选后的大表格。

4. 事件优先级不等于“代码执行优先级”

事件处理函数本身会先执行。优先级主要影响它触发的 React 更新何时渲染和提交,而不是让 JavaScript 函数在同一调用栈中被魔法暂停。

function handleClick() {
  console.log('1');

  setCount((value) => value + 1);

  console.log('2');
}

同一次调用中通常先输出:

1
2

状态更新的渲染和提交发生在后续调度阶段。

5. startTransition:把非紧急更新标记为可延后

React 提供 startTransition,用于标记不需要阻塞当前交互的更新:

import { startTransition, useState } from 'react';

function SearchBox() {
  const [query, setQuery] = useState('');
  const [listQuery, setListQuery] = useState('');

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const nextQuery = event.currentTarget.value;

    setQuery(nextQuery);

    startTransition(() => {
      setListQuery(nextQuery);
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <SearchResults query={listQuery} />
    </>
  );
}

这里有两个不同目的:

  • query 控制输入框显示,应尽快更新;
  • listQuery 驱动可能昂贵的结果渲染,可以延后。

startTransition 不会让计算本身变快,也不会自动取消网络请求。它只是告诉 React:这部分更新可以被更高优先级的交互打断并稍后继续。

需要注意:

  • 不应把受控输入框本身的更新放进 transition,以免输入响应不符合预期;
  • transition 中触发的异步请求仍需要应用层处理竞态;
  • 优先级是调度提示,不是性能保证。

6. flushSync 是强制同步提交,不是常规工具

React DOM 提供 flushSync,可以在少数需要立即读取 DOM 的场景强制刷新:

import { flushSync } from 'react-dom';

function handleClick() {
  flushSync(() => {
    setOpen(true);
  });

  const dialog = document.querySelector('[role="dialog"]');
  dialog?.focus();
}

它的因果关系是:

setOpen(true)
→ flushSync 要求 React 立即完成相关更新
→ 后续代码更可能读取到新 DOM

但它会破坏 React 的调度空间,可能强制执行更多待处理更新,带来性能和时序风险。通常应优先使用 useLayoutEffect、回调 ref 或合适的 DOM 生命周期;只有确实需要在同一段命令式代码中读取已更新 DOM 时才考虑它。


六、闭包:为什么事件处理函数会读到旧状态

1. 每次渲染都会形成新的变量环境

考虑以下组件:

function Timer() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setTimeout(() => {
      console.log(count);
    }, 1000);

    setCount((value) => value + 1);
  }

  return <button onClick={handleClick}>{count}</button>;
}

初始渲染时:

count = 0
handleClick 闭包捕获 count = 0

点击后:

调用旧的 handleClick
安排定时器,定时器闭包也捕获 count = 0
提交 setCount
下一次渲染显示 count = 1
1 秒后定时器输出 0

这不是定时器读取失败,而是它忠实地读取了创建它的那次渲染中的变量。

2. 直接更新和函数式更新解决的是不同问题

函数式更新可以解决“更新队列之间依赖旧状态”的问题:

setCount((value) => value + 1);

但它不能让已经创建的异步闭包自动看到未来的 count

setTimeout(() => {
  console.log(count); // 仍然是创建定时器时的 count
}, 1000);

这是两个不同问题:

问题 解决方式
多个状态更新互相依赖 函数式更新
异步回调需要读取最新值 ref、重新创建回调、或重新设计数据流

3. 用 ref 保存需要被异步回调读取的最新值

import { useEffect, useRef, useState } from 'react';

function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count;
  }, [count]);

  function handleClick() {
    setTimeout(() => {
      console.log('定时器读取:', countRef.current);
    }, 1000);

    setCount((value) => value + 1);
  }

  return <button onClick={handleClick}>{count}</button>;
}

这里:

  • useState 驱动画面;
  • useRef 保存可变的、不会因为修改而触发渲染的引用;
  • effect 在提交后把 ref 同步到最新状态。

如果要求在状态更新后立刻让 ref 反映新值,也可以在函数式更新之外明确维护它,但必须保证单一数据流,避免 ref 和 state 分叉:

function handleClick() {
  setCount((value) => {
    const next = value + 1;
    countRef.current = next;
    return next;
  });
}

4. 事件处理函数不是“永远最新函数”

可以用 useCallback 缓存函数身份:

const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

useCallback 不能消除闭包。依赖变化时,它会得到捕获新 count 的新函数;依赖不变时,它就继续捕获旧值。

错误用法:

const handleClick = useCallback(() => {
  console.log(count);
}, []); // count 被永久固定在初次渲染的闭包中

这会造成典型的 stale closure(陈旧闭包)问题。

5. React 19 的 Effect Event 不等于普通点击回调

React 19 提供了用于 Effect 场景的 Effect Event 能力,例如 useEffectEvent。它适合让 effect 中注册的非响应式回调读取最新值,同时避免把该值作为 effect 的重新连接依赖。

但它不是用来替代普通 onClick 处理函数的通用“最新函数”工具。普通事件处理仍应根据需求选择:

  • 直接使用当前渲染值;
  • 使用函数式更新;
  • 使用 ref;
  • 调整 effect 或数据流;
  • 使用稳定的事件回调抽象,但遵循其 API 适用范围。

不要为了规避依赖数组而把任意业务逻辑包进不适用的 Effect Event。


七、一个完整示例:传播、默认行为、函数式更新和闭包

下面的组件可以直接放入 React + TypeScript 项目中运行:

import { useEffect, useRef, useState } from 'react';

export default function EventSystemDemo() {
  const [count, setCount] = useState(0);
  const [logs, setLogs] = useState<string[]>([]);
  const latestCount = useRef(count);

  useEffect(() => {
    latestCount.current = count;
  }, [count]);

  function appendLog(message: string) {
    setLogs((items) => [...items, message]);
  }

  function handlePanelClick() {
    appendLog('panel bubble');
  }

  function handleIncrement(event: React.MouseEvent<HTMLButtonElement>) {
    event.stopPropagation();

    appendLog(`handler captured count = ${count}`);

    setCount((value) => value + 1);
    setCount((value) => value + 1);

    setTimeout(() => {
      appendLog(
        `timeout captured count = ${count}, ref count = ${latestCount.current}`
      );
    }, 1000);
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    appendLog('form default action prevented');
  }

  return (
    <div
      onClick={handlePanelClick}
      style={{ border: '1px solid #999', padding: 16 }}
    >
      <p>count: {count}</p>

      <button onClick={handleIncrement}>
        增加两次
      </button>

      <form onSubmit={handleSubmit} style={{ marginTop: 16 }}>
        <button type="submit">提交表单</button>
      </form>

      <pre>{logs.join('\n')}</pre>
    </div>
  );
}

初始状态为 count = 0 时,点击“增加两次”:

  1. stopPropagation() 阻止外层 divonClick
  2. appendLog 采用函数式更新,日志不会因为连续调用而互相覆盖;
  3. 两个 setCount 都是函数式更新,因此最终 count0 变成 2
  4. 当前处理函数中的 count 仍是 0
  5. 一秒后,定时器中的 count 通常仍是 0
  6. latestCount.current 在下一次提交后的 effect 中更新为 2,因此通常输出 2

典型日志类似:

handler captured count = 0
timeout captured count = 0, ref count = 2

这组结果同时展示了:

  • 传播控制不会自动取消默认行为;
  • 函数式更新能按顺序累加;
  • 事件函数中的变量属于当前渲染;
  • ref 可以提供异步回调所需的最新可变值。

八、React 事件与原生 addEventListener

1. 两种注册方式

React 事件:

function Component() {
  return <button onClick={handleClick}>点击</button>;
}

原生事件:

import { useEffect } from 'react';

function Component() {
  useEffect(() => {
    function handleDocumentClick(event: MouseEvent) {
      console.log(event.target);
    }

    document.addEventListener('click', handleDocumentClick);

    return () => {
      document.removeEventListener('click', handleDocumentClick);
    };
  }, []);

  return <button>点击</button>;
}

原生监听器必须成对清理。removeEventListener 要求事件类型、函数身份以及相关选项匹配;在 effect 中使用同一个函数引用最安全。

2. 原生监听器的完整选项

useEffect(() => {
  const controller = new AbortController();

  function handlePointerMove(event: PointerEvent) {
    console.log(event.clientX, event.clientY);
  }

  window.addEventListener('pointermove', handlePointerMove, {
    passive: true,
    signal: controller.signal,
  });

  return () => {
    controller.abort();
  };
}, []);

这里:

  • passive: true 表示监听器承诺不会调用 preventDefault()
  • 如果在 passive 监听器中调用 preventDefault(),浏览器可能忽略它并发出警告;
  • signal 允许通过 AbortController 统一取消监听;
  • effect 清理发生时调用 abort(),避免监听器泄漏。

不要把 passive: true 用在需要阻止滚动默认行为的场景。

3. 原生事件对象和合成事件对象不是同一个类型

React 处理函数中的类型:

function handleClick(event: React.MouseEvent<HTMLButtonElement>) {}

原生监听器中的类型:

function handleClick(event: MouseEvent) {}

React 合成事件通常提供:

event.nativeEvent

以访问底层原生事件,但具体原生事件映射不是应用代码应依赖的稳定映射。例如某些 React 事件名与底层浏览器事件名并非简单一一对应。应该优先使用 React 对外提供的事件属性,只有在确实需要底层能力时才访问 nativeEvent

4. React 事件和原生事件的传播可能相互影响

例如:

function Example() {
  useEffect(() => {
    function onDocumentClick() {
      console.log('native document');
    }

    document.addEventListener('click', onDocumentClick);

    return () => {
      document.removeEventListener('click', onDocumentClick);
    };
  }, []);

  return (
    <div onClick={() => console.log('React div')}>
      <button>点击</button>
    </div>
  );
}

这里至少存在两套因素:

  • 浏览器的 DOM 传播;
  • React 对原生事件的委托和合成分发。

如果在 React 处理函数中调用:

event.stopPropagation();

它通常也会影响底层事件向 DOM 祖先继续传播,因此 document 上的原生监听器可能不执行。但当监听器位于同一 DOM 节点、使用捕获阶段、或多个 React 根容器和 Portal 交错时,具体顺序会变复杂。

因此,不应依赖“React 处理函数一定先于某个原生监听器”这类未公开保证。需要混用时,应明确:

  • 监听器挂载在哪个 DOM 节点;
  • 使用捕获还是冒泡;
  • 是否可能有多个 React root;
  • 是否存在 Portal;
  • 是否由某个监听器调用了 stopPropagationstopImmediatePropagation

5. 什么时候应该使用原生事件

原生事件适合:

  • 监听 windowdocument 或 React 管理范围外的节点;
  • 使用浏览器特有事件或高级监听选项;
  • 与第三方 DOM 库集成;
  • 需要捕获阶段、passive 或 AbortSignal 等底层能力。

React 事件适合:

  • 组件内部交互;
  • 需要随着组件挂载和卸载自动管理;
  • 需要 React 状态更新和组件树传播;
  • 需要让事件处理逻辑与组件生命周期保持一致。

不要在每次渲染时直接注册原生监听器:

function BadComponent() {
  document.addEventListener('click', handleClick);
  return <div>内容</div>;
}

这会导致重复注册,且组件卸载后仍可能保留监听器。应该放进 effect 并返回清理函数。


九、事件传播的特殊边界

1. onChange 不是简单照搬 HTML 的 change

React 的 onChange 用于反映表单控件值变化,但它的触发时机和浏览器原生 change 事件的传统语义并不完全相同。对文本输入框,React 通常会在用户输入时触发:

function Input() {
  const [value, setValue] = useState('');

  return (
    <input
      value={value}
      onChange={(event) => {
        setValue(event.currentTarget.value);
      }}
    />
  );
}

受控输入的闭环是:

用户输入
→ onChange
→ setValue
→ React 渲染 value
→ DOM input.value 更新

如果提供了 value 却没有同步更新状态,输入框会表现为不可编辑或输入被回写:

<input value="固定文本" onChange={() => {}} />

这不是事件没触发,而是组件每次渲染都把 DOM 值重新设回固定值。

2. 键盘事件中的修饰键和按键语义

不要只根据 event.keyCode 判断按键;现代代码应使用:

function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
  if (event.key === 'Enter' && !event.shiftKey) {
    event.preventDefault();
    // 提交
  }
}

event.key 表示按键语义,例如 "Enter"event.code 更接近物理键位置,例如 "KeyA"。快捷键逻辑还应考虑:

event.ctrlKey
event.metaKey
event.altKey
event.shiftKey

macOS 和 Windows 的主修饰键通常不同,不能把 ctrlKey 单独当作所有平台的“命令键”。

3. 指针事件比鼠标事件更适合统一输入

现代应用通常优先考虑:

<div
  onPointerDown={handlePointerDown}
  onPointerMove={handlePointerMove}
  onPointerUp={handlePointerUp}
/>

指针事件可以统一鼠标、触摸笔等输入设备。拖拽场景还必须考虑指针离开元素后的继续追踪,常用 setPointerCapture

function Slider() {
  function handlePointerDown(
    event: React.PointerEvent<HTMLDivElement>
  ) {
    event.currentTarget.setPointerCapture(event.pointerId);
  }

  return <div onPointerDown={handlePointerDown}>滑块</div>;
}

调用后,后续属于该指针的事件可以继续发送到捕获元素,即使指针暂时离开其几何区域。释放和取消时应处理:

function handlePointerUp(
  event: React.PointerEvent<HTMLDivElement>
) {
  if (event.currentTarget.hasPointerCapture(event.pointerId)) {
    event.currentTarget.releasePointerCapture(event.pointerId);
  }
}

十、服务端渲染、Hydration 和 Server Components

1. 服务端不能执行浏览器事件处理

服务端渲染可以输出:

<button>点击</button>

但服务端不会执行:

<button onClick={handleClick}>点击</button>

因为服务端没有浏览器事件循环,也没有用户点击。

事件处理函数属于客户端交互逻辑。使用服务端渲染的应用通常需要客户端 hydration,把 React 组件树连接到已经存在的 HTML 上,之后客户端事件才具备交互能力。

2. Server Components 不能直接拥有客户端事件处理

在支持 React Server Components 的框架中,服务端组件不能直接使用事件处理函数:

// 服务端组件中不能直接这样做
export default function ServerComponent() {
  return <button onClick={() => console.log('click')}>点击</button>;
}

原因是函数不能作为普通可序列化数据从服务端传到浏览器执行。

应把交互部分放入客户端组件。例如:

// Counter.tsx
'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      {count}
    </button>
  );
}

服务端组件可以渲染这个客户端边界,但具体边界语法和文件约定由使用的框架决定。'use client' 是许多支持 RSC 的框架采用的约定,不是所有 React 渲染器的通用语法。

3. Hydration 期间的事件边界

在 hydration 尚未完成时,静态 HTML 可能已经显示,但客户端事件处理还没有完全接管。部分框架和 React 的客户端机制会对早期事件进行捕获或重放,但这属于 hydration 机制中的实现能力,不能把它当作任意异步初始化都能自动保留事件。

交互组件应尽早完成客户端加载,并避免让服务端输出与客户端首次渲染不一致。否则可能出现:

  • hydration 警告;
  • 事件绑定到错误结构;
  • 用户点击没有产生预期行为;
  • 客户端重新生成节点导致输入丢失。

十一、常见失败表现与诊断路径

1. “点击后父组件也执行了”

先检查是不是冒泡:

function ChildButton() {
  function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
    event.stopPropagation();
  }

  return <button onClick={handleClick}>操作</button>;
}

如果仍然执行,继续检查:

  • 父级是否使用的是 onClickCapture
  • 是否还有独立的原生监听器;
  • 被调用的是否其实是另一个父级处理函数;
  • 是否通过 Portal 渲染,React 树和 DOM 树层级不同;
  • 是否有多个 React 根容器。

2. “preventDefault 后还是执行了业务逻辑”

preventDefault() 只取消浏览器默认动作,不会停止当前处理函数,也不会自动阻止业务代码:

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
  event.preventDefault();
  validate();
  save();
}

如果不想继续业务流程,需要显式返回或分支:

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
  event.preventDefault();

  if (!isValid()) {
    return;
  }

  save();
}

3. “状态更新了,但事件函数里打印的还是旧值”

检查打印位置:

function handleClick() {
  setCount((value) => value + 1);
  console.log(count);
}

这是正常的闭包行为。要观察提交后的值,可使用 effect:

useEffect(() => {
  console.log(count);
}, [count]);

要让异步回调读取最新值,可使用 ref,但要明确 ref 的同步时机。

4. “原生监听器越来越多”

常见原因是 effect 没有清理,或者依赖变化时不断使用新函数注册:

useEffect(() => {
  window.addEventListener('resize', handleResize);

  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, [handleResize]);

这里要求 handleResize 的身份和依赖设计合理。若函数每次渲染都变化,effect 会反复卸载和注册;这不一定错误,但应确认是否有必要。

开发环境下,React Strict Mode 可能在开发阶段额外执行 setup/cleanup 流程,以帮助发现副作用清理问题。若代码注册一次、清理缺失,往往会在开发环境更早暴露;不要通过全局标记粗暴绕过,而应让 setup 和 cleanup 对称。

5. “点击处理函数根本没有执行”

按以下顺序排查:

  1. JSX 是否真的渲染了目标元素;
  2. 属性名是否为 onClick 而不是 onclick
  3. 是否把函数调用结果传给了 onClick
  4. 是否在服务端组件中期待客户端事件执行;
  5. 是否处于 hydration 尚未完成的阶段;
  6. 是否被上层逻辑或原生监听器阻止;
  7. 是否点击的是视觉上的元素,但实际目标被遮罩层覆盖;
  8. 是否在 pointermouseclick 之间选择了不匹配的事件类型。

可以临时记录:

function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
  console.log({
    type: event.type,
    target: event.target,
    currentTarget: event.currentTarget,
    defaultPrevented: event.defaultPrevented,
    cancelBubble: event.cancelBubble,
  });
}

不要只打印 event.targetcurrentTarget、传播阶段和默认行为状态通常更能说明问题。


十二、事件系统中的几个重要取舍

React 事件不是 DOM 事件的完全替代品

React 事件适合组件交互,但不是所有浏览器事件能力都通过 JSX 属性直接暴露。遇到窗口级监听、第三方节点、特殊监听选项时,原生 API 更合适。

stopPropagation 不应替代组件边界设计

如果一个卡片和内部多个控件频繁依赖停止冒泡来避免冲突,通常说明交互责任边界不清晰。事件传播本身没有问题,但组件可能需要把“卡片导航”和“内部操作”拆成更明确的区域,或根据 currentTargettarget 设计更稳定的判断。

优先级不能替代性能优化

把更新放入 transition 不能消除昂贵计算。复杂列表仍可能需要:

  • 减少无关渲染;
  • 虚拟化;
  • 分页或增量计算;
  • 缓存派生结果;
  • 将网络请求竞态纳入状态模型。

优先级解决的是“何时调度”,不是“工作量有多少”。

Ref 能解决陈旧读取,但也会绕过响应式数据流

使用 ref 读取最新值很有用,但 ref 更新不会自动触发渲染。如果画面必须反映该值,就不能只存 ref。应让 state 负责 UI,让 ref 只承担非渲染读取、实例句柄或命令式集成。

原生事件混用时要拥有清晰的生命周期

一个事件同时由 React 和原生监听器处理并非错误,但需要明确:

  • 谁负责注册;
  • 谁负责清理;
  • 谁负责阻止默认行为;
  • 谁负责停止传播;
  • 是否有多个 root 或 Portal;
  • 事件顺序是否属于公开保证。

如果不能回答这些问题,问题通常不在“React 事件不稳定”,而在两套事件系统的边界没有被显式设计。


React 事件系统的核心不是一个名为 SyntheticEvent 的包装类,而是几条相互连接的规则:

浏览器产生原生事件
→ React 按组件树分发合成事件
→ 捕获阶段进入、目标处理、冒泡阶段返回
→ 处理函数读取当前渲染闭包
→ 状态更新进入批处理和优先级调度
→ React 完成后续渲染与 DOM 提交

掌握这条链后,可以分别解释:

  • 为什么 stopPropagation 不等于 preventDefault
  • 为什么多个 setState 可能只增加一次;
  • 为什么异步回调看到旧状态;
  • 为什么 Portal 的 React 冒泡和 DOM 位置不一致;
  • 为什么 transition 能延后结果列表,却不应延后输入框本身;
  • 为什么原生事件监听器必须通过 effect 管理生命周期;
  • 为什么服务端能输出按钮,却不能在服务端执行点击处理函数。

这些规则共同构成了 React 事件系统的可预测边界。理解边界,比记住某个事件属性或某个内部实现名称更重要。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。