1

引言

官方文档对React事件的介绍包含以下几点

  • React事件是合成事件
  • 有stopPropagation和preventDefault
  • 有nativeEvent上的所有属性
  • 可以通过nativeEvent获取到原生事件
  • 跨浏览器兼容

那么在看源码之前,有以下疑问:

  • 如何监听?监听的什么元素?
  • 如何模拟捕获和冒泡?
  • 如何实现stopPropagation?
  • 为什么要使用合成事件?
React 版本号 16.9.0

源码阅读说明

了解源码最好的方式是单步调试,找一个最简单的例子,在源码中打断点进行调试。本文采用create-react-app创建了最简单的demo,只含有click事件。页面内容如下

import React from 'react';
import './App.css';

class App extends React.Component {
  spanClickEvent = null;
  headerClickEvent = null;

  componentDidMount () {
    // document.addEventListener('click', () => {
    //   console.log('document click');
    // })
  }

  spanClick (event) {
    event.stopPropagation();
    console.log('spanClick');
    console.log(event);
    // this.spanClickEvent = event;
  }
  headerClick (event) {
    console.log('headerClick');
    console.log(event);
    // this.headerClickEvent = event;
    // console.log(this.headerClickEvent === this.spanClickEvent);
  }
  inputChange (event) {
    console.log('inputChange');
    console.log(event);
  }
  render () {
    return (
    <div className="App">
      <header className="App-header" onClick={(event) => this.headerClick(event)}>
        <div className="btn-wrapper">
          <span className="btn" onClick={(event) => this.spanClick(event)}>
            <span>点击</span>
          </span>
          {/* <input onChange={(event) => this.inputChange(event)}/> */}
        </div>
      </header>
    </div>
    )};
}

export default App;

事件注册

首先刷新页面,在render过程中会走到如下逻辑

function createInstance(type, props, rootContainerInstance, hostContext, internalInstanceHandle) {
      var parentNamespace = void 0;
      {
        // 此处省略代码...
      }
      var domElement = createElement(type, props, rootContainerInstance, parentNamespace);
      // 将internalInstanceHandle和props挂载在真实的DOM上,后面会用到
      precacheFiberNode(internalInstanceHandle, domElement);
      updateFiberProps(domElement, props);
      return domElement;
    }

updateFiberProps会走到setInitialDOMProperties里面

function setInitialDOMProperties(tag, domElement, rootContainerElement, nextProps, isCustomComponentTag) {
    // 此处省略代码
    else if (registrationNameModules.hasOwnProperty(propKey)) {
      if (nextProp != null) {
        if (true && typeof nextProp !== 'function') {
          warnForInvalidEventListener(propKey, nextProp);
        }
        // 如果props含有事件相关的属性,则去监听对应的事件
        ensureListeningTo(rootContainerElement, propKey);
      }
    }
}

注意此处使用的registrationNameModules存放了所有React事件。

clipboard.png

ensureListeningTo会判断当前是否在iframe里面,以决定监听哪里的事件。最后走到listenTo逻辑里面。

function listenTo(registrationName, mountAt) {
      //listeningSet存放了已经监听过的事件,避免重复去监听。
      var listeningSet = getListeningSetForElement(mountAt);
      var dependencies = registrationNameDependencies[registrationName];

      for (var i = 0; i < dependencies.length; i++) {
        var dependency = dependencies[i];

        if (!listeningSet.has(dependency)) {
          // 初始化span标签的时候会走到这个逻辑里面,header的时候就不会再重复去监听click了
          switch (dependency) {
            // 此处省略代码

            default:
              var isMediaEvent = mediaEventTypes.indexOf(dependency) !== -1;

              if (!isMediaEvent) {
                trapBubbledEvent(dependency, mountAt);
              }

              break;
          }

          listeningSet.add(dependency);
        }
      }
    }

registrationNameDependencies存放了React事件与原生事件需要监听的对应关系。如下图中,如果使用onBlur则会监听window的blur事件,如果使用onChange则会监听blur/change/..等事件

clipboard.png

接下来说trapBubbledEvent

function addEventBubbleListener(element, eventType, listener) {
  // 注意此处element是document,第三个参数是false 
  element.addEventListener(eventType, listener, false);
}

此时的listener为dispatchDiscreteEvent

至此,事件注册完成。值得注意的是,React在生成的真实DOM中加入了两个React属性,一个放了元素的props,一个放了元素对应的FiberNode。
原生DOM和FiberNode的一个双向关系。
clipboard.png
传中...]

事件触发

点击span元素后,会走到dispatchDiscreteEvent逻辑里面,会带着nativeEvent调dispatchEvent方法。

