目 录CONTENT

文章目录

React(九)事件

React(九)绑定事件


一、前置导读

1.1 本章节教学目标

学完本章后,你将能够:

序号 能力目标 具体表现
1 理解本质 说清楚"React 合成事件"和"原生 DOM 事件"到底有什么区别
2 掌握写法 在函数组件和类组件中都能正确绑定事件,处理 this 指向问题
3 处理传参 给事件处理函数传递自定义参数,且不会写出 bug
4 识别坑点 一眼看出"事件提前执行""this 丢失""重复渲染"等常见错误
5 性能优化 使用 useCallback 减少不必要的事件处理函数重建
6 跟进新特性 了解 React 19 事件系统的新变化,写兼容新旧版本的代码

打比方:在 React 里绑定事件,就像在餐厅里安排服务员——每个按钮(DOM 元素)上都分配了一个服务员(事件处理函数),客人点击按钮时,服务员就要做出响应。本章教你如何精准地"安排服务员",不出乱子。


二、核心定义

2.1 原生 DOM 事件 vs React 合成事件

类比理解

想象你在一个国际化公司工作:

  • 原生 DOM 事件 就像给每个国家的分公司分别发通知——用法因浏览器而异,你要记住在 Chrome 怎么写、在 Firefox 怎么写、在 Safari 又怎么写。
  • React 合成事件(SyntheticEvent) 就像公司统一发布一份内部公告——React 帮你把不同浏览器的差异"翻译"成统一的格式,你只需要学一套写法就能在所有浏览器上正常工作。

本质区别

对比维度 原生 DOM 事件 React 合成事件
绑定方式 element.addEventListener('click', handler) JSX 中写 onClick={handler}
事件对象 浏览器原生的 MouseEvent / KeyboardEvent React 封装的 SyntheticEvent,内部包装了原生事件
跨浏览器兼容 需要自己处理 IE、Chrome、Firefox 的差异 React 统一处理,一套代码通吃
命名规则 全小写:clickkeydownmouseenter 驼峰命名:onClickonKeyDownonMouseEnter
绑定目标 绑定到具体的 DOM 元素上 React 17+ 绑定到根节点(#root),通过事件委托处理
事件池 不存在池化概念 React 16 有事件池,React 17+ 已移除

代码直观对比

// ========== 原生 DOM 写法 ==========
const btn = document.getElementById("myBtn");
btn.addEventListener("click", function (e) {
  console.log("按钮被点击了", e.target);
});

// ========== React 写法 ==========
function MyButton() {
  const handleClick = (e) => {
    console.log("按钮被点击了", e.target);
  };
  return <button onClick={handleClick}>点我</button>;
}

关键记忆点:React 中的事件名都是驼峰命名(小驼峰),而且你传的是一个函数引用 {handleClick},不是字符串 "handleClick()"


2.2 React 事件冒泡与捕获流程

生活中的类比

把事件冒泡想象成"向领导汇报":

  1. 捕获阶段(Capture Phase):总经理 → 部门经理 → 小组长 → 你(事件从最外层一路向下传递到目标元素)
  2. 目标阶段(Target Phase):事件到达你这里(实际触发事件的元素)
  3. 冒泡阶段(Bubble Phase):你 → 小组长 → 部门经理 → 总经理(事件从目标元素一路向上冒泡到最外层)

React 中的事件传播流程图(文字版)

点击 <button> 元素时的完整事件流:

  捕获阶段(从上往下)             冒泡阶段(从下往上)
  ┌─────────────┐                 ┌─────────────┐
  │  document   │  ──────────→   │  document   │
  │    ↓        │                 │    ↑        │
  │  <div>      │                 │  <div>      │
  │    ↓        │                 │    ↑        │
  │  <form>     │                 │  <form>     │
  │    ↓        │                 │    ↑        │
  │ <button> ★  │  ← 目标阶段 →   │ <button> ★  │
  └─────────────┘                 └─────────────┘

  React 默认在冒泡阶段处理事件(onClick)
  如需在捕获阶段处理,使用 onClickCapture

代码验证冒泡与捕获

function EventFlowDemo() {
  return (
    <div
      onClick={() => console.log("DIV - 冒泡阶段")}
      onClickCapture={() => console.log("DIV - 捕获阶段")}
      style={{ padding: 40, background: "#e8f5e9" }}
    >
      <button
        onClick={() => console.log("BUTTON - 冒泡阶段")}
        onClickCapture={() => console.log("BUTTON - 捕获阶段")}
      >
        点击我观察控制台
      </button>
    </div>
  );
}

// 点击按钮后控制台输出顺序:
// 1. "DIV - 捕获阶段"      ← 先从外向内捕获
// 2. "BUTTON - 捕获阶段"
// 3. "BUTTON - 冒泡阶段"   ← 再从内向外冒泡
// 4. "DIV - 冒泡阶段"

记忆口诀:"先捕获,后冒泡;外到内是捕获,内到外是冒泡。React 默认走冒泡,加个 Capture 走捕获。"


2.3 合成事件池机制的变更

什么是事件池?

在 React 16 及更早版本中,React 为了节省内存,会把合成事件对象放入一个"池子"里循环使用。事件处理函数执行完后,事件对象的所有属性会被清空(设为 null),以便下一次事件复用。

// ❌ React 16 中的经典 bug
function handleClick(e) {
  setTimeout(() => {
    console.log(e.target); // React 16:null!事件已被回收
  }, 1000);
}

// ✅ React 16 中需要用 e.persist() 保留事件
function handleClick(e) {
  e.persist(); // 从事件池中取出,不再回收
  setTimeout(() => {
    console.log(e.target); // 正常获取
  }, 1000);
}

React 19 中的变化

React 17 开始就逐步废弃了事件池,React 19 中已彻底移除。 这意味着:

  • 不再需要 e.persist()
  • 在异步代码(setTimeout、Promise、async/await)中访问事件属性完全安全
  • 代码更简洁,不再有"事件属性神秘消失"的 bug
// ✅ React 19:直接写,完全没问题
function handleClick(e) {
  setTimeout(() => {
    console.log(e.target); // 正常获取,不需要 e.persist()
  }, 1000);
}

2.4 React 支持的事件类型一览

React 的合成事件覆盖了几乎所有常见的浏览器事件,以下是完整的分类对照表:

鼠标事件

事件名 触发时机 常用场景
onClick 鼠标点击(按下+释放) 按钮点击、链接跳转
onDoubleClick 鼠标双击 表格行双击编辑
onMouseDown 鼠标按下(未释放) 拖拽开始
onMouseUp 鼠标释放 拖拽结束
onMouseEnter 鼠标进入元素(不冒泡) hover 高亮
onMouseLeave 鼠标离开元素(不冒泡) hover 还原
onMouseOver 鼠标进入元素(会冒泡) 经过检测
onMouseOut 鼠标离开元素(会冒泡) 离开检测
onMouseMove 鼠标在元素内移动 鼠标跟随效果
onContextMenu 鼠标右键点击 自定义右键菜单

键盘事件

事件名 触发时机 常用场景
onKeyDown 键盘按键按下 快捷键监听、游戏操作
onKeyUp 键盘按键释放 输入完成检测
onKeyPress 字符键被按下(已废弃) 不推荐使用

表单事件

事件名 触发时机 常用场景
onChange 表单元素值发生变化 实时输入监控、受控组件
onInput 输入框内容即时变化 输入实时搜索
onSubmit 表单提交 登录、注册表单
onFocus 元素获得焦点 输入框高亮
onBlur 元素失去焦点 表单校验
onSelect 选中文本 文本选中操作
onReset 表单重置 清空表单

触摸事件(移动端)

事件名 触发时机 常用场景
onTouchStart 手指触摸屏幕 触摸反馈
onTouchMove 手指在屏幕上移动 滑动操作
onTouchEnd 手指离开屏幕 滑动结束
onTouchCancel 触摸被中断 异常处理

Pointer 事件(React 19 重点增强)

事件名 触发时机 常用场景
onPointerDown 指针按下(鼠标/触笔/手指) 统一按下逻辑
onPointerMove 指针移动 统一拖拽
onPointerUp 指针释放 统一释放逻辑
onPointerEnter 指针进入 统一 hover
onPointerLeave 指针离开 统一离开
onPointerCancel 指针操作取消 异常还原

其他常用事件

事件名 触发时机 常用场景
onScroll 元素滚动 无限滚动加载、吸顶效果
onWheel 鼠标滚轮 缩放、翻页
onCopy / onPaste / onCut 剪贴板操作 复制粘贴控制
onLoad 资源加载完成 图片加载完成回调
onError 资源加载失败 图片加载失败替换
onAnimationEnd CSS 动画结束 动画完成后操作
onTransitionEnd CSS 过渡结束 过渡效果后续操作

速记技巧:React 事件名 = on + 原生事件名的驼峰形式。比如原生 mouseenter → React onMouseEnter,原生 pointerdown → React onPointerDown


三、基础用法

3.1 基础内联箭头函数绑定

这是最简洁的写法,适合逻辑简单、一句就能写完的场景。

// 示例1:最简单的事件绑定
function SimpleClick() {
  return <button onClick={() => alert("你好,React!")}>点我打招呼</button>;
}

// 逐行注释:
// 第1行:定义函数组件 SimpleClick
// 第2-6行:返回 JSX
// 第3行:onClick 是 React 的点击事件属性
// 第3行:{() => alert('你好,React!')} 是一个箭头函数,点击时执行
// 执行效果:点击按钮后弹出浏览器原生弹窗 "你好,React!"
// 示例2:内联函数操作状态
import { useState } from "react";

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

  return (
    <div>
      <p>当前计数:{count}</p>
      {/* 内联箭头函数直接调用 setCount */}
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => setCount(count - 1)}>-1</button>
      <button onClick={() => setCount(0)}>重置</button>
    </div>
  );
}

