React中阻止事件冒泡的问题详析


Posted in Javascript onApril 12, 2019

前言

最近在研究react、redux等,网上找了很久都没有完整的答案,索性自己整理下,这篇文章就来给大家介绍了关于React阻止事件冒泡的相关内容,下面话不多说了,来一起看看详细的介绍吧

在正式开始前,先来看看 JS 中事件的触发与事件处理器的执行。

JS 中事件的监听与处理

事件捕获与冒泡

DOM 事件会先后经历 捕获 与 冒泡 两个阶段。捕获即事件沿着 DOM 树由上往下传递,到达触发事件的元素后,开始由下往上冒泡。

IE9 及之前的版本只支持冒泡

                  |  A
 -----------------|--|-----------------
 | Parent         |  |                |
 |   -------------|--|-----------     |
 |   |Children    V  |          |     |
 |   ----------------------------     |
 |                                    |
 --------------------------------------

事件处理器

默认情况下,事件处理器是在事件的冒泡阶段执行,无论是直接设置元素的 onclick 属性还是通过 EventTarget.addEventListener() 来绑定,后者在没有设置 useCapture 参数为 true 的情况下。

考察下面的示例:

<button onclick="btnClickHandler(event)">CLICK ME</button>
<script>
 document.addEventListener("click", function(event) {
 console.log("document clicked");
 });

 function btnClickHandler(event) {
 console.log("btn clicked");
 }
</script>

输出:

btn clicked
document clicked

阻止事件的冒泡

通过调用事件身上的 stopPropagation() 可阻止事件冒泡,这样可实现只我们想要的元素处理该事件,而其他元素接收不到。

<button onclick="btnClickHandler(event)">CLICK ME</button>
<script>
 document.addEventListener(
 "click",
 function(event) {
 console.log("document clicked");
 },
 false
 );

 function btnClickHandler(event) {
 event.stopPropagation();
 console.log("btn clicked");
 }
</script>

输出:

btn clicked

一个阻止冒泡的应用场景

常见的弹窗组件中,点击弹窗区域之外关闭弹窗的功能,可通过阻止事件冒泡来方便地实现,而不用这种方式的话,会引入复杂的判断当前点击坐标是否在弹窗之外的复杂逻辑。

document.addEventListener("click", () => {
 // close dialog
});

dialogElement.addEventListener("click", event => {
 event.stopPropagation();
});

但如果你尝试在 React 中实现上面的逻辑,一开始的尝试会让你怀疑人生。

React 下事件执行的问题

了解了 JS 中事件的基础,一切都没什么难的。在引入 React 后,,事情开始起变化。将上面阻止冒泡的逻辑在 React 里实现一下,代码大概像这样:

function App() {
 useEffect(() => {
 document.addEventListener("click", documentClickHandler);
 return () => {
 document.removeEventListener("click", documentClickHandler);
 };
 }, []);

 function documentClickHandler() {
 console.log("document clicked");
 }

 function btnClickHandler(event) {
 event.stopPropagation();
 console.log("btn clicked");
 }

 return <button onClick={btnClickHandler}>CLICK ME</button>;
}

输出:

btn clicked
document clicked

document 上的事件处理器正常执行了,并没有因为我们在按钮里面调用 event.stopPropagation() 而阻止。

那么问题出在哪?

React 中事件处理的原理

考虑下面的示例代码并思考点击按钮后的输出。

import React, { useEffect } from "react";
import ReactDOM from "react-dom";

window.addEventListener("click", event => {
 console.log("window");
});

document.addEventListener("click", event => {
 console.log("document:bedore react mount");
});

document.body.addEventListener("click", event => {
 console.log("body");
});

function App() {
 function documentHandler() {
 console.log("document within react");
 }

 useEffect(() => {
 document.addEventListener("click", documentHandler);
 return () => {
 document.removeEventListener("click", documentHandler);
 };
 }, []);

 return (
 <div
 onClick={() => {
 console.log("raect:container");
 }}
 >
 <button
 onClick={event => {
  console.log("react:button");
 }}
 >
 CLICK ME
 </button>
 </div>
 );
}

ReactDOM.render(<App />, document.getElementById("root"));

document.addEventListener("click", event => {
 console.log("document:after react mount");
});

