react 事件冒泡 踩坑

5,104 阅读2分钟

问题背景

写react弹窗类组件时,经常遇到这样的需求:点击弹窗外的任意地方,关闭弹窗。我想通过在document上添加事件代理执行关闭逻辑,然后通过阻止打开弹窗的元素上的时间冒泡,这种方式来实现。

import React, { useEffect, useRef } from 'react';
export default function Demo() {
    const btnRef = useRef();

    useEffect(() => {
        document.addEventListener('click', function () {
            // 关闭弹窗
            close();
        })
    }, []);
    
    function close(){
    	// 关闭弹窗逻辑
    }
    
    funtion open(e) {
    	// 打开弹窗逻辑
        e.stopPropagation();
    }

    return (
        <div className="App">
            <button onClick={onOpen}>打开弹窗</button>
        </div>
    )
}

但是结果发现 open方法中的stopPropation并不生效,document上的事件仍然正常执行。通过查阅资料,发现react的 SyntheticEvent事件模型是通过在document对象上添加事件代理,来模拟原生dom事件的,所有通过jsx标签上绑定的事件(如本例中的onOpen),都是通过冒泡到document上绑定的代理事件上再进行dispatch处理的。

所以,button上绑定的react事件,是冒泡到document上后才执行的,e.stopPropagation 不可能阻止document上绑定的原生dom事件。

问题解释

可以通过下面的例子来理解:

import React, { useEffect, useRef } from 'react';
export default function Demo() {
    const appRef = useRef();
    const btnRef = useRef();

    useEffect(() => {
        document.addEventListener('click', function () {
            console.log('document click')
        })
        btnRef.current.addEventListener('click', function () {
            console.log('btn click');
        })
        appRef.current.addEventListener('click', function () {
            console.log('app click');
        })
    }, []);

    function onBtnClick(e) {
        console.log('react button click');
    }

    function onAppClick(e) {
        console.log('react app click');
    }

    return (
        <div className="App" ref={appRef} onClick={onAppClick}>
            <button ref={btnRef} onClick={onBtnClick}>按钮</button>
        </div>
    )
}

不添加任何阻止冒泡逻辑时,打印顺序如下

btn click
app click
react button click
react app click
document click

在react 按钮点击事件中阻止冒泡

...
    function onBtnClick(e) {
        e.stopPropagation();
        console.log('react button click');
    }
...

打印结果,react app click 没有被打印,app clickdocumnet click仍然打印

btn click
app click
react button click
document click

说明 react 事件中添加的阻止冒泡,只能阻止react 类的事件。

在原生按钮点击事件中阻止冒泡

btnRef.current.addEventListener('click', function (e) {
	e.stopPropagation();
    console.log('btn click');
})

打印结果

btn click

当按钮原生事件上阻止冒泡后,app 和 document上的原生事件无法触发是很容易理解的。然后,由于document上接收不到 按钮原生事件的冒泡,所以react的事件代理机制就失效了,进而导致react绑定的 app click btn click事件都无法触发了。

解决方案

  1. 通过原生事件阻止冒泡,阻止 document 原生事件;
btnRef.current.addEventListener('click', function (e) {
	e.stopPropagation();
    console.log('btn click');
})
  1. 在react事件中调用 e.nativeEvent.stopImmediatePropagation()
    stopImmediatePropagation 会阻止元素上所有同类型的事件监听器调用, 具体参考mdn。 这种方式要求用户在document上绑定的事件 要晚于react render
    function onBtnClick(e) {
        // e.stopPropagation();
        e.nativeEvent.stopImmediatePropagation();
        console.log('react button click');
    }
    
    <button ref={btnRef} onClick={onBtnClick}>按钮</button>
  1. 在window 上绑定事件
    因为window上事件的冒泡顺序位于document之后,所以react 事件中的e.stopPropagation阻止了react在document上的代理事件的冒泡,进而不会触发window上的事件。

总结

react jsx事件回调里的e.stopPropagation 只能阻止react 在document上绑定的代理事件的冒泡行为。