返回首页

说说 React 的事件机制?

问题解析(面试官考察点)

面试官通过此问题主要考察:

  • 对 React 合成事件的理解
  • 事件委托机制的了解
  • 事件执行顺序的掌握
  • 对原生事件和合成事件区别的认识

核心概念(基础知识点)

合成事件(SyntheticEvent)

React 基于浏览器的事件机制实现了一套自己的事件系统:

  • 事件注册:将事件统一注册到 document 上
  • 事件合成:封装浏览器原生事件,提供跨浏览器兼容性
  • 事件冒泡:使用事件委托机制处理事件
  • 事件派发:统一的事件监听器处理所有组件事件

为什么使用合成事件

  1. 跨浏览器兼容:抹平不同浏览器的事件差异
  2. 性能优化:事件委托减少内存占用
  3. 统一接口:与原生事件相似的 API
  4. 事件池:React 17 之前使用事件池复用事件对象

详细解答(代码示例)

基本事件绑定

const button = <button onClick={handleClick}>按钮</button>;

const handleClick = (e) => {
  console.log(e); // SyntheticEvent 合成事件对象
  console.log(e.nativeEvent); // 原生 DOM 事件
};

获取原生事件

const handleClick = (e) => {
  // e 是 React 的合成事件
  console.log(e.target); // 触发事件的元素

  // e.nativeEvent 是原生 DOM 事件
  console.log(e.nativeEvent.target); // 原生事件的目标元素
  console.log(e.nativeEvent.currentTarget); // 原生事件的当前目标
};

事件执行顺序示例

import React from 'react';

class App extends React.Component {
  constructor(props) {
    super(props);
    this.parentRef = React.createRef();
    this.childRef = React.createRef();
  }

  componentDidMount() {
    console.log("React componentDidMount");

    // 原生事件绑定
    this.parentRef.current?.addEventListener("click", () => {
      console.log("原生事件:父元素 DOM 事件监听!");
    });

    this.childRef.current?.addEventListener("click", () => {
      console.log("原生事件:子元素 DOM 事件监听!");
    });

    document.addEventListener("click", (e) => {
      console.log("原生事件:document DOM 事件监听!");
    });
  }

  parentClickFun = () => {
    console.log("React 事件:父元素事件监听!");
  };

  childClickFun = () => {
    console.log("React 事件:子元素事件监听!");
  };

  render() {
    return (
      <div ref={this.parentRef} onClick={this.parentClickFun}>
        <div ref={this.childRef} onClick={this.childClickFun}>
          分析事件执行顺序
        </div>
      </div>
    );
  }
}

export default App;

输出顺序

1. 原生事件:子元素 DOM 事件监听!
2. 原生事件:父元素 DOM 事件监听!
3. React 事件:子元素事件监听!
4. React 事件:父元素事件监听!
5. 原生事件:document DOM 事件监听!

深入理解(原理剖析)

React 事件绑定原理

┌─────────────────────────────────────┐
│           document                  │  ← 所有事件绑定在这里
│  ┌─────────────────────────────┐    │
│  │     统一事件监听器           │    │
│  │  ┌─────────────────────┐    │    │
│  │  │   事件映射表         │    │    │
│  │  │ 组件A: handleClickA  │    │    │
│  │  │ 组件B: handleClickB  │    │    │
│  │  └─────────────────────┘    │    │
│  └─────────────────────────────┘    │
└─────────────────────────────────────┘

事件处理流程

  1. 事件触发:用户点击按钮
  2. 原生事件冒泡:事件冒泡到 document
  3. 统一监听器处理:React 的统一事件监听器捕获事件
  4. 查找处理函数:在映射表中找到对应的处理函数
  5. 调用处理函数:执行组件中定义的事件处理函数

合成事件 vs 原生事件

特性 React 合成事件 原生 DOM 事件
事件名称 小驼峰(onClick) 全小写(onclick)
事件处理 函数引用 字符串或函数
绑定位置 document 目标元素
阻止冒泡 e.stopPropagation() e.stopPropagation()
默认行为 e.preventDefault() e.preventDefault()

事件命名对比

// 原生事件绑定方式
<button onclick="handleClick()">按钮</button>

// React 合成事件绑定方式
<button onClick={handleClick}>按钮</button>

