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() {<div className="wrapper" onClickCapture={this.handleWrapperCaptureClick}> <button id="btn" onClick={this.handleButtonClick}> click me </button> </div> } }
-
异步回调中获取事件对象失败问题
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> | 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<AnyNativeEvent> = 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<MouseEvent> & {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 时(通过点击或者使用键盘):<input type=”radio”> 和 <input type=”checkbox”>;
- 当用户完成提交动作时(例如:点击了 <select> 中的一个选项,从 <input type=”date”> 标签选择了一个日期,通过 <input type=”file”> 标签上传了一个文件,等);
- 当标签的值被修改并且失焦后,但并未进行提交(例如:对 <textarea> 或者 <input type=”text”> 的值进行编辑后。)。
ChangeEventPlugin 中 shouldUseChangeEvent 对应的 <input type=”date”> 与 <input type=”file”> 元素,监听 change 事件;isTextInputElement 对应普通 input 元素,监听 input 事件;shouldUseClickEvent 对应 <input type=”radio”> 与 <input type=”checkbox”> 元素,监听 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 事件系统初看比较复杂,其实理解后也并没有那么难。在解决跨平台和兼容性的问题时,保持了高性能,有很多值得学习的地方。
在看源代码的时候,一开始也没有头绪,多打断点,一点点调试,也就慢慢理解。
文中如有不正确的地方,还望不吝指正。