React 学习之 React 事件

883 阅读5分钟

React 中的事件

这里的事件,指的是 React 内部封装 DOM 组件中的事件,如 onClick, onFocus等,而非我们自己通过 props 传递的属性,并在子组件中手动触发的事件

看个栗子

我们可能会有这么个 需求:需要做一个弹窗打开/关闭 的功能,当点击 button 的时候打开;打开的情况下,点击弹窗 区域 外,就需要关闭弹窗

简单嘛,我们在 button 上注册一个事件,点击则展示 Modal,在 document.body 上注册一个关闭 Modal 的事件,在 Modal 内容区阻止事件冒泡不就可以了吗?!说干就干,示例代码如下:

export default class Modal extends PureComponent {
    state = { showModal: false }
    componentDidMount() {
        document.body.addEventListener('click', this.handleClose, false)
    }
    componentWillUnmount() {
        document.body.removeEventListener('click', this.handleClose, false)
    }
    handleClose = () => {
        this.setState({ showModal: false })
    }
    handleShow = () => {
        this.setState({ showModal: true })
    }
    stopBubble = e => {
        e.stopPropagation()
    }
    render() {
        return (<div>
            <button onClick={this.handleShow}>Show Modal</button>
            {this.state.showModal && (
                <div onClick={this.stopBubble}>Modal Content</div>
            )}
        </div> )
    }
}

如果 React 版本 < 17,那么你就会发现表现结果跟上面实现的想法并不一致,点击 Modal Content 区同样会关闭 Modal,也就是说 stopBubble 函数并未完成按照咱的预期去阻止事件冒泡的功能;但如果 React 版本 >= 17,那么上面的方案确实能够生效了

真实 DOM 事件与 React 事件调用顺序

比如下面这个栗子:

import React, { PureComponent } from 'react'
import {createPortal} from 'react-dom'

function Comp() {
    return <h1
        style={{ color: "#008c8c" }}
        onClick={() => {
            console.log('H1 事件')
        }}>Comp</h1>
}
/* 插槽组件 */
const PortalComp = () => {
    return createPortal(<Comp />, document.body)
}
export default class Test extends PureComponent {
    componentDidMount() {
        document.addEventListener('click', () => {
            console.log('document 事件')
        })
        const dom = document.querySelector("#root")
        dom.addEventListener('click', () => {
            console.log('真实的 DOM root div 被点击')
        })
    }
    handleClick = () => {
        console.log('red div 事件')
        // e.stopPropagation() // 阻止冒泡
    }
    render() {
        return (
            <div
                style={{
                    width: 100,
                    height: 100,
                    backgroundColor: "#f40"
                }}
                onClick={this.handleClick}
            >
                <PortalComp /> {/* 插槽组件 */}
            </div>
        )
    }
}

我这里是 react 17.0.0 版,所以点击元素后的打印结果及说明:

1. 点击 h1 元素

H1 事件
red div 事件
document 事件

说明:h1 元素真实结构是 body 的直接子元素,但是也可以看到可以通过事件冒泡,触发其虚拟 DOM 树结构的父元素 (红色 div) 的点击事件以及 document 绑定的点击事件,但 没有触发 root 节点绑定的真实 DOM 的点击事件 (这个表现说明它既不遵循 虚拟 DOM 树 冒泡机制,也不遵循 真实 DOM 树 的冒泡机制)

2. 点击红色 div 元素

red div 事件
真实的 DOM root div 被点击
document 事件

说明:红色 div 元素在结构是 root div 的直接子元素,可以看到可以通过事件冒泡,能够触发 root 节点绑定的真实 DOM 的点击事件以及 document 绑定的点击事件

到这里,其实有点诧异,为何点击 h1 元素没有触发 root 节点绑定的点击事件?!

上面的栗子改造一下

import React, { PureComponent } from 'react'

function Comp() {
    return <h1
        style={{ color: "#008c8c" }}
        onClick={e => {
            console.log('H1 事件')
            // e.stopPropagation() // 阻止事件冒泡
        }}>Comp</h1>
}

