朋友问起在做一个下拉框组件,下拉的点击事件是用react的onClick触发,外部区域点击关闭则用dom的原生点击事件绑定,问题是下拉的点击事件无法阻止冒泡到dom的原生事件。
我说,react的合成事件 和 原生事件是不一样的,尽可能不要混用,不然很绕。翻开之前在codepen写的demo
https://codepen.io/shellphon-the-encoder/pen/vYPEggK
也把自己绕晕了一下。
react的合成事件,注入onClick等事件,是在根元素上事件代理模拟的。react 16.8.0和之前的版本,是在document上事件代理,react 17则是在root
以demo上的div结构为例:v17.0.2
const {useState, useEffect, useRef} = React;
document.getElementById('root').addEventListener('click', (e) =>{console.log('外部原生root 点击', e);//e.stopPropagation();});
document.addEventListener('click', (e) =>{console.log('外部原生document 点击', e);});
const App = () => {const sonRef = useRef(null);const parentRef = useRef(null);const parentClick = (e)=>{console.log('合成事件parent click',e);//e.stopPropagation();};const sonClick = (e)=>{console.log('合成事件son click', e);}const sonClickNo = (e)=>{console.log('合成事件son click并阻止冒泡', e);e.stopPropagation();}useEffect(() => {document.addEventListener('click', (e) =>{console.log('内部原生document click', e);}); document.getElementById('root').addEventListener('click', (e) =>{console.log('内部原生 root click', e);});parentRef.current.addEventListener('click', (e) =>{console.log('内部原生事件ref p', e);});sonRef.current.addEventListener('click', (e) =>{console.log('内部原生事件ref son', e);});}, [])return <div ref={parentRef} onClick={parentClick}><div onClick={sonClick}>son</div><div onClick={sonClickNo} ref={sonRef}>son no</div></div>
};ReactDOM.render(<App />, document.getElementById('root'));
document>root>div>.parent>.son
son上的onClick(合成事件),实际是react root上的点击事件,在内部做模拟冒泡。
因为demo写的事件比较多,比较绕,所以画了出来。
当只有合成事件的时候,无非就是 son点击响应,然后parent点击响应。
同一个元素的原生事件和合成事件
当往son原生div加click事件时,点击son,会先响应原生click,再然后才是去合成事件,这中间如果parent也有原生click,那也是先原生click再到合成事件去。
如下:
const {useState, useEffect, useRef} = React;const App = () => {const sonRef = useRef(null);const parentRef = useRef(null);const parentClick = (e)=>{console.log('合成事件parent click',e);//e.stopPropagation();};const sonClick = (e)=>{console.log('合成事件son click', e);}const sonClickNo = (e)=>{console.log('合成事件son click并阻止冒泡', e);e.stopPropagation();}useEffect(() => {parentRef.current.addEventListener('click', (e) =>{console.log('内部原生事件ref p', e);});sonRef.current.addEventListener('click', (e) =>{console.log('内部原生事件ref son', e);});}, [])return <div ref={parentRef} onClick={parentClick}><div onClick={sonClick}>son</div><div onClick={sonClickNo} ref={sonRef}>son no</div></div>
};ReactDOM.render(<App />, document.getElementById('root'));
点击第一个son时:
可以看到先响应了parent的原生事件,然后才到son的合成事件
合成事件和外部root事件的关系
在react外面给root绑定click事件,看合成事件的顺序
const {useState, useEffect, useRef} = React;
document.getElementById('root').addEventListener('click', (e) =>{console.log('外部原生root 点击', e);//e.stopPropagation();});
document.addEventListener('click', (e) =>{console.log('外部原生document 点击', e);});
const App = () => {const sonRef = useRef(null);const parentRef = useRef(null);const parentClick = (e)=>{console.log('合成事件parent click',e);//e.stopPropagation();};const sonClick = (e)=>{console.log('合成事件son click', e);}const sonClickNo = (e)=>{console.log('合成事件son click并阻止冒泡', e);e.stopPropagation();}useEffect(() => {parentRef.current.addEventListener('click', (e) =>{console.log('内部原生事件ref p', e);});sonRef.current.addEventListener('click', (e) =>{console.log('内部原生事件ref son', e);});}, [])return <div ref={parentRef} onClick={parentClick}><div onClick={sonClick}>son</div><div onClick={sonClickNo} ref={sonRef}>son no</div></div>
};ReactDOM.render(<App />, document.getElementById('root'));
点击第一个son时,先原生事件, 然后 到了外部root绑定的事件,再到合成事件的son、parent,再然后是document
合成事件是在root上模拟的,而外部绑定root的事件和这个合成事件也就是在一个dom上多次绑定事件响应,各不相干,顺序上谁先绑定谁先响应,于是外部root原生先响应,再到合成事件的处理。而document是在root的上层,因此document事件是在最后才响应。
如果在组件周期里也给root加一个原生事件响应,那它会在合成事件完成之后才响应,因为它也是给同一个dom绑定的事件之一,只是晚于合成事件。
回到最开始的demo代码,当在son的onClick上阻止冒泡时,它做了两件事情:
1. 阻止了向上冒泡的模拟
2. 调用了原生事件的阻止冒泡 (解释了合成事件阻止冒泡后,为什么document事件没有响应)
由此,如果朋友在react 17版本上document上绑定的事件应该是能被合成事件阻止冒泡的。
但如果在react 16.8.0 合成事件是在document上绑定,那么额外绑定的document事件不会被合成事件阻止冒泡。
参考资料:
https://github.com/youngwind/blog/issues/107
https://mdnice.com/writing/85c044f9087746dcbd719e4a0b847278