// 执行效果:
// 点击 "+1":页面数字递增 1
// 点击 "-1":页面数字递减 1
// 点击 "重置":页面数字归零

适用场景:逻辑只有一行代码、不需要复用的事件处理。

注意:如果事件处理逻辑超过 2-3 行,建议提取成独立函数(见下节),否则 JSX 会变得杂乱。


3.2 类组件中 constructor 绑定 this

这是 React 类组件中经典的"三件套"写法。

import React from "react";

class ToggleButton extends React.Component {
  // 第1步:在 constructor 中初始化 state
  constructor(props) {
    super(props); // 必须先调用 super
    this.state = {
      // 初始化状态
      isOn: false,
    };
    // 第2步:在 constructor 中绑定 this
    // 这行是关键!把 handleToggle 中的 this 永久绑定到当前组件实例
    this.handleToggle = this.handleToggle.bind(this);
  }

  // 第3步:定义事件处理方法(普通方法)
  handleToggle() {
    // 这里的 this 已经通过 bind 绑定,所以可以正常访问 this.setState
    this.setState((prevState) => ({
      isOn: !prevState.isOn,
    }));
  }

  render() {
    return (
      <div>
        <p>开关状态:{this.state.isOn ? "开" : "关"}</p>
        {/* 使用已绑定好 this 的方法 */}
        <button onClick={this.handleToggle}>切换</button>
      </div>
    );
  }
}

// 逐行注释:
// 第4行:定义 constructor,接收 props 参数
// 第5行:super(props) 是 ES6 class 的强制要求,调用父类构造函数
// 第6-8行:this.state 初始化组件状态,isOn 默认为 false
// 第11行:关键!用 .bind(this) 将方法中的 this 绑定到当前组件实例
// 第15行:定义事件处理方法,这是一个普通函数(不是箭头函数)
// 第16行:this.setState 更新状态,prevState 是上一次的状态值
// 第23行:在 JSX 中直接传入已绑定的方法引用,不加括号

// 执行效果:每点击一次按钮,开关状态在"开"和"关"之间切换

为什么需要 bind(this)

// 问题根源:JavaScript 中的 this 是"运行时绑定"的
// 当你写 onClick={this.handleToggle} 时,React 内部大致是这样调用的:

// var callback = this.handleToggle;  // 把方法赋值给变量
// callback();                        // 作为普通函数调用

// 这时 callback 内部的 this 不是组件实例,而是 undefined(严格模式)
// 所以 this.setState 会报错:Cannot read property 'setState' of undefined

// .bind(this) 的作用:创建一个新函数,这个新函数内部的 this 永远指向传入的值
// this.handleToggle = this.handleToggle.bind(this);
// 相当于说:"不管你之后怎么调用我,我的 this 始终是当前组件实例"

3.3 类组件中类属性箭头函数绑定

这是类组件中最推荐的写法,简洁且不需要手动 bind。

import React from "react";

class ToggleButton extends React.Component {
  // state 可以直接用类属性初始化(无需 constructor)
  state = {
    isOn: false,
  };

  // 用箭头函数定义事件处理方法(类属性语法)
  // 箭头函数没有自己的 this,它会自动"捕获"外层作用域的 this
  // 这里的外层作用域就是 class 定义本身,this 就是组件实例
  handleToggle = () => {
    this.setState((prevState) => ({
      isOn: !prevState.isOn,
    }));
  };

  // 如果需要传参,也超级简单
  handleCustomToggle = (newStatus) => {
    this.setState({ isOn: newStatus });
  };

  render() {
    return (
      <div>
        <p>开关状态:{this.state.isOn ? "开" : "关"}</p>
        {/* 直接使用,不需要 bind! */}
        <button onClick={this.handleToggle}>切换</button>

        {/* 传参时用箭头函数包装一层 */}
        <button onClick={() => this.handleCustomToggle(true)}>强制打开</button>
        <button onClick={() => this.handleCustomToggle(false)}>强制关闭</button>
      </div>
    );
  }
}

// 逐行注释:
// 第6行:state 直接用类属性赋值,不需要 constructor
// 第12行:handleToggle 使用箭头函数(类属性语法)
//         箭头函数自动绑定定义时的 this,即组件实例
// 第17行:需要传参时,用箭头函数包装一层
//         注意写法是 () => this.method(args),不是 this.method(args)

// 为什么推荐这种方式?
// 1. 不需要 constructor,不需要 .bind(this)
// 2. 代码更简洁清晰
// 3. 不容易忘记绑定 this

// 执行效果:点击"切换"在开关状态间切换,
//           点击"强制打开"/"强制关闭"直接设置指定状态

类属性箭头函数 vs constructor 绑定对比

对比维度 constructor + bind 类属性箭头函数
代码量 较多(需 constructor) 较少(直接定义)
忘记绑定的风险 高(容易遗忘) 无(自动绑定)
性能 略好(原型方法共享) 略差(每个实例一份)
可读性 一般

结论:绝大多数场景下,推荐使用类属性箭头函数写法。性能差异微乎其微,代码可维护性更重要。


3.4 函数组件中 useCallback 优化绑定

import { useState, useCallback } from "react";

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: "学习 React 事件", done: false },
    { id: 2, text: "做课后练习", done: false },
    { id: 3, text: "复习本章内容", done: false },
  ]);

  // ✅ 使用 useCallback 包裹事件处理函数
  // useCallback 会"记住"这个函数,只有依赖项变化时才重新创建
  // 依赖项是空数组 [] = 这个函数永远不会变
  const handleAdd = useCallback(() => {
    const newId = Math.max(...todos.map((t) => t.id), 0) + 1;
    setTodos((prev) => [...prev, { id: newId, text: "新任务", done: false }]);
  }, [todos]); // 依赖 todos,todos 变化时重新创建函数

  // ✅ 需要传参时,用 useCallback + 闭包的组合
  const handleToggle = useCallback((id) => {
    setTodos((prev) =>
      prev.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo,
      ),
    );
  }, []); // 依赖为空,因为用的是 setTodos 的函数式更新

  // ✅ 删除操作同理
  const handleDelete = useCallback((id) => {
    setTodos((prev) => prev.filter((todo) => todo.id !== id));
  }, []);

  return (
    <div>
      <h3>我的待办列表</h3>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <span
              style={{ textDecoration: todo.done ? "line-through" : "none" }}
            >
              {todo.text}
            </span>
            {/* 传参方式:用箭头函数包装 useCallback 返回的函数 */}
            <button onClick={() => handleToggle(todo.id)}>
              {todo.done ? "撤销" : "完成"}
            </button>
            <button onClick={() => handleDelete(todo.id)}>删除</button>
          </li>
        ))}
      </ul>
      <button onClick={handleAdd}>添加任务</button>
    </div>
  );
}

