Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

日常Debug 事件监听 #45

Open
MrErHu opened this issue Nov 25, 2021 · 0 comments
Open

日常Debug 事件监听 #45

MrErHu opened this issue Nov 25, 2021 · 0 comments

Comments

@MrErHu
Copy link
Owner

MrErHu commented Nov 25, 2021

问题引出

在日常功能开发中,我接领了开发一个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实现出了问题。白白浪费了不少时间。最后兜兜转转又回到了原地。

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

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

No branches or pull requests

1 participant