返回介绍

一次点击事件

发布于 2025-01-10 12:49:04 字数 12057 浏览 0 评论 0 收藏 0

如果你是一个经常使用 React 的打工人,就会发现 React 中的 event 是“阅后即焚的”。假设这样一段代码:

import React, { MouseEvent } from 'react'

function TestPersist () {

    const handleClick = (
        event: MouseEvent<HTMLElement, globalThis.MouseEvent>
    ) => {
    setTimeout(() => console.log('event', event))
  }

    return (
        <div onClick={handleClick}>O2</div>
    )
}

如果我们需要异步的获取这次点击事件在屏幕中的位置并且做出相应处理,那么在 setTimeout 中能否达到目的呢。

答案是否定的,因为 React 使用了 事件委托 机制,我们拿到的 event 对象并不是原生的 nativeEvent ,而是被 React 挟持处理过的合成事件 SyntheticEvent ,这一点从 ts 类型中也可以看出, 我们使用的 MouseEvent 是从 React 包中引入的而不是全局的默认事件类型。在 handleClick 函数同步执行完毕的一瞬间,这个 event 就已经在 React 事件池中被销毁了,我们可以跑这个组件康一康。

http://www.wenjiangs.com/wp-content/uploads/2024/docimg16/1005-hqbqgxpxnof.png

当然 React 也提供了使用异步事件对象的解决方案,它提供了一个 persist 函数,可以让事件不再进入事件池。(在 React17 中为了解决某些 issue ,已经重写了合成事件机制,事件不再由 document 来代理,官网的说法是 合成事件 不再由事件池管理,也没有了 persist 函数)

那,为什么要用事件委托呢。还是回到那个经典的命题,渲染 2 个 div 当然横着写竖着写都没关系,如果是 1000 个组件 2000 个点击事件呢。事件委托的收益就是:

  1. 简化了事件注册的流程,优化性能。
  2. dom 元素不断在更新,你无法保证下一帧的 div 和上一帧中的 div 在内存中的地址是同一个。既然不是同一个,事件又要全部重新绑定,烦死了(指浏览器)。

ok,言归正传。我们点击事件到底发生了什么呢。首先是在 React 的 render 函数执行之前,在 JS 脚本中就已经自动执行了事件的注入。

事件注入

事件注入的过程稍微有一点复杂,不光模块之间有顺序,数据也做了不少处理,这里不 po 太详细的代码。可能有人会问为啥不直接写死呢,浏览器的事件不也就那么亿点点。就像 Redux 不是专门为 React 服务的一样, React 也不是专门为浏览器服务的。文章开头也说了 React 只是一个 javascipt 库,它也可以服务 native 端、桌面端甚至各种终端。所以根据底层环境的不同动态的注入事件集也是非常合理的做法。

当然注入过程并不重要,我们需要知道的就是 React 安排了每种事件在 JSX 中的写法和原生事件的对应关系(例如 onClick 和 onclick ),以及事件的优先级。

/* ReactDOM 环境 */

// DOM 环境的事件 plugin
const DOMEventPluginOrder = [
  'ResponderEventPlugin',
  'SimpleEventPlugin',
  'EnterLeaveEventPlugin',
  'ChangeEventPlugin',
  'SelectEventPlugin',
  'BeforeInputEventPlugin',
];

// 这个文件被引入的时候自动执行 injectEventPluginOrder
// 确定 plugin 被注册的顺序,并不是真正引入
EventPluginHub.injectEventPluginOrder(DOMEventPluginOrder)

// 真正的注入事件内容
EventPluginHub.injectEventPluginByName({
  SimpleEventPlugin: SimpleEventPlugin
})

这里以 SimpleEventPlugin 为例,点击事件等我们平时常用的事件都属于这个 plugin。

// 事件元组类型
type EventTuple = [
  DOMTopLevelEventType, // React 中的事件类型
  string,               // 浏览器中的事件名称
  EventPriority         // 事件优先级
]

