日常Debug 事件监听
Opened this issue · 0 comments
问题引出
在日常功能开发中,我接领了开发一个Web版本的类Excel电子表格的任务。因为我们在项目中尽量使用React Hooks,使开发时我遇到了一个非常有意思的现象。
在项目工程中,一方面为了方便,另一方面为了符合React声明式语义的特点,我们一般会在工程中使用 react-event-listener 去代替手动调用 addEventListener。在打印模板功能中,我们需要在表格中实现 ContextMenu 功能:
用户在表格中通过鼠标右键点击出现 ContextMenu 菜单,而出现ContextMenu 菜单后,在菜单外任意位置点击鼠标,则ContextMenu 菜单消失。实现该功能并不复杂,只需要在出现ContextMenu 菜单后监听document的mousedown事件。
基本逻辑实现
import React from 'react';
import EventListener from 'react-event-listener';
import { Menu } from '@fx-ui/jdy-design';
const ContextMenu = () => {
const handleMouseDown = (e: MouseEvent) => {
console.log('handleMouseDown');
};
return (
<div className={className} style={{ left: position.x, top: position.y }}>
<Menu
className="context-menu"
items={menu}
menuWidth={[180, 194]}
onAfterSelect={handleMenuClick}
/>
<EventListener
target="document"
onMouseDown={handleMouseDown}
/>
</div>
);
};
export default ContextMenu;
上面的逻辑并不复杂,我们期待 EventListener 的 onMouseDown 可以监听到 document的 mousedown 事件,但是事实上,在触发时,并没有回调函数。此类写法在之前Class类型的React组件非常常见,那么在FC中又和不同?
EventListener基本逻辑
为了了解为什么EventListener 的 onMouseDown并没有触发到对应回调函数,首先我怀疑可能是react-event-listener内部实现的问题,大致先看了一下内部实现:
class EventListener extends React.PureComponent {
componentDidMount() {
this.applyListeners(on);
}
componentDidUpdate(prevProps) {
this.applyListeners(off, prevProps);
this.applyListeners(on);
}
componentWillUnmount() {
this.applyListeners(off);
}
applyListeners(onOrOff, props = this.props) {
const { target } = props;
if (target) {
let element = target;
if (typeof target === 'string') {
element = window[target];
}
forEachListener(props, onOrOff.bind(null, element));
}
}
render() {
return this.props.children || null;
}
}
function forEachListener(props, iteratee) {
const {
children,
target,
...eventProps
} = props;
Object.keys(eventProps).forEach(name => {
if (name.substring(0, 2) !== 'on') {
return;
}
const prop = eventProps[name];
const type = typeof prop;
const isObject = type === 'object';
const isFunction = type === 'function';
if (!isObject && !isFunction) {
return;
}
const capture = name.substr(-7).toLowerCase() === 'capture';
let eventName = name.substring(2).toLowerCase();
eventName = capture ? eventName.substring(0, eventName.length - 7) : eventName;
if (isObject) {
iteratee(eventName, prop.handler, prop.options);
} else {
iteratee(eventName, prop, mergeDefaultEventOptions({ capture }));
}
});
}
function on(target, eventName, callback, options) {
target.addEventListener.apply(target, getEventListenerArgs(eventName, callback, options));
}
function off(target, eventName, callback, options) {
target.removeEventListener.apply(target, getEventListenerArgs(eventName, callback, options));
}
EventListener的内部实现并不复杂,主要是在生命周期函数中手动监听或卸载对应事件。理论上并不会出现不会调用的问题。为了简化问题,排除产生的原因是react-event-listener内部实现所导致的,因此将这部分逻辑替换成我们自定义且足够简单的的EventListener:
import React from 'react';
interface EventListenerProps {
onMouseDown: () => void;
}
class EventListener extends React.Component<EventListenerProps> {
componentDidMount() {
document.addEventListener('mousedown', this.props.onMouseDown);
}
componentDidUpdate(prevProps: Readonly<EventListenerProps>) {
document.removeEventListener('mousedown', prevProps.onMouseDown);
document.addEventListener('mousedown', this.props.onMouseDown);
}
render() {
return null;
}
}
export default EventListener;
将react-event-listener库替换为我们自定义实现的EventListener,问题依旧存在,这就排除了问题是react-event-listener内部实现所导致的。那么问题出在哪里了呢?甚至一度让我怀疑了是不是React的事件代理出现了问题。在调试的时候,通过给EventListener的componentDidUpdate与ContextMenu的handleMouseDown添加端倪,让我开始发现问题的端倪。
出现ContextMenu之后,点击右键,断点会首先暂停在componentDidUpdate,而不是handleMouseDown。
查看了一下,产生该问题的主要原因是在ContextMenu的父组件也监听了onMouseDown,并在回调函数中使用useState更新了状态。到这里首先明确了一个概念,React使用的是事件代理,并且React 16版本和17版本也有些许区别:
React所使用的事件代理是指React在固定节点上为每种事件类型附加一个处理器,这使得在大型应用程序中具有一定的性能优势。在React16中是在document中添加处理,而React17则将事件处理器添加到渲染React树的根DOM容器中。目前简道云使用的React版本是16,因此所有组件事件都是委托到document。并且React对document事件监听是先于EventListener,因此按照必然是先执行父组件的事件处理,然后再去执行EventListener的事件处理。
问题其实到这边已经就比较清楚了,因此先执行了父组件的事件处理函数,并且父组件在事件处理函数中更新了状态,导致ContextMenu组件重新渲染,为EventListener传入了新的事件处理函数。EventListener则会在componentDidUpdate先卸载之前的事件处理函数,然后添加新的事件处理函数。
低级错误
事情到这里已经基本水落石出,我自己也猜想到原因:
如果事件已经触发到某个节点,在该节点事件处理函数中再为节点添加同类型的事件,本轮事件处理队列是不会被触发新添加的处理函数
虽然之前并没有在学习中涉及到这个问题,但是因为之前在学习Redux源码时,Redux在处理dispatch触发时就处理过该逻辑。每次调用dispatch前都会生成对应监听者listeners的快照,在listeners被调用期间发生订阅(subscribe)或者解除订阅(unsubscribe),在本次通知中并不会立即生效,而是在下次中生效。我猜想这边也是同样的逻辑,因此我写了一个非常简单的demo去验证我这个想法:
document.addEventListener('mousedown', () => {
document.addEventListener('mousedown', () => {
console.log('mousedown');
});
});
按照这个理论,首次点击的事实上是不会打印 mousedown,然而在我自己验证的时候,犯了严重的低级错误,不小心连续点击了两次,导致打印 mousedown。
至此我陷入了深深的沉思,我以为我的猜想是错误的。导致我又去找了其他的原因,比如这边渲染用了Canvas,是不是Canvas的事件处理机制与DOM不同,甚至我一度都开始怀疑是不是React实现出了问题。白白浪费了不少时间。最后兜兜转转又回到了原地。
通过这个小问题以及后面白白浪费的时间,我得到了几条宝贵经验:
- 节点事件处理函数中再为节点添加同类型的事件,本轮事件处理队列是不会被触发新添加的处理函数
- 调试代码需要耐心,尤其越是调试都后面心情焦躁越需要耐心,否则你可能要为焦躁付出更多的时间成本。
- 调试代码打印输出时,不同的输出结果区分度越明显越好。比如输出结果带个时间或者标记之类,这样不容易被误导,走弯路。