昨天面试被问到一个元素同时绑定 React 合成事件和 DOM 原生事件的问题。首先来说,我在项目中真的没遇到过这样的场景,我也没想到要用的那些场景中。
React 合成事件
先来说说 React 合成事件是怎么回事。
React 合成事件是如何工作的
React 的事件系统沿袭了事件委托的思想。在 React 中,除了少数特殊的不可冒泡的事件(比如媒体类型的事件)无法被事件系统处理外,绝大部分的事件都不会被绑定在具体的元素上,而是统一被绑定在页面的 document 上。当事件在具体的 DOM 节点上被触发后,最终都会冒泡到 document 上,document 上所绑定的统一事件处理程序会将事件分发到具体的组件实例。在分发事件之前,React 首先会对事件进行包装,把原生 DOM 事件包装成合成事件。
为什么 React 要自己定义合成事件
首先一定要说的,也是 React 官方说明过的一点是:合成事件符合W3C规范,在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的、稳定的、与 DOM 原生事件相同的事件接口。开发者们由此便不必再关注烦琐的底层兼容问题,可以专注于业务逻辑的开发。
此外,自研事件系统使 React 牢牢把握住了事件处理的主动权。
在 React 中使用原生事件
其实和原生没什么区别,就是获取 DOM 的方式可能不太一样。
import React, { useEffect, useRef } from 'react'
function Demo() {
const dome = useRef(null)
useEffect(() => {
dome.current.addEventListener('click', clickDOMButton, false)
}, [])
function clickDOMButton() {
console.log('DOM event')
}
return (
<div>
<button ref={dome}>
按钮
</button>
</div>
)
}
export default Demo
或者可以自己写一个工具函数,抹平浏览器间的差异,然后在组件中直接调用。
export const addEventListener = (
target,
type,
listener,
useCapture = false
) => {
if (target.addEventListener) {
target.addEventListener(type, listener, useCapture)
} else if (target.attachEvent) {
target.attachEvent(`on${type}`, listener)
} else {
target[`on${type}`] = listener
}
}
二者混合使用
执行顺序
import React, { useEffect, useRef } from 'react'
function Demo() {
const dome = useRef(null)
useEffect(() => {
addEventListener(dome.current, 'click', clickDOMButton, false)
}, [])
function clickDOMButton() {
console.log('DOM event')
}
function clickReactButton() {
console.log('React event')
}
return (
<div>
<button ref={dome} onClick={clickReactButton}>
按钮
</button>
</div>
)
}
export default Demo
上边代码对 button 元素分别绑定了两种事件,当点击时打印的结果为:
DOM event
React event
原因也很简单,当点击 button 时,原生事件直接就触发了,而合成事件要冒泡至 document 之后,才会去触发。
阻止冒泡
Note:
As of v0.14, returning
false
from an event handler will no longer stop event propagation. Instead,e.stopPropagation()
ore.preventDefault()
should be triggered manually, as appropriate.
React 官网有这样一句话,意思是从事件处理程序返回 false
将不再停止事件传播,而是应适当地手动触发e.stopPropagation()
或 e.preventDefault()
。
那我们就来试一试吧,先在合成事件中阻止冒泡。
import React, { useEffect, useRef } from 'react'
function Demo() {
const wrapper = useRef(null)
const dome = useRef(null)
useEffect(() => {
addEventListener(wrapper.current, 'click', clickDOMWrapper, false)
addEventListener(dome.current, 'click', clickDOMButton, false)
}, [])
function clickDOMWrapper() {
console.log('wrapper DOM event')
}
function clickDOMButton() {
console.log('button DOM event')
}
function clickReactWrapper() {
console.log('wrapper React event')
}
function clickReactButton(e) {
e.stopPropagation()
console.log('button React event')
}
return (
<div ref={wrapper} onClick={clickReactWrapper}>
<button ref={dome} onClick={clickReactButton}>
按钮
</button>
</div>
)
}
export default Demo
结果打印了这些:
button DOM event
wrapper DOM event
button React event
这证明了合成事件不会影响到原生事件。因为 React 给合成事件封装的 stopPropagation
函数在调用时给自己加了个 isPropagationStopped
的标记位来确定后续监听器是否执行。
那如果在原生事件中阻止冒泡呢?上边的例子改为:
function clickDOMWrapper() {
console.log('wrapper DOM event')
}
function clickDOMButton(e) {
e.stopPropagation()
console.log('button DOM event')
}
function clickReactWrapper() {
console.log('wrapper React event')
}
function clickReactButton() {
console.log('button React event')
}
结果只打印了 button DOM event
,那就意味着在原生事件中使用 e.stopPropagation()
会阻止合成事件的执行。因为原生事件中使用 e.stopPropagation()
后,事件不会冒泡的 document
,所以也就触发不了 document
上绑定的合成事件了。
nativeEvent
这里还有一个问题,就是当你需要访问原生事件对象时,可以通过合成事件对象的 e.nativeEvent
属性获取到它。但他可能和我们想象的不太一样。
当我们使用 e.nativeEvent.stopPropagation()
试图去阻止冒泡时,不但不能阻止原生事件的冒泡,连合成事件的冒泡也不能阻止了。执行这段代码的时候,原生事件早就执行完了,而又没有去阻止合成事件的冒泡,也不知道应该在什么情况下使用。
结论
- React 合成事件和 DOM 原生事件混用,先执行原生事件,再去执行合成事件
- 原生事件中使用
e.stopPropagation()
会阻止合成事件的执行,但在合成事件中使用e.stopPropagation()
却不会阻止原生事件的执行。