阻止冒泡的注意事项

// 阻止合成事件间的冒泡
const handleClick = (e) => {
  e.stopPropagation();
};

// 阻止合成事件与 document 上的原生事件冒泡
const handleClick = (e) => {
  e.nativeEvent.stopImmediatePropagation();
};

// 阻止合成事件与除 document 外的原生事件冒泡
componentDidMount() {
  document.body.addEventListener('click', e => {
    if (e.target && e.target.matches('div.code')) {
      return;
    }
    this.setState({ active: false });
  });
}

React 17 的变化

React 17 之前:

  • 所有事件委托到 document
  • 使用事件池(SyntheticEvent 被复用)

React 17 及之后:

  • 事件委托到 root DOM 容器
  • 不再使用事件池,可以异步访问事件属性
// React 16 - 需要调用 e.persist() 才能异步访问
const handleClick = (e) => {
  e.persist(); // 必须调用
  setTimeout(() => {
    console.log(e.target); // 可以访问
  }, 1000);
};

// React 17+ - 不需要 e.persist()
const handleClick = (e) => {
  setTimeout(() => {
    console.log(e.target); // 可以直接访问
  }, 1000);
};

最佳实践

事件处理函数绑定

// 推荐:类属性箭头函数
class MyComponent extends React.Component {
  handleClick = () => {
    console.log('clicked');
  };

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

// 推荐:函数组件
function MyComponent() {
  const handleClick = () => {
    console.log('clicked');
  };

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

传递参数

// 方式一:箭头函数(每次渲染创建新函数)
<button onClick={() => handleClick(id)}>Click</button>

// 方式二:data 属性(推荐)
<button data-id={id} onClick={handleClick}>Click</button>

const handleClick = (e) => {
  const id = e.target.dataset.id;
  // 处理点击
};

// 方式三:bind(构造函数中绑定)
constructor(props) {
  super(props);
  this.handleClick = this.handleClick.bind(this, id);
}

混合使用原生事件和合成事件

class MixedEvents extends React.Component {
  componentDidMount() {
    // 原生事件
    this.node.addEventListener('click', this.handleNativeClick);
  }

  componentWillUnmount() {
    // 必须手动移除原生事件监听
    this.node.removeEventListener('click', this.handleNativeClick);
  }

  handleNativeClick = (e) => {
    console.log('原生事件');
  };

  handleReactClick = (e) => {
    console.log('React 合成事件');
  };

  render() {
    return (
      <div
        ref={node => this.node = node}
        onClick={this.handleReactClick}
      >
        点击我
      </div>
    );
  }
}

事件委托优化

// 不推荐:每个子元素都绑定事件
<ul>
  {items.map(item => (
    <li key={item.id} onClick={() => handleClick(item.id)}>
      {item.name}
    </li>
  ))}
</ul>

// 推荐:在父元素上使用事件委托
<ul onClick={handleListClick}>
  {items.map(item => (
    <li key={item.id} data-id={item.id}>
      {item.name}
    </li>
  ))}
</ul>

const handleListClick = (e) => {
  const id = e.target.dataset.id;
  if (id) {
    handleClick(id);
  }
};

面试要点

  1. 事件委托机制

    • React 将所有事件绑定到 document(React 17 之前)或 root 容器
    • 通过映射表管理组件事件处理函数
    • 减少内存占用,提高性能
  2. 事件执行顺序

    • 原生事件先执行(冒泡到 document)
    • React 合成事件后执行
    • document 上的原生事件最后执行
  3. 合成事件的优势

    • 跨浏览器兼容
    • 事件委托优化性能
    • 统一的事件接口
  4. 常见陷阱

    • e.stopPropagation() 不能阻止原生事件的冒泡
    • React 16 中异步访问事件属性需要 e.persist()
    • 原生事件需要手动清理
  5. React 17 的变化

    • 事件委托到 root 容器而非 document
    • 移除事件池,可以异步访问事件属性
    • 更易于集成多个 React 应用
  6. 常见面试题

    • React 事件和原生事件有什么区别?
    • React 事件为什么要绑定到 document?
    • 为什么 React 事件中使用 e.stopPropagation() 无效?
    • React 17 事件机制有什么变化?