React合成事件与DOM原生事件的对比

1,005 阅读6分钟

React的事件绑定

React 合成事件(SyntheticEvent)是 React 模拟原生 DOM 事件所有能力的一个事件对象,即浏览器原生事件的跨浏览器包装器。它根据 W3C 规范 来定义合成事件,兼容所有浏览器,拥有与浏览器原生事件相同的接口。React合成事件采用了事件委托的方式,除了少数不可被事件系统代理外,绝大多数都不会像DOM原生事件一样绑定到元素上,而是统一绑定到root(17之前是绑定到document)上进行事件监听。在事件监听器上维护一个映射来保存所有组件上的监听事件以及处理函数,这样当事件触发时,都会冒泡到最外层然后进行分发到对应的组件实例。

合成事件绑定方式优点

React为什么要实现一套自己的事件机制呢?这主要是由于在生成fiber节点时,其对应的DOM节点可能还未挂载,而事件处理函数作为fiber节点的prop,也就无法绑定到真实DOM节点上。采用合成事件就解决了这个问题,同时还可以利用fiber树的结构来模拟事件的捕获与冒泡。

这种方式具有以下优点:

  • 当DOM上绑定过多事件事件处理函数时,页面性能以及内存等可能会收到影响,通过事件代理的方式,可以达到性能优化的目的。
  • 兼容所有浏览器,可能更好的跨平台。
  • 方便React进行事件的统一管理,可以对事件进行归类,使其存在不同的优先级。

合成事件的实现机制

React主要对合成事件进行了如下操作:

  • 事件委派

React将事件绑定到最外层,使用一个统一的事件监听器,事件监听器上维护一个映射来保存所有组件上的监听事件以及处理函数。当组件挂载或者卸载时,就会在事件监听器上插入或者删除一些对象,事件发生时,首先被事件监听器处理,然后再事件里找到真正的事件处理函数。

  • 自动绑定

在React内,每个方法的上下文都会指向所在组件实例,即自动绑定this为当前组件。同时React会对这种引用进行缓存来达到CPU和内存的优化。常用的绑定方法有使用bind或者箭头函数,其中bind可以在每次调用时进行绑定,也可以在构造器内完成;箭头函数则是可以自动绑定所在作用于的this,因此不用使用bind。

代理到root与document的区别

React16及之前是将事件代理到document,17之后改为root,这主要是避免多版本的React共存时事件系统发生冲突。当在document进行统一事件监听时,DOM事件触发后会冒泡到document,React会找到对应的组件,造一个React事件出来,然后模拟事件冒泡,但是这个过程中原生的DOM事件已经冒出document。而不同版本的React事件系统相互独立,在document在进行处理则会比较晚,例如e.stopPropagation则无法正常工作。

合成事件与原生事件的使用

虽然React合成事件具有诸多优点,但这并不代表我们就放弃使用React,还是有许多场景需要原生事件。首先看一下原生事件的特点:

  • 原生事件是绑定在真实DOM上的。
  • DOM上的事件优于document上的事件执行。

因此当有一些业务需要利用以上特性时,还是需要使用原生事件,例如:

  • 涉及stopPropagation(例如面板开启之后点击面板之外的区域关闭面板)
  • 引入的第三方库使用了原生事件,当需要进行事件交互时。

由于原生事件绑定到真实DOM上,所以需要在渲染后执行绑定操作,同时需要在组件卸载前进行事件的解绑来避免内存泄漏。

在React中使用原生事件

在React内使用原生事件并不存在太大区别,就是需要注意获取DOM的时机与方式。类组件内,componentDidMount会在组件已经完成挂载并存在真实DOM后调用,函数式组件内对应的hook为useEffect,我们可以在此时实现原生事件的绑定。

类组件

import React, { createRef, PureComponent, useEffect, useRef } from 'react'

export class ClassMemo extends PureComponent {
  ButtonRef = createRef<HTMLButtonElement>();
  componentDidMount() {
    this.ButtonRef.current?.addEventListener('click', this.handleClick);
  }
  handleClick() {
    console.log('DOM event');
  }
  componentWillUnmount() {
    this.ButtonRef.current?.removeEventListener('click', this.handleClick);
  }
  render() {
    return <button ref="button">click</button>;
  }
}

函数式组件

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

export const FunctionDemo = () => {
  const buttonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    const buttonDom = buttonRef.current;
    buttonDom?.addEventListener('click', clickDOMButton);
    return () => {
      buttonDom?.removeEventListener('click', clickDOMButton);
    }
  }, [])

  function clickDOMButton() {
    console.log('DOM event');
  }

  return (
    <div>
      <button ref={buttonRef}>
        click
      </button>
    </div>
  )
}

合成事件与原生事件混用

如果在开发过程中需要混用合成事件和原生事件,需要注意以下两点。

响应顺序

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

export const App = () => {
  const buttonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    buttonRef.current?.addEventListener('click', clickDOMButton, false);
  }, [])

  const clickDOMButton = () => {
    console.log('DOM event');
  }

  const clickReactButton = () => {
    console.log('React event');
  }

  return <div>
    <button ref={buttonRef} onClick={clickReactButton}>click</button>
  </div>
};

在button上同时绑定了合成事件和原生事件,控制台输出如下。这是因为原生事件在点击时就触发了,而React事件是在冒泡到document后才会触发。

DOM event
React event

阻止冒泡

import React, { useEffect, useState } from 'react'
import qr from './qr.svg';

export const App = () => {
  const [active, setActive] = useState(false);

  const closeQr = () => {
    setActive(false);
  }

  useEffect(() => {
    document.body.addEventListener('click',closeQr);
    return () => {
      document.body.removeEventListener('click',closeQr);
    }
  }, []);

  const handleClick = () => {
    setActive(!active);
    console.log(!active);
  }

  const handleClickQr = (e: React.MouseEvent) => {
    e.stopPropagation();
  }

  return <div>
    <button onClick={ handleClick }>click</button>
    <div onClick={handleClickQr} style={{display: active ? 'block' : 'none'}}>
      <img src={qr} alt={'qr'} />
    </div>
  </div>
};

这部分代码则实现了二维码的出现与隐藏,点击空白区域同样可以隐藏二维码,看起来可以行得通,但是结果却和我们想象的不一样,这是因为合成事件仅仅在最外层进行了绑定,此时再去阻止事件已经太晚了。解决的办法就是不要混用两种事件,将二维码的出现也修改为原生事件,或者通过e.target来判断当前点击元素。另外用reactEvent.nativeEvent.stopPropagation()来阻止冒泡是不行的。React事件的行为只适用于React事件系统内,而原生事件中的阻止冒泡行为却可以阻止React事件的传播。

对比合成事件与原生事件

下面从四个方面进行对比:

  • 事件传播:原生事件的传播可以分为三个阶段:事件的捕获、目标本身的事件处理以及事件的冒泡。由于事件捕获在开发过程中的意义不大,同时还会带来兼容性问题,因此React合成事件并没有实现事件捕获,仅仅支持了冒泡机制。
  • 事件类型:React合成事件类型时DOM原生事件的一个子集
  • 事件绑定方
// DOM绑定方式
<button onclick="alert(1);">Test</button>
el.onclick = e => { console.log(e); }
el.addEventListener('click', () => {}, false);

// React绑定方式
<button onClick={this.handleClick}>Test</button>
  • 事件对象:原生DOM事件在W3C和IE标准下存在差异,在低版本IE浏览器中只能使用window.event来获取事件对象,而在React合成事件系统中则不存在兼容性问题。