export default class Task extends PureComponent {
    componentDidMount() {
        document.addEventListener('click', () => {
            console.log('document 事件')
        })
        const dom = document.querySelector("#root")
        dom.addEventListener('click', () => {
            console.log('真实的 DOM root div 被点击')
        })
    }
    handleClick = () => {
        console.log('red div 事件')
    }
    render() {
        return (
            <div
                style={{
                    width: 100,
                    height: 100,
                    backgroundColor: "#f40"
                }}
                onClick={this.handleClick}
            >
                <Comp />
            </div>
        )
    }
}

这个栗子中,我没有使用 Portal 插槽去改变组件的渲染位置,在 Comp 组件不阻止 事件冒泡时,点击 h1 元素就会按照我们预想的结果一样去冒泡,并能触发真实 DOM 事件,依次打印:(1) H1 事件; (2) red div 事件; (3) 真实的 DOM root div 被点击; (4) document 事件

当我解开阻止事件冒泡 // e.stopPropagation() 的注释时,点击 h1 就只会打印:(1) H1 事件; (2) 真实的 DOM root div 被点击; (这里发现,document 注册的真实 DOM 事件也被阻止了;React v17 版不是将事件委托的节点转移到 root 根节点了吗?!为何还能阻止 document 上的事件?) 问题~~

事件说明

React 根据 W3C 规范定义了合成事件 (SyntheticEvent),我们就无需担心跨浏览器的兼容性问题

React 出于性能与事件管理方面的考量,在之前的 React 版本中 (v17 之前)

  1. 会将在 JSX 中注册的事件收集到 document 上 (即通过事件委托,在 document 上注册事件,等触发事件时,则按虚拟 DOM 树的结构进行事件触发机制去分发事件);

  2. 几乎所有的事件处理,均在 document 的事件中处理

    • 比如 onFocus 等事件不会冒泡的事件,就不做委托,直接在元素上监听
    • 一些 document 上没有的事件,也直接在元素上监听 (如:audio、video标签的事件等)
  3. 在 document 中的事件处理,会根据虚拟 DOM 树的结构完成事件函数的调用,默认是冒泡机制 (事件捕获要通过类似 onClickCapture 的方式注册)

  4. React 的事件参数,并非真实的事件参数,而是 React 合成的一个对象 (SyntheticEvent)

    • 通过调用 e.stopPropagation() 阻止事件冒泡 (仅阻止 React 事件)
    • 通过 e.nativeEvent 可以得到真实的 DOM 事件对象 (不过很少会用到)
    • 为了提高效率,React 使用事件对象池来处理事件对象 (即事件对象会重用)
  5. 在 React 17 之后,事件委托的节点就转移到了渲染的根节点上,而且也帮我们解决了此类关于事件冒泡的问题 (本文测试用例则说明,对于使用 ReactDOM.createPortal 创建的组件,表现上略有差异)

注意点

  1. 若给真实 DOM 注册事件,并阻止冒泡,则很有可能导致 React (JSX) 中注册的相关事件无法触发

  2. 若给真实 DOM 注册事件,它会先于 React 事件执行 (即通过 onClickdom.addEventListener 绑定的事件,真实 DOM 事件会先执行;因为这个元素被点击时,真实 DOM 事件会很快找到,而 React 绑定的事件则需要去找到事件委托的元素,再去调用当前点击元素绑定的事件函数)

  3. 通过 React 事件阻止事件冒泡,无法阻止真实 DOM 事件的冒泡 (如上面改造的栗子的结果)

  4. 可以使用 e.nativeEvent.stopImmediatePropagation() 去阻止 document 上剩余的事件处理程序的运行 (当我们在使用某些第三方库,在这个库有可能使用了一些事件处理,也对 document 绑定过点击事件,如: document.addEventListener("click", handler))

  5. 在事件处理程序中,不要异步使用事件对象 e;如果一定有异步使用的需求,则需要调用 e.persist() 函数持久化保存此事件对象 (代价自然是损耗效率的)