const eventTuples: EventTuple[] = [
  // 离散的事件
  // 离散事件一般指的是在浏览器中连续两次触发间隔最少 33ms 的事件(没有依据,我猜的)
  // 例如你以光速敲打键盘两次,这两个事件的实际触发时间戳仍然会有间隔
  [ DOMTopLevelEventTypes.TOP_BLUR, 'blur', DiscreteEvent ],
  [ DOMTopLevelEventTypes.TOP_CANCEL, 'cancel', DiscreteEvent ],
  [ DOMTopLevelEventTypes.TOP_CHANGE, 'change', DiscreteEvent ],
  [ DOMTopLevelEventTypes.TOP_CLICK, 'click', DiscreteEvent ],
  [ DOMTopLevelEventTypes.TOP_CLOSE, 'close', DiscreteEvent ],
  [ DOMTopLevelEventTypes.TOP_CONTEXT_MENU, 'contextMenu', DiscreteEvent ],
  [ DOMTopLevelEventTypes.TOP_COPY, 'copy', DiscreteEvent ],
  [ DOMTopLevelEventTypes.TOP_CUT, 'cut', DiscreteEvent ],
  [ DOMTopLevelEventTypes.TOP_DOUBLE_CLICK, 'doubleClick', DiscreteEvent ],
  [ DOMTopLevelEventTypes.TOP_AUX_CLICK, 'auxClick', DiscreteEvent ],
  [ DOMTopLevelEventTypes.TOP_FOCUS, 'focus', DiscreteEvent ],
  [ DOMTopLevelEventTypes.TOP_INPUT, 'input', DiscreteEvent ],
    ...
]

那么,这些事件的监听事件是如何被注册的呢。还记得在调和 Class 组件的时候会计算要向浏览器插入什么样的 dom 元素或是要如何更新 dom 元素。在这个过程中会通过 diffProperty 函数对元素的属性进行 diff 对比,其中通过 ListenTo 来添加监听函数

大家都知道,最终被绑定的监听事件一定是被 React 魔改过,然后绑定在 document 上的。

function trapEventForPluginEventSystem (
  element: Document | Element | Node,
  topLevelType: DOMTopLevelEventType,
  capture: boolean
): void {
    // 生成一个 listener 监听函数
  let listener
  switch (getEventPriority(topLevelType)) {
    case DiscreteEvent: {
      listener = dispatchDiscreteEvent.bind(
        null,
        topLevelType,
        EventSystemFlags.PLUGIN_EVENT_SYSTEM
      )
      break
    }
        ...
    default: {
      listener = dispatchEvent.bind(
        null,
        topLevelType,
        EventSystemFlags.PLUGIN_EVENT_SYSTEM
      )
    }
  }
  // @todo 这里用一个 getRawEventName 转换了一下
  // 这个函数就是 →_→
  // const getRawEventName = a => a
  // 虽然这个函数什么都没有做
  // 但是它的名字语义化的说明了这一步
  // 目的是得到浏览器环境下 addEventListener 第一个参数的合法名称
  const rawEventName = topLevelType
  // 将捕获事件 listener 挂载到根节点
  // 这两个部分都是为了为了兼容 IE 封装过的 addEventListener
  if (capture) {
    // 注册捕获事件
    addEventCaptureListener(element, rawEventName, listener)
  } else {
    // 注册冒泡事件
    addEventBubbleListener(element, rawEventName, listener)
  }
}

大家应该都知道 addEventListener 的第三个参数是控制监听捕获过程 or 冒泡过程的吧

http://www.wenjiangs.com/wp-content/uploads/2024/docimg16/1006-121jui3whqo.jpg

ok,right now,鼠标点了下页面,页面调用了这个函数。开局就一个 nativeEvent 对象,这个函数要做的第一件事就是知道真正被点的那个组件是谁,其实看了一些源码就知道, React 但凡有什么事儿第一个步骤总是找到需要负责的那个 fiber 。

首先,通过 nativeEvent 获取目标 dom 元素也就是 dom.target

const nativeEventTarget = getEventTarget(nativeEvent)
export default function getEventTarget(nativeEvent) {
  // 兼容写法
  let target = nativeEvent.target || nativeEvent.srcElement || window

  // Normalize SVG
  // @todo

  return target.nodeType === HtmlNodeType.TEXT_NODE ? target.parentNode : target
}

