React 源码揭秘 | 合成事件

前面的文章大致讲解了React具体的更新,渲染流程。这篇简单说一些细枝末节的点,即React合成事件的原理。

开始之前,依旧贴一下我的React实现

https://github.com/Gravity2333/My-React/tree/learn

为什么需要合成事件

合成事件 SyntheticEvent 设计的目的,用来统一不同浏览器之间事件处理的行为差异。在老版本浏览器(尤其IE)之间,事件名称,阻止默认行为的方法等等一些特性存在差异。 React对事件处理的逻辑进行一层封装,统一了事件的处理流程。

在新版本React中引入了优先级的概念,对于不同的事件,其优先程度是不一样的。比如用户的点击事件 click 就要比一些连续事件(比如scroll resize mousemove ) 的优先级更高,希望得到更快的处理,此时就可以通过在合成事件中引入调度器Scheduler 来按照优先级调度处理事件。

合成事件原理

传统的浏览器事件流动分为3个过程

  1. 捕获阶段,事件从根节点html开始,到事件触发节点,依次触发对应事件

  2. 目标阶段,事件到达元素本身

  3. 冒泡阶段,事件从目标target向上流动回html元素

如下图:

React采用了事件委托的原理来模拟整个事件流动的过程。

事件委托

在需要监听事件节点的祖先节点上设置监听函数,把子元素的事件统一交给父元素去监听,通过事件冒泡机制识别目标,从而节省内存、提高性能、简化管理。

React 所管理渲染的节点通常会被挂载在 container容器上,所以用来委托的父元素也就是container元素节点。如下:

在我们调用 root.render时,render函数内部会调用一个 initEvent函数,其定义在SynsenticEvent.ts文件内部,如下:

TypeScript 复制代码
// react-dom/index.ts

/** 创建根节点的入口 */
export function createRoot(container: Container) {
  // 创建FiberRootNode
  const root = createContainer(container);
  return {
    render(element: ReactElement) {
      // TODO
      // 初始化合成事件
      initEvent(container);
      // 更新contianer
      return updateContainer(element, root);
    },
  };
}

// events/SynrgeticEvent.ts

/** 初始化合成事件 */
export function initEvent(container: Container) {
  /** 本质上就是在Container上的事件委托 */
  nativeEvents.forEach((nativeEvent) => {
    /** 对每种支持的原生事件 构建委托 */
    container.addEventListener(
      nativeEvent,
      dispatchSyntheticEvent.bind(null, container, nativeEvent)
    );
  });
}

可以看到 initEvent函数内部在container元素上,对React所支持的所有事件类型都绑定了一个监听函数,nativeEvents变量被定义在 event/events.ts中,包含了浏览器中所有的原生事件名称

TypeScript 复制代码
// event/events.ts
/** 可以代理的原生事件 */
export const nativeEvents = [
  "click",
  "contextmenu",
  "dblclick",
  "mousedown",
  "mouseenter",
  "mouseleave",
  "mousemove",
  "mouseout",
...
]

也就是说,对于每种事件类型,React都在container节点上,绑定一个统一的处理函数。container的所有子节点(React管理的所有节点)触发的同类型事件,都由container上的一个统一事件处理函数处理。

dispatchSyntheticEvent

处理函数 dispatchSyntheticEvent 用来模拟事件的 捕获 目标 冒泡 三个阶段的逻辑,其主要过程包含

  1. 收集从container节点 -> 事件触发节点 路径上所有节点所绑定的对应事件的处理函数

  2. 通过MonkeyPatch扩展stopPropagation 函数,用来控制是否继续传递事件

  3. 按照捕获 目标 冒泡三个阶段,调用路径上的处理函数

具体实现如下:

TypeScript 复制代码
// event/syntheticEvent.ts
/**
 * 触发合成事件
 * @param container 委托的container
 * @param eventType 原生事件类型
 * @param event 事件对象
 */
function dispatchSyntheticEvent(
  container: Container,
  eventType: string,
  event: Event
) {
  // 收集事件路径上的代理事件
  const collectedEvents = collectEvents(container, eventType, event);

  // 没有任何代理事件,提前结束
  if (
    collectedEvents.bubbleCallbacks?.length === 0 &&
    collectedEvents.captureCallbacks?.length == 0
  ) {
    return;
  }

  // 代理阻止冒泡事件
  event[stopPropagationKey] = false;

  // 原始的 stopPropagation 函数
  const originStopPropagation = event.stopPropagation;

  // 使用Monkey Patch打补丁 包装一层 stopPropagation函数
  event.stopPropagation = () => {
    event[stopPropagationKey] = true;
    originStopPropagation();
  };

  // 执行捕获事件
  triggerEventListeners(collectedEvents.captureCallbacks, event);
  if (!event[stopPropagationKey]) {
    triggerEventListeners(collectedEvents.bubbleCallbacks, event);
  }
}
collectEvents 收集事件

