Published on

React Event Model

Authors

事件流

在浏览器中,javascriptHTML之间的交互是通过事件去实现的。在一整个页面的 DOM 结构中可能存在许许多多的事件,点击的click事件、加载的load事件、鼠标指针悬浮的mouseover事件等等。那么当用户在触发某个事件的时候,绑定在 DOM 中的事件是怎么执行的呢?

例如我们有如下的 DOM 结构:

<div>
  <p>Click me!</p>
</div>

这就会涉及事件流的概念。事件流描述的是从页面中接受事件的机制。事件发生后会在目标节点和根节点之间按照特定的顺序传播,路径经过的节点都会接收到事件。

事件捕获

假设在节点p上触发了click事件,此时会产生两种事件传递顺序。Netscape提出一种事件流名为事件捕获(event capturing)。事件会从最外层开始发生,直到用户产生行为的元素。 针对上面的DOM结构,传递顺序如下:

document -> html -> body -> div -> p

最后到达用户触发的元素p,此时也被称为事件目标阶段。

事件冒泡

在到达事件目标阶段后,微软提出了名为事件冒泡(event bubbling)的事件流,也就是事件会从最内层的元素开始发生,一直向上传播,直到document对象。 针对我们上面的DOM结构,传递顺序如下:

1-4是捕获阶段,4-5是目标阶段,5-8是冒泡阶段。

addEventListener

DOM2级事件中规定的事件流同时支持了事件捕获阶段和事件冒泡阶段,而作为开发者,可以选择事件处理函数在哪一个阶段被调用。

addEventListener 方法用来为一个特定的元素绑定一个事件处理函数,是JavaScript中的常用方法。addEventListener有三个参数:

element.addEventListener(event, function, useCapture)

event,必须。字符串,指定事件名。所有 HTML DOM 事件,可以查看完整的 HTML DOM Event 对象参考手册。

function,必须。指定要事件触发时执行的函数。当事件对象会作为第一个参数传入函数。事件对象的类型取决于特定的事件。