现在对代码做一些变动,在 body 的事件处理器中把冒泡阻止,再思考其输出。

document.body.addEventListener("click", event => {
+ event.stopPropagation();
 console.log("body");
});

下面是剧透环节,如果你懒得自己实验的话。

点击按钮后的输出:

body
document:bedore react mount
react:button
raect:container
document:after react mount
document within react
window

bdoy 上阻止冒泡后,你可能会觉得,既然 body 是按钮及按钮容器的父级,那么按钮及容器的事件会正常执行,事件到达 body 后, body 的事件处理器执行,然后就结束了。 document 上的事件处理器一个也不执行。

事实上,按钮及按钮容器上的事件处理器也没执行,只有 body 执行了。

输出:

body

通过下面的分析,你能够完全理解上面的结果。

SyntheticEvent

React 有自身的一套事件系统,叫作 SyntheticEvent。叫什么不重要,实现上,其实就是通过在 document 上注册事件代理了组件树中所有的事件(facebook/react#4335),并且它监听的是 document 冒泡阶段。你完全可以忽略掉 SyntheticEvent 这个名词,如果觉得它有点让事情变得高大上或者增加了一些神秘的话。

除了事件系统,它有自身的一套,另外还需要理解的是,界面上展示的 DOM 与我们代码中的 DOM 组件,也是两样东西,需要在概念上区分开来。

所以,当你在页面上点击按钮,事件开始在原生 DOM 上走捕获冒泡流程。React 监听的是 document 上的冒泡阶段。事件冒泡到 document 后,React 将事件再派发到组件树中,然后事件开始在组件树 DOM 中走捕获冒泡流程。

现在来尝试理解一下输出结果:

  • 事件最开始从原生 DOM 按钮一路冒泡到 body,body 的事件处理器执行,输出 body。注意此时流程还没进入 React。为什么?因为 React 监听的是 document 上的事件。
  • 继续往上事件冒泡到 document。
    • 事件到达 document 之后,发现 document 上面一共绑定了三个事件处理器,分别是代码中通过 document.addEventListener ReactDOM.render 前后调用的,以及一个隐藏的事件处理器,是 ReactDOM 绑定的,也就是前面提到的 React 用来代理事件的那个处理器。
    • 同一元素上如果对同一类型的事件绑定了多个处理器,会按照绑定的顺序来执行。
    • 所以 ReactDOM.render 之前的那个处理器先执行,输出 document:before react mount
    • 然后是 React 的事件处理器。此时,流程才真正进入 React,走进我们的组件。组件里面就好理解了,从 button 冒泡到 container,依次输出。
    • 最后 ReactDOM.render 之后的那个处理器先执行,输出 document:after react mount
  • 事件完成了在 document 上的冒泡,往上到了 window,执行相应的处理器并输出 window。

理解 React 是通过监听 document 冒泡阶段来代理组件中的事件,这点很重要。同时,区分原生 DOM 与 React 组件,也很重要。并且,React 组件上的事件处理器接收到的 event 对象也有别于原生的事件对象,不是同一个东西。但这个对象上有个 nativeEvent 属性,可获取到原生的事件对象,后面会用到和讨论它。

紧接着的代码的改动中,我们在 body 上阻止了事件冒泡,这样事件在 body 就结束了,没有到达 document,那么 React 的事件就不会被触发,所以 React 组件树中,按钮及容器就没什么反应。如果没理解到这点,光看表象还以为是 bug。

进而可以理解,如果在 ReactDOM.render() 之前的的 document 事件处理器上将冒泡结束掉,同样会影响 React 的执行。只不过这里需要调用的不是 event.stopPropagation() ,而是 event.stopImmediatePropagation()

document.addEventListener("click", event => {
+ event.stopImmediatePropagation();
 console.log("document:bedore react mount");
});

输出:

body
document:bedore react mount

stopImmediatePropagation 会产生这样的效果,即,如果同一元素上同一类型的事件(这里是 click)绑定了多个事件处理器,本来这些处理器会按绑定的先后来执行,但如果其中一个调用了 stopImmediatePropagation,不但会阻止事件冒泡,还会阻止这个元素后续其他事件处理器的执行。

所以,虽然都是监听 document 上的点击事件,但 ReactDOM.render() 之前的这个处理器要先于 React,所以 React 对 document 的监听不会触发。

解答前面按钮未能阻止冒泡的问题

如果你已经忘了,这是相应的代码及输出。

到这里,已经可以解答为什么 React 组件中 button 的事件处理器中调用 event.stopPropagation() 没有阻止 document 的点击事件执行的问题了。因为 button 事件处理器的执行前提是事件达到 document 被 React 接收到,然后 React 将事件派发到 button 组件。既然在按钮的事件处理器执行之前,事件已经达到 document 了,那当然就无法在按钮的事件处理器进行阻止了。

问题的解决

要解决这个问题,这里有不止一种方法。

用 window 替换 document

来自 React issue 回答中提供的这个方法是最快速有效的。使用 window 替换掉 document 后,前面的代码可按期望的方式执行。

function App() {
 useEffect(() => {
+ window.addEventListener("click", documentClickHandler);
 return () => {
+  window.removeEventListener("click", documentClickHandler);
 };
 }, []);

 function documentClickHandler() {
 console.log("document clicked");
 }

 function btnClickHandler(event) {
 event.stopPropagation();
 console.log("btn clicked");
 }

 return <button onClick={btnClickHandler}>CLICK ME</button>;
}

这里 button 事件处理器上接到到的 event 来自 React 系统,也就是 document 上代理过来的,所以通过它阻止冒泡后,事件到 document 就结束了,而不会往上到 window。

Event.stopImmediatePropagation()

组件中事件处理器接收到的 event 事件对象是 React 包装后的 SyntheticEvent 事件对象。但可通过它的 nativeEvent 属性获取到原生的 DOM 事件对象。通过调用这个原生的事件对象上的 stopImmediatePropagation() 方法可达到阻止冒泡的目的。

function btnClickHandler(event) {
+ event.nativeEvent.stopImmediatePropagation();
 console.log("btn clicked");
}

至于原理,其实前面已经有展示过。React 在 render 时监听了 document 冒泡阶段的事件,当我们的 App 组件执行时,准确地说是渲染完成后(useEffect 渲染完成后执行),又在 document 上注册了 click 的监听。此时 document 上有两个事件处理器了,并且组件中的这个顺序在 React 后面。

当调用 event.nativeEvent.stopImmediatePropagation() 后,阻止了 document 上同类型后续事件处理器的执行,达到了想要的效果。

但这种方式有个缺点很明显,那就是要求需要被阻止的事件是在 React render 之后绑定,如果在之前绑定,是达不到效果的。

通过元素自身来绑定事件处理器

当绕开 React 直接通过调用元素自己身上的方法来绑定事件时,此时走的是原生 DOM 的流程,都没在 React 的流程里面。

function App() {
 const btnElement = useRef(null);
 useEffect(() => {
 document.addEventListener("click", documentClickHandler);
 if (btnElement.current) {
  btnElement.current.addEventListener("click", btnClickHandler);
 }

 return () => {
  document.removeEventListener("click", documentClickHandler);
  if (btnElement.current) {
  btnElement.current.removeEventListener("click", btnClickHandler);
  }
 };
 }, []);

 function documentClickHandler() {
 console.log("document clicked");
 }

 function btnClickHandler(event) {
 event.stopPropagation();
 console.log("btn clicked");
 }

 return <button ref={btnElement}>CLICK ME</button>;
}

很明显这样是能解决问题,但你根本不会想要这样做。代码丑陋,不直观也不易理解。

结论

注意区分 React 组件的事件及原生 DOM 事件,一般情况下,尽量使用 React 的事件而不要混用。如果必需要混用比如监听 document,window 上的事件,处理 mousemove,resize 等这些场景,那么就需要注意本文提到的顺序问题,不然容易出 bug。

相关资源

  • e.stopPropagation() seems to not be working as expect. #4335
  • ReactJS SyntheticEvent stopPropagation() only works with React events?
  • Event.stopImmediatePropagation()
  • SyntheticEvent

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对三水点靠木的支持。

Javascript 相关文章推荐
根据判断浏览器类型屏幕分辨率自动调用不同CSS的代码
Feb 22 Javascript
Javascript动态创建表格及删除行列的方法
May 15 Javascript
jquery实现的代替传统checkbox样式插件
Jun 19 Javascript
深入理解JavaScript中为什么string可以拥有方法
May 24 Javascript
jQuery获取及设置表单input各种类型值的方法小结
May 24 Javascript
JavaScript创建对象的七种方式(推荐)
Jun 26 Javascript
200行代码实现blockchain 区块链实例详解
Mar 14 Javascript
微信小程序引用iconfont图标的方法
Oct 22 Javascript
JS实现深度优先搜索求解两点间最短路径
Jan 17 Javascript
IE浏览器下JS脚本提交表单后,不能自动提示问题解决方法
Jun 04 Javascript
Vue实例的对象参数options的几个常用选项详解
Nov 08 Javascript
Javascript基于OOP实实现探测器功能代码实例
Aug 26 Javascript
TypeScript中的方法重载详解
Apr 12 #Javascript
vue-cli 3.x配置跨域代理的实现方法
Apr 12 #Javascript
解决微信小程序调用moveToLocation失效问题【超简单】
Apr 12 #Javascript
详解Bootstrap 学习(一)入门
Apr 12 #Javascript
vue组件中iview的modal组件爬坑问题之modal的显示与否应该是使用v-show
Apr 12 #Javascript
vue+echarts实现可拖动节点的折线图(支持拖动方向和上下限的设置)
Apr 12 #Javascript
详解Vue中使用插槽(slot)、聚类插槽
Apr 12 #Javascript
You might like
dedecms防止FCK乱格式化你的代码的修改方法
2007/03/17 PHP
ThinkPHP模版中导入CSS和JS文件的方法
2014/11/29 PHP
php的sso单点登录实现方法
2015/01/08 PHP
php通过Chianz.com获取IP地址与地区的方法
2015/01/14 PHP
php的闭包(Closure)匿名函数初探
2016/02/14 PHP
Zend Framework框架路由机制代码分析
2016/03/22 PHP
项目中应用Redis+Php的场景
2016/05/22 PHP
JavaScript window.setTimeout() 的详细用法
2009/11/04 Javascript
JQUBAR1.1 jQuery 柱状图插件发布
2010/11/28 Javascript
关于火狐(firefox)及ie下event获取的两种方法
2012/12/27 Javascript
千分位数字格式化(用逗号隔开 代码已做了修改 支持0-9位逗号隔开)的JS代码
2013/12/05 Javascript
js整数字符串转换为金额类型数据(示例代码)
2013/12/26 Javascript
jquery批量设置属性readonly和disabled的方法
2014/01/24 Javascript
jQuery使用fadein方法实现渐出效果实例
2015/03/27 Javascript
javascript实现一个数值加法函数
2015/06/26 Javascript
jQuery数据类型小结(14个)
2016/01/08 Javascript
详解原生JavaScript实现jQuery中AJAX处理的方法
2016/05/10 Javascript
JavaScript实现窗口抖动效果
2016/10/19 Javascript
vue登录注册及token验证实现代码
2017/12/14 Javascript
axios发送post请求springMVC接收不到参数的解决方法
2018/03/05 Javascript
使用Python实现博客上进行自动翻页
2017/08/23 Python
在cmd中运行.py文件: python的操作步骤
2018/05/12 Python
tensorflow实现图像的裁剪和填充方法
2018/07/27 Python
浅谈Python中的异常和JSON读写数据的实现
2020/02/27 Python
Django-migrate报错问题解决方案
2020/04/21 Python
基于python requests selenium爬取excel vba过程解析
2020/08/12 Python
python 简单的调用有道翻译
2020/11/25 Python
html5 初试 indexedDB(推荐)
2016/07/21 HTML / CSS
详解HTML5 data-* 自定义属性
2018/01/24 HTML / CSS
露营世界:Camping World
2017/02/02 全球购物
牵手50新加坡:专为黄金岁月的单身人士而设的交友网站
2020/08/16 全球购物
中专生职业生涯规划书范文
2014/01/10 职场文书
pytorch交叉熵损失函数的weight参数的使用
2021/05/24 Python
Nginx反向代理至go-fastdfs案例讲解
2021/08/02 Servers
纯CSS如何禁止用户复制网页的内容
2021/11/01 HTML / CSS
HTML中link标签属性的具体用法
2023/05/07 HTML / CSS