MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

React 中合成事件系统的工作原理

2021-03-094.4k 阅读

React 事件系统简介

在传统的 DOM 编程中,我们直接给 DOM 元素添加事件监听器,例如:

<button id="myButton">Click me</button>
<script>
  const button = document.getElementById('myButton');
  button.addEventListener('click', function() {
    console.log('Button clicked!');
  });
</script>

而在 React 中,事件处理采用了一种不同的方式。React 使用合成事件(SyntheticEvent)系统,它是对原生浏览器事件的跨浏览器包装。例如:

import React, { useState } from 'react';

function Button() {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };
  return (
    <button onClick={handleClick}>
      Click me {count} times
    </button>
  );
}

React 的合成事件系统有以下几个优点:

  1. 兼容性:它抹平了不同浏览器之间事件处理的差异,使得开发者无需担心浏览器特定的行为。
  2. 性能优化:通过事件委托机制,React 可以在顶层统一处理事件,减少内存开销。
  3. 方便的事件对象:合成事件对象提供了统一的 API,无论在哪个浏览器上运行,都能以相同的方式访问事件相关信息。

合成事件的创建

当一个事件在 React 应用中触发时,React 会创建一个合成事件对象。这个对象并不是原生的浏览器事件对象,而是对原生事件的封装。

React 首先会从一个事件池(event pool)中获取一个合成事件对象。事件池是一个预先创建好的对象池,这样做的目的是为了避免频繁地创建和销毁对象,从而提高性能。

例如,当点击一个按钮时,React 会从事件池中取出一个 SyntheticMouseEvent 对象,并将原生的 MouseEvent 的相关信息填充到这个合成事件对象中。

import React from'react';

function Button() {
  const handleClick = (e) => {
    console.log(e.target); // 这里的 e 就是合成事件对象
  };
  return (
    <button onClick={handleClick}>Click me</button>
  );
}

在上述代码中,handleClick 函数的参数 e 就是合成事件对象。这个对象包含了诸如 target(触发事件的 DOM 元素)、type(事件类型,如 'click')等常见属性,以及一些用于操作事件的方法,如 preventDefault()stopPropagation()

事件委托机制

React 使用事件委托来处理事件。事件委托是一种设计模式,它利用了 DOM 事件冒泡的特性。

在传统的 DOM 事件处理中,如果我们有大量的子元素都需要绑定相同类型的事件,比如一个列表中的每个列表项都需要绑定点击事件,我们可能会这样做:

<ul id="myList">
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
</ul>
<script>
  const items = document.querySelectorAll('#myList li');
  items.forEach((item) => {
    item.addEventListener('click', function() {
      console.log('Item clicked:', this.textContent);
    });
  });
</script>

这样做会为每个列表项都绑定一个事件监听器,当列表项数量很多时,会消耗大量的内存。

而在 React 中,采用事件委托机制,React 会在文档的最顶层(通常是 document 对象)添加一个单一的事件监听器。当事件触发时,事件会冒泡到顶层,React 会根据事件的目标(target)来判断应该调用哪个组件的事件处理函数。

例如,对于上述的列表:

import React from'react';

function ListItem({ text }) {
  const handleClick = () => {
    console.log('Item clicked:', text);
  };
  return <li onClick={handleClick}>{text}</li>;
}

function List() {
  const items = ['Item 1', 'Item 2', 'Item 3'];
  return (
    <ul>
      {items.map((text, index) => (
        <ListItem key={index} text={text} />
      ))}
    </ul>
  );
}

在这个 React 示例中,虽然每个 <ListItem> 看起来都有自己的 onClick 处理函数,但实际上,React 是在顶层处理所有的点击事件,然后根据事件的目标来分发到具体的 <ListItem> 组件的 handleClick 函数。

事件分发流程

  1. 捕获阶段:当事件触发时,首先进入捕获阶段。事件从文档的根节点开始,自上而下向目标节点传播。在 React 中,捕获阶段的事件处理函数命名规则是在事件名前加上 onCapture,例如 onClickCapture
  2. 目标阶段:事件到达目标节点,此时会调用目标节点上注册的事件处理函数。
  3. 冒泡阶段:事件从目标节点开始,自下而上向文档的根节点传播。React 中大多数情况下处理的是冒泡阶段的事件,例如 onClick 就是冒泡阶段的事件处理函数。

下面通过一个具体的示例来展示事件分发流程:

import React from'react';

function Outer() {
  const handleClickCapture = () => {
    console.log('Outer - Click capture');
  };
  const handleClick = () => {
    console.log('Outer - Click');
  };
  return (
    <div onClickCapture={handleClickCapture} onClick={handleClick}>
      <Inner />
    </div>
  );
}

function Inner() {
  const handleClick = () => {
    console.log('Inner - Click');
  };
  return <div onClick={handleClick}>Click me</div>;
}

function App() {
  return <Outer />;
}

当点击 <Inner> 组件中的 div 时,控制台会依次输出:

Outer - Click capture
Inner - Click
Outer - Click

这清晰地展示了事件从捕获阶段(Outer - Click capture),到目标阶段(Inner - Click),再到冒泡阶段(Outer - Click)的完整流程。

