听风是风

学或不学,知识都在那里,只增不减。

导航

react 八千字长文深入了解react合成事件底层原理,原生事件中阻止冒泡是否会阻塞合成事件?

壹 ❀ 引

在前面两篇文章中,我们花了较大的篇幅介绍reactsetState方法,在介绍setState同步异步时提到,在react合成事件中react对于this.state更新都是异步,但在原生事件中更新却是同步,这说明react在合成事件处理上必定与原生事件存在部分差异,那么本篇文章就来着重介绍react中的合成事件,在文章开始之前我们先罗列部分问题:

  • 了解原生事件监听机制吗?为什么需要事件代理?
  • react如何实现的合成事件?这么做的好处是什么?
  • 合成事件与原生事件执行先后顺序。
  • 合成事件阻止冒泡会阻塞原生事件吗?原生事件阻止冒泡会阻塞合成事件吗?

在文章开始前,大家可以先自行思考这些问题,假设这些在面试中遇到,你能回答多少呢?那么本文开始。

贰 ❀ 从原生事件说起

虽然本文的核心重点是介绍react的合成事件,但文章开头既然给了合成事件与原生事件相关对比问题,因此了解原生事件是有必要的,考虑到可能有同学对此类知识存在遗忘,这里做个简单复习。

首先让我们复习下事件监听api addEventListener,基本语法如下:

element.addEventListener(event, function, useCapture);

其中element表示你需要监听的dom元素;event表示监听的事件类型,比如onclick,onblurfunction表示触发事件后的回调,你需要做什么都可以写在这里;useCapture是一个布尔值,表示是否开启捕获阶段,默认false

以如下代码结构为例,当我们点击span时,必然会经历过捕获阶段----》目标阶段----》冒泡阶段

我们用一个例子复习这个过程:

<div id="div">
    我是div
    <p id="p">
        我是p
        <span id="span">我是span</span>
    </p>
</div>
const div = document.querySelector("#div");
const p = document.querySelector("#p");
const span = document.querySelector("#span");
// 捕获阶段,这里将useCapture设置为true
div.addEventListener("click",()=>console.log("捕获阶段--div"),true);
p.addEventListener("click",()=>console.log("捕获阶段--p"),true);
// 目标阶段
span.addEventListener("click",()=>console.log("目标阶段--span"));
// 冒泡阶段,useCapture默认false,不写了
div.addEventListener("click",()=>console.log("捕获阶段--div"));
p.addEventListener("click",()=>console.log("捕获阶段--p"));

既然提到了事件监听,那么有三个API就不得不提了,它们分别是event.preventDefaultevent.stopPropagationevent.stopImmediatePropagation。让我们先聊聊stopPropagation,此方法一般用于阻止冒泡,比如父子都绑定了点击事件,但点击子时我不希望父在冒泡阶段也被触发,因此通过在子的事件回调中添加此方法能做到这一点,修改上述例子中目标阶段的代码为:

span.addEventListener("click", (e) => {
    e.stopPropagation();
    console.log("目标阶段--span")
});

此时点击span会发现只会输出span,而冒泡阶段的divp都被阻止执行了。

关于event.preventDefault,此方法常用于阻止元素默认行为,比如点击a标签除了执行我们绑定的click事件外,它还会执行a标签默认的跳转。再或者form表达点击提交会将form的值传递给action指定地址并刷新页面,像这类行为我们均可以通过preventDefault阻止。

在介绍stopImmediatePropagation之前,我们需要知道事件监听相对于普通事件绑定的一大好处是,事件监听支持为同一dom监听多个行为,但如果是普通的事件绑定后者会覆盖前者:

span.onclick = ()=>console.log('事件绑定-1');
// 后绑定的事件会覆盖前面的绑定
span.onclick = ()=>console.log('事件绑定-2');
// 事件监听就不会存在覆盖,下面2个都会执行
span.addEventListener("click", (e) => {
    console.log("事件监听-1")
});
span.addEventListener("click", (e) => {
    console.log("事件监听-2")
});

那既然事件监听支持为同一dom绑定多个,我在执行了某个监听后,需要将其它监听都阻止掉怎么办?此时就轮到stopImmediatePropagation出场立大功了,看个例子:

// 捕获阶段
div.addEventListener("click", () => console.log("捕获阶段--div-1"), true);
div.addEventListener("click", () => console.log("捕获阶段--div-2"), true);
p.addEventListener("click", () => console.log("捕获阶段--p-1"), true);
p.addEventListener("click", () => console.log("捕获阶段--p-2"), true);
// 目标阶段
span.addEventListener("click", (e) => {
    e.stopImmediatePropagation();
    console.log("目标阶段---span-1")
});
span.addEventListener("click", (e) => {
    console.log("目标阶段---span-2")
});
// 冒泡阶段
div.addEventListener("click", () => console.log("捕获阶段--div-1"));
div.addEventListener("click", () => console.log("捕获阶段--div-2"));
p.addEventListener("click", () => console.log("捕获阶段--p-1"));
p.addEventListener("click", () => console.log("捕获阶段--p-2"));

