Open MrErHu opened 2 years ago
在日常功能开发中,我接领了开发一个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 的 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实现出了问题。白白浪费了不少时间。最后兜兜转转又回到了原地。
通过这个小问题以及后面白白浪费的时间,我得到了几条宝贵经验:
问题引出
在日常功能开发中,我接领了开发一个Web版本的类Excel电子表格的任务。因为我们在项目中尽量使用React Hooks,使开发时我遇到了一个非常有意思的现象。
在项目工程中,一方面为了方便,另一方面为了符合React声明式语义的特点,我们一般会在工程中使用 react-event-listener 去代替手动调用 addEventListener。在打印模板功能中,我们需要在表格中实现 ContextMenu 功能:
用户在表格中通过鼠标右键点击出现 ContextMenu 菜单,而出现ContextMenu 菜单后,在菜单外任意位置点击鼠标,则ContextMenu 菜单消失。实现该功能并不复杂,只需要在出现ContextMenu 菜单后监听document的mousedown事件。
基本逻辑实现
上面的逻辑并不复杂,我们期待 EventListener 的 onMouseDown 可以监听到 document的 mousedown 事件,但是事实上,在触发时,并没有回调函数。此类写法在之前Class类型的React组件非常常见,那么在FC中又和不同?
EventListener基本逻辑
为了了解为什么EventListener 的 onMouseDown并没有触发到对应回调函数,首先我怀疑可能是react-event-listener内部实现的问题,大致先看了一下内部实现:
EventListener的内部实现并不复杂,主要是在生命周期函数中手动监听或卸载对应事件。理论上并不会出现不会调用的问题。为了简化问题,排除产生的原因是react-event-listener内部实现所导致的,因此将这部分逻辑替换成我们自定义且足够简单的的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去验证我这个想法:
按照这个理论,首次点击的事实上是不会打印 mousedown,然而在我自己验证的时候,犯了严重的低级错误,不小心连续点击了两次,导致打印 mousedown。
至此我陷入了深深的沉思,我以为我的猜想是错误的。导致我又去找了其他的原因,比如这边渲染用了Canvas,是不是Canvas的事件处理机制与DOM不同,甚至我一度都开始怀疑是不是React实现出了问题。白白浪费了不少时间。最后兜兜转转又回到了原地。
通过这个小问题以及后面白白浪费的时间,我得到了几条宝贵经验: