从0实现React18系列七-事件系统
本系列是讲述从0开始实现一个react18的基本版本。由于React
源码通过Mono-repo 管理仓库,我们也是用pnpm
提供的workspaces
来管理我们的代码仓库,打包我们使用rollup
进行打包。
在使用React的时候,我们都知道React实现了一套自己的事件系统,今天我们以click
事件为例,讲解React事件系统的内部实践。
思考一下如下代码,在React中, 当我们点击p
标签的时候,是一个什么顺序输出。div -> div -> p
分别绑定了onClickCapture
和onClick
的函数。
export default function App() {
const [num, setNum] = useState(3);
return (
<div
onClick={() => {
console.log("container click");
}}
onClickCapture={() => {
console.log("container onClickCapture");
}}
>
<div
onClick={() => {
console.log("div click");
}}
onClickCapture={() => {
console.log("div onClickCapture");
}}
>
<p
onClickCapture={() => {
console.log("p onClickCapture");
}}
onClick={() => {
console.log("p click");
setNum(num + 1);
}}
>
{num}
</p>
</div>
</div>
);
}
container onClickCapture
-> div onClickCapture
-> p onClickCapture
-> p click
-> div click
-> container click
。
当我们使用e.stopPropagation
在某一次点击的时候,又会发送什么呢?
这篇文章就从事件本质去解释执行的流程。
绑定事件参数-入口
我们知道在书写jsx
的时候,事件绑定onClick
会被babel
转换到ReactElement
的props
上。事件系统又是和宿主环境相关,在浏览器中是使用react-dom
的包进行处理。事件相关处理主要文件SyntheticEvent.ts
。
那如何将我们传递的事件参数onClick、onClickCapture
等传递到react-dom
中呢?
分2步:
- 初始化阶段
- 更新阶段
初始化阶段
从前面调和章节中,我们知道在初始化阶段,为了构建离屏DOM
树,我们会在completeWork
的时候去调用react-dom
中hostConfig
的createInstance
的创建dom。
在这里,我们就可以将ReactElement props
传递给react-dom
。 这样在createInstance
的时候,我们就得到了props
的数据了。
// react-reconciler - completeWork 1. 构建DOM
const instance = createInstance(wip.type, newProps);
// react-dom - hostConfig 1. 创建DOM
export const createInstance = (type: string, props: Props): Instance => {
const element = document.createElement(type) as unknown;
updateFiberProps(element as DOMElement, props);
return element as DOMElement;
};
之后updateFiberProps
,我们就可以将props
保存在新建的dom
的某一个属性(__props
)中。方便之后事件处理
// react-dom - SyntheticEvent.ts
export function updateFiberProps(node: DOMElement, props: Props) {
// dom.__props = reactElement props
node[elementPropsKey] = props;
}
更新阶段
正常在更新阶段,我们需要进行如下步骤
completeWork
的时候,对于HostComponent
,对比前后props
属性的变化。- 如果属性变动后,执行
markUpdate
标记更新 - 然后在
commitMutationEffectsOnFibers
的时候,触发commitUpdate
。 commitUpdate
中针对HostComponent
执行updateFiberProps
操作。
这样就可以得到了最新的属性,并赋值给对应的dom
。
注册事件
由于react兼容各个平台的事件机制,自己实现了一套事件系统,在React17之前,事件都是绑定在document
上,React17后,事件被绑定到同一容器container
统一管理。同时对于微前端项目,也可以隔离多个容器。
let container = document.getElementById("root") // 这个就是绑定事件的contaienr
ReactDOM.createRoot(container).render(<App />
由于不是绑定在真实的Dom上,所以React模拟出了一套事件流: 事件捕获 -> 事件源 -> 事件冒泡
。也基于自身重写了事件源对象event
。
在初始化渲染的时候,我们就需要注册事件, 这里以click
为例。
// react-dom -> root.ts
export function createRoot(container: Container) {
const root = createContainer(container);
return {
render(element: ReactElementType) {
initEvent(container, "click");
return updateContainer(element, root);
},
};
}
调用initEvent
,传递我们实际绑定的DOM
节点。进行事件绑定。主要是通过addEventListener
绑定事件。
// react-dom SyntheticEvent.ts
export function initEvent(container: Container, eventType: string) {
container.addEventListener(eventType, (e: Event) => {
dispatchEvent(container, eventType, e);
});
}
所以我们平时的点击事件,实际监听触发的都在container
上,并不是本身监听了事件。这样统一处理,也可以减少事件的监听。
事件分发dispatchEvent
注册事件后,我们知道,当我们点击一个元素的时候,实际上触发的是container
元素,那目标元素是如何执行绑定的onClick
或者onClickCapture
函数的呢?
很容易想到的是,我们应该需要收集container
到目标元素
的沿途过程中的事件绑定,然后依次去触发它们。
所以dispatchEvent
的主要流程分为下面4步骤:
- 收集沿途的绑定事件(
onClick
或者onClickCapture
冒泡或捕获) - 基于原始事件参数
event
构造合成事件参数 - 遍历捕获
capture
,依次执行 - 遍历冒泡
bubble
,依次执行
function dispatchEvent(container: Container, eventType: string, e: Event) {
const targetElement = e.target;
if (targetElement === null) {
console.warn("事件不存在target", e);
}
// 1. 收集沿途的事件
const { bubble, capture } = collectPaths(
targetElement as DOMElement,
container,
eventType
);
// 2. 构造合成事件
const se = createSyntheticEvent(e);
// 3. 遍历capture
triggerEventFlow(capture, se);
// 4. bubble
if (!se.__stopPropagation) {
triggerEventFlow(bubble, se);
}
}
接下来我们依次分析:
1. 收集绑定事件collectPaths
collectPaths
这个函数主要是收集从实际点击的Dom元素
到container
的绑定事件。接收三个函数
targetElement
: 点击的目标元素e.target
container
: 容器Dom
元素eventType
: 事件类型,主要是用于映射,例如click
映射onClick
、onClickCapture
从之前的绑定事件参数中我们得知,ReactElemenet
的参数绑定在对应的Dom
元素的__props
属性中。
所以向上遍历的过程中,要获取elementProps
, 然后根据是否存在事件绑定进行对应的逻辑。通过collectPaths
后
我们得到一个capture
和bubble
的数组。
function collectPaths(
targetElement: DOMElement,
container: Container,
eventType: string
) {
const paths: Paths = {
capture: [],
bubble: [],
};
while (targetElement && targetElement !== container) {
// 收集
const elementProps = targetElement[elementPropsKey];
if (elementProps) {
const callbackNameList = getEventCallbackNameFromEventType(eventType);
// callbackNameList = ["onClickCapture", "onClick"]
if (callbackNameList) {
callbackNameList.forEach((callbackName, i) => {
const eventCallback = elementProps[callbackName];
if (eventCallback) {
if (i === 0) {
//capture
paths.capture.unshift(eventCallback);
} else {
paths.bubble.push(eventCallback);
}
}
});
}
}
targetElement = targetElement.parentNode as DOMElement;
}
return paths;
}
这里可能有一个疑惑点,就是为什么在catpure
的时候我们要使用unshift
,而在bubble
中,我们使用push
。
捕获阶段是从上到下的,所以最上面的元素应该是最先执行的。使用unShift
保证后遍历到的,先执行。
2. 构造合成事件参数createSyntheticEvent
由于react自己实现的事件系统,所以在处理事件冒泡和捕获的时候时候,需要自定义一些实现来模拟阻止冒泡。
例如e.stopPropagation
,除了执行本身之外,还需要额外的逻辑。
当用户调用e.stopPropagation
的时候,我们将一个私有遍历__stopPropagation
设置为true
。在之后的遍历中使用。
function createSyntheticEvent(e: Event) {
const syntheticEvent = e as SyntheticEvent;
syntheticEvent.__stopPropagation = false;
const originStopPropagation = e.stopPropagation;
syntheticEvent.stopPropagation = () => {
syntheticEvent.__stopPropagation = true;
if (originStopPropagation) {
originStopPropagation();
}
};
return syntheticEvent;
}
3. 遍历捕获事件triggerEventFlow
这个阶段主要是依次执行在第一步中收集的函数,执行的时候传入第二步构造的事件对象。
当发现上层有调用e.stopPropagation()
的时候,就停止循环。
// dispatchEvent 调用
// 3. capture
triggerEventFlow(capture, se);
function triggerEventFlow(paths: EventCallback[], se: SyntheticEvent) {
for (let i = 0; i < paths.length; i++) {
const callback = paths[i];
callback.call(null, se);
if (se.__stopPropagation) {
break;
}
}
}
4. 遍历冒泡事件triggerEventFlow
// dispatchEvent 调用
// 4. bubble
if (!se.__stopPropagation) {
triggerEventFlow(bubble, se);
}
总结
这样就实现了一个基于click的事件系统。
思考如下例子,在react中的执行顺序是怎么样的呢?
function App() {
const [num, setNum] = useState(3);
return (
<div
onClick={() => {
console.log("container click");
}}
onClickCapture={() => {
console.log("container onClickCapture");
}}
>
<div
onClick={(e) => {
e.stopPropagation()
console.log("div click");
}}
onClickCapture={() => {
console.log("div onClickCapture");
}}
>
<p
onClickCapture={() => {
console.log("p onClickCapture");
}}
onClick={() => {
console.log("p-click");
setNum(num + 1);
}}
>
{num}
</p>
</div>
</div>
);
}
container onClickCapture
->div onClickCapture
->p onClickCapture
->p-click
->div click
如果改成div的 onClickCapture
执行e.stopPropagation()
呢?打断继续向下遍历的流程,并也不会执行冒泡操作了。所以输出如下:
container onClickCapture
->div onClickCapture
下一节
下一节我们讲解多节点的diff
的原理
转载自:https://juejin.cn/post/7189564391648395321