谈谈React事件机制和未来(react-events)
当我们在组件上设置事件处理器时,React并不会在该DOM元素上直接绑定事件处理器. React内部自定义了一套事件系统,在这个系统上统一进行事件订阅和分发.
具体来讲,React利用事件委托机制在Document上统一监听DOM事件,再根据触发的target将事件分发到具体的组件实例。另外上面e是一个合成事件对象(SyntheticEvent), 而不是原始的DOM事件对象.
文章大纲
截止本文写作时,React版本是16.8.6
那为什么要自定义一套事件系统?
如果了解过Preact(笔者之前写过一篇文章解析Preact的源码),Preact裁剪了很多React的东西,其中包括事件机制,Preact是直接在DOM元素上进行事件绑定的。
在研究一个事物之前,我首先要问为什么?了解它的动机,才有利于你对它有本质的认识。
React自定义一套事件系统的动机有以下几个:
1. 抹平浏览器之间的兼容性差异。 这是估计最原始的动机,React根据W3C 规范来定义这些合成事件(SyntheticEvent), 意在抹平浏览器之间的差异。
另外React还会试图通过其他相关事件来模拟一些低版本不兼容的事件, 这才是‘合成’的本来意思吧?。
2. 事件‘合成’, 即事件自定义。事件合成除了处理兼容性问题,还可以用来自定义高级事件,比较典型的是React的onChange事件,它为表单元素定义了统一的值变动事件。另外第三方也可以通过React的事件插件机制来合成自定义事件,尽管很少人这么做。
3. 抽象跨平台事件机制。 和VirtualDOM的意义差不多,VirtualDOM抽象了跨平台的渲染方式,那么对应的SyntheticEvent目的也是想提供一个抽象的跨平台事件机制。
4. React打算做更多优化。比如利用事件委托机制,大部分事件最终绑定到了Document,而不是DOM节点本身. 这样简化了DOM事件处理逻辑,减少了内存开销. 但这也意味着,React需要自己模拟一套事件冒泡的机制。
5. React打算干预事件的分发。v16引入Fiber架构,React为了优化用户的交互体验,会干预事件的分发。不同类型的事件有不同的优先级,比如高优先级的事件可以中断渲染,让用户代码可以及时响应用户交互。
Ok, 后面我们会深入了解React的事件实现,我会尽量不贴代码,用流程图说话。
基本概念
整体的架构
- ReactEventListener - 事件处理器. 在这里进行事件处理器的绑定。当DOM触发事件时,会从这里开始调度分发到React组件树
- ReactEventEmitter - 暴露接口给React组件层用于添加事件订阅
- EventPluginHub - 如其名,这是一个‘插件插槽’,负责管理和注册各种插件。在事件分发时,调用插件来生成合成事件
Plugin - React事件系统使用了插件机制来管理不同行为的事件。这些插件会处理自己感兴趣的事件类型,并生成合成事件对象。目前ReactDOM有以下几种插件类型:
- SimpleEventPlugin - 简单事件, 处理一些比较通用的事件类型,例如click、input、keyDown、mouseOver、mouseOut、pointerOver、pointerOut
EnterLeaveEventPlugin - mouseEnter/mouseLeave和pointerEnter/pointerLeave这两类事件比较特殊, 和
*over/*leave
事件相比, 它们不支持事件冒泡,*enter
会给所有进入的元素发送事件, 行为有点类似于:hover
; 而*over
在进入元素后,还会冒泡通知其上级. 可以通过这个实例观察enter和over的区别.如果树层次比较深,大量的mouseenter触发可能导致性能问题。另外其不支持冒泡,无法在Document完美的监听和分发, 所以ReactDOM使用
*over/*out
事件来模拟这些*enter/*leave
。ChangeEventPlugin - change事件是React的一个自定义事件,旨在规范化表单元素的变动事件。
它支持这些表单元素: input, textarea, select
SelectEventPlugin - 和change事件一样,React为表单元素规范化了select(选择范围变动)事件,适用于input、textarea、contentEditable元素.
- BeforeInputEventPlugin - beforeinput事件以及composition事件处理。
本文主要会关注
SimpleEventPlugin
的实现,有兴趣的读者可以自己阅读React的源代码.EventPropagators 按照DOM事件传播的两个阶段,遍历React组件树,并收集所有组件的事件处理器.
- EventBatching 负责批量执行事件队列和事件处理器,处理事件冒泡。
SyntheticEvent 这是‘合成’事件的基类,可以对应DOM的Event对象。只不过React为了减低内存损耗和垃圾回收,使用一个对象池来构建和释放事件对象, 也就是说SyntheticEvent不能用于异步引用,它在同步执行完事件处理器后就会被释放。
SyntheticEvent也有子类,和DOM具体事件类型一一匹配:
- SyntheticAnimationEvent
- SyntheticClipboardEvent
- SyntheticCompositionEvent
- SyntheticDragEvent
- SyntheticFocusEvent
- SyntheticInputEvent
- SyntheticKeyboardEvent
- SyntheticMouseEvent
- SyntheticPointerEvent
- SyntheticTouchEvent
- ….
事件分类与优先级
SimpleEventPlugin将事件类型划分成了三类, 对应不同的优先级(优先级由低到高):
- DiscreteEvent 离散事件. 例如blur、focus、 click、 submit、 touchStart. 这些事件都是离散触发的
- UserBlockingEvent 用户阻塞事件. 例如touchMove、mouseMove、scroll、drag、dragOver等等。这些事件会’阻塞’用户的交互。
- ContinuousEvent 可连续事件。例如load、error、loadStart、abort、animationEnd. 这个优先级最高,也就是说它们应该是立即同步执行的,这就是Continuous的意义,即可连续的执行,不被打断.
可能要先了解一下React调度(Schedule)的优先级,才能理解这三种事件类型的区别。截止到本文写作时,React有5个优先级级别:
Immediate
- 这个优先级的任务会同步执行, 或者说要马上执行且不能中断UserBlocking
(250ms timeout) 这些任务一般是用户交互的结果, 需要即时得到反馈 .Normal
(5s timeout) 应对哪些不需要立即感受到的任务,例如网络请求Low
(10s timeout) 这些任务可以放后,但是最终应该得到执行. 例如分析通知Idle
(no timeout) 一些没有必要做的任务 (e.g. 比如隐藏的内容).
目前ContinuousEvent对应的是Immediate优先级; UserBlockingEvent对应的是UserBlocking(需要手动开启); 而DiscreteEvent对应的也是UserBlocking, 只不过它在执行之前,先会执行完其他Discrete任务。
本文不会深入React Fiber架构的细节,有兴趣的读者可以阅读文末的扩展阅读列表.
实现细节
现在开始进入文章正题,React是怎么实现事件机制?主要分为两个部分: 绑定和分发.
事件是如何绑定的?
为了避免后面绕晕了,有必要先了解一下React事件机制中的插件协议。 每个插件的结构如下:
export type EventTypes = {[key: string]: DispatchConfig}; |
eventTypes声明该插件负责的事件类型, 它通过DispatchConfig
来描述:
export type DispatchConfig = { |
看一下实例:
上面列举了三个典型的EventPlugin:
SimpleEventPlugin - 简单事件最好理解,它们的行为都比较通用,没有什么Trick, 例如不支持事件冒泡、不支持在Document上绑定等等. 和原生DOM事件是一一对应的关系,比较好处理.
EnterLeaveEventPlugin - 从上图可以看出来,
mouseEnter
和mouseLeave
依赖的是mouseout
和mouseover
事件。也就是说*Enter/*Leave
事件在React中是通过*Over/*Out
事件来模拟的。这样做的好处是可以在document上面进行委托监听,还有避免*Enter/*Leave
一些奇怪而不实用的行为。ChangeEventPlugin - onChange是React的一个自定义事件,可以看出它依赖了多种原生DOM事件类型来模拟onChange事件.
另外每个插件还会定义extractEvents
方法,这个方法接受事件名称、原生DOM事件对象、事件触发的DOM元素以及React组件实例, 返回一个合成事件对象,如果返回空则表示不作处理. 关于extractEvents的细节会在下一节阐述.
在ReactDOM启动时就会向EventPluginHub
注册这些插件:
EventPluginHubInjection.injectEventPluginsByName({ |
Ok, 回到正题,事件是怎么绑定的呢? 打个断点看一下调用栈:
前面调用栈关于React树如何更新和渲染就不在本文的范围内了,通过调用栈可以看出React在props初始化和更新时会进行事件绑定。这里先看一下流程图,忽略杂乱的跳转:
- 1. 在props初始化和更新时会进行事件绑定。首先React会判断元素是否是
媒体类型
,媒体类型的事件是无法在Document监听的,所以会直接在元素上进行绑定 - 2. 反之就在Document上绑定. 这里面需要两个信息,一个就是上文提到的’事件依赖列表’, 比如
onMouseEnter
依赖mouseover/mouseout
; 第二个是ReactBrowserEventEmitter维护的’已订阅事件表’。事件处理器只需在Document订阅一次,所以相比在每个元素上订阅事件会节省很多资源.
代码大概如下:
export function listenTo( |
- 接下来就是根据事件的’优先级’和’捕获阶段’(是否是capture)来设置事件处理器:
function trapEventForPluginEventSystem( |
事件绑定的过程还比较简单, 接下来看看事件是如何分发的。
事件是如何分发的?
按惯例还是先上流程图:
事件触发调度
通过上面的trapEventForPluginEventSystem
函数可以知道,不同的事件类型有不同的事件处理器, 它们的区别是调度的优先级不一样:
// 离散事件 |
最终不同的事件类型都会调用dispatchEvent
函数. dispatchEvent
中会从DOM原生事件对象获取事件触发的target,再根据这个target获取关联的React节点实例.
export function dispatchEvent(topLevelType: DOMTopLevelEventType, eventSystemFlags: EventSystemFlags, nativeEvent: AnyNativeEvent): void { |
接着(中间还有一些步骤,这里忽略)会调用EventPluginHub
的runExtractedPluginEventsInBatch
,这个方法遍历插件列表来处理事件,生成一个SyntheticEvent列表:
export function runExtractedPluginEventsInBatch( |
插件是如何处理事件?
现在来看看插件是如何处理事件的,我们以SimpleEventPlugin
为例:
const SimpleEventPlugin: PluginModule<MouseEvent> & { |
SimpleEventPlugin
的extractEvents
主要做以下三个事情:
- 1️⃣ 根据事件的类型确定SyntheticEvent构造器
- 2️⃣ 构造SyntheticEvent对象。
- 3️⃣ 根据DOM事件传播的顺序获取用户事件处理器列表
为了避免频繁创建和释放事件对象导致性能损耗(对象创建和垃圾回收),React使用一个事件池来负责管理事件对象,使用完的事件对象会放回池中,以备后续的复用。
这也意味着,在事件处理器同步执行完后,SyntheticEvent对象就会马上被回收,所有属性都会无效。所以一般不会在异步操作中访问SyntheticEvent事件对象。你也可以通过以下方法来保持事件对象的引用:
- 调用
SyntheticEvent#persist()
方法,告诉React不要回收到对象池 - 直接引用
SyntheticEvent#nativeEvent
, nativeEvent是可以持久引用的,不过为了不打破抽象,建议不要直接引用nativeEvent
构建完SyntheticEvent对象后,就需要遍历组件树来获取订阅该事件的用户事件处理器了:
function accumulateTwoPhaseDispatchesSingle(event) { |
遍历方法其实很简单:
export function traverseTwoPhase(inst, fn, arg) { |
accumulateDirectionalDispatches
函数则是简单查找当前节点是否有对应的事件处理器:
function accumulateDirectionalDispatches(inst, phase, event) { |
例如下面的组件树, 遍历过程是这样的:
最终计算出来的_dispatchListeners
队列是这样的:[handleB, handleC, handleA]
批量执行
遍历执行插件后,会得到一个SyntheticEvent列表,runEventsInBatch
就是批量执行这些事件中的_dispatchListeners
事件队列
export function runEventsInBatch( |
OK, 到这里React的事件机制就基本介绍完了,这里只是简单了介绍了一下SimpleEventPlugin
, 实际代码中还有很多事件处理的细节,限于篇幅,本文就不展开去讲了。有兴趣的读者可以亲自去观摩React的源代码.
未来
React内部有一个实验性的事件API,React内部称为React Flare
、正式名称是react-events
, 通过这个API可以实现跨平台、跨设备的高级事件封装.
react-events定义了一个事件响应器(Event Responders)的概念,这个事件响应器可以捕获子组件树或应用根节点的事件,然后转换为自定义事件.
比较典型的高级事件是press、longPress、swipe这些手势。通常我们需要自己或者利用第三方库来实现这一套手势识别, 例如
import Gesture from 'rc-gesture'; |
那么react-events的目的就是提供一套通用的事件机制给开发者来实现’高级事件’的封装, 甚至实现事件的跨平台、跨设备, 现在你可以通过react-events来封装这些手势事件.
react-events除了核心的Responder
接口,还封装了一些内置模块, 实现跨平台的、常用的高级事件封装:
- Focus module
- Hover module
- Press module
- FocusScope module
- Input module
- KeyBoard module
- Drag module
- Pan module
- Scroll module
- Swipe module
举Press
模块作为例子, Press模块会响应它包裹的元素的press事件。press事件包括onContextMenu、onLongPress、onPress、onPressEnd、onPressMove、onPressStart等等. 其底层通过mouse、pen、touch、trackpad等事件来转换.
看看使用示例:
import { PressResponder, usePressListener } from 'react-events/press'; |
react-events的运作流程图如下, 事件响应器(Event Responders)会挂载到host节点,它会在host节点监听host或子节点分发的原生事件(DOM或React Native), 并将它们转换/合并成高级的事件:
初探Responder的创建
我们挑一个简单的模块来了解一些react-events的核心API, 目前最简单的是Keyboard模块. Keyboard模块的目的就是规范化keydown和keyup事件对象的key属性(部分浏览器key属性的行为不一样),它的实现如下:
/** |
再来看看dispatchKeyboardEvent:
function dispatchKeyboardEvent( |
导出Responder:
// ⚛️createResponder把keyboardResponderImpl转换为组件形式 |
现在读者应该对Responder的职责有了一些基本的了解,它主要做以下几件事情:
- 声明要监听的原生事件(如DOM), 如上面的
targetEventTypes
- 处理和转换合成事件,如上面的
onEvent
- 创建并分发自定义事件。如上面的
context.dispatchEvent
和上面的Keyboard模块相比,现实中的很多高级事件,如longPress, 它们的实现则要复杂得多. 它们可能要维持一定的状态、也可能要独占响应的所有权(即同一时间只能有一个Responder可以对事件进行处理, 这个常用于移动端触摸手势,例如React Native的GestureResponderSystem)。
react-events目前都考虑了这些场景, 看一下API概览:
详细可以看react-events官方仓库
react-events意义何在?
上文提到了React事件内部采用了插件机制,来实现事件处理和合成,比较典型的就是onChange事件。onChange事件其实就是所谓的‘高级事件’,它是通过表单组件的各种原生事件来模拟的。
也就是说,React通过插件机制本质上是可以实现高级事件的封装的。但是如果读者看过源代码,就会觉得里面逻辑比较绕,而且依赖React的很多内部实现。所以这种内部的插件机制并不是面向普通开发者的。
react-events
接口就简单很多了,它屏蔽了很多内部细节,面向普通开发者。我们可以利用它来实现高性能的自定义事件分发,更大的意义是通过它可以实现跨平台/设备的事件处理方式.
目前react-events还是实验阶段,特性是默认关闭,API可能会出现变更, 所以不建议在生产环境使用。可以通过这个Issue来关注它的进展。
最后赞叹一下React团队的创新能力!
完!