首页 / React 学习笔记 / 06

REACT · Vol.I · LESSON 06 · 核心原语

事件处理与合成事件系统

入门#核心#事件

一个 onClick 就够了

React 里处理用户交互极其简单——给元素加一个 onXxx 属性,传一个函数:

EventDemo.tsx
function EventDemo() {
  function handleClick() {
    alert("点到了!");
  }

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

对比原生 JS 的事件绑定:

原生 vs React
// 原生:addEventListener
btn.addEventListener("click", handleClick);

// React:直接在 JSX 上声明
<button onClick={handleClick}>...</button>

两种写法的差别不只是语法:原生写法是「命令式地去某个节点上挂监听」,你得自己记得在哪里挂、何时摘;React 的写法是「声明这个元素有这个行为」,绑定和清理都由 React 在渲染时自动完成。这又是第 1 篇那句 UI = f(state) 的延伸——事件也是界面描述的一部分

看起来只是语法糖,但背后有个重要区别——React 用的不是浏览器原生事件对象,而是「合成事件(SyntheticEvent)」

合成事件:跨浏览器 + 事件委托

你给每个按钮都写了 onClick,但 React 并不会真的给每个按钮都绑定一个原生监听器。它利用了浏览器的事件冒泡机制,把监听器统一挂到 React 应用的根容器上,用「事件委托」统一分发:

根容器(唯一监听) 按钮 A 按钮 B 按钮 C 事件冒泡到根容器,React 按「谁被点的」分发到对应 handler
图 1事件冒泡到根容器后,由 React 统一调度,再派发给实际的 onClick 处理函数。

这样做的好处:

  • 性能更好:几百个按钮也只有一个真实监听器;
  • 跨浏览器:React 抹平了各浏览器事件对象的差异;
  • 自动清理:组件卸载时不用手动 removeEventListener,避免内存泄漏。
后端类比:从「每个类自己处理」到「DispatcherServlet 统一分发」

你写过 Servlet 就知道:早期一个 URL 一个 Servlet,后来 Spring MVC 用 DispatcherServlet 统一拦截所有请求,再按注解分发给具体的 @Controller。React 的合成事件就是前端版的 DispatcherServlet:入口唯一、按「目标」路由、业务函数只管自己的事。

一段历史包袱,知道即可:React 16 及更早,合成事件对象是「池化复用」的——事件处理一结束,对象属性就被清空回收,想在 setTimeout 里异步访问得先调 e.persist()React 17 起彻底废除了事件池,事件对象就是普通对象,同步异步随便用,不用再记这些暗坑。

事件对象与最常用的一批事件

处理函数可以接收一个参数——事件对象 e,里面装着这次交互的全部信息:

事件对象里有什么
function handleChange(e) {
  console.log(e.target.value);      // 触发事件的元素(输入框 → 当前输入值)
  console.log(e.type);              // 事件类型,如 "change"
  e.preventDefault();               // 阻止浏览器默认行为
  e.stopPropagation();              // 阻止事件继续冒泡
}

日常开发出场率最高的事件,整理成一张表:

属性触发时机典型用途
onClick点击(含键盘回车触发)按钮、链接、任何可点区域
onChange输入框内容变化 / 单选多选切换受控输入、表单联动
onSubmit表单提交登录、搜索、下单
onKeyDown键盘按键按下回车提交、快捷键、方向键
onFocus / onBlur聚焦 / 失焦校验提示、埋点
onScroll滚动回到顶部按钮、懒加载(注意节流)

表单提交:preventDefault 的主场

最常用 preventDefault 的场景就是表单。浏览器对 <form> 提交的默认行为是「整页刷新发请求」——这正是单页应用最不想要的:

LoginForm.tsx · 拦住整页刷新,自己接管提交
import { useState } from "react";

function LoginForm({ onLogin }) {
  const [name, setName] = useState("");

  function handleSubmit(e) {
    e.preventDefault();          // ← 拦住浏览器的整页刷新
    if (!name.trim()) return;    // 参数校验(对应后端 @Valid)
    onLogin(name);               // 剩下的交给父组件 / 请求层
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button type="submit">登录</button>
    </form>
  );
}

两个小习惯值得养成:能用 onSubmit 就别用按钮的 onClick 提交表单——这样回车键提交、无障碍支持全都自动有了;输入框记得写 value + onChange,这就是「受控组件」,第 24 篇会正式展开。

冒泡与 stopPropagation

事件默认会从目标元素一路「冒泡」到根。有时这不是你想要的——典型场景:整行可点击的列表里嵌了一个「删除」按钮,点删除不该触发整行的点击:

阻止冒泡
<li onClick={() => openDetail(item.id)}>
  {item.name}
  <button onClick={(e) => {
    e.stopPropagation();          // 事件到此为止,不再冒泡到 li
    remove(item.id);
  }}>删除</button>
</li>

函数组件没有 this,但有个「闭包」

老 React(类组件)里,事件处理最大的坑是 this 指向问题,需要 .bind(this) 或箭头函数。而现在的函数组件彻底没有 this,你直接写函数即可——但这引出了一个「新的坑」:闭包(第 2 卷第 10 篇细讲)。这里先扫一眼。

需要传参时:用箭头函数包装

传参的几种写法
function List({ users }) {
  function del(id) { console.log("删除", id); }

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {/* ❌ 错误:渲染时立刻执行了 del(id),而不是点击时 */}
          <button onClick={del(user.id)}>删</button>

          {/* ✅ 正确:返回一个新函数,点击时才调用 */}
          <button onClick={() => del(user.id)}>删</button>
        </li>
      ))}
    </ul>
  );
}

记一条铁律:onClick 需要的是一个「函数」,而不是「函数的调用结果」。所以要么传函数引用 onClick={handleClick},要么用箭头函数包一层 onClick={() => handleClick(id)}。用 Java 类比:前者是传 Runnable,后者是传 Runnablerun() 返回值——签名都不对。

核心要点
  • React 事件是合成事件,靠事件委托统一分发——类比 DispatcherServlet。
  • 事件处理器传函数引用,需要参数就用箭头函数包装,别写 onClick={fn()}
  • 表单用 onSubmit + e.preventDefault();嵌套点击用 stopPropagation()
  • 函数组件无 this,但要注意闭包读取旧 state 的问题(第 2 卷展开)。

这一篇的关键动作

本章回顾

事件处理本身不难,难的是理解「合成事件 + 事件委托」这层封装。它解释了为什么 React 能优雅地管理几百个事件的绑定和清理——把重复、易错的底层细节,交给框架统一收口,这正是 React 一贯的设计哲学。

到此为止,「用户操作 → 改 state → 界面更新」这条完整链路你已经全部打通。下一篇我们把链路跑在一个真实的小项目上:手写一个待办清单,把第一章全部知识点串成闭环。

Comments · 评论