15

  最近在阅读《深入React技术栈》一书中,发现了之前使用React中并没有注意到的React事件与浏览器原生事件之间的区别,鉴于好久已经没有写东西了,就想写一下关于React事件的文章。
  首先我们举个例子,如果我们需要实现一个组件,这个组件点击按钮会显示一个二维码,点击二维码之外的区域可以隐藏二维码,但是点击二维码本身却不会关闭,代码如下:

//代码来源于《深入React技术栈》2.1.4节
class QrCode extends Component {
  constructor(props) {
    super(props);
    this.handleClick = this.handleClick.bind(this);
    this.handleClickQr = this.handleClickQr.bind(this);
    this.state = {
      active: false,
    };
  }
  
  componentDidMount() {
    document.body.addEventListener('click', e => {
      this.setState({
        active: false,
      });
    });
  }

  componentWillUnmount() {
    document.body.removeEventListener('click');
  }
  
  handleClick() {
    this.setState({
      active: !this.state.active,
    });
  }
  
  handleClickQr(e) {
    e.stopPropagation();
  }

  render() {
    return (
      <div className="qr-wrapper">
        <button className="qr" onClick={this.handleClick}>二维码</button>
        <div
          className="code"
          style={{ display: this.state.active ? 'block' : 'none' }}
          onClick={this.handleClickQr}
        >
          <img src="qr.jpg" alt="qr" />
        </div>
      </div>
    );
  }
}

  上面代码从感官上感觉确实可以实现要求的组件,但事实上我们运行上述代码可以发现,点击二维码本身也会导致二维码的隐藏,现在就有意思了,我们来仔细分析一下。
  其实React事件并没有原生的绑定在真实的DOM上,而是使用了行为委托方式实现事件机制。
  DOM事件机制

  如上图所示,在JavaScript中,事件的触发实质上是要经过三个阶段:事件捕获、目标对象本身的事件处理和事件冒泡,假设在div中触发了click事件,实际上首先经历捕获阶段会由父级元素将事件一直传递到事件发生的元素,执行完目标事件本身的处理事件后,然后经历冒泡阶段,将事件从子元素向父元素冒泡。正因为事件在DOM的传递经历这样一个过程,从而为行为委托提供了可能。通俗地讲,行为委托的实质就是将子元素事件的处理委托给父级元素处理。React会将所有的事件都绑定在最外层(document),使用统一的事件监听,并在冒泡阶段处理事件,当挂载或者卸载组件时,只需要在通过的在统一的事件监听位置增加或者删除对象,因此可以提高效率。
  并且React并没有使用原生的浏览器事件,而是在基于Virtual DOM的基础上实现了合成事件(SyntheticEvent),事件处理程序接收到的是SyntheticEvent的实例。SyntheticEvent完全符合W3C的标准,因此在事件层次上具有浏览器兼容性,与原生的浏览器事件一样拥有同样的接口,可以通过stopPropagation()preventDefault()相应的中断。如果需要访问当原生的事件对象,可以通过引用nativeEvent获得。
  此处输入图片的描述
  上图为大致的React事件机制的流程图,React中的事件机制分为两个阶段:事件注册和事件触发:

  1. 事件注册  
      React在组件加载(mount)和更新(update)时,其中的ReactDOMComponent会对传入的事件属性进行处理,对相关事件进行注册和存储。document中注册的事件不处理具体的事件,仅对事件进行分发。ReactBrowserEventEmitter作为事件注册入口,担负着事件注册和事件触发。注册事件的回调函数由EventPluginHub来统一管理,根据事件的类型(type)和组件标识(_rootNodeID)为key唯一标识事件并进行存储。

  2. 事件执行
      事件执行时,document上绑定事件ReactEventListener.dispatchEvent会对事件进行分发,根据之前存储的类型(type)和组件标识(_rootNodeID)找到触发事件的组件。ReactEventEmitter利用EventPluginHub中注入(inject)的plugins(例如:SimpleEventPluginEnterLeaveEventPlugin)会将原生的DOM事件转化成合成的事件,然后批量执行存储的回调函,回调函数的执行分为两步,第一步是将所有的合成事件放到事件队列里面,第二步是逐个执行。需要注意的是,浏览器原生会为每个事件的每个listener创建一个事件对象,可以从这个事件对象获取到事件的引用。这会造成高额的内存分配,React在启动时就会为每种对象分配内存池,用到某一个事件对象时就可以从这个内存池进行复用,节省内存。

  再回到我们刚开始的问题,现在看起来就很没有很费解了,之所以会出现上面的问题是因为我们混用了React的事件机制和DOM原生的事件机制,认为通过:

handleClickQr(e) {
    e.stopPropagation();
}

就能阻止原生的事件传播,其实在事件委托的情形下是不能实现这一点的。当然解决的办法也不复杂,不要将React事件和DOM原生事件混用。

componentDidMount() {
  document.body.addEventListener('click', e => {
    this.setState({
      active: false,
    });
  });
 
  document.querySelector('.code').addEventListener('click', e => {
    e.stopPropagation();
  })
}

componentWillUnmount() {
  document.body.removeEventListener('click');
  document.querySelector('.qr').removeEventListener('click');
}

或者通过事件原件对象中的target进行判断:

componentDidMount() {
  document.body.addEventListener('click', e => {
    if (e.target && e.target.matches('div.code')) {
      return;
    }
 
    this.setState({
      active: false,
    });
  });
}

都可以解决异常关闭的问题。


请叫我王磊同学
1.1k 声望169 粉丝

编程新人