合成事件(SyntheticEvent)

SyntheticEvent 实例将被传递给你的事件处理函数,它是浏览器的原生事件的跨浏览器包装器。除兼容所有浏览器外,它还拥有和浏览器原生事件相同的接口,包括 stopPropagation()preventDefault()

为什么要手动绑定this?

在了解合成事件之前,我们都知道在React中如果不使用箭头函数,我们给每个事件都需要手动的绑定一个this。先来一个例子:

import React from 'react';
import ReactDOM from 'react-dom';

class TestPage extends React.Component {

  constructor(props) {
    super(props);
    this.handleClick = this.handleClick.bind(this); //绑定this
  }
  
  handleClick(){
    console.log( this )
  }

  render() {
    const { count } = this.state;
    return (
      <button onClick={ this.handleClick } >点我测试</button>
    );
  }
}

ReactDOM.render(
  <TestPage/>,document.getElementById('root')
);

上述代码编译后的结果大致是这样:

render() {
    return React.createElement(
      'button',
      { onClick: this.handleClick },
      'Hello'
    );
}

WARNING

在ES6 class 内定义方法时,如果不是箭头函数,方法是挂载在 prototype 原型对象上的。当我们没有去绑定this的时候,这时候的this默认指向的是window,因此访问的时候就是undefined了。

事件池

SyntheticEvent 是合并而来。这意味着 SyntheticEvent 对象可能会被重用,而且在事件回调函数被调用后,所有的属性都会无效。出于性能考虑,你不能通过异步访问事件。

function onClick(event) {
  console.log(event); // => nullified object.
  console.log(event.type); // => "click"
  const eventType = event.type; // => "click"

  setTimeout(function() {
    console.log(event.type); // => null
    console.log(eventType); // => "click"
  }, 0);

  // 不起作用,this.state.clickEvent 的值将会只包含 null
  this.setState({clickEvent: event});

  // 你仍然可以导出事件属性
  this.setState({eventType: event.type});
}

WARNING

如果你想异步访问事件属性,你需在事件上调用 event.persist(),此方法会从池中移除合成事件,允许用户代码保留对事件的引用。

支持的事件

React 通过将事件 normalize 以让他们在不同浏览器中拥有一致的属性。

React 中的 click 事件被命名为 onClick类似的其他事件都以on开头,事件默认都是在冒泡阶段被触发的。

如需注册捕获阶段的事件处理函数,则应为事件名添加 Capture。例如,处理捕获阶段的点击事件请使用 onClickCapture,而不是 onClick

为什么与怎么做?

Q: 为什么React要自己实现一套事件系统?以及React的事件系统是怎么运作起来的?

先来看几个简单的例子:

1. 运行下面的代码,输出ABCD的顺序是?如果在innerClick中把e.stopPropagation()加上,输出ABCD的顺序是?

class App extends React.Component {
  innerClick = e => {
    console.log("A: react inner click.");
    // e.stopPropagation();
  };

  outerClick = () => {
    console.log("B: react outer click.");
  };

  componentDidMount() {
    document.getElementById("outer").addEventListener("click", () => console.log("C: native outer click"));

    window.addEventListener("click", () =>
      console.log("D: native window click")
    );
  }

