React合成事件

背景知识:事件的冒泡和捕获

  1. 这是JS事件的两种机制,冒泡和捕获分别由微软和网景提出并作为其浏览器对事件的处理机制——又一个浏览器大战时代的产物。
  2. 我们都知道,页面是用DOM堆叠起来的,DOM元素里可以包含其他元素,而上面的两个事件机制解释的,就是页面里的事件流(事件在DOM上的发生顺序)
  3. 页面是DOM元素以兄弟或者父子关系组成的,顶层的是Document,也只有Document,可以理解这个“顶层”就是“最上面”
  4. 事件冒泡:就是从最具体的元素开始发生,逐渐向“上”传播,直到Document
  5. 事件捕获:事件在最外层也就是Document开始发生,然后逐渐向“下”传播直到最具体的元素
  6. 大战落幕于W3C的和稀泥,最终结果是定了顺序标准:事件捕获 -> 目标阶段 -> 事件冒泡element.addEventListener(event, function, useCapture)这个Api的第三个参数,默认false:表示在事件冒泡阶段调用第二参数;true则表示在事件捕获阶段调用第二参数
  7. W3C的这个规则可能会让人困惑,需要注意到,这并不是针对一个DOM上的一个事件和这个事件的一个响应,而是考虑到在父子关系复杂的DOM之间,当一个事件触发的时候,绑定在不同层级的DOM上的这个事件的响应函数是怎么样的触发顺序。
  8. 举个例子,触发一个click事件,那么绑定click的函数会这样被触发:捕获阶段事件触发! Document -> 具体DOM -> 冒泡阶段事件触发!-> Document
  9. 应用:事件代理。这也就是React合成事件的基础原理了。所谓事件代理,就是只在一个祖先节点上绑定事件,事件触发的时候判断是哪个子节点触发的,并且执行相应的逻辑。事件代理不管是在捕获还是冒泡阶段来处理,都是允许的,由于事件冒泡的事件流模型被所有主流的浏览器兼容,从兼容性角度,选择冒泡阶段比较合理(就是上面的第三参数,默认false即可)

React合成事件概述

  1. 为了避免DOM事件滥用,并且屏蔽底层不同浏览器的事件系统差异,由此实现了一个中间层SyntheticEvent
  2. 事件没有绑定在真实的DOM上,而是委托给了Document
  3. 在React项目的写法上,在JSX里写HTML的事件绑定并没有太大的不同。但实际上,是从具体DOM的事件冒泡到Document之后,在合成事件层里有统一的react event,再具体分到事件处理函数。这里的event对象是复用的,事件处理函数执行完后会被清空,因此其属性不能被异步访问。

如何在React项目里使用原生事件

  1. 不在JSX里,而是使用DOM的事件绑定
  2. 一般在componentDidMount或者ref的函数执行阶段
  3. 可以在componentWillUnmount的时候再加上解绑操作,防止内存泄漏

原生事件对React合成事件的干扰

  1. 根本在于合成事件利用了冒泡机制
  2. 如果在事件传播链上,同一个事件阻止了冒泡event.stopPropagation()会导致这个事件传播链上,阻止冒泡的节点之上的原生事件不响应(这是符合预期的),并且,整条事件传播链上所有合成事件失效——这就很不妙了。
  3. 如下,正常的返回顺序是dog dom -> child dom -> parent dom -> dog react -> child react -> parent react点击child之后,只会依次输出:dog domchild dom

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    componentDidMount() {
    const $parent = document.querySelector('.demo')
    const $child = $parent.querySelector('.child')
    const $dog = $parent.querySelector('.dog')

    $parent.addEventListener('click', this.onParentDOMClick, false)
    $child.addEventListener('click', this.onChildDOMClick, false)
    $dog.addEventListener('click', this.onDogDOMClick, false)
    }

    onParentDOMClick = evt => {
    console.log('parent dom')
    }

    onChildDOMClick = evt => {
    evt.stopPropagation()
    console.log('child dom')
    }
    onDogDOMClick = evt => {
    console.log('dog dom')
    }

    onParentClick = evt => {
    console.log('parent react')
    }

    onChildClick = evt => {
    console.log('child react')
    }
    onDogClick = evt => {
    console.log('dog react')
    }

    render() {
    return (
    <div className="demo" onClick={this.onParentClick}>
    <div className="child" onClick={this.onChildClick}>
    <div className="dog" onClick={this.onDogClick}>click me!</div>
    </div>
    </div>
    )
    }
  4. 但是如果不在onChildDOMClick里去阻止冒泡,而是在onChildClick里会发生什么?答案是:dog dom -> child dom -> parent dom -> dog react -> child react

  5. 如果是在onDogClick里,则是:dog dom -> child dom -> parent dom -> dog react
  6. 这就符合我们对冒泡的预期了。这是因为React对合成事件做了阻止冒泡的判定。
  7. 小结一下,就是在React里使用原生事件需要注意,而反过来,合成事件是不会影响到原生事件的。