可以看到stopImmediatePropagation同样会阻止事件冒泡,但除此之外,它还会阻止同一dom身上的其它事件执行。

那么聊完事件监听,什么是事件代理?在现实生活中,我们网购到公司的快递大部分都会由前台代签收,而不是分别送到我们每个人手上,此时前台就相当于做了一个代理的事情,原本需要不同的多个人分别签收的行为,统一与前台代理处理。

映射到代码中,假设有ul>li的结构,我们希望点击li显示出li的文本内容,如果给每个li绑定就得这么写:

<ul id="ul">
    <li onclick="handleClick(event)">1</li>
    <li onclick="handleClick(event)">2</li>
    <li onclick="handleClick(event)">3</li>
    <li onclick="handleClick(event)">4</li>
    <li onclick="handleClick(event)">5</li>
</ul>
const handleClick=(e)=>{
    console.log(e.target.innerHTML);
};

但如果通过事件代码,我们将点击行为委托给li共同的父元素ul,代码将更为清晰简单:

<ul id="ul">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
</ul>
const span = document.getElementById("span");
const handleClick = (e) => {
    span.innerHTML =`此时点击的是第${e.target.innerHTML}个li`;
};
const ul = document.querySelector("#ul");
ul.addEventListener("click", handleClick)

虽然事件被代理给了ul,但通过event.target我们还是能拿到实际操作的li。这让我想起了17我还在写JQ的年代,当li是动态遍历生成时,如果给li绑事件你会发现此时li其实是不存在的,这就导致事件绑定最终失败,而事件代理在性能更优的前景下更是巧妙解决了这一问题。

OK,关于原生事件监听与事件代理我们就介绍到这里,这部分知识也利于我们理解react的合成事件。

叁 ❀ 浅析合成事件原理(16.13.1)

叁 ❀ 壹 绑定阶段前置处理

当我们访问react官方关于合成事件的文档,第一能获取到的信息是react通过SyntheticEvent包装器来统一生成合成事件。需要注意的是react并不是独立创造了一套事件系统,所有的合成事件本质上依旧依赖了原生事件;而通过包装器react也对原生事件做了normalize操作,以达到抹平不同浏览器之间事件处理差异的目的。

还记得我们在介绍原生事件时提到的事件代理吗?出于性能优化考虑,react中的合成事件其实也做了类似处理,绝大多数的合成事件(并不是所有事件)最终都挂载在了document上,而非你定义组件的真实dom上,我们先初步了解这个概念,接下来我们通过源码层面来了解react合成事件的绑定阶段与执行阶段。

注意,我在查阅资料的过程中发现,不同react版本对于合成事件的处理其实是存在差异的,比如react 17中事件就不再注册在document上,而是你的组件所绑定的container上,我这里的源码版本我采用的是16.13.1,另外文中源码均可在react-dom.development.js文件中找到。

<button className="button" onClick={this.handleClick}>点击</button>

在上文我们已经提到,react合成事件其实依赖了原生事件,那么合成事件类型自然跟原生事件有着一一对应的关系,毕竟react的点击事件是驼峰的onClick,而原生的却是onclick,以上述代码为例,当react渲染到button时,发现此组件的props中有一个合成事件,理论上来说此时react要做的就是注册操作,找到对应onClick的原生事件类型,并做后续包装动作。

而对于事件类型,其实在react中提供了一个名为injectEventPluginsByName的事件分类插件,它会初始化阶段自执行注入,通过命名可以发现react做了不同事件类型的分类:

// 用于copy injectedNamesToPlugins的全局对象
var namesToPlugins = {};

// 这里的injectedNamesToPlugins就是下面自调用注入的不同事件插件对象,我删除了部分不影响理解的代码
function injectEventPluginsByName(injectedNamesToPlugins) {
  var isOrderingDirty = false;
  // 遍历所有插件对象
  for (var pluginName in injectedNamesToPlugins) {
    if (!injectedNamesToPlugins.hasOwnProperty(pluginName)) {
      continue;
    }
    // 按插件key依次获取value
    var pluginModule = injectedNamesToPlugins[pluginName];

    if (!namesToPlugins.hasOwnProperty(pluginName) || namesToPlugins[pluginName] !== pluginModule) {
      // 将插件对象按key-value依次赋值给全局对象namesToPlugins
      namesToPlugins[pluginName] = pluginModule;
      isOrderingDirty = true;
    }
  }

  if (isOrderingDirty) {
    recomputePluginOrdering();
  }
}
// 初始化阶段自执行,注入不同类型的事件插件
injectEventPluginsByName({
  SimpleEventPlugin: SimpleEventPlugin,
  EnterLeaveEventPlugin: EnterLeaveEventPlugin,
  ChangeEventPlugin: ChangeEventPlugin,
  SelectEventPlugin: SelectEventPlugin,
  BeforeInputEventPlugin: BeforeInputEventPlugin
});

