1、概览
React实现自己封装了一套事件系统,基本原理为将所有的事件都代理到顶层元素上(如documen元素)上进行处理,带来的好处有:
- 抹平各平台的兼容性问题,其中不仅包括不同浏览器之间的差异,而且在RN上也能带来一致的开发体验。
- 更好的性能。事件代理是开发中常见的优化手段,React更进一步,包括复用合成事件类、事件池、批量更新等进一步提高性能。
本文基于React 16.8.1
2、几个小问题
在详细讲解之前,先思考几个问题,可以帮助我们更好理解React的事件系统。
-
React事件系统与原生事件混用的执行顺序问题
class App extends React.Component { handleWrapperCaptureClick() { console.log('wrapper capture click') } handleButtonClick() { console.log('button click') } componentDidMount() { const buttonEle = document.querySelector('#btn') buttonEle.addEventListener('click', () => { console.log('button native click') }) window.addEventListener('click', () => { console.log('window native click') }) } render() {
-
异步回调中获取事件对象失败问题
handleClick(e) { fetch('/a/b/c').then(() => { console.log(e) }) }
- React事件系统中与浏览器原生change事件有哪些差别
如果看完本文后,能清晰的回答出这几个问题,说明你对React事件系统已经有比较清楚的理解了。下面就正式进入正文了。
3、事件的绑定
事件绑定在/packages/react-dom/src/client/ReactDOMComponent.js
文件中
} else if (registrationNameModules.hasOwnProperty(propKey)) {
if (nextProp != null) {
ensureListeningTo(rootContainerElement, propKey);
}
}
如果propkey是registrationNameModules中的一个事件名,则通过ensureListeningTo方法绑定,其中registrationNameModules为包含React所有事件一个的map,在事件plugin部分中会再提到。
function ensureListeningTo(rootContainerElement, registrationName) {
const isDocumentOrFragment =
rootContainerElement.nodeType === DOCUMENT_NODE ||
rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE;
const doc = isDocumentOrFragment
? rootContainerElement
: rootContainerElement.ownerDocument;
listenTo(registrationName, doc);
}
从ensureListeningTo方法中可以看出,React事件挂载在document节点或者DocumentFragment上,listenTo方法则是真正将事件注册的入口,截取部分代码如下:
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,
// but this ensures we mark both as attached rather than just one.
isListening[TOP_BLUR] = true;
isListening[TOP_FOCUS] = true;
break;
case TOP_CANCEL:
case TOP_CLOSE:
if (isEventSupported(getRawEventName(dependency))) {
trapCapturedEvent(dependency, mountAt);
}
break;
case TOP_INVALID:
case TOP_SUBMIT:
case TOP_RESET:
// We listen to them on the target DOM elements.
// Some of them bubble so we don't want them to fire twice.
break;
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.
const isMediaEvent = mediaEventTypes.indexOf(dependency) !== -1;
if (!isMediaEvent) {
trapBubbledEvent(dependency, mountAt);
}
break;
部分特殊事件做单独处理,默认将事件通过trapBubbledEvent放到绑定,trapBubbledEvent根据字面意思可知就是绑定到冒泡事件上。其中注意的是blur等事件是通过trapCapturedEvent绑定的,这是因为blur等方法不支持冒泡事件,但是支持捕获事件,所以需要使用trapCapturedEvent绑定。
接下来我们看下trapBubbledEvent方法。
function trapBubbledEvent(
topLevelType: DOMTopLevelEventType,
element: Document | Element,
) {
if (!element) {
return null;
}
const dispatch = isInteractiveTopLevelEventType(topLevelType)
? dispatchInteractiveEvent
: dispatchEvent;
addEventBubbleListener(
element,
getRawEventName(topLevelType),
// Check if interactive and wrap in interactiveUpdates
dispatch.bind(null, topLevelType),
);
}
trapBubbledEvent就是将事件通过addEventBubbleListener绑定到document上的。dispatch则是事件的回调函数。dispatchInteractiveEvent和dispatchEvent的区别为,dispatchInteractiveEvent在执行前会确保之前所有的任务都已执行,具体见/packages/react-reconciler/src/ReactFiberScheduler.js
中的interactiveUpdates方法,该模块不是本文讨论的重点,感兴趣可以自己看看。
事件的绑定已经介绍完毕,下面介绍事件的合成及触发,该部分为React事件系统的核心。
4、事件的合成
事件在dispatch方法中将事件的相关信息保存到bookKeeping中,其中bookKeeping也有个bookKeeping池,从而避免了反复创建销毁变量导致浏览器频繁GC。
创建完bookkeeping后就传入handleTopLevel处理了,handleTopLevel主要是缓存祖先元素,避免事件触发后找不到祖先元素报错。接下来就进入runExtractedEventsInBatch方法了。
function runExtractedEventsInBatch(
topLevelType: TopLevelType,
targetInst: null | Fiber,
nativeEvent: AnyNativeEvent,
nativeEventTarget: EventTarget,
) {
const events = extractEvents(
topLevelType,
targetInst,
nativeEvent,
nativeEventTarget,
);
runEventsInBatch(events);
}
runExtractedEventsInBatch代码很短,但是非常重要,其中extractEvents通过不同插件合成事件,runEventsInBatch则是完成事件的触发,事件触发放到下一小节中再讲,接下来先讲事件的合成。
function extractEvents(
topLevelType: TopLevelType,
targetInst: null | Fiber,
nativeEvent: AnyNativeEvent,
nativeEventTarget: EventTarget,
): Array | ReactSyntheticEvent | null {
let events = null;
for (let i = 0; i < plugins.length; i++) {
// Not every plugin in the ordering may be loaded at runtime.
const possiblePlugin: PluginModule = plugins[i];
if (possiblePlugin) {
const extractedEvents = possiblePlugin.extractEvents(
topLevelType,
targetInst,
nativeEvent,
nativeEventTarget,
);
if (extractedEvents) {
events = accumulateInto(events, extractedEvents);
}
}
}
return events;
}
可以看到extractEvents通过遍历所有插件的extractEvents方法合成事件,如果一个插件适用该事件,则返回一个events,否则返回为null,意味着最后产生的events有可能是个数组。每个插件至少有两部分组成:eventTypes和extractEvents,eventTypes会在初始化的时候生成前文提到的registrationNameModules,extractEvents用于合成事件。下面介绍SimpleEventPlugin和ChangeEventPlugin两个插件。
插件是在初始化的时候通过EventPluginHubInjection插入的,并对其进行排序等初始化工作,不同的平台会注入不同的插件。
SimpleEventPlugin
const SimpleEventPlugin: PluginModule & {
isInteractiveTopLevelEventType: (topLevelType: TopLevelType) => boolean,
} = {
eventTypes: eventTypes,
isInteractiveTopLevelEventType(topLevelType: TopLevelType): boolean {
const config = topLevelEventsToDispatchConfig[topLevelType];
return config !== undefined && config.isInteractive === true;
},
extractEvents: function(
topLevelType: TopLevelType,
targetInst: null | Fiber,
nativeEvent: MouseEvent,
nativeEventTarget: EventTarget,
): null | ReactSyntheticEvent {
const dispatchConfig = topLevelEventsToDispatchConfig[topLevelType];
if (!dispatchConfig) {
return null;
}
let EventConstructor;
switch (topLevelType) {
case DOMTopLevelEventTypes.TOP_KEY_PRESS:
// Firefox creates a keypress event for function keys too. This removes
// the unwanted keypress events. Enter is however both printable and
// non-printable. One would expect Tab to be as well (but it isn't).
if (getEventCharCode(nativeEvent) === 0) {
return null;
}
/* falls through */
case DOMTopLevelEventTypes.TOP_KEY_DOWN:
case DOMTopLevelEventTypes.TOP_KEY_UP:
EventConstructor = SyntheticKeyboardEvent;
break;
case DOMTopLevelEventTypes.TOP_BLUR:
case DOMTopLevelEventTypes.TOP_FOCUS:
EventConstructor = SyntheticFocusEvent;
break;
case DOMTopLevelEventTypes.TOP_CLICK:
// Firefox creates a click event on right mouse clicks. This removes the
// unwanted click events.
if (nativeEvent.button === 2) {
return null;
}
/* falls through */
case DOMTopLevelEventTypes.TOP_AUX_CLICK:
case DOMTopLevelEventTypes.TOP_DOUBLE_CLICK:
case DOMTopLevelEventTypes.TOP_MOUSE_DOWN:
case DOMTopLevelEventTypes.TOP_MOUSE_MOVE:
case DOMTopLevelEventTypes.TOP_MOUSE_UP:
/* falls through */
case DOMTopLevelEventTypes.TOP_MOUSE_OUT:
case DOMTopLevelEventTypes.TOP_MOUSE_OVER:
case DOMTopLevelEventTypes.TOP_CONTEXT_MENU:
EventConstructor = SyntheticMouseEvent;
break;
case DOMTopLevelEventTypes.TOP_DRAG:
case DOMTopLevelEventTypes.TOP_DRAG_END:
case DOMTopLevelEventTypes.TOP_DRAG_ENTER:
case DOMTopLevelEventTypes.TOP_DRAG_EXIT:
case DOMTopLevelEventTypes.TOP_DRAG_LEAVE:
case DOMTopLevelEventTypes.TOP_DRAG_OVER:
case DOMTopLevelEventTypes.TOP_DRAG_START:
case DOMTopLevelEventTypes.TOP_DROP:
EventConstructor = SyntheticDragEvent;
break;
case DOMTopLevelEventTypes.TOP_TOUCH_CANCEL:
case DOMTopLevelEventTypes.TOP_TOUCH_END:
case DOMTopLevelEventTypes.TOP_TOUCH_MOVE:
case DOMTopLevelEventTypes.TOP_TOUCH_START:
EventConstructor = SyntheticTouchEvent;
break;
case DOMTopLevelEventTypes.TOP_ANIMATION_END:
case DOMTopLevelEventTypes.TOP_ANIMATION_ITERATION:
case DOMTopLevelEventTypes.TOP_ANIMATION_START:
EventConstructor = SyntheticAnimationEvent;
break;
case DOMTopLevelEventTypes.TOP_TRANSITION_END:
EventConstructor = SyntheticTransitionEvent;
break;
case DOMTopLevelEventTypes.TOP_SCROLL:
EventConstructor = SyntheticUIEvent;
break;
case DOMTopLevelEventTypes.TOP_WHEEL:
EventConstructor = SyntheticWheelEvent;
break;
case DOMTopLevelEventTypes.TOP_COPY:
case DOMTopLevelEventTypes.TOP_CUT:
case DOMTopLevelEventTypes.TOP_PASTE:
EventConstructor = SyntheticClipboardEvent;
break;
case DOMTopLevelEventTypes.TOP_GOT_POINTER_CAPTURE:
case DOMTopLevelEventTypes.TOP_LOST_POINTER_CAPTURE:
case DOMTopLevelEventTypes.TOP_POINTER_CANCEL:
case DOMTopLevelEventTypes.TOP_POINTER_DOWN:
case DOMTopLevelEventTypes.TOP_POINTER_MOVE:
case DOMTopLevelEventTypes.TOP_POINTER_OUT:
case DOMTopLevelEventTypes.TOP_POINTER_OVER:
case DOMTopLevelEventTypes.TOP_POINTER_UP:
EventConstructor = SyntheticPointerEvent;
break;
default:
// HTML Events
// @see http://www.w3.org/TR/html5/index.html#events-0
EventConstructor = SyntheticEvent;
break;
}
const event = EventConstructor.getPooled(
dispatchConfig,
targetInst,
nativeEvent,
nativeEventTarget,
);
accumulateTwoPhaseDispatches(event);
return event;
},
};
可以看到不同的事件类型会有不同的合成事件基类,然后再通过EventConstructor.getPooled生成事件。在default中的SyntheticEvent我们可以看到熟悉的preventDefault、stopPropagation、persist等方法,其中有个persist需要说明下,由上文可知事件对象会循环使用,所以一个事件完成后事件就会被回收,因此在异步回调中是拿不到事件的,而调用persist方法后会保持事件的引用不被回收。preventDefault则调用原生事件的preventDefault方法,并标记isDefaultPrevented,该属性下一节会再继续讲。
合成事件之后,会通过accumulateTwoPhaseDispatches收集父级事件监听并储存到_dispatchListeners中,这里是React事件系统模拟冒泡的关键。
export function traverseTwoPhase(inst, fn, arg) {
const path = [];
// 遍历父级元素
while (inst) {
path.push(inst);
inst = getParent(inst);
}
let i;
// 分别放入捕获和冒泡队列中
// fn为accumulateDirectionalDispatches方法
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) {
// 提取绑定的监听事件
const listener = listenerAtPhase(inst, event, phase);
if (listener) {
// 将提取到的绑定添加到_dispatchListeners中
event._dispatchListeners = accumulateInto(
event._dispatchListeners,
listener,
);
event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);
}
}
ChangeEventPlugin
const ChangeEventPlugin = {
eventTypes: eventTypes,
_isInputEventSupported: isInputEventSupported,
extractEvents: function(
topLevelType,
targetInst,
nativeEvent,
nativeEventTarget,
) {
const targetNode = targetInst ? getNodeFromInstance(targetInst) : window;
let getTargetInstFunc, handleEventFunc;
if (shouldUseChangeEvent(targetNode)) {
getTargetInstFunc = getTargetInstForChangeEvent;
} else if (isTextInputElement(targetNode)) {
if (isInputEventSupported) {
getTargetInstFunc = getTargetInstForInputOrChangeEvent;
} else {
getTargetInstFunc = getTargetInstForInputEventPolyfill;
handleEventFunc = handleEventsForInputEventPolyfill;
}
} else if (shouldUseClickEvent(targetNode)) {
getTargetInstFunc = getTargetInstForClickEvent;
}
if (getTargetInstFunc) {
const inst = getTargetInstFunc(topLevelType, targetInst);
if (inst) {
const event = createAndAccumulateChangeEvent(
inst,
nativeEvent,
nativeEventTarget,
);
return event;
}
}
if (handleEventFunc) {
handleEventFunc(topLevelType, targetNode, targetInst);
}
// When blurring, set the value attribute for number inputs
if (topLevelType === TOP_BLUR) {
handleControlledInputBlur(targetNode);
}
},
};
MDN中对change事件有以下描述:
事件触发取决于表单元素的类型(type)和用户对标签的操作:
- 当元素被:checked时(通过点击或者使用键盘): 和 ;
- 当用户完成提交动作时(例如:点击了
- 当标签的值被修改并且失焦后,但并未进行提交(例如:对
ChangeEventPlugin中shouldUseChangeEvent对应的与元素,监听change事件;isTextInputElement对应普通input元素,监听input事件;shouldUseClickEvent对应与元素,监听click事件。
所以普通input元素中当时区焦点后才会触发change事件,而React的change事件在每次输入的时候都会触发,因为监听的是input事件。
5、事件的触发
截止到目前已经完成了事件的绑定与合成,接下来就是最后一步事件的触发了。事件触发的入口为前文提到的runEventsInBatch方法,该方法中会遍历触发合成的事件。
function executeDispatchesInOrder(event) {
const dispatchListeners = event._dispatchListeners;
const dispatchInstances = event._dispatchInstances;
// 遍历触发dispatchListeners中收集的事件
if (Array.isArray(dispatchListeners)) {
for (let i = 0; i < dispatchListeners.length; i++) {
if (event.isPropagationStopped()) {
break;
}
// Listeners and Instances are two parallel arrays that are always in sync.
executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
}
} else if (dispatchListeners) {
executeDispatch(event, dispatchListeners, dispatchInstances);
}
event._dispatchListeners = null;
event._dispatchInstances = null;
}
其中event.isPropagationStopped()
为判断是否需要阻止冒泡,需要注意的是因为是代理到document上的,原生事件早已冒泡到了document上,所以stopPropagation是无法阻止原生事件的冒泡,只能阻止React事件的冒泡。executeDispatch
就是最终触发回调事件的地方,并捕获错误。至此React事件的绑定、合成与触发都已经结束了。
6、结束
React事件系统初看比较复杂,其实理解后也并没有那么难。在解决跨平台和兼容性的问题时,保持了高性能,有很多值得学习的地方。
在看源代码的时候,一开始也没有头绪,多打断点,一点点调试,也就慢慢理解。
文中如有不正确的地方,还望不吝指正。