原生捕获机制和React合成事件的关系

  1. 从上面已经能够看出,React利用事件冒泡机制,进行事件代理,做了合成事件的中间层。React的事件是不能指定在捕获阶段的,没有这个参数,那么如果混合了原生的捕获事件,情况会如何?
  2. 还是上面的例子,修改对应的代码之后,返回的结果是:parent dom -> child dom -> dog dom -> dog react -> child react -> parent react。这说明在捕获阶段,原生也先于合成。(注意,原生这里的顺序已经变了!)

    1
    2
    3
    $parent.addEventListener('click', this.onParentDOMClick, true)
    $child.addEventListener('click', this.onChildDOMClick, true)
    $dog.addEventListener('click', this.onDogDOMClick, true)
  3. 更进一步的做测试,修改dog的第三参数,结果是:parent dom -> child dom -> dog dom -> dog react -> child react -> parent react

    1
    2
    3
    $parent.addEventListener('click', this.onParentDOMClick, true)
    $child.addEventListener('click', this.onChildDOMClick, true)
    $dog.addEventListener('click', this.onDogDOMClick, false)
  4. 修改child的第三个参数,结果是:parent dom -> dog dom -> child dom -> dog react -> child react -> parent react(注意原生事件的顺序)

    1
    2
    3
    $parent.addEventListener('click', this.onParentDOMClick, true)
    $child.addEventListener('click', this.onChildDOMClick, false)
    $dog.addEventListener('click', this.onDogDOMClick, true)
  5. 以上例子说明:合成事件永远是在冒泡完成后在Document被代理,它只有冒泡阶段的事件监听器,原生事件会总是早于合成事件触发,合成事件会按照DOM的层级顺序触发,这个顺序就是事件链上的冒泡顺序。

  6. 再进一步,结合上面的阻止冒泡,如果在原生的捕获事件里阻止冒泡stopPropagation,会怎么样?答案是会阻断阻止冒泡的节点之后的事件捕获,并且阻断所有合成事件。stopPropagation会阻断冒泡,也会阻断捕获和目标阶段。

React合成事件的优、缺点

  1. 每当将事件处理程序指定给元素时,运行中的浏览器代码与支持页面交互的 JavaScript 代码之间就会建立一个连接。这种连接越多,页面执行起来就越慢。React的合成事件其实就是利用事件委托。
  2. 通过事件委托因为只关联了一个DOM元素(Document),只添加了一个事件处理程序,所以对内存的占用就小。虽然对用户来说最后的结果相同,但这种技术需要占用的内存更少,而所有用到按钮的事件(多数鼠标事件和键盘事件)都适合采用事件委托技术。
  3. DOM变动的时候,也不因为有没被正确回收的空事件处理程序占用内存的风险
  4. 但是React的合成事件也并非没有缺点。addEventListener可以接受第三个参数,并不仅仅如上只是一个bool值,还可以是一个对象,这个对象里可以通过传递一个键值对,表示该 listener 永远不会调用 e.preventDefault()。如果 listener 仍然调用了e.preventDefault(),客户端将会忽略它并抛出一个控制警告/错误。
  5. 这个值默认是false。但Chrome为了提高移动端的页面滚动流畅度,把touchstarttouchmove的事件监听的options中的passive的默认值改成了true……而通过React合成事件,你对此将无能为力。在某些特殊的场景下,你还是不得不利用DOM2级事件绑定机制。关于这个问题请移步《React在移动端Web项目里touch事件带来的页面滚动问题》
  6. 另外,并非所有事件都会被React委托到Document——很简单的道理:如果Document没有这个事件,就不可能去代理。比如audiovideo标签的媒体事件onplayonpause等。