// 逐行注释:
// 第15行:useCallback 的第一个参数是要"记住"的函数
// 第18行:useCallback 的第二个参数是依赖数组
//         空数组 []  = 函数在组件生命周期内永不变化
//         有依赖 [todos] = todos 变化时才重新创建函数
// 第21行:需要传参的事件处理,先用 useCallback 包裹
// 第32行:在 JSX 中用箭头函数调用:() => handleToggle(todo.id)

// useCallback 的好处:
// 1. 函数引用稳定,避免子组件不必要的重渲染
// 2. 配合 React.memo 使用时效果显著
// 3. 作为 useEffect 等 Hook 的依赖时更安全

// 执行效果:可以添加、完成、删除待办事项,界面实时更新

useCallback 什么时候用?什么时候不用?

// ❌ 不需要 useCallback 的场景:简单组件、无子组件优化需求
function SimpleComponent() {
  const handleClick = () => {
    console.log("clicked");
  };
  return <button onClick={handleClick}>点我</button>;
}

// ✅ 需要使用 useCallback 的场景:
// 场景1:事件处理函数作为子组件的 props(子组件用了 React.memo)
// 场景2:事件处理函数是 useEffect 的依赖
// 场景3:事件处理函数创建成本很高

// 简单记忆:普通按钮点击不需要 useCallback,直接写就行

3.5 传参事件的 3 种合规方式

向事件处理函数传递额外参数是实际开发中的高频需求。

方式一:箭头函数包装(推荐,最常用)

function ItemList() {
  const handleDelete = (id, name, e) => {
    // e 是 React 的合成事件对象,始终作为最后一个参数
    console.log(`正在删除:${name}(ID: ${id})`);
    console.log("事件类型:", e.type);
  };

  const items = [
    { id: 1, name: "第一项" },
    { id: 2, name: "第二项" },
    { id: 3, name: "第三项" },
  ];

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>
          {item.name}
          {/* 方式一:箭头函数包装,显式传参 */}
          <button onClick={(e) => handleDelete(item.id, item.name, e)}>
            删除
          </button>
        </li>
      ))}
    </ul>
  );
}

// 执行效果:点击任意"删除"按钮后,控制台输出被删除项的 id 和 name

方式二:bind 传参

function ItemList() {
  const handleDelete = (id, name, e) => {
    // 注意:用 bind 传参时,事件对象 e 自动追加到参数列表末尾
    console.log(`删除 ID: ${id}, 名称: ${name}`);
  };

  return (
    <ul>
      <li>
        项目A
        {/* 方式二:用 .bind() 传参,事件对象自动追加 */}
        <button onClick={handleDelete.bind(null, 1, "项目A")}>删除</button>
      </li>
      <li>
        项目B
        <button onClick={handleDelete.bind(null, 2, "项目B")}>删除</button>
      </li>
    </ul>
  );
}

// 注意:bind 的第一个参数是 this 值(函数组件中通常传 null)
//       后续参数会依次传给函数,事件对象 e 自动追加到最后

方式三:data 属性 + 事件对象取值

function ItemList() {
  const handleDelete = (e) => {
    // 通过 e.currentTarget.dataset 获取自定义数据
    const id = e.currentTarget.dataset.id;
    const name = e.currentTarget.dataset.name;
    console.log(`删除 ID: ${id}, 名称: ${name}`);
  };

  return (
    <ul>
      <li>
        项目A
        {/* 方式三:在 DOM 上存数据,事件中读取 */}
        <button data-id="1" data-name="项目A" onClick={handleDelete}>
          删除
        </button>
      </li>
    </ul>
  );
}

错误传参写法对比

// ❌ 错误写法1:直接调用函数(会立即执行!)
<button onClick={handleDelete(item.id)}>删除</button>
// 问题:handleDelete(item.id) 在渲染时就执行了,不是点击时才执行
// 结果:页面加载时函数就执行,点击按钮反而什么都不发生

// ❌ 错误写法2:传参位置错误
<button onClick={handleDelete}>删除</button>
// 问题:缺少必要的 id 参数,函数内部拿不到 item.id
// 结果:handleDelete 收到的参数只有事件对象 e

// ❌ 错误写法3:箭头函数中忘记传事件对象
<button onClick={() => handleDelete(item.id)}>删除</button>
// 问题:某些场景下如果需要用到事件对象 e(如 e.preventDefault()),会丢失
// 结果:handleDelete 收不到事件对象

// ✅ 正确写法汇总
// 1. 箭头函数 + 显式传参(推荐)
<button onClick={(e) => handleDelete(item.id, e)}>删除</button>

// 2. bind 传参
<button onClick={handleDelete.bind(null, item.id)}>删除</button>

// 3. data 属性
<button data-id={item.id} onClick={handleDelete}>删除</button>

记忆口诀:JSX 事件里,花括号内必须是函数引用,不能是函数调用。需要传参就用箭头函数"包一层"。


四、进阶用法

4.1 Pointer Events 全量适配(React 19 重点特性)

Pointer Events 是 W3C 的现代标准,统一了鼠标、触笔、触摸等多种输入方式。React 19 对其提供了完整的支持。

为什么需要 Pointer Events?

// 旧方式:需要同时监听 mouse 和 touch 事件,代码冗长
// onMouseDown + onTouchStart
// onMouseMove + onTouchMove
// onMouseUp   + onTouchEnd

// 新方式:一套 Pointer Events 搞定所有输入设备
// onPointerDown  → 统一处理鼠标按下 / 手指触摸 / 触笔接触
// onPointerMove  → 统一处理鼠标移动 / 手指滑动 / 触笔移动
// onPointerUp    → 统一处理鼠标释放 / 手指抬起 / 触笔离开

实战示例:跨设备拖拽

import { useState, useCallback } from "react";

function DraggableBox() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isDragging, setIsDragging] = useState(false);
  // 记录拖拽起始位置
  const dragStart = useRef({ x: 0, y: 0, startX: 0, startY: 0 });

  // 使用 Pointer Events 替代 Mouse/Touch Events
  const handlePointerDown = useCallback(
    (e) => {
      // setPointerCapture:将后续的 pointer 事件都"锁定"到这个元素
      // 即使指针移出元素范围,事件也会继续触发
      e.currentTarget.setPointerCapture(e.pointerId);

      setIsDragging(true);
      dragStart.current = {
        x: e.clientX,
        y: e.clientY,
        startX: position.x,
        startY: position.y,
      };
    },
    [position],
  );

  const handlePointerMove = useCallback(
    (e) => {
      if (!isDragging) return;

      const deltaX = e.clientX - dragStart.current.x;
      const deltaY = e.clientY - dragStart.current.y;

      setPosition({
        x: dragStart.current.startX + deltaX,
        y: dragStart.current.startY + deltaY,
      });
    },
    [isDragging],
  );

  const handlePointerUp = useCallback(() => {
    setIsDragging(false);
  }, []);

  return (
    <div
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      style={{
        position: "relative",
        left: position.x,
        top: position.y,
        width: 100,
        height: 100,
        background: isDragging ? "#1976d2" : "#42a5f5",
        borderRadius: 8,
        cursor: isDragging ? "grabbing" : "grab",
        touchAction: "none", // 阻止浏览器默认的触摸滚动行为
        userSelect: "none",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        color: "#fff",
        fontWeight: "bold",
      }}
    >
      拖我
    </div>
  );
}

