React16 升级至 React17 后的 document.addEventListener 异常分析与处理

相比 React 16 来说,React17 的新特性就是无特性,所以从 16 升级至 17 是相对比较平滑的。但一个具有破坏性变更的就是事件系统的更改。

In React 17, React will no longer attach event handlers at the document level. Instead, it will attach them to the root DOM container into which your React tree is rendered.

在React 17中,React将不再在该document级别附加事件处理程序。相反,它将把它们附加到渲染您的React树的根DOM容器中。

项目中使用了 document.addEventListener 的地方,均会出现异常。官方给出的解决方案是将该事件行为改为 useCapture 阶段:

document.addEventListener('click', onClick, { capture: true });

该方法能解决大部分的问题,但在某些时候仍会存在一些问题。

由于该事件的管理控制已经与 React 无关,当页面组件中还有其他元素的事件监听与协同逻辑时,比如对冒泡行为的阻止逻辑就无法实现。

一个简单的显示 popover 并在点击外部元素时则关闭它的主要代码示例:

const TipTest = () => {
  const [visible, setVisible] = React.useState(false);
    const toggleTip = () => setVisible(!visible);
  const popClick = (e) => {
    // 阻止继续向上冒泡
    e.nativeEvent.stopImmediatePropagation();
  };

  React.useLayoutEffect(() => {
    const hideTip = () => setVisible(false);
    document.addEventListener('click', hideTip, { capture: true });
    return () => document.removeEventListener('click', hideTip, { capture: true });
  });

  return (
    <div>
      <button onClick={toggleTip}/>TIPS</button>
      <Tip onClick={popClick} visible={visible}>
    </div>
  );
}

上述例子的逻辑在 React 16 中是没问题的,这是一种利用事件冒泡机制取巧的做法。
但在 React 17 中由于 popClick 中的 stopImmediatePropagation 并不能阻止到 document 的事件行为,异常现象就发生了,每一次的 button 点击同时也会触发 hideTip,导致 Tip 能力失效。

针对此种情况,并没有一个较好的解决办法。为了兼容已有逻辑不进行大范围的变更,一个折中的方式是将 document.addEventListener 的事件监听迁移到 root 节点上,实现一个事件代理来替换 document.addEventListener 以实现最小化变动。具体可以怎么做呢?

首先,实现一个 helper 工具对象,用于替代 document.addEventListener

type EventNames = 'click';

const emiter = new EventEmitter();

export const rootNodeEventHelper = {
    addEventListener(eventName: EventNames, callback: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void, _options?) {
        emiter.on(eventName, callback);
    },
    removeEventListener(eventName: EventNames, callback: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void, _options?) {
        emiter.off(eventName, callback);
    },
    emit(eventName: EventNames, event: React.MouseEvent<HTMLDivElement, MouseEvent>) {
        emiter.emit(eventName, event);
    },
    once(eventName: EventNames, callback: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void, context?) {
        emiter.once(eventName, callback, context);
    },
}

然后,在 root App 组件上注册和绑定相关事件:

export default class App extends Component {
    onClick: React.MouseEventHandler<HTMLDivElement> = (event) => {
        rootNodeEventHelper.emit('click', event);
    }
    render() {
        return (
            <ErrorBoundary name="App">
                <div className="FramelessApp" onClick={this.onClick}>
                    <TitleBar />
                    <App />
                </div>
            </ErrorBoundary>
        );
    }
}

最后,将前述例子中的 document. 替换为 rootNodeEventHelper. 即可,这样组件内的逻辑就可以和在 React16 中表现一致了。

当然,对于例子中的具体场景,我们也可以将逻辑修改一下,在 hideTip 方法中增加对点击节点的识别与处理。这是稍微繁琐但较为常规的做法。示例:

const TipTest = () => {
  const [visible, setVisible] = React.useState(false);
  const toggleTip = () => setVisible(!visible);
  const popRef = React.useRef();

  React.useLayoutEffect(() => {
    const hideTip = (e) => {
      if (e.path.includes(findDomNode(poRef.current))) return;
      setVisible(false);
    };

    document.addEventListener('click', hideTip, { capture: true });
    return () => document.removeEventListener('click', hideTip, { capture: true });
  });

  return (
    <div>
      <button onClick={toggleTip}/>TIPS</button>
      <Popover ref={popRef} onClick={popClick} visible={visible}>
    </div>
  );
}

相关参考:

点赞 (1)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Captcha Code