那么如何通过 dom 拿到这个 dom 对应的 fiber 呢,事实上, React 会给这个 dom 元素添加一个属性指向它对应的 fiber 。对于这个做法我是有疑问的,这样的映射关系也可以通过维护一个 WeekMap 对象来实现,操作一个 WeakMap 的性能或许会优于操作一个 DOM 的属性,且后者似乎不太优雅,如果你有更好的想法也欢迎在评论区指出。

每当 completeWork 中为 fiber 构造了新的 dom,都会给这个 dom 一个指针来指向它的 fiber

// 随机 Key
const randomKey = Math.random().toString(36).slice(2)

// 随机 Key 对应的当前实例的 Key
const internalInstanceKey = '__reactInternalInstance$' + randomKey
// Key 对应 render 之后的 props
const internalEventHandlersKey = '__reactEventHandlers$' + randomKey
// 对应实例
const internalContianerInstanceKey = '__reactContainer$' + randomKey

// 绑定操作
export function precacheFiberNode (
  hostInst: object,
  node: Document | Element | Node
): void {
  node[internalInstanceKey] = hostInst
}

// 读取操作
export function getClosestInstanceFromNode (targetNode) {
  let targetInst = targetNode[internalInstanceKey]
  // 如果此时没有 Key,直接返回 null
  if (targetInst) {
    return targetInst
  }

    // 省略了一部分代码
    // 如果这个 dom 上面找不到 internalInstanceKey 这个属性
  // 就会向上寻找父节点,直到找到一个拥有 internalInstanceKey 属性的 dom 元素
  // 这也是为什么这个函数名要叫做 从 node 获取最近的 (fiber) 实例
    ...

  return null
}

此时我们已经拥有了原生事件的对象,以及触发了事件的 dom 以及对应的 fiber ,就可以从 fiber.memorizedProps 中取到我们绑定的 onClick 事件。这些信息已经足够生成一个 React 合成事件 ReactSyntheticEvent 的实例了。

React 声明了一个全局变量 事件队列 eventQueue ,这个队列用来存储某次更新中所有被触发的事件,我们需要让这个点击事件入队。然后触发。

// 事件队列
let eventQueue: ReactSyntheticEvent[] | ReactSyntheticEvent | null = null

export function runEventsInBatch (
  events: ReactSyntheticEvent[] | ReactSyntheticEvent | null
) {
  if (events !== null) {
    // 存在 events 的话,加入事件队列

    // react 自己写的合并数组函数 accumulateInto
    // 或许是 ES3 时期写的吧
    eventQueue = accumulateInto<ReactSyntheticEvent>(eventQueue, events)
  }

    const processingEventQueue = eventQueue

  // 执行完毕之后要清空队列
  // 虽然已经这些 event 已经被释放了,但还是会被遍历
  eventQueue = null

  if (!processingEventQueue) return

    // 将这些事件逐个触发

  // forEachAccumulated 是 React 自己实现的 foreach
  forEachAccumulated(processingEventQueue, executeDispatchesAndReleaseTopLevel)
}
// 触发一个事件并且立刻将事件释放到事件池中,除非执行了 presistent
const executeDispatchesAndRelease = function (event: ReactSyntheticEvent) {
  if (event) {
    // 按照次序依次触发和该事件类型绑定的所有 listener
    executeDispatchesInOrder(event)
  }

  // 如果没有执行 persist 持久化 , 立即销毁事件
  if (!event.isPersistent()) {
    (event.constructor as any).release(event)
  }
}

可以看到合成事件的构造函数实例上挂载了一个函数 release ,用来释放事件。我们看一看 SyntheticEvent 的代码,可以发现这里使用了一个事件池的概念 eventPool 。