// 关键知识点:
// 1. setPointerCapture(pointerId):将 pointer 事件锁定到当前元素
//    解决了"拖拽太快导致鼠标移出元素,事件丢失"的问题
// 2. touchAction: 'none':阻止移动端的默认触摸行为(页面滚动等)
// 3. 通过 e.pointerType 可以判断当前输入设备类型:
//    'mouse' = 鼠标 | 'pen' = 触笔 | 'touch' = 手指

// 执行效果:可以用鼠标或手指(移动端)拖拽蓝色方块在页面上任意移动

Pointer Events 与传统事件的对应关系

// 写一次 Pointer Events,兼容所有设备
// 传统的 Mouse Events 和 Touch Events 在 React 19 中仍然可用
// 但推荐新项目统一使用 Pointer Events

function InputDemo() {
  const handlePointerDown = (e) => {
    // e.pointerType: 'mouse' | 'pen' | 'touch'
    console.log(`输入设备类型:${e.pointerType}`);
    // e.pressure: 0~1,压感值(触笔支持)
    console.log(`压力值:${e.pressure}`);
    // e.pointerId: 区分多点触摸时的手指
    console.log(`指针 ID:${e.pointerId}`);
  };

  return (
    <div
      onPointerDown={handlePointerDown}
      style={{ padding: 50, border: "2px solid #333", borderRadius: 8 }}
    >
      用鼠标 / 触笔 / 手指点我试试
    </div>
  );
}

4.2 事件委托的最佳实践

什么是事件委托?

类比:学校传达室统一收快递,然后根据收件人姓名分发给每个学生,而不是每个学生都在校门口等着自己的快递。

React 内部已经在根节点做了全局事件委托,但组件级别你仍然可以做"局部分发"来优化性能。

场景一:长列表的事件委托

import { useState, useCallback } from 'react';

function LongList() {
  const [items] = useState(() =>
    Array.from({ length: 1000 }, (_, i) => ({
      id: i + 1,
      name: `项目 ${i + 1}`,
    }))
  );
  const [selectedId, setSelectedId] = useState(null);

  // ✅ 事件委托:在父元素上绑定一个事件处理函数
  const handleListClick = useCallback((e) => {
    // 找到被点击的 li 元素
    const li = e.target.closest('li');
    if (!li) return; // 点击的不是 li,忽略

    // 从 DOM 上获取数据
    const id = Number(li.dataset.id);
    setSelectedId(id);
    console.log(`选中了项目 ${id}`);
  }, []);

  return (
    <div>
      <p>当前选中:{selectedId ?? '无'}</p>
      {/* 在 ul 上绑定一个事件,而不是每个 li 上绑一个 */}
      <ul onClick={handleListClick} style={{ maxHeight: 400, overflow: 'auto' }}>
        {items.map(item => (
          <li
            key={item.id}
            data-id={item.id}  {/* 数据存在 DOM 属性上 */}
            style={{
              padding: '8px 12px',
              background: selectedId === item.id ? '#bbdefb' : 'transparent',
              cursor: 'pointer',
              borderBottom: '1px solid #eee',
            }}
          >
            {item.name}
          </li>
        ))}
      </ul>
    </div>
  );
}

// 性能对比:
// ❌ 不委托:1000 个 li → 1000 个 onClick → 1000 个事件监听器
// ✅ 委托:1 个 ul + 1 个 onClick → 1 个事件监听器
// 内存节省:999 个事件监听器的内存!列表越长效果越明显

// 执行效果:点击任意列表项高亮选中,控制台输出选中信息

场景二:动态列表的事件委托

function DynamicList() {
  const [items, setItems] = useState([
    { id: 1, text: "第一项" },
    { id: 2, text: "第二项" },
  ]);

  // 事件委托:统一在 ul 上处理所有操作
  const handleAction = useCallback(
    (e) => {
      const action = e.target.dataset.action;
      const id = Number(e.target.closest("li").dataset.id);

      switch (action) {
        case "delete":
          setItems((prev) => prev.filter((item) => item.id !== id));
          break;
        case "edit":
          setItems((prev) =>
            prev.map((item) =>
              item.id === id
                ? { ...item, text: `${item.text}(已编辑)` }
                : item,
            ),
          );
          break;
        case "copy":
          const item = items.find((i) => i.id === id);
          if (item) {
            setItems((prev) => [
              ...prev,
              { id: Date.now(), text: `${item.text} 副本` },
            ]);
          }
          break;
        default:
          break;
      }
    },
    [items],
  );

  const handleAdd = () => {
    setItems((prev) => [
      ...prev,
      { id: Date.now(), text: `新项目 ${prev.length + 1}` },
    ]);
  };

  return (
    <div>
      {/* 所有操作委托到 ul 上 */}
      <ul onClick={handleAction}>
        {items.map((item) => (
          <li key={item.id} data-id={item.id}>
            <span>{item.text}</span>
            <button data-action="edit">编辑</button>
            <button data-action="copy">复制</button>
            <button data-action="delete">删除</button>
          </li>
        ))}
      </ul>
      <button onClick={handleAdd}>添加新项</button>
    </div>
  );
}

// 说明:即使 ul 内部的 button 在渲染之后(动态添加的),
//       事件委托也能正常工作,因为事件冒泡会到达 ul 的监听器。
//       这就是事件委托的核心优势:对新添加的元素也自动生效。

// 执行效果:可添加、编辑、复制、删除列表项,所有操作通过 ul 上的一个监听器处理

4.3 阻止默认行为与阻止冒泡

e.preventDefault() —— 阻止默认行为

类比:快递小哥默认会按门铃,但你告诉小哥"别按门铃,放门口就行"。

适用场景

  • 阻止 <a> 标签的页面跳转
  • 阻止表单的默认提交刷新页面
  • 阻止右键菜单弹出
  • 阻止空格键滚动页面
function PreventDefaultDemo() {
  // 场景1:阻止链接跳转(SAP单页应用中非常常见)
  const handleLinkClick = (e) => {
    e.preventDefault(); // 阻止浏览器的默认跳转行为
    console.log("链接被点击,但不会跳转");
    // 你可以用 React Router 做路由跳转
    // navigate('/target-page');
  };

  // 场景2:阻止表单默认提交
  const handleSubmit = (e) => {
    e.preventDefault(); // 阻止表单提交导致的页面刷新
    const formData = new FormData(e.target);
    console.log("表单数据:", Object.fromEntries(formData));
    // 用 fetch/axios 发异步请求
  };

  // 场景3:阻止右键菜单
  const handleContextMenu = (e) => {
    e.preventDefault();
    // 显示自定义右键菜单
    console.log("自定义右键菜单应在此处显示");
  };

  return (
    <div>
      <a href="https://example.com" onClick={handleLinkClick}>
        点我不会跳转
      </a>

      <form onSubmit={handleSubmit}>
        <input name="username" placeholder="用户名" />
        <button type="submit">提交(不会刷新页面)</button>
      </form>

      <div
        onContextMenu={handleContextMenu}
        style={{ background: "#f5f5f5", padding: 40, marginTop: 10 }}
      >
        在此区域内右键,不会弹出浏览器菜单
      </div>
    </div>
  );
}

// 执行效果:
// 1. 点击链接 → 页面不跳转,控制台输出日志
// 2. 提交表单 → 页面不刷新,控制台输出表单数据
// 3. 在灰色区域右键 → 不会弹出浏览器右键菜单

e.stopPropagation() —— 阻止事件冒泡

类比:小组长知道了一个消息,但他决定"到我为止,不向上汇报了"。

function StopPropagationDemo() {
  return (
    <div
      onClick={() => console.log("外层 DIV 被点击")}
      style={{
        padding: 40,
        background: "#fff3e0",
        border: "2px solid #ff9800",
      }}
    >
      <p>点击橙色边框内的任意位置</p>
      {/* 不阻止冒泡:点击会先输出 Button,再输出 DIV */}
      <button onClick={() => console.log("【普通按钮】被点击")}>
        普通按钮(会冒泡)
      </button>{" "}
      {/* 阻止冒泡:点击只输出 Button,不输出 DIV */}
      <button
        onClick={(e) => {
          e.stopPropagation(); // 阻止事件继续向上冒泡
          console.log("【阻止冒泡按钮】被点击");
        }}
      >
        阻止冒泡按钮(不冒泡)
      </button>
    </div>
  );
}