useCapture,可选。布尔值,指定事件是否在捕获或冒泡阶段执行。可能值:true,事件在捕获阶段执行(即在事件捕获阶段调用处理函数);false,默认。事件在冒泡阶段执行(即表示在事件冒泡的阶段调用事件处理函数。

事件代理(委托)

利用事件流的特性,可以实现一种叫做事件代理的方法。 例如如下demo,有一些li元素,当点击每个li元素时,输出该元素的颜色:

<ul class="color_list">
  <li>red</li>
  <li>orange</li>
  <li>yellow</li>
  <li>green</li>
  <li>blue</li>
  <li>purple</li>
</ul>
<div class="box"></div>

<style>
  .color_list {
    display: flex;
    display: -webkit-flex;
  }
  .color_list li {
    width: 100px;
    height: 100px;
    list-style: none;
    text-align: center;
    line-height: 100px;
  }
  //每个li加上对应的颜色,此处省略 
.box {
    width: 600px;
    height: 150px;
    background-color: #cccccc;
    line-height: 150px;
    text-align: center;
  }
</style>

如果想要在点击每个li标签时,输出li当中的颜色(innerHTML)。常规做法是遍历每个li,然后在每个li上绑定一个点击事件。

var color_list = document.querySelector('.color_list')
var colors = color_list.getElementsByTagName('li')
var box = document.querySelector('.box')
for (var n = 0; n < colors.length; n++) {
  colors[n].addEventListener('click', function () {
    console.log(this.innerHTML)
    box.innerHTML = '该颜色为 ' + this.innerHTML
  })
}

这种做法在li较少时可以使用,但如果有一万个li,那就会导致性能降低。这时候使用事件代理,利用事件流的特性,只绑定一个事件处理函数就可以完成:

function colorChange(e) {
  var e = e || window.event //兼容性的处理
  if (e.target.nodeName.toLowerCase() === 'li') {
    box.innerHTML = '该颜色为 ' + e.target.innerHTML
  }
}
color_list.addEventListener('click', colorChange, false)

由于事件冒泡机制,点击了li会冒泡到ul,此时就会触发绑定在ul上的点击事件,再利用target找到事件实际发生的元素,就可以达到预期的效果。

使用事件代理的好处不仅在于将多个事件处理函数减为一个,而且对于不同的元素可以有不同的处理方法。例如上述列表元素当中添加了其它的元素节点(如:a、span等),不必再一次循环给每一个元素绑定事件,直接修改事件代理的处理函数即可。

React的事件模型

在使用react的jsx来编写代码时,以点击事件为例,通常是这样定义的:

function App() {
  return <div onClick={add}></div>
}

可以看到在jsx中定义的onClick明显不是DOM原生的事件名,这其实是react植根于浏览器事件模型实现的一套事件系统。react会将所有dom事件都绑定到document上面,而不是某一个元素上,统一的使用事件监听,并在冒泡阶段处理事件,所以当在挂载或销毁组件时,只需要在统一的事件监听上增加或者删除对象,当事件被触发时,组件会生成一个合成事件,然后传递到document中,document会通过dispatchEvent回调函数依次执行dispatchListener中同类型事件监听函数。

在react初始化时对事件进行挂载,react应用的入口函数:

ReactDOM.createRoot(document.getElementById('root')).render(<App />)

在入口函数createRoot中,container参数是整个应用的跟DOM挂载点,在开始执行render函数时,为根DOM节点挂载自定义的click事件。

export function createRoot(container) {
  const root = createContainer(container)

  return {
    render(element) {
      // 挂载click事件
      initEvent(container, 'click')
      // ...省略
    },
  }
}

eventType只被允许传入支持的事件,首先定义一个事件列表:

const validEventTypeList = ['click']
export function initEvent(container, eventType) {
  // 验证是否支持指定的事件类型
  if (!validEventTypeList.includes(eventType)) {
    console.warn('当前不支持', eventType, '事件')
    return
  }
  // 绑定事件
  container.addEventListener(eventType, (e) => {
    dispatchEvent(container, eventType, e)
  })
}

为根节点绑定事件,当事件被触发时,调用dispatchEvent回调函数依次执行dispatchListener中同类型事件监听函数。 既然要实现整个DOM的事件模型,自然也要实现原生DOM的整个事件流程,包括事件捕获和事件冒泡。 所以在dispatchEvent函数中需要完成的事件如下:

  • 收集沿途的事件
  • 构造合成事件
  • 执行事件捕获列表
  • 执行事件冒泡列表
function dispatchEvent(container, eventType, e) {
  const targetElement = e.target

  if (targetElement === null) {
    console.warn('事件不存在target', e)
    return
  }

  // 1. 收集沿途的事件
  const { bubble, capture } = collectPaths(targetElement, container, eventType)
  // 2. 构造合成事件
  const se = createSyntheticEvent(e)

  // 3. 遍历captue
  triggerEventFlow(capture, se)
  // 阻止冒泡事件执行
  if (!se.__stopPropagation) {
    // 4. 遍历bubble
    triggerEventFlow(bubble, se)
  }
}

收集沿途的事件

在一个事件被触发时,首先就要收集目标DOM节点及其所有父节点定义的所有事件,根据目标事件对象,查找所有父级onClickonClickCapture事件,react默认的事件流方式是冒泡,如果想要在捕获阶段触发,可以添加onClickCapture来定义事件。 首先创建一个函数来映射事件名:

function getEventCallbackNameFromEventType(eventType) {
  return {
    click: ['onClickCapture', 'onClick'],
  }[eventType]
}

getEventCallbackNameFromEventType函数通过接收一个DOM事件名来获取对应的捕获及冒泡的事件名列表。 为了方便获取节点中的属性,也就是根据事件名称获取对应节点的事件处理函数,使用一个自定义属性来保存所有的节点属性,并在初始化以及更新流程保存。

export const elementPropsKey = '__props'
// 接收节点和属性
export function updateFiberProps(node, props) {
  node[elementPropsKey] = props
}

render流程中处理props初始化以及更新是一个合适的时机。在completeWork函数的执行时对节点的props进行保存:

export const completeWork = (wip: FiberNode) => {
	const newProps = wip.pendingProps;
	const current = wip.alternate;

	switch (wip.tag) {
		case HostComponent:
			if (current !== null && wip.stateNode) {
				// update 更新流程
				// 1. props是否变化 {onClick: xx} {onClick: xxx}
				updateFiberProps(wip.stateNode, newProps);
			} else {
				// mount 初始化流程
				// 1. 构建DOM
				const instance = createInstance(wip.type, newProps);
				// ...省略
			}

			return null;
		case HostText:
			// ...省略
			return null;
		case HostRoot:
			// ...省略
		case FunctionComponent:
			// ...省略
		default:
			if (__DEV__) {
				console.warn('未处理的completeWork情况', wip);
			}
			break;
	}
};

  • 初始化

初始化时直接在创建fiber节点的真实DOM时保存props。

export const createInstance = (type, props) => {
  // 处理props
  const element = document.createElement(type)
  // 初始化时保存props
  updateFiberProps(element, props)
  return element
}
  • 更新

节点的更新阶段直接将新的props重新保存。

updateFiberProps(wip.stateNode, newProps)

在收集事件的过程中使用节点的parentNode属性向上查找,直到根节点 定义两个数组,分别收集捕获与冒泡事件的处理函数,但是在逐层收集时,捕获事件使用unshift(前插),而冒泡使用push(后增)。这样做的原因是,在两个数组收集完毕后两者的顺序完全不同:

capture: [-- >]
bubble: [-- >]
两者的顺序刚好相反,capture父节点的事件处理函数在前,子节点的在后,bubble子节点的事件处理函数在前,父节点的在后。 在后续遍历这两个数组执行事件回调函数的时候,执行顺序正好与DOM事件流的触发顺序一致。先由父到子逐层捕获,再由子到父逐层冒泡。
function collectPaths(targetElement, container, eventType) {
  // 定义事件收集数组
  const paths = {
    capture: [],
    bubble: [],
  }

  while (targetElement && targetElement !== container) {
    // 收集
    const elementProps = targetElement[elementPropsKey]
    if (elementProps) {
      // 获取事件映射
      const callbackNameList = getEventCallbackNameFromEventType(eventType)
      if (callbackNameList) {
        callbackNameList.forEach((callbackName, i) => {
          // 获取事件回调函数
          const eventCallback = elementProps[callbackName]
          if (eventCallback) {
            if (i === 0) {
              // capture
              paths.capture.unshift(eventCallback)
            } else {
              // bubble
              paths.bubble.push(eventCallback)
            }
          }
        })
      }
    }
    targetElement = targetElement.parentNode
  }
  return paths
}

构造合成事件

构造合成事件可以使我们更灵活的添加一些额外的处理逻辑。通过劫持事件对象一些原生的方法,添加额外的逻辑。 下面这段逻辑劫持了事件原生的阻止冒泡的方法stopPropagation,使用自定义属性__stopPropagation来控制是否阻止冒泡,然后重写stopPropagation方法。

function createSyntheticEvent(e: Event) {
	const syntheticEvent = e;
	// 初始化__stopPropagation
	syntheticEvent.__stopPropagation = false;
	// 保存原有的stopPropagation
	const originStopPropagation = e.stopPropagation;
	// 自定义stopPropagation执行,执行原有的stopPropagation方法
	syntheticEvent.stopPropagation = () => {
		syntheticEvent.__stopPropagation = true;
		if (originStopPropagation) {
			originStopPropagation();
		}
	};
	return syntheticEvent;
}

执行事件流

执行事件流就是依次遍历定义的capture数组和bubble数组。当然,如果设置了自定义属性__stopPropagationtrue,那么会阻止冒泡(阻止bubble数组的遍历执行)。

function triggerEventFlow(paths, se) {
  for (let i = 0; i < paths.length; i++) {
    const callback = paths[i]
    // 执行事件处理函数
    callback.call(null, se)

    if (se.__stopPropagation) {
      break
    }
  }
}