出于好奇,我直接在初始化阶段断点,这里就能看到SimpleEventPlugin每个事件插件类型都包含eventTypesextractEvents两个对象

其中extractEvents就是事件最终要执行的函数,而eventTypes则包含了合成事件对应的原生事件相关信息:

在上述代码中的recomputePluginOrdering方法,我们继续往下跟,能找到下面这个方法:

// 这两个也是全局对象
var registrationNameModules = {};
var registrationNameDependencies = {};

function publishRegistrationName(registrationName, pluginModule, eventName) {
  // 建立合成事件名与事件插件的映射
  registrationNameModules[registrationName] = pluginModule;
  // 建立合成事件名与原生事件的映射
  registrationNameDependencies[registrationName] = pluginModule.eventTypes[eventName].dependencies;

  {
    var lowerCasedName = registrationName.toLowerCase();
    possibleRegistrationNames[lowerCasedName] = registrationName;

    if (registrationName === 'onDoubleClick') {
      possibleRegistrationNames.ondblclick = registrationName;
    }
  }
}

这个方法中也做了一件比较重要的事,它也为两个全局对象做了赋值操作,其中registrationNameModules用于保存合成事件名与事件插件的映射,比如某个合成事件属于哪个事件插件,通过断点我们能看到这个结构:

前文我们已经说过了,每个事件类型对象都包含eventTypes、extractEvents两个属性,所以上图的结构本质上等同于:

{
 onClick: SimpleEventPlugin,
 onClickCapture: SimpleEventPlugin,
 onChange: ChangeEventPlugin,
 onChangeCapture: ChangeEventPlugin,
 onMouseEnter: EnterLeaveEventPlugin,
 ...
}

registrationNameDependencies用于保存合成事件与原生事件的映射关系,比如某个合成事件是由哪些原生事件组合模拟的,同样断个点:

所以它的结构等同于:

{
  onClick: ['click'],
	onClickCapture: ['click'],
  onClose: ['close'],
	onCloseCapture: ['close'],
  onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange']
	onChangeCapture: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange']
}

你会惊奇的发现像合成事件onChange对应到原生居然有8个原生事件,这说明在react底层使用了多个原生事件组合模拟一个原生事件,也正因如此,react才能抹平不同浏览器事件差异性,让同一个合成事件达到相同的交互效果。

上面我们其实省略了很多中间代码,但总结来说,就是注入合成事件插件,然后对合成事件进行了多次遍历,跟剥洋葱似的,遍历每个事件类型,以及每个事件类型下的每个合成事件,从而得到了多个为后续注册服务的全局对象。

OK,前置条件说完了,那么一个组件上定义了一个onClick属性,react是如何将它绑定到document,现在正式介绍绑定阶段。

叁 ❀ 贰 绑定阶段

当一个组件初始化或者更新阶段,react总是要重新检查组件身上的props属性,看看属性中有没有与registrationNameModules能产生对应的,如果有那说明这个属性是一个合成事件名,这里以更新组件为例(你想看初始化可以跟setInitialDOMProperties这个方法):

function diffHydratedProperties(domElement, tag, rawProps, parentNamespace, rootContainerElement) {

  switch (tag) {
    case 'video':
    case 'audio':
      // Create listener for each media event
      for (var i = 0; i < mediaEventTypes.length; i++) {
        // 注意,这里绑定事件传递的是dom自身
        trapBubbledEvent(mediaEventTypes[i], domElement);
      }
      break;

    case 'source':
      trapBubbledEvent(TOP_ERROR, domElement);
      break;

    case 'select':
      ensureListeningTo(rootContainerElement, 'onChange');
      break;

    case 'textarea':
      ensureListeningTo(rootContainerElement, 'onChange');
      break;
  }
  // 遍历props
  for (var propKey in rawProps) {
    if (propKey === CHILDREN) {
      // 合成事件与插件映射如果能找到这个propKey,那说明是个合成事件
    } else if (registrationNameModules.hasOwnProperty(propKey)) {
      if (nextProp != null) {
        // 注册事件,注意这里传的是document
        ensureListeningTo(rootContainerElement, propKey);
      }
    } else if (){
      // ...
    }
  }
  return updatePayload;
}

这个方法巨长,我删除了很多多余的代码,这里提炼下信息,前文我们说绝大多数的事件最终都挂载在document上,原因其实就是在switch这里,像video这类媒体标签,document没办法模拟它们的事件,因此绑定传递的dom其实是domElement,就是说你们这类元素的事件太特殊了,我代劳不了,你们还是自己绑自己的。

出于好奇,我跟了下trapBubbledEvent这个方法,下面贴一下大致过程:

function trapBubbledEvent(topLevelType, element) {
  // 调用名为trapEventForPluginEventSystem的方法
  trapEventForPluginEventSystem(element, topLevelType, false);
}
// 这个方法跟上面唯一区别就是捕获为true
function trapCapturedEvent(topLevelType, element) {
  trapEventForPluginEventSystem(element, topLevelType, true);
}