// 执行效果:
// 点击"普通按钮" → 控制台输出:
//   1. "【普通按钮】被点击"
//   2. "外层 DIV 被点击"
// 点击"阻止冒泡按钮" → 控制台只有:
//   1. "【阻止冒泡按钮】被点击"

preventDefaultstopPropagation 的区别

// 核心区别对比
function DifferenceDemo() {
  return (
    <div>
      {/* e.preventDefault():阻止浏览器的默认行为,不阻止冒泡 */}
      <div onClick={() => console.log("DIV 被点击")}>
        <a
          href="https://example.com"
          onClick={(e) => {
            e.preventDefault(); // 只阻止跳转,不阻止冒泡
          }}
        >
          点我:不会跳转,但会冒泡到 DIV
        </a>
      </div>

      {/* e.stopPropagation():阻止事件冒泡,不阻止默认行为 */}
      <div onClick={() => console.log("DIV 被点击")}>
        <a
          href="https://example.com"
          onClick={(e) => {
            e.stopPropagation(); // 只阻止冒泡,不阻止跳转
          }}
        >
          点我:会跳转,但不会冒泡到 DIV
        </a>
      </div>

      {/* 两者都要:既阻止默认行为,又阻止冒泡 */}
      <div onClick={() => console.log("DIV 被点击")}>
        <a
          href="https://example.com"
          onClick={(e) => {
            e.preventDefault(); // 阻止跳转
            e.stopPropagation(); // 阻止冒泡
          }}
        >
          点我:既不会跳转,也不会冒泡
        </a>
      </div>
    </div>
  );
}

// 记忆对照表
// ┌──────────────────────┬────────────────────┬──────────────────────┐
// │                      │  preventDefault()  │  stopPropagation()   │
// ├──────────────────────┼────────────────────┼──────────────────────┤
// │  阻止什么?           │  浏览器的默认行为    │  事件向上冒泡         │
// ├──────────────────────┼────────────────────┼──────────────────────┤
// │  举例                 │  链接跳转、表单提交  │  父元素也被触发       │
// ├──────────────────────┼────────────────────┼──────────────────────┤
// │  影响父元素监听器?    │  ❌ 不影响          │  ✅ 父元素收不到       │
// ├──────────────────────┼────────────────────┼──────────────────────┤
// │  影响子元素默认行为?  │  ✅ 阻止            │  ❌ 不影响            │
// └──────────────────────┴────────────────────┴──────────────────────┘

五、注意事项

5.1 this 绑定失效的 4 种常见场景与解决方案

场景一:类组件中忘记绑定 this

// ❌ 错误写法
class Counter extends React.Component {
  state = { count: 0 };

  handleClick() {
    // 报错!this 是 undefined
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    return <button onClick={this.handleClick}>+1</button>;
  }
}

// 错误原因:
// 当 JSX 中写 onClick={this.handleClick} 时,React 拿到的是方法的引用
// 调用时 this 上下文丢失,变成 undefined(ES6 class 严格模式)

// ✅ 解决方案1:constructor 中 bind
class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.handleClick = this.handleClick.bind(this); // 绑定 this
  }
  handleClick() {
    /* ... */
  }
}

// ✅ 解决方案2:类属性箭头函数
class Counter extends React.Component {
  state = { count: 0 };
  handleClick = () => {
    // 箭头函数自动绑定
    this.setState({ count: this.state.count + 1 });
  };
}

// ✅ 解决方案3:JSX 中箭头函数
class Counter extends React.Component {
  state = { count: 0 };
  handleClick() {
    /* ... */
  }
  render() {
    return <button onClick={() => this.handleClick()}>+1</button>;
  }
}

场景二:回调中将方法传给别的函数

// ❌ 错误写法
class Timer extends React.Component {
  handleTick() {
    console.log('tick');
  }
  componentDidMount() {
    // 这里 this 也会丢失!
    setInterval(this.handleTick, 1000);
  }
}

// ✅ 解决方案
componentDidMount() {
  setInterval(() => this.handleTick(), 1000);  // 箭头函数包装
}

场景三:解构赋值导致 this 丢失

// ❌ 错误写法
class Form extends React.Component {
  handleSubmit() {
    this.setState({ submitted: true });
  }
  render() {
    const { handleSubmit } = this; // 解构后 this 丢失!
    return <button onClick={handleSubmit}>提交</button>;
  }
}

// ✅ 解决方案:不要解构事件处理方法
// 或者用箭头函数定义方法

场景四:map 回调中的 this

// ❌ 错误写法
class List extends React.Component {
  handleItemClick(id) {
    console.log(id);
  }
  render() {
    return this.props.items.map(function(item) {
      // function 关键字定义的函数,this 指向全局 / undefined
      return <button onClick={() => this.handleItemClick(item.id)}>点我</button>;
    });
  }
}

// ✅ 解决方案:使用箭头函数作为 map 回调
render() {
  return this.props.items.map((item) => {
    // 箭头函数的 this 继承自外层(render 方法的作用域)
    return <button onClick={() => this.handleItemClick(item.id)}>点我</button>;
  });
}

5.2 合成事件与原生事件混用的冲突问题

// ❌ 危险写法:同时绑定合成事件和原生事件
function MixedEvents() {
  const divRef = useRef(null);

  useEffect(() => {
    const div = divRef.current;
    // 原生事件监听器
    div.addEventListener("click", () => {
      console.log("原生 click 事件");
    });
    return () => div.removeEventListener("click", () => {});
  }, []);

  return (
    <div ref={divRef} onClick={() => console.log("React 合成事件")}>
      点我看看输出顺序
    </div>
  );
}

// 问题:
// React 17+ 中,合成事件绑定在根节点(#root),而非具体 DOM 上
// 原生事件直接绑定在 DOM 上
// 导致执行顺序可能与预期不同:
//
// 点击后输出顺序:
// 1. "原生 click 事件"(直接绑定在 DOM 上,最先触发)
// 2. "React 合成事件"(通过根节点事件委托触发)

// ✅ 解决方案:只使用一种事件系统
// 推荐:在 React 中统一使用合成事件,不混用
// 如果必须使用原生事件(如监听 window/document),注意执行时序

// ✅ 正确的原生事件监听方式(仅限必要场景)
function ScrollListener() {
  useEffect(() => {
    const handleScroll = () => {
      console.log("页面滚动了", window.scrollY);
    };
    // 监听 window 上的原生事件(React 合成事件不覆盖的场景)
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
  }, []);

  return <div style={{ height: 2000 }}>往下滚动试试</div>;
}

原则:在 React 中能用合成事件就不要用原生事件。非要用原生事件时,确认你真的有这个需求。


5.3 事件处理函数中异步更新状态的取值陷阱

import { useState } from "react";

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

  // ❌ 错误写法:异步操作中使用"当前值"
  const handleClickWrong = () => {
    setCount(count + 1); // count = 0 → setCount(1)
    setCount(count + 1); // count 还是 0!→ setCount(1)  ← 注意!
    setCount(count + 1); // count 还是 0!→ setCount(1)  ← 注意!
    // 期望结果:3,实际结果:1
  };

  // ✅ 正确写法:使用函数式更新
  const handleClickCorrect = () => {
    setCount((prev) => prev + 1); // prev = 0 → 1
    setCount((prev) => prev + 1); // prev = 1 → 2
    setCount((prev) => prev + 1); // prev = 2 → 3
    // 结果:3 ✓
  };

  return (
    <div>
      <p>当前计数:{count}</p>
      <button onClick={handleClickWrong}>错误写法(期望+3,实际+1)</button>
      <button onClick={handleClickCorrect}>正确写法(+3)</button>
    </div>
  );
}

// 错误原因:
// 事件处理函数在一次渲染中"捕获"了当前的 count 值
// 即使调用了 setCount,当前函数作用域中的 count 不会改变
// React 会批量处理状态更新,但每次都用同一个"闭包中的旧值"