合成事件与原生事件的交互

有时候,我们可能需要在 React 应用中使用原生事件。例如,某些特定的浏览器功能可能只能通过原生事件来实现。

在 React 组件中,可以通过 ref 来获取 DOM 元素,并为其添加原生事件监听器。例如:

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

function MyComponent() {
  const myRef = useRef(null);
  useEffect(() => {
    if (myRef.current) {
      myRef.current.addEventListener('click', function() {
        console.log('Native click event');
      });
    }
    return () => {
      if (myRef.current) {
        myRef.current.removeEventListener('click', function() {
          console.log('Removed native click event');
        });
      }
    };
  }, []);
  return <div ref={myRef}>Click me for native event</div>;
}

在这个示例中,我们使用 useRef 创建了一个 ref,并在 useEffect 钩子中为 ref.current(即对应的 DOM 元素)添加和移除原生的点击事件监听器。

需要注意的是,当同时存在合成事件和原生事件时,事件的执行顺序可能会受到影响。一般来说,原生事件会在合成事件之前触发。例如:

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

function MyComponent() {
  const myRef = useRef(null);
  const handleClick = () => {
    console.log('Synthetic click event');
  };
  useEffect(() => {
    if (myRef.current) {
      myRef.current.addEventListener('click', function() {
        console.log('Native click event');
      });
    }
    return () => {
      if (myRef.current) {
        myRef.current.removeEventListener('click', function() {
          console.log('Removed native click event');
        });
      }
    };
  }, []);
  return <div ref={myRef} onClick={handleClick}>Click me</div>;
}

当点击 div 时,控制台会先输出 Native click event,然后输出 Synthetic click event

合成事件的复用与释放

如前文所述,React 使用事件池来管理合成事件对象。当一个事件处理函数执行完毕后,React 会将合成事件对象放回事件池,以便下次复用。

这意味着,在事件处理函数执行完毕后,合成事件对象中的属性可能会被重置或释放。例如:

import React from'react';

function Button() {
  const handleClick = (e) => {
    setTimeout(() => {
      console.log(e.target); // 这里可能会输出 null 或 undefined
    }, 1000);
  };
  return (
    <button onClick={handleClick}>Click me</button>
  );
}

在上述代码中,setTimeout 中的回调函数在事件处理函数执行完毕 1 秒后执行。此时,合成事件对象 e 可能已经被放回事件池,其属性可能不再有效,所以 console.log(e.target) 可能会输出 nullundefined

如果需要在事件处理函数执行完毕后仍然访问事件对象的属性,可以通过 e.persist() 方法。这个方法会将合成事件对象从事件池中移除,从而避免其被重置或释放。例如:

import React from'react';

function Button() {
  const handleClick = (e) => {
    e.persist();
    setTimeout(() => {
      console.log(e.target); // 这里会正常输出触发事件的 DOM 元素
    }, 1000);
  };
  return (
    <button onClick={handleClick}>Click me</button>
  );
}

通过调用 e.persist(),即使在事件处理函数执行完毕后,仍然可以在 setTimeout 的回调函数中正常访问合成事件对象的属性。

合成事件系统的局限性

尽管 React 的合成事件系统带来了很多优点,但它也存在一些局限性。

  1. 性能开销:虽然事件委托机制减少了内存开销,但由于合成事件是对原生事件的封装,在创建和处理合成事件时仍然会有一定的性能开销。特别是在处理大量频繁触发的事件时,这种开销可能会变得明显。
  2. 与原生事件的兼容性:尽管 React 努力抹平不同浏览器之间的差异,但在某些极端情况下,合成事件可能无法完全模拟原生事件的行为。例如,一些浏览器特定的事件属性或行为可能无法在合成事件中准确体现。
  3. 调试难度:由于合成事件是经过封装的,调试合成事件相关的问题可能会比调试原生事件更困难。例如,在调试工具中查看事件对象的属性时,可能需要额外的步骤来理解合成事件对象与原生事件对象之间的关系。

优化合成事件的使用

  1. 减少不必要的事件绑定:避免在不必要的组件上绑定事件,只在真正需要处理事件的组件上进行绑定。例如,如果一个组件只是用于展示数据,没有任何交互行为,就不应该为其绑定事件。
  2. 合理使用 e.persist():只有在确实需要在事件处理函数执行完毕后仍然访问事件对象属性的情况下,才使用 e.persist()。否则,尽量让 React 复用事件池中的对象,以提高性能。
  3. 优化事件处理函数:确保事件处理函数尽可能简单高效,避免在事件处理函数中执行复杂的计算或长时间运行的任务。如果需要进行复杂操作,可以考虑将其放在异步任务中执行,以避免阻塞主线程。

总结合成事件系统

React 的合成事件系统是其核心特性之一,它为开发者提供了一种方便、高效且兼容的事件处理方式。通过事件委托、合成事件对象的复用等机制,React 大大提高了应用的性能和开发效率。然而,开发者也需要了解其工作原理和局限性,以便在实际开发中能够更好地优化应用,处理可能出现的问题。无论是处理简单的按钮点击,还是复杂的交互场景,深入理解合成事件系统都将有助于开发者编写出高质量的 React 应用。