作为前端开发者,我们对浏览器事件再熟悉不过了,它的传播会经过捕获和冒泡两个阶段。jquery对于事件的处理比较容易理解,通过一定的封装去适配不同的浏览器,让开发者使用起来更方便。而React对事件的处理就没这么简单了。
React合成事件(SyntheticEvent)是浏览器原生事件的上层封装。SyntheticEvent和原生事件有类似的结构,比如都有stopPropagation
和preventDefault
方法。但它会另外增加一些属性或方法,比如nativeEvent
属性用于保存原生事件,又比如persist
方法用于避免事件被释放掉(React17移除了该方法)。
function App() {
const onClick = (event) => {
// 这里的event不是真实的DOM Event
event.stopPropagation();
console.log(event._reactName);
};
return (
<button id="btn" onClick={onClick}>Click Here</button>
);
}
上面这段代码的运行结果并不等价于<button id="btn" onclick={onClick()}>Click Here</button>
或者document.getElementById('#btn').addEventListener('click', onClick)
。
因为React的事件不是挂载到jsx定义的DOM节点上,而是通过事件代理挂载到某个祖先节点上。React 16.x及以前的版本这个祖先节点是document,而React 17之后是根容器,也就是下面代码的rootNode
。
const rootNode = document.getElementById('root');
ReactDOM.render(<App />, rootNode);
也可以看下面的经典图:
React 16.x及以前的合成事件
以下分析基于React 16.8。
事件委托
React每次render都会递归构建Fiber树,这个过程中会设置节点的属性,其中有两个很重要的点:
- 使用前缀为
__reactInternalInstance$
的key将Fiber节点关联到DOM实例上,方便查找; - 如果节点上有事件相关的props,比如
onClick
,则在document上绑定该类型的监听,类似这样:
document.addEventListener('click', listener, false);
当用户点击页面元素时,事件会经过捕获/冒泡阶段。根据当前所在的阶段,执行listener
。但这里的listener
并不等同于我们在jsx上定义的onClick
之类的事件回调,它是对这些事件回调的深度封装,里面做了很多事情,最后会执行这些回调。
并不是所有的事件都会委托到document,有些事件还是直接绑定到当前元素,比如img的load事件、input的invalid事件、video和audio的相关事件等。
事件执行顺序如下图:
React事件的capture阶段等到原生事件的bubble阶段结束后才执行。
事件池
为了提高性能,React使用事件池来管理事件。
不同的事件类比如SyntheticInputEvent、SyntheticClipboardEvent、SyntheticAnimationEvent等都是扩展自SyntheticEvent基类,每个类有自己的事件池,用来管理事件的实例。
事件的创建和释放
回到上文说到的listener
,它的执行过程,也是合成事件的创建和释放过程。
当我们需要合成事件的实例时,React并不是每次都重新创建,而是从池子里面取出一个,并修改它的一些属性。
真实的事件监听都绑定到了document上。当我们点击一个DOM元素,事件必须先经过捕获再冒泡到document,然后才执行listener
回调。
前面说过,DOM节点通过前缀为__reactInternalInstance$
的key,指向了对应的Fiber实例。执行listener
时,通过event
参数可以拿到事件源对应的DOM节点和Fiber实例,然后递归往上收集我们定义的事件回调(比如点击按钮时的onClick
方法)和Fiber实例,结果分别保存在event._dispatchListeners
和event._dispatchInstances
。这个收集过程模拟了捕获->冒泡这样一个事件传播的顺序,所以之后正序递归执行event._dispatchListeners
里的回调方法就可以了。
执行完之后重置event对象,并维护事件池,这就是事件的释放过程。
event.persist
合成事件执行完之后,React会释放事件对象(大部分属性置为null)。如果我们想异步访问事件对象里的属性,就会出错。比如下面的代码:
function TextInput() {
const [input, setInput] = useState({
text: '',
editCount: 0,
});
const onChange = useCallback((event) => {
setInput((prev) => ({
// 出错,event.target为null
text: event.target.value,
editCount: prev.editCount + 1,
}));
}, []);
return (
<div>
<span>{input.editCount}次</span>
<input type="text" value={input.text} onChange={onChange} />
</div>
);
}
一个简单的办法当然是用变量将event.target.value
提前保存起来。另外,我们也可以用React提供的event.persist()
方法,它避免了event
的释放。注意,它并不是原生DOM事件的方法。
event.stopPropagation失效
很多开发者反馈过stopPropagation失效的问题,他们明明在事件回调里调用了event.stopPropagation()
,document上的监听却依然会触发。比如:
function App() {
const boxRef = useRef(null);
const onClickButton = useCallback((event/* React合成事件 */) => {
console.log('点击button');
event.stopPropagation();
}, []);
const onClickBox = useCallback((event/* 真实DOM事件 */) => {
console.log('点击box');
}, []);
const onClickDoc = useCallback((event/* 真实DOM事件 */) => {
console.log('点击document');
}, []);
// 正常在react里我们不会写原生的事件监听
useEffect(() => {
const box = boxRef.current;
box?.addEventListener('click', onClickBox, false);
document.addEventListener('click', onClickDoc, false);
return () => {
box?.removeEventListener('click', onClickBox, false);
document.removeEventListener('click', onClickDoc, false);
};
}, [onClickBox, onClickDoc]);
return (
<div ref={boxRef}>
<button onClick={onClickButton}>点击我</button>
</div>
);
}
正常来说,onClickButton
里我们调用了event.stopPropagation()
,事件是不会冒泡到box和document的。但实际上,onClickBox
和onClickDoc
都执行了打印。也就是说stopPropagation
失效了。
前面已经说过,走完浏览器的捕获/冒泡阶段之后,才会执行合成事件的回调。所以,当我们点击真实的button时,事件冒泡到box,执行了onClickBox
,然后继续冒泡到document,然后执行onClickButton
和onClickDoc
这两个回调方法。但onClickButton
在mount阶段就绑定,onClickDoc
则在mounted之后才绑定,所以onClickButton
先执行。
理解了这点,也就不难明白为什么stopPropagation
失效,以及打印顺序为什么是:点击box => 点击button => 点击document。
这也是Atom编辑器团队想在应用里嵌套多个React版本时所遇到的问题,即无法阻止内层DOM树触发的event传播到外层DOM树。
为什么使用合成事件
- 浏览器兼容,统一行为,比如事件对象有统一的属性和方法,又比如,移除不想要的点击事件(Firefox右键点击会生成点击事件),再比如无论注册onMouseLeave还是onMouseOut都会映射成原生的mouseout事件;
- 多平台适配,ReactNative也能使用;
- 实现事件委托,避免大量创建事件监听;
- 事件池机制,避免频繁创建和销毁SyntheticEvent对象,释放过程将SyntheticEvent对象的大部分属性置为null,提升旧浏览器的性能。
React 17合成事件的变更
以下分析基于React v17.0.2版本。
事件委托
React 17合成事件的执行过程和React 16差不多,不过在构造Fiber树时会从current fiber往上找到root fiber,然后把listener绑定到root fiber对应的DOM元素上,也就是下面代码的rootNode
。
const rootNode = document.getElementById('root');
ReactDOM.render(<App />, rootNode);
事件的执行顺序如下图:
React事件的capture阶段和原生事件的capture阶段同时进行,然后是原生事件的bubble阶段,最后是React事件的bubble阶段。
事件委托到rootNode
的好处:
- 可以在jquery里使用react不会有
event.stopPropagation()
失效的问题,可以同时存在多个react版本,对于想在旧项目里尝试新版本react的开发者来说应该是一个福音; - 比较容易实现事件重放(replaying events)。
事件重放是React服务端渲染Selective Hydration的一项能力,简单来说就是注水之前用户的点击等操作在注水后能够重放。Dan有在twitter上发过一个demo,链接为twitter.com/dan_abramov…。
移除事件池
React 17移除了事件池机制,主要有两个原因:
- 虽然事件池能够在大量事件触发时减少内存分配,但在执行完事件之后释放和复用对象上做的事情有点多。这不一定能给现代浏览器带来收益,而且也没有其他人有类似的实践;
- 事件池的时候给开发者带来困惑,因为在异步方法里面使用
event.target
会得到null
,需要通过event.persist()
或者用变量暂存起来这样的方式解决,这不符合我们的习惯。
对于这个变更,开发者不用对代码进行任何的修改,因为event.persist
变成了一个空方法,event.isPersistent()
总是返回true
。
事件优先级
React 17开始,会根据事件的类型创建不同优先级的事件监听器listener,当真正触发DOM事件时,调度器Scheduler会按优先级调度listener的执行,执行过程包含合成事件的收集和按捕获->冒泡顺序执行onClick
等事件回调。
不同事件的优先级:
- 离散事件(DiscreteEvent),非连续触发,包括click、input、keydown、focusin等,优先级为0;
- 用户阻塞事件(UserBlockingEvent),连续触发,包括drag、mousemove、touchmove、scroll等,优先级为1;
- 连续事件(ContinuousEvent),包括load、progress、playing、error等音视频相关的事件,优先级为2。
数值越大优先级越高,但实际上DiscreteEvent和UserBlockingEvent优先级相同,都对应调度器的UserBlockingPriority。
对齐浏览器
其他一些关于事件的变更,更好地贴近浏览器的表现:
- onScroll事件不再经过冒泡阶段,因为浏览器的scroll也不会冒泡;
- onFocus和onBlur事件映射到原生的focusin和focusout事件。
总结
React在浏览器原生事件的基础上实现了一套合成事件。
React 16.x及以前的合成事件:
- 事件委托到document;
- 部分事件还是会绑定到当前元素;
- 存在React事件和原生事件的映射关系,比如onMouseLeave会映射成原生的mouseout事件;
- 事件池机制。
React 17的合成事件:
- 事件委托到root;
- React capture阶段的合成事件提前到原生事件capture阶段执行;
- 移除事件池机制;
- 事件有优先级。
参考资料
原文链接:https://juejin.cn/post/7216703870684168250 作者:文言前端