function trapEventForPluginEventSystem(container, topLevelType, capture) {
  var listener;
	// 根据事件类型不同等级,对应生成最终的事件监听回调
  switch (getEventPriorityForPluginSystem(topLevelType)) {
    case DiscreteEvent:
      listener = dispatchDiscreteEvent.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);
      break;

    case UserBlockingEvent:
      listener = dispatchUserBlockingUpdate.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);
      break;

    case ContinuousEvent:
    default:
      listener = dispatchEvent.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);
      break;
  }
  // 获取事件名
  var rawEventName = getRawEventName(topLevelType);

  // 判断是不是捕获阶段,分别调用最终的事件监听方法
  if (capture) {
    addEventCaptureListener(container, rawEventName, listener);
  } else {
    addEventBubbleListener(container, rawEventName, listener);
  }
}

// 我们看下捕获与非捕获阶段的实现
function addEventBubbleListener(element, eventType, listener) {
  // 可以看到还真是直接绑定在dom上,且捕获为false
  element.addEventListener(eventType, listener, false);
}

function addEventCaptureListener(element, eventType, listener) {
  // 同样绑定在原神自身,但捕获为true
  element.addEventListener(eventType, listener, true);
}

一共三次方法调用:

  • trapBubbledEventtrapCapturedEvent是一对兄弟,它们为捕获与非捕获不同类型事件进行注册
  • trapEventForPluginEventSystem是上面两个方法都会调用的方法,在此方法内部,你会发现react还会根据事件名类型来定义不同事件等级,最终生成不同级别的事件回调callback,也就是listener,但最终它们又根据capture分别调用了不同方法。
  • 不管是addEventBubbleListener还是addEventCaptureListener,它们绑定执行都是我们再熟悉不过的addEventListener方法,只是此时的element并不是document而是元素自身。所以你会发现像video这类标签的事件绑定对象是也就是这些标签自己,而非document!!

OK,代码继续往下看,来到上上段代码中的rawProps遍历,这里的registrationNameModules.hasOwnProperty(propKey)就是检验你这个key是否存在于registrationNameModules(合成事件与事件插件的映射)中,如果有那说明你一定是个合成事件,然后咱们帮你绑定,紧接着执行:

ensureListeningTo(rootContainerElement, propKey);

注意,特殊的事件前面的switch已经做过特化处理了,能到这的肯定是平平无奇且document能代劳的事件。另外,有的同学不理解前面说的documentcontainer分别表示谁,咱们断个点分别演示下:

上图中我直接在createElement处断点,看一眼rootContainerElement,你会发现所谓的container其实就是我们创建应用的container

ReactDOM.render(element, container[, callback])

那么对应到我的demo中,其实就是一个id名为rootdiv

<div id="root"></div>

那么上文提到的document又是谁呢?在源码中有专门获取document的代码:

var ownerDocument = getOwnerDocumentFromRootContainer(rootContainerElement)

我们可以断点输出它,可以看到其实就是html

让我们回到事件绑定函数ensureListeningTo,看看它做了什么:

// rootContainerElement是容器元素,registrationName是合成事件名
function ensureListeningTo(rootContainerElement, registrationName) {
  // 判断我们的rootContainerElement是不是document或者代码片段
  var isDocumentOrFragment = rootContainerElement.nodeType === DOCUMENT_NODE || rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE;
  // 前面已经说过了这里的rootContainerElement是一个普通div,所以一定是false,取rootContainerElement.ownerDocument
  var doc = isDocumentOrFragment ? rootContainerElement : rootContainerElement.ownerDocument;
  // 这里的doc就是document
  legacyListenToEvent(registrationName, doc);
}

可能有同学看到传递的参数是rootContainerElement,就在想不是说绑定给document吗,怎么传递的是container,其实这个方法就是为了判断你传递的dom节点是不是document,是的话直接用,不是的话就取rootContainerElement.ownerDocument。而上述代码因为我们已知rootContainerElement是一个div容器,因此isDocumentOrFragment一定是false,那么doc取值就自然是rootContainerElement.ownerDocument,我们同样可以断点看看这个属性输出什么,结果如下:

其实还是document......总结来说,ensureListeningTo方法就是为了确保你的事件最终帮在document上!

方法最后执行了legacyListenToEvent,同理看看代码:

// registrationName合成事件名 mountAt是document
function legacyListenToEvent(registrationName, mountAt) {
  // 拿到document上目前已经监听过的对象
  var listenerMap = getListenerMapForElement(mountAt);
  // 获取合成事件名所对应的原生事件数组
  var dependencies = registrationNameDependencies[registrationName];
	// 遍历原生对象数组,依次调用legacyListenToTopLevelEvent进行挂载
  for (var i = 0; i < dependencies.length; i++) {
    var dependency = dependencies[i];
    legacyListenToTopLevelEvent(dependency, mountAt, listenerMap);
  }
}