  render() {
    return (
      <div id="outer" onClick={this.outerClick}>
        <button id="inner" onClick={this.innerClick}>
          BUTTON
        </button>
      </div>
    );
  }
}
/**
 * C: native outer click
 * A: react inner click.
 * B: react outer click.
 * D: native window click
 * 
 * 加上stopPropagation
 * 
 * C: native outer click
 * A: react inner click.
 * /

2. 有一个Modal(弹窗组件),我们需要在点击除弹窗内容意外的区域关闭弹窗,点击弹窗区域的话保留弹窗,但是实际点击弹窗也会被关闭?

class Modal extends React.Component {
    constructor(props) {
        super(props)
        this.state = {
            visible: props.visible
        }
    }

    componentDidMount() {
        document.body.addEventListener('click', this.handleClickBody, false)
    }

    componentWillUnmount() {
        document.body.removeEventListener('click', this.handleClickBody, false)
    }

    handleClickBody = (e) => {
        document.body.style.overflow = 'visible'
        console.log('click body')
        this.setState({
            showBox:false
        })
    }
    
    handleClickButton=(e)=>{
        document.body.style.overflow = 'hidden'
        this.setState({
            showBox:true
        })
    }
    clickModal=(e)=>{
        console.log('click modal',e)
        e.stopPropagation()

    }
    render() {
        const { showBox } = this.state 
        return (
            <div>
                <button onClick={this.handleClickButton}>点我显示弹窗</button>
                {showBox && <div 
                    className="modal"
                    onClick={this.clickModal}
                >
                {this.props.children}
                </div>
                }
            </div> 
        )
    }
}

/**
 * 只会输出 click body
 * 
 * /

Q: 为什么React要实现自己的事件合成系统?

A: 主要是为了性能复用两个方面来考虑。

React事件合成系统架构

  1. 首先对于性能来说,React作为一套View层面的UI框架,通过渲染得到vDOM,再由diff算法决定DOM树哪些结点需要新增、替换或修改,假如直接在DOM结点插入原生事件监听,则会导致频繁的调用addEventListenerremoveEventListener,造成性能的浪费。所以React采用了事件代理的方法,对于大部分事件而言都在document上做监听,然后根据Event中的target来判断事件触发的结点。

  2. 其次React合成的SyntheticEvent采用了的思想,从而达到节约内存,避免频繁的创建和销毁事件对象的目的。这也是如果我们需要异步使用一个syntheticEvent,需要执行event.persist()才能防止事件对象被释放的原因。

  3. 最后在React源码中随处可见batch做批量更新,基本上凡是可以批量处理的事情(最普遍的setState)React都会将中间过程保存起来,留到最后面才flush掉。就如浏览器对DOM树进行StyleLayoutPaint一样,都不会在操作ele.style.color='red';之后马上执行,只会将这些操作打包起来并最终在需要渲染的时候再做渲染。

ele.style.color='red'; 
ele.style.color='blue';
ele.style.color='red';
// 只会执行最后一次

而对于复用来说,React看到在不同的浏览器和平台上,用户界面上的事件其实非常相似,例如普通的clickchange等等。React希望通过封装一层事件系统,将不同平台的原生事件都封装成SyntheticEvent。这样做的好处主要有以下亮点:

  1. 使得不同平台只需要通过加入EventEmitter以及对应的Renderer就能使用相同的一个事件系统,WEB平台上加入ReactBrowserEventEmitterNative上加入ReactNativeEventEmitter。如下图,对于不同平台,React只需要替换掉左边部分,而右边EventPluginHub部分可以保持复用。

  2. 而对于不同的浏览器而言,React帮我们统一了事件,做了浏览器的兼容,例如对于transitionEnd,webkitTransitionEnd,MozTransitionEndoTransitionEnd, React都会集合成topAnimationEnd,所以我们只用处理这一个标准的事件即可。

React合成事件复用架构

Q: React的事件系统是怎么运作起来的?

事件绑定

我们来看一下我们在JSX中写的onClickhandler是怎么被记录到DOM结点上,并且在document上做监听的。

React合成事件绑定架构

React对于大部分事件的绑定都是使用trapBubbledEventtrapCapturedEvent这两个函数来注册的。如上图所示,当我们执行了render或者setState之后,ReactFiber调度系统会在最后commitDOM树之前执行trapBubbledEventtrapCapturedEvent,通过执行addEventListenerdocument结点上绑定对应的dispatch作为handler负责监听类型为topLevelType的事件。

这里面的dispatchInteractiveEventdispatchEvent两个回调函数的区别为,React16开始换掉了原本Stack ReconciliationFiber希望实现异步渲染,所以异步渲染的情况下假如入我点了两次按钮,那么第二次按钮响应的时候,可能第一次按钮的handlerA中调用的setState还未最终被commitDOM树上,这时需要把第一次按钮的结果先给flush掉并commitDOM树,才能够保持一致性。

这个时候就会用到dispatchInteractiveEvent。可以理解成dispatchInteractiveEvent在执行前都会确保之前所有操作都已最终commitDOM树,再开始自己的流程,并最终触发dispatchEvent。但由于目前React仍是同步渲染的,所以这两个函数在目前的表现是一致的,希望React17会带给我们默认打开的异步渲染功能。

到现在我们已经在document结点上监听了事件了,现在需要来看如何将我们在jsx中写的handler存起来对应到相应的结点上。

在我们每次新建或者更新结点时,React最终会调用createInstance或者commitUpdate这两个函数,而这两个函数都会最终调用updateFiberProps这个函数,将props也就是我们的onClickonChangehandler给存到DOM结点上。

至此,我们我们已经在document上监听了事件,并且将handler存在对应DOM结点。接下来需要看React怎么监听并处理浏览器的原生事件,最终触发对应的handler了。

事件触发

我会大概用下图这种方式来解析代码,左边是我点击一个绑定了handleClick的按钮后的js调用栈,右边是每一步的代码,均已删除部分不影响理解的代码。希望通过这种方式能使大家更易理解React的事件触发机制。

在这里插入图片描述

当我们点击一个按钮时,click事件将会最终冒泡至document,并触发我们监听在document上的handler dispatchEvent,接着触发batchedUpdatesbatchedUpdates这个格式的代码在React的源码里面会频繁的出现,基本上React将所有能够批量处理的事情都会先收集起来,再一次性处理。

可以看到默认的isBatchingfalse的,当调用了一次batchedUpdatesisBatching的值将会变成true,此时如果在接下来的调用中有继续调用batchedUpdates的话,就会直接执行handleTopLevel,此时的setState等不会被更新到DOM上。直到调用栈重新回到第一次调用batchedUpdates的时候,才会将所有结果一起flush掉(更新到DOM上)。

在这里插入图片描述

有的同学可能问调用栈中的BatchedUpdates$1是什么?或者浏览器的renderer和Native的renderer是如何挂在到React的事件系统上的?

其实React事件系统里面提供了一个函数setBatchingImplementation,用来动态挂载不同平台的renderer,这个也体现了React事件系统复用

在这里插入图片描述

handleTopLevel会调用runExtractedEventsInBatch(),这是React事件处理最重要的函数。在EventEmitter里面做的事,其实主要就是这个函数的两步。

  1. 第一步是根据原生事件合成合成事件,并且在vDOM上模拟捕获冒泡,收集所有需要执行的事件回调构成回调数组。
  2. 第二步是遍历回调数组,触发回调函数。

在这里插入图片描述

首先调用extractEvents,传入原生事件eReact事件系统根据可能的事件插件合成合成事件Synthetic e。 这里我们可以看到调用了EventConstructor.getPooled(),从事件池中去取一个合成事件对象,如果事件池为空,则新创建一个合成事件对象,这体现了React为了性能实现了的思想。

在这里插入图片描述

然后传入Propagator,在vDOM上模拟捕获和冒泡,并收集所有需要执行的事件回调和对应的结点。traverseTwoPhase模拟了捕获和冒泡的两个阶段,这里实现很巧妙,简单而言就是正向和反向遍历了一下数组。接着对每一个结点,调用listenerAtPhase取出事件绑定时挂载在结点上的回调函数,把它加入回调数组中。

在这里插入图片描述

接着遍历所有合成事件。这里可以看到当一个事件处理完的时候,React会调用event.isPersistent()来查看这个合成事件是否需要被持久化,如果不需要就会释放这个合成事件,这也就是为什么当我们需要异步读取操作一个合成事件的时候,需要执行event.persist(),不然React就是在这里释放掉这个事件。

在这里插入图片描述

最后这里就是回调函数被真正触发的时候了,取出回调数组event._dispatchListeners,遍历触发回调函数。并通过event.isPropagationStopped()这一步来模拟停止冒泡。这里我们可以看到,React在收集回调数组的时候并不会去管我们是否调用了stopPropagation,而是会在触发的阶段才会去检查是否需要停止冒泡。

至此,一个事件回调函数就被触发了,里面如果执行了setState等就会等到调用栈弹回到最低部的interactiveUpdate中的被最终flush掉,构造vDOM,和好,并最终被commit到DOM上。

这就是事件触发的整个过程了,可以再看一下React合成事件动画,相信你会更加理解这个过程的。

相信看到这里,如果你已经对React事件系统有所理解,现在回过头来让我们看看之前的两个例子。

例子一:

  1. 因为React事件监听是挂载在document上的,所以原生系统在#outer上监听的回调C会最先被输出;接着原生事件冒泡至document进入React事件系统,React事件系统模拟捕获冒泡输出A和B;最后React事件系统执行完毕回到浏览器继续冒泡到window,输出D。

  2. 原生系统在#outer上监听的回调C会最先被执行;接着原生事件冒泡至document进入React事件系统,输出A,在React事件处理中#inner调用了stopPropagation,事件被停止冒泡。

WARNING

所以,最好不要混用React事件系统原生事件系统,如果混用了,请保证你清楚知道会发生什么。

例子二:

我们将事件监听绑定在document对象上,并在点击弹窗和按钮的时候阻止事件冒泡。

export default class Modal extends PureComponent {
    constructor(props) {
        super(props)
        this.state = {
            showBox: props.showBox
        }
    }

    componentDidMount() {
        document.addEventListener('click', this.handleClickBody, false)
    }

    componentWillUnmount() {
        document.removeEventListener('click', this.handleClickBody, false)
    }

    handleClickBody = (e) => {
        document.body.style.overflow = 'visible'
        console.log('click body',e.target)
        this.setState({
            showBox:false
        })
    }
    
    handleClickButton=(e)=>{
        // React中专属的阻止事件冒泡方法
        e.nativeEvent.stopImmediatePropagation() 
        if(this.state.showBox) return
        document.body.style.overflow = 'hidden'
        console.log('click button')
        this.setState({
            showBox:true
        })
    }
    clickModal=(e)=>{
        e.nativeEvent.stopImmediatePropagation() 
        console.log('click modal',e.target)
    }
    render() {
        const { showBox } = this.state 
        return (
            <div>
                <button onClick={this.handleClickButton}>点我显示弹窗</button>
                {showBox && <div 
                    className="modal"
                    onClick={this.clickModal}
                />
                }
                {this.props.children}
            </div> 
        )
    }
}  
上次更新时间: 2021-03-29 22:47:00