MrErHu/blog

日常Debug 事件监听

Opened this issue · 0 comments

问题引出

在日常功能开发中,我接领了开发一个Web版本的类Excel电子表格的任务。因为我们在项目中尽量使用React Hooks,使开发时我遇到了一个非常有意思的现象。

在项目工程中,一方面为了方便,另一方面为了符合React声明式语义的特点,我们一般会在工程中使用 react-event-listener 去代替手动调用 addEventListener。在打印模板功能中,我们需要在表格中实现 ContextMenu 功能:

image

用户在表格中通过鼠标右键点击出现 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。

image

查看了一下,产生该问题的主要原因是在ContextMenu的父组件也监听了onMouseDown,并在回调函数中使用useState更新了状态。到这里首先明确了一个概念,React使用的是事件代理,并且React 16版本和17版本也有些许区别:

image

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实现出了问题。白白浪费了不少时间。最后兜兜转转又回到了原地。

通过这个小问题以及后面白白浪费的时间,我得到了几条宝贵经验:

  • 节点事件处理函数中再为节点添加同类型的事件,本轮事件处理队列是不会被触发新添加的处理函数
  • 调试代码需要耐心,尤其越是调试都后面心情焦躁越需要耐心,否则你可能要为焦躁付出更多的时间成本。
  • 调试代码打印输出时,不同的输出结果区分度越明显越好。比如输出结果带个时间或者标记之类,这样不容易被误导,走弯路。