这个方法大家看注释应该就很清楚了,registrationNameDependencies就是前面我们专门解释过的合成事件名与对应原生事件的映射,然后遍历开始进行注册。OK,我们接着看registrationNameDependencies代码:

// topLevelType原生事件名  mountAt此时是document listenerMap ducument此时已经监听过的对象
function legacyListenToTopLevelEvent(topLevelType, mountAt, listenerMap) {
  // 已经监听过的就不要重复监听了,没监听过的才会执行内部代码
  if (!listenerMap.has(topLevelType)) {
    switch (topLevelType) {
      case TOP_SCROLL:
        trapCapturedEvent(TOP_SCROLL, mountAt);
        break;

      case TOP_FOCUS:
      case TOP_BLUR:
        trapCapturedEvent(TOP_FOCUS, mountAt);
        trapCapturedEvent(TOP_BLUR, mountAt); // We set the flag for a single dependency later in this function,
			// 这里我删除了一部分代码
      default:
        // By default, listen on the top level to all non-media events.
        // Media events don't bubble so adding the listener wouldn't do anything.
        var isMediaEvent = mediaEventTypes.indexOf(topLevelType) !== -1;

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

        break;
    }

    listenerMap.set(topLevelType, null);
  }
}

legacyListenToTopLevelEvent方法看着很长,但其实做的事情很简单,根据已知的listenerMap判断当前原生事件之前有没有被绑定没,没绑定那就执行绑定,而方法内部一共就只出现了trapCapturedEventtrapBubbledEvent这两个绑定事件方法,大家可以直接在本文搜索这两个方法名,你会发现它两就是上文已经解释过的两个方法,而且最终都走到了element.addEventListener(eventType, listener, true/false)这一句。

那么到这里,我们完整解释了事件监听阶段的整个过程,你知道了不同合成事件是如何对应到原生事件,以及最终是怎么样挂在到document亦或者元素自身之上的,那么我们紧接着介绍执行阶段。

叁 ❀ 叁 执行阶段

在说执行阶段之前,我们还是得想一想执行阶段执行什么,在绑定阶段,我们知道最终react还是会执行如下代码:

element.addEventListener(eventType, listener, false);

而这里的listener照理说就应该是事件触发后执行的callback,那这个listener是怎么生成的?它跟我写在react代码中真正的执行回调又是如何关联的?这就得再次回到上面已经解释过的trapEventForPluginEventSystem方法。

trapEventForPluginEventSystem方法中我们说会根据事件优先级分别调用dispatchDiscreteEventdispatchUserBlockingUpdate或者dispatchEvent来生成listener,但我在尝试跟前两个方法过程中发现,这两个方法最终都是调用了dispatchEvent这个方法,以dispatchUserBlockingUpdate为例:

listener = dispatchUserBlockingUpdate.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);

function dispatchUserBlockingUpdate(topLevelType, eventSystemFlags, container, nativeEvent) {
  // 本质上还是调用的dispatchEvent.bind()来生成的listener
  runWithPriority(UserBlockingPriority, dispatchEvent.bind(null, topLevelType, eventSystemFlags, container, nativeEvent));
}

因此我们只用将目光放到dispatchEvent上即可,上代码:

/**
 * 
 * @param {*} topLevelType 原生事件名
 * @param {*} eventSystemFlags 一个数字常量1
 * @param {*} container 监听事件的容器
 * @param {*} nativeEvent event对象
 * @returns 
 */
function dispatchEvent(topLevelType, eventSystemFlags, container, nativeEvent) {
	// 删除多余代码
  {
    // 最终又调用了一个han'shu
    dispatchEventForLegacyPluginEventSystem(topLevelType, eventSystemFlags, nativeEvent, null);
  }
} 

function dispatchEventForLegacyPluginEventSystem(topLevelType, eventSystemFlags, nativeEvent, targetInst) {
  var bookKeeping = getTopLevelCallbackBookKeeping(topLevelType, nativeEvent, targetInst, eventSystemFlags);

  try {
    // Event queue being processed in the same cycle allows
    // `preventDefault`.
    batchedEventUpdates(handleTopLevel, bookKeeping);
  } finally {
    releaseTopLevelCallbackBookKeeping(bookKeeping);
  }
}

我们省略了dispatchEvent中多余的代码,发现它最终执行了dispatchEventForLegacyPluginEventSystem,进一步跟进,此方法一共做了三件事,获取bookKeeping对象,调用批量事件更新事件batchedEventUpdates(本质上又调用handleTopLevel),以及调用完成后又执行releaseTopLevelCallbackBookKeeping存储bookKeeping方法达到复用目的,无奈继续看handleTopLevel实现:

function handleTopLevel(bookKeeping) {
  var targetInst = bookKeeping.targetInst; 
  var ancestor = targetInst;
	// 这里一直在while,遍历保存现有dom结构
  do {
    if (!ancestor) {
      var ancestors = bookKeeping.ancestors;
      ancestors.push(ancestor);
      break;
    }
		// 寻找当前节点信息的父节点,往上冒泡
    var root = findRootContainerNode(ancestor);

    if (!root) {
      break;
    }

    var tag = ancestor.tag;
    if (tag === HostComponent || tag === HostText) {
      bookKeeping.ancestors.push(ancestor);
    }
    ancestor = getClosestInstanceFromNode(root);
  } while (ancestor);
	
  for (var i = 0; i < bookKeeping.ancestors.length; i++) {
    targetInst = bookKeeping.ancestors[i];
    var eventTarget = getEventTarget(bookKeeping.nativeEvent);
    var topLevelType = bookKeeping.topLevelType;
    var nativeEvent = bookKeeping.nativeEvent;
    var eventSystemFlags = bookKeeping.eventSystemFlags; // If this is the first ancestor, we mark it on the system flags

    if (i === 0) {
      eventSystemFlags |= IS_FIRST_ANCESTOR;
    }
		// 最终生成合成事件的方法
    runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, eventTarget, eventSystemFlags);
  }
}

handleTopLevel方法的作用其实注释有解释,考虑到事件回调可能改变现有的DOM结构,导致先深度遍历保存现有的组件层次结构。而从代码解释上来看,findRootContainerNode很明显就是在找当前节点元素的父元素,如果有父继续while循环,这很明显就是在做一个冒泡操作,紧接着下面的for循环也正是在根据冒泡的顺序依次调用runExtractedPluginEventsInBatch来生成合成事件。

function runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  // 生成合成事件
  var events = extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
  // 执行事件
  runEventsInBatch(events);
}

可能你现在看到extractPluginEvents已经有点陌生了,但在文章前面我们介绍合成事件名与事件插件映射属性registrationNameModules时,有介绍每个对象上都有一个extractEvents属性,而这个属性就是为了将我们代码中所写的事件回调,绑定到生成的合成事件上:

function extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  var events = null;

  for (var i = 0; i < plugins.length; i++) {
    var possiblePlugin = plugins[i];
    if (possiblePlugin) {
      // 获取每个事件插件的extractEvents方法,用于生成合成事件
      var extractedEvents = possiblePlugin.extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);

      if (extractedEvents) {
        events = accumulateInto(events, extractedEvents);
      }
    }
  }

  return events;
}

这个方法内有一个全局对象plugins,这个玩意其实就是一个存放了事件插件对象的数组:

var plugins = [LegacySimpleEventPlugin, LegacyEnterLeaveEventPlugin, ...];

所以上述其实就在遍历事件插件,并尝试生成对应的合成事件,因此我们可以看看extractEvents的内部实现:

extractEvents: function (topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  var dispatchConfig = topLevelEventsToDispatchConfig.get(topLevelType);

  if (!dispatchConfig) {
    return null;
  }

  var EventConstructor;

  switch (topLevelType) {
    case TOP_KEY_PRESS:
      if (getEventCharCode(nativeEvent) === 0) {
        return null;
      }
    case TOP_KEY_DOWN:
    case TOP_KEY_UP:
      EventConstructor = SyntheticKeyboardEvent;
      break;

    case TOP_BLUR:
    case TOP_FOCUS:
      EventConstructor = SyntheticFocusEvent;
      break;
    default:
      EventConstructor = SyntheticEvent;
      break;
  }

  var event = EventConstructor.getPooled(dispatchConfig, targetInst, nativeEvent, nativeEventTarget);
  accumulateTwoPhaseDispatches(event);
  return event;
}

代码中我省略了一部分case分支情况,但不管哪种情况,都会出现类似SyntheticFocusEvent以及SyntheticKeyboardEvent这类方法,稍微看了实现代码,发现这些构建器其实都是通过SyntheticEvent.extend继承而来的子类,而且在代码最后的switch default执行,默认也赋予SyntheticEvent这个构造器。

在拿到构造器后紧接着调用了EventConstructor.getPooled从事件池中获取合成事件实例,这也解释了为什么react官网一开始就说合成事件是由SyntheticEvent包装器生成而来。

我们可以上述代码中的accumulateTwoPhaseDispatches继续往下跟:

function accumulateTwoPhaseDispatchesSingle(event) {
  if (event && event.dispatchConfig.phasedRegistrationNames) {
    traverseTwoPhase(event._targetInst, accumulateDirectionalDispatches, event);
  }
}

// 模拟两个阶段的遍历,捕获/冒泡事件分派。
function traverseTwoPhase(inst, fn, arg) {
  var path = [];
  while (inst) {
    path.push(inst);
    inst = getParent(inst);
  }
  var i;
  for (i = path.length; i-- > 0;) {
    fn(path[i], 'captured', arg);
  }
  for (i = 0; i < path.length; i++) {
    fn(path[i], 'bubbled', arg);
  }
}