React定义了一个 CollectedEvents对象,用来存储从container到target沿途收集到的事件处理函数,其ts定义如下

TypeScript 复制代码
type CollectedEvents = {
  captureCallbacks: SyntheticEventListener[];
  bubbleCallbacks: SyntheticEventListener[];
};

可以看到,其中包含了captureCallbacks 和 bubbleCallbacks两个数组,分别存储捕获处理函数和冒泡处理函数,这个对象就是作为collectEvents的返回结果,具体收集过程如下:

TypeScript 复制代码
/**
 * 从target到source 收集冒泡/捕获事件
 * @param source 一般为代理节点 container
 * @param eventType 事件类型 比如 click hover ...
 * @param event 事件对象本身
 * @returns
 */
function collectEvents(
  source: Element,
  eventType: string,
  event: Event
): CollectedEvents {
  /** 收集的事件对象 */
  const events: CollectedEvents = {
    captureCallbacks: [],
    bubbleCallbacks: [],
  };
  // 收集顺序 从 target -> source 初始化收集节点为 target (事件触发节点)
  let currentNode = event.target as Element;
  // 根据事件类型,获取react合成事件代理的回调函数名 比如 click => [onClickCapture,onClick]
  const reactEvent = reactEvents[eventType];
  // 没有匹配到 合成事件无法处理!
  if (!reactEvent) return events;
  while (currentNode !== source) {
    // 从target收集到source
    // 当前收集节点的 所有属性 props
    const nodeProps = getFiberProps(currentNode);
    // 收集事件处理函数
    if (nodeProps[reactEvent[1]]) {
      // 冒泡事件
      events.bubbleCallbacks.push(nodeProps[reactEvent[1]]);
    }
    if (nodeProps[reactEvent[0]]) {
      // 捕获事件, 注意捕获顺序是反向收集的
      events.captureCallbacks.unshift(nodeProps[reactEvent[0]]);
    }
    currentNode = currentNode.parentNode as Element;
  }

  return events;
}

CollectEvents函数内部会先获得当前收集的事件所对应的事件处理函数,比如 我们通过 onClick/onClickCapture来绑定某个节点的click事件的冒泡捕获阶段回调。

这个事件通过ReactEvents字典获得,其中包含了所有原生事件对应的 捕获 和 冒泡函数名称(即我们通常绑定事件所用的属性名) 如下

TypeScript 复制代码
// React 合成事件对象
export const reactEvents = {
  click: ["onClickCapture", "onClick"],
  contextmenu: ["onContextMenuCapture", "onContextMenu"],
  dblclick: ["onDoubleClickCapture", "onDoubleClick"],
  mousedown: ["onMouseDownCapture", "onMouseDown"],
  mouseenter: ["onMouseEnterCapture", "onMouseEnter"],
  mouseleave: ["onMouseLeaveCapture", "onMouseLeave"],
  ...
]

拿到对应的事件函数名,就开始从目标元素 target 向上收集到 container元素,每经过一个路径上的元素节点,都会检查,其上是否绑定了当前收集事件对应的处理函数

我们对某个节点绑定一个事件 比如onClick事件,那么对应的回调函数会被保存在这个DOM节点的 __prop属性上,我们可以通过 __prop来拿到一个DOM节点上所绑定的所有属性信息,包括事件处理函数!

TypeScript 复制代码
/** 合成事件 */
const elementPropsKey = "__props";

/** 判断是否为事件 */
const isEvent = (key) => reactEventSet.has(key);

/** 获取事件 */
export function getFiberEvents(node: Element) {
  const _prop = node[elementPropsKey];
  return _prop.filter(isEvent);
}

如果存在对应的捕获处理函数(onClickCapture)那么就会从captureEvents数组的前面 unshift入数组,如果存在冒泡处理函数 (onClick) 就会从后方push到bubbleEvents内部,如下:

最终收集结果events如下

此时,按顺序便利 captureEvents和bubbleEvents两个数字,就是事件捕获 目标 冒泡的顺序了。

处理stopPropagation

收集完事件处理函数之后,就可以按顺序执行事件处理函数了,但是在这之前,react需要知道什么时候停止事件的传播。

在传统dom事件监听处理中,event事件上提供了一个stopPropagation 函数,调用后,浏览器内部会停止事件的传播。

但是在我们模拟的合成事件中,我们无法知道何时停止传播,所以就需要在事件上绑定一个 是否停止传播的状态,当某个事件处理函数调用了stopPropagation之后,改变这个状态即可。

这就需要我们用Monkey Patch的方式,对stopPropagation函数进行一层包装替换:

TypeScript 复制代码
/** 阻止冒泡Key */
const stopPropagationKey = "__stopPropagation";

 
// 代理阻止冒泡事件
  event[stopPropagationKey] = false;

  // 原始的 stopPropagation 函数
  const originStopPropagation = event.stopPropagation;

  // 使用Monkey Patch打补丁 包装一层 stopPropagation函数
  event.stopPropagation = () => {
    event[stopPropagationKey] = true;
    originStopPropagation();
  };

当我们调用替换之后的stopPropagation函数后,会先改变 event.__stopPropagation属性的值,再调用原生的stopPropagation ` 函数

triggerEvents 执行事件处理函数

最后一步,就是顺序执行事件处理函数,并且在每执行完一个事件处理函数之后,检查当前的 __stopPropagation属性是否为true,如果是就停止执行过程

TypeScript 复制代码
  // 执行捕获事件
  triggerEventListeners(collectedEvents.captureCallbacks, event);
  if (!event[stopPropagationKey]) {
   // 执行冒泡事件
    triggerEventListeners(collectedEvents.bubbleCallbacks, event);
  }


/* 执行事件 */
function triggerEventListeners(
  listeners: SyntheticEventListener[],
  event: Event
) {
  for (let i = 0; i < listeners.length; i++) {
    const listener = listeners[i];
    // 获得时间对应的优先级,并且交给scheduler调度
    scheduler.runWithPriority(eventTypeToSchedulerPriority(event.type), () => {
      listener(event);
    });

    // 结束传播
    if (!event[stopPropagationKey]) {
      break;
    }
  }
}

优先级的处理

合成事件的一大优势就是给不同类型的事件引入了不同的处理优先级。

比如,用户的点击事件click和缩放事件resize同时触发,哪个重要一点?

传统的事件处理,浏览器采用谁先触发,谁先被放到宏任务队列中,谁先执行的方式,就会有些问题。

由于没有优先级的概念,对于resize 拖拽这种每秒可能触发几百次的 连续事件,可能会影响到点击事件的响应时间,可能用户点击之后需要先等待连续事件处理完才能得到回馈,这就造成了用户体验的下降。

react合成事件对不同的原生事件进行分类,根据优先级不同将事件分成

立即执行事件 (click )> 用户阻塞事件(input) > 可以推迟的事件(连续事件 scroll resize)

这个分类被保存在 event/events.ts内

TypeScript 复制代码
/** 事件转优先级 */
export function eventTypeToSchedulerPriority(eventType: string) {
  switch (eventType) {
    case "click":
    case "keydown":
    case "keyup":
    case "keydown":
    case "keypress":
    case "keyup":
    case "focusin":
    case "focusout":
      return PriorityLevel.IMMEDIATE_PRIORITY;

    case "input":
    case "change":
    case "submit":
    case "focus":
    case "blur":
    case "select":
    case "drag":
    case "drop":
    case "pause":
    case "play":
    case "waiting":
    case "ended":
    case "canplay":
    case "canplaythrough":
      return PriorityLevel.USER_BLOCKING_PRIORITY;
    case "scroll":
    case "resize":
    case "mousemove":
    case "mouseenter":
    case "mouseleave":
    case "touchstart":
    case "touchmove":
    case "touchend":
      return PriorityLevel.NORMAL_PRIORITY;
    case "abort":
    case "load":
    case "loadeddata":
    case "loadedmetadata":
    case "error":
    case "durationchange":
      return PriorityLevel.LOW_PRIORITY;

    default:
      return PriorityLevel.IDLE_PRIORITY;
  }
}

由上到下优先级逐渐降低,对于click这种立即执行优先级的函数,会被调度器同步执行,确保不被阻塞!

在triggerEvents函数中,会根据事件的类型,通过上述分类匹配到对应的优先级,将处理函数根据优先级交给scheduler调度器进行调度运行

TypeScript 复制代码
    // 获得时间对应的优先级,并且交给scheduler调度
    scheduler.runWithPriority(eventTypeToSchedulerPriority(event.type), () => {
      listener(event);
    });

通过这种方式,就能保证事件根据对应的优先级被处理,并且先处理高优先级事件触发的渲染re

我们就完成了对原生事件的流动和处理的模拟。

相关推荐
ziyue75756 小时前
vue修改element-ui的默认的class
前端·vue.js·ui
树叶会结冰6 小时前
HTML语义化:当网页会说话
前端·html
冰万森6 小时前
解决 React 项目初始化(npx create-react-app)速度慢的 7 个实用方案
前端·react.js·前端框架
牧羊人_myr6 小时前
Ajax 技术详解
前端
浩男孩6 小时前
🍀封装个 Button 组件,使用 vitest 来测试一下
前端
蓝银草同学6 小时前
阿里 Iconfont 项目丢失?手把手教你将已引用的 SVG 图标下载到本地
前端·icon
布列瑟农的星空7 小时前
重学React —— React事件机制 vs 浏览器事件机制
前端
一小池勺7 小时前
CommonJS
前端·面试