clipboard.png

通过getEventTarget(nativeEvent)拿到当前的nativeEvent.target<span>点击元素</span>,然后拿到DOM上含有__reactInternalInstance*的最近的元素,此处为<span>点击元素</span>。调用dispatchEventForPluginEventSystem,调用batchedEventUpdates,中间会调用runExtractedPluginEventsInBatch处理原生事件,将原生事件合成为合成事件。最后会调用到traverseTwoPhase。这个方法主要是找到当前的path链

温习currentTarget和target currentTarget表示事件处理程序当前正在处理事件的那个元素
target 事件的目标

clipboard.png

function traverseTwoPhase(inst, fn, arg) {
  var path = [];

  while (inst) {
    path.push(inst);
    inst = getParent(inst);
  }

  var i = void 0;

  for (i = path.length; i-- > 0;) {
    // 从外层到里层遍历元素,模拟捕获
    fn(path[i], 'captured', arg);
  }

  for (i = 0; i < path.length; i++) {
    // 从里层到外层遍历元素,模拟冒泡
    fn(path[i], 'bubbled', arg);
  }
}

调用对应的fn也就是accumulateDirectionalDispatches

function accumulateDirectionalDispatches(inst, phase, event) {
      // 省略代码
      // 在'bubble'阶段的onClick对应onClick,而captured的onClick对应onClickCaptured。因此我们在捕获阶段没有事件可以触发。感兴趣的可以将demo中的onClick更改为onClickCaptured模拟捕获触发
      var listener = listenerAtPhase(inst, event, phase);

      if (listener) {
        // 依次拿到span.btn和header上的onClick,并且放进event._dispatchListeners
        event._dispatchListeners = accumulateInto(event._dispatchListeners, listener);
        event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);
      }
    }

最后调用executeDispatchesInOrder,遍历_dispatchListeners依次触发。触发的时候会判断event.isPropagationStopped()是true还是false

clipboard.png

  function executeDispatch(event, listener, inst) {
    var type = event.type || 'unknown-event';
    // 赋值给currentTarget
    event.currentTarget = getNodeFromInstance(inst);
    invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event);
    event.currentTarget = null;
  }

最后调用到fakeNode.dispatchEvent触发callCallback真正的onClick事件。此处采用fakeNode.dispatchEvent是为了让事件仍然是浏览器发起的。

clipboard.png

调用完毕后,会将event初始化为最初的状态

整个过程,可以发现以下问题

  • 在事件冒泡阶段,使用的是同一个事件,只不过调用时的currentTarget不一样。即spanClickEvent ==== headerClickEvent
  • 因此在处理事件时,一定提前把值取出来。否则像有异步操作的时候,是取不到currentTarget的值的
  • onClick对应有onClickCaptured
  • 因为整个过程使用的是同一个event,所以stopPropagation在调用的时候会将event上的isPropagationStopped置为true,则后面的事件将不再触发,以此来模拟冒泡停止

clipboard.png

总结

回到最初的问题提问

  • 如何监听?监听的什么元素?(使用到的根据registrationNameDependencies对应关系才会去监听,且使用一个set避免重复监听。监听了document元素)
  • 如何模拟捕获和冒泡?(找到元素的path链,按不同顺序依次取出对应的事件)
  • 如何实现stopPropagation?(同一个event,利用其属性stopPropagation是否返回false标记)
  • 为什么要使用合成事件?(抹平浏览器差异)

课后题

例子中的span调用了stopPropagation,那么以下代码会触发吗

componentDidMount () {
    document.addEventListener('click', () => {
      // 依然会触发。为什么?
      console.log('document click');
    })
    window.addEventListener('click', () => {
        // 不会触发。为什么?
      console.log('document click');
    })
  }

另一个问题,React是什么时候removeEventListner的?目前的出来的结论是并没有。如下例子,在点击header时会触发React的DispatchEvent没有问题。但是在isShow为false后,点击span元素,仍然会触发DispatchEvent。因此目前的结论是,React并没有去移除无用的EventListner。这个问题欢迎在评论区交流

class App extends React.Component {

  constructor () {
      super();
      this.state = {
          isShow: true
      };
  }

  headerClick (event) {
    this.setState({
        isShow: false
    });
  }
  render () {
    return (
    <div className="App">
        {
            this.state.isShow ?
            <header className="App-header" onClick={(event) => this.headerClick(event)}>
            </header>
            : <span>点击</span>
        }
    </div>
    )};
}

深蓝一人
1.6k 声望65 粉丝

暂时没有个人简介


« 上一篇
vue实现梳理
下一篇 »
webkit理论知识