写在前面
本文致力于实现一个最简的事件模型,代码均已上传至github
,期待star!✨:
本文是系列文章,阅读的连续性非常重要!!
手写mini-react!超万字实现mount首次渲染流程🎉🎉
进击的hooks!实现react中的hooks架构和useState 🚀🚀
食用前指南!本文涉及到react的源码知识,需要对react有基础的知识功底,建议没有接触过react的同学先去官网学习一下基础知识,再看本系列最佳!
事件流
在浏览器中,javascript
与HTML
之间的交互是通过事件去实现的。在一整个页面的DOM结构中可能存在许许多多的事件,点击的click
事件、加载的load
事件、鼠标指针悬浮的mouseover
事件等等。那么当用户在触发某个事件的时候,绑定在DOM中的事件是怎么执行的呢?
例如我们有如下DOM结构:
html
<div>
<p>Click me!</p>
</div>
这就会涉及事件流的概念,事件流描述的是从页面中接收事件的顺序。事件发生后会在目标节点和根节点之间按照特定的顺序传播,路径经过的节点都会接收到事件。
事件捕获
假设我们在节点p上触发了click
事件,此时会产生两种事件传递顺序。网景提出一种事件流名为事件捕获(event capturing)。事件会从最外层开始发生,直到用户产生行为的元素。
针对我们上面的DOM结构,他的传递传递顺序如下:
js
document -> html -> body -> div -> p
最后到达用户触发的元素p,此时也被称为事件目标阶段。
事件冒泡
在到达时间目标阶段后,微软提出了名为事件冒泡(event bubbling) 的事件流,也就是事件会从最内层的元素开始发生,一直向上传播,直到document
对象。
针对我们上面的DOM结构,他的传递传递顺序如下:
js
p -> div -> body -> html -> document
1-4是捕获过程,4-5是目标阶段,5-8是冒泡阶段。
addEventListener
DOM2级事件中规定的事件流同时支持了事件捕获阶段和事件冒泡阶段,而作为开发者,我们可以选择事件处理函数在哪一个阶段被调用。
addEventListener
方法用来为一个特定的元素绑定一个事件处理函数,是JavaScript
中的常用方法。addEventListener
有三个参数:
js
element.addEventListener(event, function, useCapture)
参数 | 描述 |
---|---|
event | 必须。字符串,指定事件名。 所有 HTML DOM 事件,可以查看完整的HTML DOM Event 对象参考手册。 |
function | 必须。指定要事件触发时执行的函数。 当事件对象会作为第一个参数传入函数。 事件对象的类型取决于特定的事件。 |
useCapture | 可选。布尔值,指定事件是否在捕获或冒泡阶段执行。 可能值:true - 事件在捕获阶段执行(即在事件捕获阶段调用处理函数)false- 默认。事件在冒泡阶段执行(即表示在事件冒泡的阶段调用事件处理函数) |
事件代理(委托)
利用事件流的特性,我们可以实现一种叫做事件代理的方法。
我们有一些li元素,当点击每个 li 元素时,输出该元素的颜色:
html
<ul class="color_list">
<li>red</li>
<li>orange</li>
<li>yellow</li>
<li>green</li>
<li>blue</li>
<li>purple</li>
</ul>
<div class="box"></div>
<style>
.color_list{
display: flex;
display: -webkit-flex;
}
.color_list li{
width: 100px;
height: 100px;
list-style: none;
text-align: center;
line-height: 100px;
}
//每个li加上对应的颜色,此处省略
.box{
width: 600px;
height: 150px;
background-color: #cccccc;
line-height: 150px;
text-align: center;
}
</style>
我们想要在点击每个 li 标签时,输出li当中的颜色(innerHTML)。常规做法是遍历每个 li ,然后在每个 li 上绑定一个点击事件:
js
var color_list=document.querySelector(".color_list");
var colors=color_list.getElementsByTagName("li");
var box=document.querySelector(".box");
for(var n=0;n<colors.length;n++){
colors[n].addEventListener("click",function(){
console.log(this.innerHTML)
box.innerHTML="该颜色为 "+this.innerHTML;
})
}
这种做法在 li 较少的时候可以使用,但如果有一万个 li ,那就会导致性能降低。
这时候使用事件代理,利用事件流的特性,只绑定一个事件处理函数就可以完成:
js
function colorChange(e){
var e=e||window.event;//兼容性的处理
if(e.target.nodeName.toLowerCase()==="li"){
box.innerHTML="该颜色为 "+e.target.innerHTML;
}
}
color_list.addEventListener("click",colorChange,false)
由于事件冒泡机制,点击了 li 后会冒泡到 ul ,此时就会触发绑定在 ul 上的点击事件,再利用 target 找到事件实际发生的元素,就可以达到预期的效果。
使用事件代理的好处不仅在于将多个事件处理函数减为一个,而且对于不同的元素可以有不同的处理方法。假如上述列表元素当中添加了其他的元素节点(如:a、span等),我们不必再一次循环给每一个元素绑定事件,直接修改事件代理的事件处理函数即可。
React的事件模型
我们在使用react的jsx来编写代码时,以点击事件为例,通常是这样定义的:
js
function App() {
return (
<div onClick={add}></div>
)
}
可以看到在jsx中定义的onClick
明显不是DOM原生的事件名,这其实是react 植根于浏览器事件模型实现的一套事件系统。
react会将所有dom事件都绑定到document
上面,而不是某一个元素上,统一的使用事件监听,并在冒泡阶段处理事件,所有当在挂载或者销毁组件时,只需要在统一的事件监听上增加或者删除对象,当事件被触发时,我们的组件会生成一个合成事件,然后传递到document
中,document
会通过dispatchEvent
回调函数依次执行dispatchListener
中同类型事件监听函数。
接下来开始定义在react初始化时对事件进行挂载,还记得在初始化那一篇文章中,整个应用的入口函数:
js
ReactDOM.createRoot(document.getElementById('root')).render(
<App />
);
在入口函数createRoot
中,container
参数则是整个应用的根DOM挂载点,在开始执行render
函数时,为根DOM节点挂载自定义的click
事件。
js
export function createRoot(container) {
const root = createContainer(container);
return {
render(element) {
// 挂载click事件
initEvent(container, 'click');
// ...省略
}
};
}
eventType
只被允许传入我们支持的事件,首先定义一个事件列表:
js
const validEventTypeList = ['click'];
js
export function initEvent(container, eventType) {
// 验证是否支持指定的事件类型
if (!validEventTypeList.includes(eventType)) {
console.warn('当前不支持', eventType, '事件');
return;
}
// 绑定事件
container.addEventListener(eventType, (e) => {
dispatchEvent(container, eventType, e);
});
}
为根节点绑定事件,当事件被触发时,调用dispatchEvent
回调函数依次执行dispatchListener
中同类型事件监听函数。
既然要实现整个DOM的事件模型,自然也要实现原生DOM的整个事件流程,包括事件捕获及事件冒泡。
所以在dispatchEvent
函数中需要完成的步骤如下:
- 收集沿途的事件
- 构造合成事件
- 执行事件捕获列表
- 执行事件冒泡列表
js
function dispatchEvent(container, eventType, e) {
const targetElement = e.target;
if (targetElement === null) {
console.warn('事件不存在target', e);
return;
}
// 1. 收集沿途的事件
const { bubble, capture } = collectPaths(
targetElement,
container,
eventType
);
// 2. 构造合成事件
const se = createSyntheticEvent(e);
// 3. 遍历captue
triggerEventFlow(capture, se);
// 阻止冒泡事件执行
if (!se.__stopPropagation) {
// 4. 遍历bubble
triggerEventFlow(bubble, se);
}
}
收集沿途的事件
在一个事件被触发时,首先就要收集目标DOM节点及其所有父节点定义的所有事件,根据目标事件对象,查找所有父级的onClick
和onClickCapture
事件,react默认的事件流方式是冒泡,如果想要在捕获阶段触发,可以添加onClickCapture
来定义事件。
首先创建一个函数来映射事件名:
js
function getEventCallbackNameFromEventType(
eventType
) {
return {
click: ['onClickCapture', 'onClick']
}[eventType];
}
getEventCallbackNameFromEventType
函数通过接收一个DOM事件名来获取对应的捕获及冒泡的事件名列表。
为了方便获取节点中的属性,也就是根据事件名称获取对应节点的事件处理函数,我们使用一个自定义属性来保存所有的节点属性,并在初始化以及更新流程保存。
js
export const elementPropsKey = '__props';
// 接收节点和属性
export function updateFiberProps(node, props) {
node[elementPropsKey] = props;
}
在render
流程中是处理props
初始化以及更新的一个合适的时机。在completeWork
函数的执行时对节点的props
进行保存:
js
export const completeWork = (wip: FiberNode) => {
const newProps = wip.pendingProps;
const current = wip.alternate;
switch (wip.tag) {
case HostComponent:
if (current !== null && wip.stateNode) {
// update 更新流程
// 1. props是否变化 {onClick: xx} {onClick: xxx}
updateFiberProps(wip.stateNode, newProps);
} else {
// mount 初始化流程
// 1. 构建DOM
const instance = createInstance(wip.type, newProps);
// ...省略
}
return null;
case HostText:
// ...省略
return null;
case HostRoot:
// ...省略
case FunctionComponent:
// ...省略
default:
if (__DEV__) {
console.warn('未处理的completeWork情况', wip);
}
break;
}
};
- 初始化
初始化时直接在创建fiber
节点的真实DOM时保存props
。
js
export const createInstance = (type, props) => {
// 处理props
const element = document.createElement(type);
// 初始化时保存props
updateFiberProps(element, props);
return element;
};
- 更新
节点的更新阶段直接将新的props
重新保存。
js
updateFiberProps(wip.stateNode, newProps);
在收集事件的过程中使用节点的parentNode
属性向上查找,直到根节点。
在这里有一个需要注意的地方,我们定义了两个数组,分别收集捕获与冒泡事件的处理函数,但是我们在逐层收集时,捕获事件使用unshift
(前插),而冒泡事件使用push
(后增)。这样做的原因是,在两个数组收集完毕后两者的顺序完全不同:
js
capture: [父 --> 子]
bubble: [子 --> 父]
两者的顺序刚好相反,capture
父节点的事件处理函数在前,子节点的在后,bubble
子节点的事件处理函数在前,父节点的在后。
在后续遍历这两个数组执行事件回调函数的时候,执行顺序正好与DOM事件流的触发顺序一致。先由父到子逐层捕获,再由子到父逐层冒泡。
js
function collectPaths(
targetElement,
container,
eventType
) {
// 定义事件收集数组
const paths = {
capture: [],
bubble: []
};
while (targetElement && targetElement !== container) {
// 收集
const elementProps = targetElement[elementPropsKey];
if (elementProps) {
// 获取事件映射
const callbackNameList = getEventCallbackNameFromEventType(eventType);
if (callbackNameList) {
callbackNameList.forEach((callbackName, i) => {
// 获取事件回调函数
const eventCallback = elementProps[callbackName];
if (eventCallback) {
if (i === 0) {
// capture
paths.capture.unshift(eventCallback);
} else {
// bubble
paths.bubble.push(eventCallback);
}
}
});
}
}
targetElement = targetElement.parentNode;
}
return paths;
}
构造合成事件
构造合成事件可以使我们更加灵活的添加一些额外的处理逻辑。通过劫持事件对象一些原生的方法,添加额外的逻辑。
下面这段逻辑中我们劫持了事件原生的阻止冒泡的方法stopPropagation
,使用自定义属性__stopPropagation
来控制是否组织部冒泡,然后重写了stopPropagation
方法。
js
function createSyntheticEvent(e: Event) {
const syntheticEvent = e;
// 初始化__stopPropagation
syntheticEvent.__stopPropagation = false;
// 保存原有的stopPropagation
const originStopPropagation = e.stopPropagation;
// 自定义stopPropagation执行,执行原有的stopPropagation方法
syntheticEvent.stopPropagation = () => {
syntheticEvent.__stopPropagation = true;
if (originStopPropagation) {
originStopPropagation();
}
};
return syntheticEvent;
}
执行事件流
执行事件流就是依次遍历我们定义的capture
数组和bubble
数组。当然,如果设置了我们的自定义属性__stopPropagation
为true
,那么将会组织冒泡(阻止bubble
数组的遍历执行)。
js
function triggerEventFlow(paths, se) {
for (let i = 0; i < paths.length; i++) {
const callback = paths[i];
// 执行事件处理函数
callback.call(null, se);
if (se.__stopPropagation) {
break;
}
}
}
写在最后 ⛳
未来可能会更新实现mini-react
和antd
源码解析系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