// 函数式更新 prev => prev + 1 则不同:
// React 保证 prev 始终是上次更新后的最新值

// 另外,在 setTimeout/Promise 中也容易出现同样的问题:
const handleAsyncClick = () => {
  setTimeout(() => {
    // ❌ count 是闭包中"过时的"值
    console.log("count:", count);
  }, 3000);
};

// ✅ 解决方案:用 useEffect 同步最新值 或 使用 ref

补充:使用 useEffect 追踪最新状态

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

  // 保持 ref 与 state 同步
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  const handleClick = () => {
    setCount((prev) => prev + 1);

    // ✅ 用 ref 在异步中读取最新值
    setTimeout(() => {
      console.log("3秒后的最新 count:", countRef.current);
    }, 3000);
  };

  return (
    <div>
      <p>count: {count}</p>
      <button onClick={handleClick}>+1(3秒后打印最新值)</button>
    </div>
  );
}

// 执行效果:
// 在3秒内可以连续点击多次,3秒后打印的是最新的 count 值,而非点击时的值

5.4 匿名函数、内联函数导致的不必要重渲染

import { useState, useCallback, memo } from "react";

// 使用 React.memo 的子组件:只有 props 变化时才重新渲染
const ExpensiveButton = memo(function ExpensiveButton({ onClick, label }) {
  console.log(`ExpensiveButton "${label}" 渲染了`);
  return <button onClick={onClick}>{label}</button>;
});

function Parent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");

  // ❌ 内联箭头函数:每次渲染都创建新函数
  // 导致 ExpensiveButton 即使 props 逻辑没变也会重渲染
  const badHandleClick = () => setCount((c) => c + 1);

  // ✅ useCallback:函数引用稳定,不会触发子组件重渲染
  const goodHandleClick = useCallback(() => {
    setCount((c) => c + 1);
  }, []); // 空依赖 → 函数永不变化

  return (
    <div>
      <p>count: {count}</p>

      {/* ❌ 坏:text 变化导致 Parent 渲染,ExpensiveButton 也跟着渲染 */}
      <ExpensiveButton onClick={badHandleClick} label="坏写法(会多余渲染)" />

      {/* ✅ 好:text 变化时 ExpensiveButton 不会渲染 */}
      <ExpensiveButton onClick={goodHandleClick} label="好写法(已优化)" />

      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="输入文字测试"
      />
    </div>
  );
}

// 演示流程:
// 1. 在输入框中打字 → text 变化 → Parent 重新渲染
// 2. badHandleClick 重新创建(新引用)→ ExpensiveButton props 变化 → 重渲染 ❌
// 3. goodHandleClick 是同一个引用 → ExpensiveButton props 没变 → 不渲染 ✓
// 这就是 useCallback + React.memo 组合拳的精髓

// 执行效果:打开控制台,在输入框中打字:
//   "坏写法"按钮的日志每次都出现(多余渲染)
//   "好写法"按钮的日志只在首次出现(已优化)

什么时候不需要优化?

// ✅ 以下情况不必优化,直接内联即可:
// 1. 组件很简单,渲染成本极低
// 2. 没有嵌套子组件
// 3. 事件处理函数不传给子组件