Object.assign(SyntheticEvent.prototype, {

    // 模拟原生的 preventDefault 函数
  preventDefault: function() {
    this.defaultPrevented = true;
    const event = this.nativeEvent;
    if (!event) {
      return;
    }

    if (event.preventDefault) {
      event.preventDefault();
    } else {
      event.returnValue = false;
    }
    this.isDefaultPrevented = functionThatReturnsTrue;
  },

  // 模拟原生的 stopPropagation
  stopPropagation: function() {
    const event = this.nativeEvent;
    if (!event) {
      return;
    }

    if (event.stopPropagation) {
      event.stopPropagation();
    } else {
      event.cancelBubble = true;
    }

    this.isPropagationStopped = functionThatReturnsTrue;
  },

  /**
   * 在每次事件循环之后,所有被 dispatch 过的合成事件都会被释放
   * 这个函数能够允许一个引用使用事件不会被 GC 回收
   */
  persist: function() {
    this.isPersistent = functionThatReturnsTrue;
  },

  /**
   * 这个 event 是否会被 GC 回收
   */
  isPersistent: functionThatReturnsFalse,

  /**
   * 销毁实例
   * 就是将所有的字段都设置为 null
   */
  destructor: function() {
    const Interface = this.constructor.Interface;
    for (const propName in Interface) {
      this[propName] = null;
    }
    this.dispatchConfig = null;
    this._targetInst = null;
    this.nativeEvent = null;
    this.isDefaultPrevented = functionThatReturnsFalse;
    this.isPropagationStopped = functionThatReturnsFalse;
    this._dispatchListeners = null;
    this._dispatchInstances = null;
  },
});

React 在构造函数上直接添加了一个事件池属性,其实就是一个数组,这个数组将被全局共用。每当事件被释放的时候,如果线程池的长度还没有超过规定的大小(默认是 10 ),那么这个被销毁后的事件就会被放进事件池

// 为合成事件构造函数添加静态属性
// 事件池为所有实例所共用
function addEventPoolingTo (EventConstructor) {
  EventConstructor.eventPool = []
  EventConstructor.getPooled = getPooledEvent
  EventConstructor.release = releasePooledEvent
}

// 将事件释放
// 事件池有容量的话,放进事件池
function releasePooledEvent (event) {
  const EventConstructor = this
  event.destructor()
  if (EventConstructor.eventPool.length < EVENT_POOL_SIZE) {
    EventConstructor.eventPool.push(event)
  }
}

我们都知道单例模式,就是对于一个类在全局最多只会有一个实例。而这种事件池的设计相当于是 n 例模式,每次事件触发完毕之后,实例都要还给构造函数放进事件池,后面的每次触发都将复用这些干净的实例,从而减少内存方面的开销。

// 需要事件实例的时候直接从事件池中取出
function getPooledEvent(dispatchConfig, targetInst, nativeEvent, nativeInst) {
  const EventConstructor = this
  if (EventConstructor.eventPool.length) {
    // 从事件池中取出最后一个
    const instance = EventConstructor.eventPool.pop()
    EventConstructor.call(
      instance,
      dispatchConfig,
      targetInst,
      nativeEvent,
      nativeInst
    )
    return instance
  }
  return new EventConstructor (
    dispatchConfig,
    targetInst,
    nativeEvent,
    nativeInst
  )
}

如果在短时间内浏览器事件被频繁触发,那么将出现的现象是,之前事件池中的实例都被取出复用,而后续的合成事件对象就只能被老老实实重新创建,结束的时候通过放弃引用来被 V8 引擎的 GC 回收。

回到之前的事件触发,如果不特地将属性名写成 onClickCapture 的话,那么默认将被触发的就会是冒泡过程。这个过程也是 React 模拟的,就是通过 fiber 逐层向上触发的方式,捕获过程也是同理。

我们都知道正常的事件触发流程是:

  1. 事件捕获
  2. 处于事件
  3. 事件冒泡

处于事件 阶段是一个 try-catch 语句,这样即使发生错误也会处于 React 的错误捕获机制当中。我们真正想要执行的函数实体就是在此被触发:

export default function invodeGuardedCallbackImpl<
  A,
  B,
  C,
  D,
  E,
  F,
  Context
>(
  name: string | null,
  func: (a: A, b: B, c: C, d: D, e: E, f: F) => void,
  context?: Context,
  a?: A,
  b?: B,
  c?: C,
  d?: D,
  e?: E,
  f?: F,
): void {
  const funcArgs = Array.prototype.slice.call(arguments, 3)
  try {
    func.apply(context, funcArgs)
  } catch (error) {
    this.onError(error)
  }
}

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文