REACT · Vol.I · LESSON 06 · 核心原语
事件处理与合成事件系统
一个 onClick 就够了
React 里处理用户交互极其简单——给元素加一个 onXxx 属性,传一个函数:
function EventDemo() {
function handleClick() {
alert("点到了!");
}
return (
<button onClick={handleClick}>点我</button>
);
}
对比原生 JS 的事件绑定:
// 原生:addEventListener
btn.addEventListener("click", handleClick);
// React:直接在 JSX 上声明
<button onClick={handleClick}>...</button>
两种写法的差别不只是语法:原生写法是「命令式地去某个节点上挂监听」,你得自己记得在哪里挂、何时摘;React 的写法是「声明这个元素有这个行为」,绑定和清理都由 React 在渲染时自动完成。这又是第 1 篇那句 UI = f(state) 的延伸——事件也是界面描述的一部分。
看起来只是语法糖,但背后有个重要区别——React 用的不是浏览器原生事件对象,而是「合成事件(SyntheticEvent)」。
合成事件:跨浏览器 + 事件委托
你给每个按钮都写了 onClick,但 React 并不会真的给每个按钮都绑定一个原生监听器。它利用了浏览器的事件冒泡机制,把监听器统一挂到 React 应用的根容器上,用「事件委托」统一分发:
这样做的好处:
- 性能更好:几百个按钮也只有一个真实监听器;
- 跨浏览器:React 抹平了各浏览器事件对象的差异;
- 自动清理:组件卸载时不用手动
removeEventListener,避免内存泄漏。
你写过 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> 提交的默认行为是「整页刷新发请求」——这正是单页应用最不想要的:
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,后者是传 Runnable 的 run() 返回值——签名都不对。
- React 事件是合成事件,靠事件委托统一分发——类比 DispatcherServlet。
- 事件处理器传函数引用,需要参数就用箭头函数包装,别写
onClick={fn()}。 - 表单用
onSubmit+e.preventDefault();嵌套点击用stopPropagation()。 - 函数组件无
this,但要注意闭包读取旧 state 的问题(第 2 卷展开)。
这一篇的关键动作
本章回顾
事件处理本身不难,难的是理解「合成事件 + 事件委托」这层封装。它解释了为什么 React 能优雅地管理几百个事件的绑定和清理——把重复、易错的底层细节,交给框架统一收口,这正是 React 一贯的设计哲学。
到此为止,「用户操作 → 改 state → 界面更新」这条完整链路你已经全部打通。下一篇我们把链路跑在一个真实的小项目上:手写一个待办清单,把第一章全部知识点串成闭环。
Comments · 评论