function accumulateDirectionalDispatches(inst, phase, event) {
  var listener = listenerAtPhase(inst, event, phase);
  if (listener) {
    event._dispatchListeners = accumulateInto(event._dispatchListeners, listener);
    event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);
  }
}

其中traverseTwoPhase方法至关重要,这个函数的官方注释也说的尤为清楚,通过正向反向遍历模拟事件捕获与事件冒泡阶段,而它所执行的fn其实就是函数accumulateDirectionalDispatches,这个函数内部的主要职责便是找到节点上事件定义的回调,并将其加入到生成的合成事件event_dispatchListeners属性中,直到这里,我们走完了合成事件的生成(onClickCaptureonClick执行顺序差异原来是在合成事件生成阶段通过不同方向遍历来绑定模拟的)以及与合成事件我们定义的callback建立联系。

让我们再次回到runExtractedPluginEventsInBatch方法,去看一看runEventsInBatch方法。

function runEventsInBatch(events) {
  var processingEventQueue = eventQueue;
  eventQueue = null;
	// 删除多余代码,最终执行
  forEachAccumulated(processingEventQueue, executeDispatchesAndReleaseTopLevel);
}


var executeDispatchesAndReleaseTopLevel = function (e) {
  return executeDispatchesAndRelease(e);
};

代码很简单,然后我的目光就被executeDispatchesAndReleaseTopLevel这个方法所吸引,直译过来就是事件执行派发与释放,因此我们继续跟进executeDispatchesAndRelease这个方法:

var executeDispatchesAndRelease = function (event) {
  // 如果事件存在,那就按顺序执行派发事件
  if (event) {
    executeDispatchesInOrder(event);
    if (!event.isPersistent()) {
      event.constructor.release(event);
    }
  }
};

function executeDispatchesInOrder(event) {
  var dispatchListeners = event._dispatchListeners;
  var dispatchInstances = event._dispatchInstances;
  {
    validateEventDispatches(event);
  }
  if (Array.isArray(dispatchListeners)) {
    for (var i = 0; i < dispatchListeners.length; i++) {
      if (event.isPropagationStopped()) {
        break;
      }

      executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
    }
  }
}

进一步跟进,终于定位到了executeDispatchesInOrder方法,而且我们甚至看到了在合成事件生成阶段,将事件回调与合成事件与之关联的event._dispatchListeners对象,在此方法内部就是按照绑定顺序,依次遍历进行执行。

那么经过长篇大论的代码跟踪,我们总算是粗略的跟完了合成事件的生成、绑定与执行三个阶段

肆 ❀ 合成事件与原生事件执行先后

在了解完合成事件后,我不禁有一个疑问,如果我给一个dom同时绑定合成事件与原生事件,到底谁会先执行呢?来看个例子:

class Echo extends Component {
  componentDidMount() {
    const parentDom = ReactDOM.findDOMNode(this);
    const childrenDom = parentDom.querySelector(".button");
    childrenDom.addEventListener('click', this.onDomClick, false);
  }
  onDomClick = (e) => {
    console.log('原生事件click');
  }
  onReactClick = () => {
    console.log('合成事件click');
  }
  render() {
    return (
      <div>
        <button className="button" onClick={this.onReactClick}>点击</button>
      </div>
    )
  }
}

为什么原生事件比合成事件快呢?通过上面的源码分析,其实很容易联想到,在冒泡到document之前,原生事件已经被触发,这之后才到了document开始事件派发,遍历数组进行react合成事件callback的执行,合成事件慢的合情合理。

哎?那如果我们同时给一个dom绑定原生捕获事件与合成捕获事件呢?那按照这个说法,document在最顶层,那是不是应该合成捕获事件要早于原生捕获事件执行呢?来看个例子:

class Echo extends Component {
  componentDidMount() {
    const parentDom = ReactDOM.findDOMNode(this);
    const childrenDom = parentDom.querySelector(".button");
    childrenDom.addEventListener('click', this.onDomClick, true);
  }
  onDomClick = (e) => {
    console.log('原生事件捕获click');
  }
  onReactClick = () => {
    console.log('合成事件捕获click');
  }
  render() {
    return (
      <div>
        <button className="button" onClickCapture={this.onReactClick}>点击</button>
      </div>
    )
  }
}

怎么还是原生事件早于合成事件的捕获阶段?????

在合成事件生成源码分析中,我们介绍了handleTopLevel方法提到,合成事件是在当前节点冒泡不断向上搜集同名的合成事件回调,并且在traverseTwoPhase这个方法中,通过正向负向两个遍历,去模拟的捕获与冒泡,说直白,根本不存在所谓的合成事件捕获,其实全都是靠冒泡搜集事件后,控制遍历顺序,来模拟了捕获与冒泡的事件执行顺序!!!

function traverseTwoPhase(inst, fn, arg) {
  var path = [];
  while (inst) {
    path.push(inst);
    inst = getParent(inst);
  }
  var i;
  // 捕获倒序遍历
  for (i = path.length; i-- > 0;) {
    fn(path[i], 'captured', arg);
  }
  for (i = 0; i < path.length; i++) {
    // 冒泡正向遍历
    fn(path[i], 'bubbled', arg);
  }
}