function SimpleButton() {
  const [count, setCount] = useState(0);
  // 这里不需要 useCallback,因为 onClick 没有传给子组件
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
// 过度优化反而增加代码复杂度!按需使用即可。

5.5 React 19 中移除 event.persist() 的适配说明

旧版本中的 persist 是干什么的?

在 React 16 及更早版本中,合成事件对象会被回收复用(事件池机制)。

// React 16 中的典型问题
function SearchInput() {
  const handleChange = (e) => {
    // ❌ React 16 中,异步访问事件会报错或返回 null
    setTimeout(() => {
      console.log(e.target.value); // 报错!事件已被回收
    }, 100);
  };
  return <input onChange={handleChange} />;
}

// React 16 中的解决方案
function SearchInput() {
  const handleChange = (e) => {
    e.persist(); // 从池中"取出"事件对象,阻止回收
    setTimeout(() => {
      console.log(e.target.value); // 正常获取
    }, 100);
  };
  return <input onChange={handleChange} />;
}

React 19 中的变化

// React 19:彻底不需要 e.persist() 了
function SearchInput() {
  const handleChange = (e) => {
    // ✅ 直接异步访问,完全安全
    setTimeout(() => {
      console.log(e.target.value); // 正常工作
    }, 1000);
  };
  return <input onChange={handleChange} />;
}

// 但注意:虽然不需要 persist,但更好的写法是"提前取值"
function SearchInputBetter() {
  const handleChange = (e) => {
    const value = e.target.value; // 先存起来
    setTimeout(() => {
      console.log(value); // 用存好的值,意图更清晰
    }, 1000);
  };
  return <input onChange={handleChange} />;
}

// 迁移建议:
// 1. 删除代码中所有的 e.persist() 调用
// 2. 事件对象在异步操作中可以直接使用
// 3. 不过最好养成"先存值再用"的习惯,代码更清晰

5.6 表单事件中 onChange 与 onInput 的区别

function InputEventDemo() {
  const [onChangeValue, setOnChangeValue] = useState("");
  const [onInputValue, setOnInputValue] = useState("");

  return (
    <div>
      {/* onChange:值确实变化 + 元素失去焦点时才触发(原生行为) */}
      {/* 但在 React 中,onChange 被"改造"为每次输入都触发 */}
      <div>
        <label>onChange:</label>
        <input
          value={onChangeValue}
          onChange={(e) => {
            setOnChangeValue(e.target.value);
            console.log("onChange 触发");
          }}
        />
        <span>→ {onChangeValue}</span>
      </div>

      {/* onInput:每次输入即时触发(原生行为) */}
      <div>
        <label>onInput:</label>
        <input
          value={onInputValue}
          onInput={(e) => {
            setOnInputValue(e.target.value);
            console.log("onInput 触发");
          }}
        />
        <span>→ {onInputValue}</span>
      </div>
    </div>
  );
}

// 在 React 中:
// onChange 和 onInput 的行为基本相同(每输入一个字符都触发)
// React 对 onChange 做了"语义增强",使其行为等同于原生 onInput
//
// 但在原生 DOM 中:
// onInput:每输入一个字符立即触发
// onChange:输入内容变化 + 元素失去焦点后才触发
//
// 推荐:React 表单统一使用 onChange,因为它是最常见的 React 写法
// onInput 只在需要与第三方非 React 库配合时使用

// ❌ 常见错误:受控组件中忘记绑定 onChange
function BrokenInput() {
  const [value, setValue] = useState("");
  return <input value={value} />;
  // 报错:You provided a `value` prop without an `onChange` handler
  // 原因:设置了 value(受控组件)就必须提供 onChange
}

// ✅ 正确写法
function WorkingInput() {
  const [value, setValue] = useState("");
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}

5.7 事件绑定中传参错误导致函数提前执行

这是新手最容易踩的坑。

function PassingArgsPitfall() {
  const [message, setMessage] = useState("");

  const showMessage = (text) => {
    setMessage(text);
    console.log("显示消息:", text);
  };

  return (
    <div>
      <p>当前消息:{message || "(无)"}</p>

      {/* ❌ 错误:函数在渲染时就执行了! */}
      <button onClick={showMessage("你好!")}>打招呼(错误写法)</button>

      {/* ✅ 正确:用箭头函数包装,点击时才执行 */}
      <button onClick={() => showMessage("你好!")}>打招呼(正确写法1)</button>

      {/* ✅ 正确:用 bind 包装 */}
      <button onClick={showMessage.bind(null, "你好!")}>
        打招呼(正确写法2)
      </button>
    </div>
  );
}

// 错误原因图解:
// onClick={showMessage('你好!')}
//         └────────┬──────┘
//              函数调用!
// 这不是传引用,而是直接调用了 showMessage 函数
// 调用发生在 JSX 解析时(即渲染阶段),而非点击时

// onClick={() => showMessage('你好!')}
//       └──────────┬──────────┘
//            函数引用!
// 这是一个箭头函数的引用,点击时才会调用内部的 showMessage

// 诊断方法:如果页面一加载就看到函数被执行了(弹窗/日志/状态变化),
//          基本可以确定是 onClick={fn()} 而非 onClick={fn} 的锅

六、教学辅助

6.1 课堂实操练习

练习一:基础绑定练习 —— 评论点赞按钮

题目描述:实现一个评论区,每条评论有一个点赞按钮,点击后点赞数 +1,再次点击取消点赞(-1)。

要求

  • 使用函数组件 + useState
  • 每条评论初始点赞数为 0
  • 点赞状态用不同的按钮颜色区分(已点赞蓝色,未点赞灰色)

参考答案

import { useState } from "react";

function CommentList() {
  const [comments, setComments] = useState([
    { id: 1, text: "这篇文章写得真好!", likes: 0, liked: false },
    { id: 2, text: "学习了,感谢分享!", likes: 0, liked: false },
    { id: 3, text: "请问有配套的视频教程吗?", likes: 0, liked: false },
  ]);

  const handleLike = (id) => {
    setComments((prev) =>
      prev.map((comment) => {
        if (comment.id !== id) return comment;
        return {
          ...comment,
          liked: !comment.liked,
          likes: comment.liked ? comment.likes - 1 : comment.likes + 1,
        };
      }),
    );
  };

  return (
    <div>
      <h3>评论区</h3>
      {comments.map((comment) => (
        <div
          key={comment.id}
          style={{
            border: "1px solid #ddd",
            padding: 12,
            marginBottom: 8,
            borderRadius: 8,
          }}
        >
          <p>{comment.text}</p>
          <button
            onClick={() => handleLike(comment.id)}
            style={{
              background: comment.liked ? "#1976d2" : "#e0e0e0",
              color: comment.liked ? "#fff" : "#333",
              border: "none",
              padding: "6px 16px",
              borderRadius: 4,
              cursor: "pointer",
            }}
          >
            👍 {comment.likes}
          </button>
        </div>
      ))}
    </div>
  );
}

// 执行效果:
// 每条评论下方有点赞按钮,点击数字 +1 且按钮变蓝,再点击 -1 且按钮变灰

练习二:传参事件练习 —— 多按钮操作面板

题目描述:创建一个商品管理面板,每个商品有"编辑""上架/下架""删除"三个操作按钮,要求使用事件委托方式实现。

要求

  • 使用事件委托(在父元素上统一绑定事件)
  • 按钮携带商品 ID 和操作类型参数
  • 编辑操作弹出商品名称
  • 上架/下架按钮文字动态切换
  • 删除按钮点击后移除商品

参考答案

import { useState, useCallback } from "react";

function ProductPanel() {
  const [products, setProducts] = useState([
    { id: 1, name: "React 从入门到精通", status: "上架" },
    { id: 2, name: "JavaScript 高级程序设计", status: "下架" },
    { id: 3, name: "CSS 世界", status: "上架" },
  ]);

  // 事件委托:在父 div 上统一处理
  const handleAction = useCallback(
    (e) => {
      const btn = e.target.closest("button");
      if (!btn) return; // 点击的不是按钮,忽略

      const action = btn.dataset.action;
      const id = Number(btn.dataset.id);

      switch (action) {
        case "edit":
          const product = products.find((p) => p.id === id);
          if (product) alert(`编辑商品:${product.name}`);
          break;

        case "toggle":
          setProducts((prev) =>
            prev.map((p) =>
              p.id === id
                ? { ...p, status: p.status === "上架" ? "下架" : "上架" }
                : p,
            ),
          );
          break;

        case "delete":
          setProducts((prev) => prev.filter((p) => p.id !== id));
          break;

        default:
          break;
      }
    },
    [products],
  );

  return (
    <div>
      <h3>商品管理面板</h3>
      <div onClick={handleAction}>
        {products.map((product) => (
          <div
            key={product.id}
            style={{
              border: "1px solid #e0e0e0",
              padding: 12,
              marginBottom: 8,
              borderRadius: 6,
              display: "flex",
              justifyContent: "space-between",
              alignItems: "center",
            }}
          >
            <span>
              {product.name}
              <span
                style={{
                  marginLeft: 8,
                  color: product.status === "上架" ? "#4caf50" : "#f44336",
                  fontSize: 12,
                }}
              >
                [{product.status}]
              </span>
            </span>
            <div>
              <button data-action="edit" data-id={product.id}>
                编辑
              </button>
              <button data-action="toggle" data-id={product.id}>
                {product.status === "上架" ? "下架" : "上架"}
              </button>
              <button data-action="delete" data-id={product.id}>
                删除
              </button>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

// 执行效果:
// 点击"编辑"弹出商品名称
// 点击"上架/下架"切换商品状态
// 点击"删除"移除商品
// 所有操作通过父 div 上的一个事件监听器处理(事件委托)

练习三:性能优化练习 —— 可拖拽排序列表

题目描述:实现一个待办列表,支持使用 Pointer Events 拖拽排序。

要求

  • 使用 Pointer Events(而非 Mouse Events)
  • 使用 useCallback 优化事件处理函数
  • 拖拽时有视觉反馈(被拖拽项半透明、插入位置有指示线)

参考答案

import { useState, useCallback, useRef } from "react";

function DraggableTodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: "学习 React 事件绑定" },
    { id: 2, text: "完成课后练习" },
    { id: 3, text: "阅读 React 19 更新日志" },
    { id: 4, text: "写项目实践代码" },
  ]);

  const dragItem = useRef(null); // 当前拖拽的项
  const dragOverItem = useRef(null); // 拖拽到的目标位置

  const handlePointerDown = useCallback((e, index) => {
    dragItem.current = index;
    e.currentTarget.style.opacity = "0.4";
    e.currentTarget.setPointerCapture(e.pointerId);
  }, []);

  const handlePointerUp = useCallback((e) => {
    e.currentTarget.style.opacity = "1";
    e.currentTarget.releasePointerCapture(e.pointerId);
    dragItem.current = null;
    dragOverItem.current = null;
  }, []);

  const handlePointerEnter = useCallback((e, index) => {
    dragOverItem.current = index;
    // 拖拽经过时高亮
    e.currentTarget.style.borderTop = "2px solid #1976d2";
  }, []);

  const handlePointerLeave = useCallback((e) => {
    e.currentTarget.style.borderTop = "none";
  }, []);

  const handlePointerOver = useCallback((e) => {
    e.preventDefault();
    if (
      dragItem.current !== null &&
      dragItem.current !== dragOverItem.current
    ) {
      setTodos((prev) => {
        const newTodos = [...prev];
        const draggedItem = newTodos.splice(dragItem.current, 1)[0];
        newTodos.splice(dragOverItem.current, 0, draggedItem);
        dragItem.current = dragOverItem.current;
        return newTodos;
      });
    }
  }, []);

  return (
    <div>
      <h3>待办列表(拖拽排序)</h3>
      <p style={{ color: "#666", fontSize: 14 }}>
        按住某一项拖动到新位置来排序
      </p>
      {todos.map((todo, index) => (
        <div
          key={todo.id}
          onPointerDown={(e) => handlePointerDown(e, index)}
          onPointerUp={handlePointerUp}
          onPointerEnter={(e) => handlePointerEnter(e, index)}
          onPointerLeave={handlePointerLeave}
          onPointerOver={handlePointerOver}
          style={{
            padding: "12px 16px",
            background: "#f5f5f5",
            marginBottom: 4,
            borderRadius: 4,
            cursor: "grab",
            userSelect: "none",
            touchAction: "none",
            borderTop: "2px solid transparent",
          }}
        >
          {todo.text}
        </div>
      ))}
    </div>
  );
}

// 执行效果:
// 拖拽任意待办项到新位置,列表自动重新排序
// 拖拽中的项半透明,目标位置有蓝色指示线

6.2 常见问题 FAQ

Q1:为什么我的事件处理函数在页面加载时就执行了?

:99% 的情况是因为你在 JSX 中写了 onClick={handleClick()} 而不是 onClick={handleClick}。前者是函数调用,渲染时就执行;后者是函数引用,点击时才执行。需要传参时用 onClick={() => handleClick(param)}


Q2:类组件中什么时候用 bind(this),什么时候不用?

:如果你的事件处理方法是普通函数(handleClick() {}),就必须绑:在 constructor 中 bind(this) 或 JSX 中用箭头函数包装。如果你用的是类属性箭头函数(handleClick = () => {}),箭头函数自动绑定 this,不需要 bind。


Q3:useCallback 到底什么时候用?是不是所有事件处理函数都应该包一层?

:不是!滥用 useCallback 反而可能降低性能。仅在以下场景使用:

  1. 函数作为 prop 传递给用了 React.memo 的子组件
  2. 函数是 useEffect / useMemo 的依赖项
  3. 函数创建成本真的很高

普通按钮的 onClick 直接写内联函数就行,不要过度优化。


Q4:为什么 React 中 onChange 的行为和原生 DOM 不一样?

:React 对 onChange 做了"语义增强"。在原生 DOM 中,onChange 需要输入框失焦才触发;但在 React 中,onChange 每输入一个字符就触发(等同于原生 onInput)。这是 React 团队为了和"受控组件"模式保持一致而做的设计决策。


Q5:e.preventDefault()e.stopPropagation() 有什么区别?

  • e.preventDefault():阻止浏览器的默认行为(如链接跳转、表单提交),但不阻止事件冒泡到父元素
  • e.stopPropagation():阻止事件向上冒泡到父元素,但不阻止浏览器的默认行为
  • 两者互不影响,可以同时使用

Q6:React 19 中还需要 e.persist() 吗?

:不需要。事件池机制在 React 17 开始已被移除,React 19 中彻底不存在。你在异步代码(setTimeout、Promise)中直接访问事件属性完全安全。


Q7:为什么我连续调用多次 setState,只有最后一次生效?

:因为你在事件处理函数中直接用了 state 变量而非函数式更新。例如 setCount(count + 1)count 是闭包中的旧值。应该改为 setCount(prev => prev + 1),这样可以保证每次拿到的都是最新值。


Q8:事件委托是什么?React 已经帮我做了吗?

:React 内部已经在根节点做了全局事件委托(React 17+ 绑定在 #root 上)。但你仍然可以在组件级别做局部的"事件委托"来优化性能——比如在一个 ul 上绑定一个事件而不是在每个 li 上各绑一个,减少事件处理函数的数量。


Q9:什么情况下事件处理函数中的 this 会是 undefined?

:在类组件中,当你把一个普通方法直接作为事件处理函数传出时(onClick={this.handleClick}),React 内部调用该方法时 this 上下文会丢失。因为 ES6 class 默认使用严格模式,所以 this 是 undefined 而不是 window。解决方案是用箭头函数(自动绑定)或在 constructor 中手动 bind(this)


Q10:合成事件和原生事件可以混用吗?会有什么问题?

:技术上可以混用,但强烈不推荐。因为 React 17+ 将合成事件统一委托到根节点,而原生事件直接绑定在 DOM 上,两者的执行顺序不同,容易产生难以调试的 bug。在 React 项目中统一使用合成事件即可满足 95% 的需求。确实需要原生事件的场景(如 window.addEventListener('scroll'))在 useEffect 中处理,并记得在 cleanup 中移除。


6.3 知识点思维导图(文字版)

React 19 事件绑定 —— 知识体系总览
│
├── 一、核心概念
│   ├── 合成事件(SyntheticEvent)
│   │   ├── 定义:React 封装的跨浏览器事件对象
│   │   ├── 优势:统一 API、性能优化、跨浏览器兼容
│   │   └── 命名规则:驼峰命名(onClick、onChange、onKeyDown)
│   │
│   ├── 事件传播
│   │   ├── 捕获阶段(Capture Phase):外 → 内
│   │   ├── 目标阶段(Target Phase):到达目标元素
│   │   └── 冒泡阶段(Bubble Phase):内 → 外(React 默认)
│   │
│   ├── 事件池(已移除)
│   │   ├── React 16:事件对象被回收,需 e.persist()
│   │   ├── React 17+:逐步移除事件池
│   │   └── React 19:彻底移除,无需 e.persist()
│   │
│   └── 事件类型分类
│       ├── 鼠标事件:onClick、onDoubleClick、onMouseEnter...
│       ├── 键盘事件:onKeyDown、onKeyUp
│       ├── 表单事件:onChange、onSubmit、onFocus、onBlur
│       ├── 触摸事件:onTouchStart、onTouchMove、onTouchEnd
│       ├── Pointer 事件:onPointerDown、onPointerMove、onPointerUp(React 19 增强)
│       └── 其他:onScroll、onWheel、onCopy、onLoad、onError
│
├── 二、基础用法
│   ├── 函数组件
│   │   ├── 内联箭头函数:onClick={() => doSomething()}
│   │   ├── 独立函数引用:onClick={handleClick}
│   │   └── useCallback 优化:const fn = useCallback(() => {}, [deps])
│   │
│   ├── 类组件
│   │   ├── constructor + bind:this.fn = this.fn.bind(this)
│   │   └── 类属性箭头函数:fn = () => { this.setState(...) }
│   │
│   └── 传参方式
│       ├── 箭头函数包装:onClick={(e) => fn(id, e)}
│       ├── bind 传参:onClick={fn.bind(null, id)}
│       └── data 属性:onClick={fn} + e.target.dataset.id
│
├── 三、进阶用法
│   ├── Pointer Events(React 19 重点)
│   │   ├── 统一鼠标/触笔/触摸
│   │   ├── setPointerCapture:锁定指针
│   │   └── pointerType 区分设备类型
│   │
│   ├── 事件委托
│   │   ├── 原理:利用冒泡,在父元素统一处理
│   │   ├── 场景:长列表、动态列表
│   │   └── 实现:e.target.closest + dataset
│   │
│   └── 阻止行为
│       ├── e.preventDefault():阻止默认行为(跳转/提交)
│       └── e.stopPropagation():阻止事件冒泡
│
├── 四、注意事项(7大坑点)
│   ├── 1. this 绑定失效(4种场景)
│   │   ├── 忘记 bind → constructor 绑定或箭头函数
│   │   ├── 回调传参丢失 → 箭头函数包装
│   │   ├── 解构赋值丢失 → 不解构事件方法
│   │   └── map 中 function 关键字 → 改用箭头函数
│   │
│   ├── 2. 合成事件与原生事件混用冲突
│   │   └── 解决方案:统一用合成事件
│   │
│   ├── 3. 异步更新取值陷阱
│   │   └── 解决方案:函数式更新 prev => prev + 1
│   │
│   ├── 4. 匿名函数导致重渲染
│   │   └── 解决方案:useCallback + React.memo
│   │
│   ├── 5. event.persist() 已移除
│   │   └── 适配:删除 persist 调用,直接异步访问
│   │
│   ├── 6. onChange vs onInput
│   │   └── React 中行为相同,推荐用 onChange
│   │
│   └── 7. 传参错误导致提前执行
│       └── onClick={fn()} → onClick={() => fn()}
│
└── 五、最佳实践总结
    ├── 函数组件用箭头函数或 useCallback
    ├── 类组件用类属性箭头函数
    ├── 列表用事件委托
    ├── 需要阻止时才用 preventDefault/stopPropagation
    ├── 异步更新用函数式 setState
    └── 不过度优化,简单场景直接内联

本章小结:事件绑定是 React 中最基础也最容易踩坑的知识点。记住三个核心公式就能覆盖 90% 的场景:

  1. 函数组件onClick={handleFn} + 传参用 onClick={() => handleFn(arg)}
  2. 类组件:用箭头函数定义方法 handleFn = () => {}
  3. 异步更新:用函数式更新 setState(prev => prev + 1)

拿这三个公式去检验你的代码,基本能避开所有常见问题!

0

评论区