MrErHu / blog

Star 就是最大的鼓励 👏👏👏
MIT License
605 stars 40 forks source link

日常Debug 事件监听 #45

Open MrErHu opened 2 years ago

MrErHu commented 2 years ago

问题引出

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

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