因此合成事件的捕获,说到底还是在原生事件冒泡之后,因为我不冒泡事件你都没搜集其,捕获个啥呢?

所以总结来说,合成事件不管捕获还是冒泡都晚于原生事件,结合之前的源码分析,非常合情合理!!来看下面这个例子加深印象:

class Echo extends Component {
  componentDidMount() {
    const parentDom = ReactDOM.findDOMNode(this);
    const childrenDom = parentDom.querySelector(".button");

    childrenDom.addEventListener('click', this.onDomChildClick, false);
    childrenDom.addEventListener('click', this.onDomChildClickCapture, true);
    parentDom.addEventListener('click', this.onDomParentClick, false);
    parentDom.addEventListener('click', this.onDomParentClickCapture, true);
  }
  onDomChildClick = (e) => {
    console.log('原生事件child--冒泡');
  }
  onDomChildClickCapture = (e) => {
    console.log('原生事件child--捕获');
  }
  onDomParentClick = (e) => {
    console.log('原生事件parent--冒泡');
  }
  onDomParentClickCapture = (e) => {
    console.log('原生事件parent--捕获');
  }
  onReactChildClick = () => {
    console.log('合成事件child--捕获');
  }
  onReactParentClick = () => {
    console.log('合成事件parent--捕获');
  }
  render() {
    return (
      <div className="parent" onClickCapture={this.onReactParentClick}>
        <button className="button" onClick={this.onReactChildClick}>点击</button>
      </div>
    )
  }
}

总结合成事件与原生事件执行顺序:

  • 合成事件不管冒泡阶段还是捕获阶段,都要晚于原生事件冒泡阶段
  • 不管合成事件还是原生事件,冒泡阶段都要晚于捕获阶段

伍 ❀ 阻止原生事件冒泡,会阻断合成事件执行吗

相信到这里,你应该能不假思索的回答,如果在原生事件中阻止冒泡,那么事件执行都到不了document,合成事件自然没机会去执行了,还是上面那个例子,我们修改如下代码:

onDomChildClick = (e) => {
  e.stopPropagation()
  console.log('原生事件child--冒泡');
}

在子元素原生冒泡阶段阻止冒泡,可以看到执行如下,整个合成事件都被阻止执行了。

原因其实在上面源码分析的executeDispatchesInOrder方法中已经给出了答案:

if (Array.isArray(dispatchListeners)) {
  for (var i = 0; i < dispatchListeners.length; i++) {
    // 如果阻止冒泡,直接break跳出循环
    if (event.isPropagationStopped()) {
      break;
    }
    executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
}}

反过来呢?如果我们在合成事件冒泡阶段阻止冒泡,会影响原生事件吗?我想你心里已经有答案了:

class Echo extends Component {
  componentDidMount() {
    const parentDom = ReactDOM.findDOMNode(this);
    const childrenDom = parentDom.querySelector(".button");
    childrenDom.addEventListener('click', this.onDomChildClick, false);
    parentDom.addEventListener('click', this.onDomParentClick, false);
  }
  onDomChildClick = (e) => {
    console.log('原生事件child--冒泡');
  }
  onDomParentClick = (e) => {
    console.log('原生事件parent--冒泡');
  }
  onReactChildClick = (e) => {
    e.stopPropagation()
    console.log('合成事件child--冒泡');
  }
  onReactParentClick = (e) => {
    console.log('合成事件parent--冒泡');
  }
  render() {
    return (
      <div className="parent" onClick={this.onReactParentClick}>
        <button className="button" onClick={this.onReactChildClick}>点击</button>
      </div>
    )
  }
}

那么到这里,我们就解释了合成事件阻止冒泡对于原生事件的影响,当然在实际开发中,我们尽量还是别混用原生事件与合成事件。

陆 ❀ 总

那么到这里,我大概阐述完了本文想要表达的观点,本文从最初立初步知识概念,到读源码梳理知识点,前前后后花了一个星期的零散时间,说来也是惭愧。不过到最后我对于这块的知识点还是清楚了一些,至少在面试阶段如果有面试官问到,多少是能聊一点了。而文中谈到的原生事件是否会阻塞合成事件这个问题,也确实是我同事在面试金山过程中所被问到的问题,现在你也已经知道答案了。

通过本文,你顺带复习了原生事件监听与代理,也粗略了解了react 16.13.1版本对于合成事件的底层实现;也正因如此我们顺利解释了原生事件与合成事件的执行差异,以及阻止冒泡对于彼此的影响,那么到这里本文结束。

参考

React合成事件和DOM原生事件混用须知

深入学习 React 合成事件

由浅到深的React合成事件

React 事件系统工作原理

posted on 2021-11-28 00:00  听风是风  阅读(687)  评论(0编辑  收藏  举报