React 不冒泡的移入 / 移出事件插件的实现
作者:YFF菲菲兔
基于原生 over/out 事件计算鼠标 / 指针进出关系,屏蔽原生冒泡缺陷、抹平浏览器与 SSR / 并发渲染差异,生成语义化非冒泡 onMouseEnter/onMouseLeave/onPointerEnter/onPointerLeave 合成事件,配套专用 Fiber 遍历、回调收集逻辑,支撑页面 hover 悬浮、移入移出交互,大幅降低拖拽、绘图、hover 悬浮等高频率场景计算开销
注册事件:registerEvents
浏览器原生 mouseenter/mouseleave、pointerenter/pointerleave 不冒泡,无法用 React 根容器事件委托实现,且原生事件不会跨 Portal、跨根容器正确隔离。React 不直接监听 enter/leave,转而监听会冒泡的 mouseover/mouseout / pointerover/pointerout,在插件内部手动模拟 enter/leave 语义
- 模块初始化一次性执行,运行时无开销:仅在框架加载阶段执行一次,预注册全部元数据,事件触发时直接读取映射,不重复计算
- 注册模拟型合成事件,建立事件依赖关系:对外提供 onMouseEnter/onMouseLeave/onPointerEnter/onPointerLeave API,声明它们依靠 over/out 冒泡原生事件实现
- 填充全局原生事件池 allNativeEvents:让根容器初始化时自动绑定 mouseover/mouseout/pointerover/pointerout,支撑事件委托模拟 enter/leave
function registerEvents() {
// 模拟 onMouseEnter / onMouseLeave,依赖原生 mouseover、mouseout
registerDirectEvent('onMouseEnter', ['mouseout', 'mouseover']);
registerDirectEvent('onMouseLeave', ['mouseout', 'mouseover']);
// 模拟 onPointerEnter / onPointerLeave,依赖原生 pointerover、pointerout
registerDirectEvent('onPointerEnter', ['pointerout', 'pointerover']);
registerDirectEvent('onPointerLeave', ['pointerout', 'pointerover']);
}
提取事件:extractEvents
- 原生 over/out 映射为非冒泡 enter/leave 合成事件:原生 mouseover/mouseout 会逐层冒泡,业务很难实现元素级单独监听;本函数基于 relatedTarget 计算进出元素,生成不冒泡的 onMouseEnter/onMouseLeave / onPointerEnter/onPointerLeave,收集对应层级回调推入调度队列
- 完整处理鼠标 / 触屏移入移出全边界场景
- 页面外部 → 组件(仅 enter)
- 组件 → 页面外部(仅 leave)
- A 组件 → B 组件(同时触发 A leave、B enter)
- 组件内部微小移动(无事件)
function extractEvents(
dispatchQueue,
// 原生事件名 mouseover/mouseout/pointerover/pointerout
domEventName,
// 触发事件DOM对应的Fiber
targetInst,
// 原生DOM Event对象
nativeEvent,
// 原生触发DOM
nativeEventTarget,
eventSystemFlags,
// React根容器DOM
targetContainer,
) {
const isOverEvent =
domEventName === 'mouseover' || domEventName === 'pointerover';
const isOutEvent =
domEventName === 'mouseout' || domEventName === 'pointerout';
if (isOverEvent && !isReplayingEvent(nativeEvent)) {
const related = nativeEvent.relatedTarget || nativeEvent.fromElement;
if (related) {
if (
getClosestInstanceFromNode(related) ||
isContainerMarkedAsRoot(related)
) {
return;
}
}
}
if (!isOutEvent && !isOverEvent) {
return;
}
let win;
if (nativeEventTarget.window === nativeEventTarget) {
win = nativeEventTarget;
} else {
const doc = nativeEventTarget.ownerDocument;
if (doc) {
win = doc.defaultView || doc.parentWindow;
} else {
win = window;
}
}
let from;
let to;
if (isOutEvent) {
const related = nativeEvent.relatedTarget || nativeEvent.toElement;
from = targetInst;
to = related ? getClosestInstanceFromNode(related) : null;
if (to !== null) {
const nearestMounted = getNearestMountedFiber(to);
const tag = to.tag;
if (
to !== nearestMounted ||
(tag !== HostComponent && tag !== HostSingleton && tag !== HostText)
) {
to = null;
}
}
} else {
from = null;
to = targetInst;
}
if (from === to) {
return;
}
let SyntheticEventCtor = SyntheticMouseEvent;
let leaveEventType = 'onMouseLeave';
let enterEventType = 'onMouseEnter';
let eventTypePrefix = 'mouse';
if (domEventName === 'pointerout' || domEventName === 'pointerover') {
SyntheticEventCtor = SyntheticPointerEvent;
leaveEventType = 'onPointerLeave';
enterEventType = 'onPointerEnter';
eventTypePrefix = 'pointer';
}
const fromNode = from == null ? win : getNodeFromInstance(from);
const toNode = to == null ? win : getNodeFromInstance(to);
const leave = new SyntheticEventCtor(
leaveEventType,
eventTypePrefix + 'leave',
from,
nativeEvent,
nativeEventTarget,
);
leave.target = fromNode;
leave.relatedTarget = toNode;
let enter = null;
const nativeTargetInst = getClosestInstanceFromNode(nativeEventTarget);
if (nativeTargetInst === targetInst) {
const enterEvent = new SyntheticEventCtor(
enterEventType,
eventTypePrefix + 'enter',
to,
nativeEvent,
nativeEventTarget,
);
enterEvent.target = toNode;
enterEvent.relatedTarget = fromNode;
enter = enterEvent;
}
accumulateEnterLeaveTwoPhaseListeners(dispatchQueue, leave, enter, from, to);
}
作用
- 标记当前是否为 over /out 原生事件
- over 事件前置过滤:仅当鼠标从页面空白或外部组件移入组件时才生成 enter 事件;内部组件互相移动不触发多余 enter,避免重复执行
- 取出 relatedTarget(鼠标来源元素)
- 如果来源元素属于 React 渲染内部 DOM(存在对应 Fiber / 是根容器),直接终止函数
- 非 over/out 事件直接退出本分支
- 获取全局 window 对象,用于页面外移入 / 移出边界兜底:兼容多窗口、iframe 场景,统一拿到当前页面 window;当鼠标从页面外移入 / 移出时,relatedTarget 为 null,使用 window 作为虚拟 target
- 计算 from(离开元素 Fiber)、to(进入元素 Fiber)
- 移出事件 mouseout/pointerout
- from = targetInst:当前鼠标离开的元素 Fiber
- to:通过 relatedTarget 找到鼠标进入的元素对应 Fiber,必须是已挂载的 Fiber 且必须是真实 DOM / 文本节点(HostComponent/HostSingleton/HostText),不满足则置为 null,视为移出页面
- 移入事件 mouseover/pointerover
- from = null(从页面外部进来)
- to = targetInst(当前进入的元素 Fiber)
- 移出事件 mouseout/pointerout
- 离开、进入为同一个元素,无需触发事件,直接返回
- 根据原生事件区分鼠标 / 指针两套合成事件构造器与事件名
- 鼠标事件:使用 SyntheticMouseEvent,事件名 onMouseLeave/onMouseEnter
- 触屏 / 手写笔指针事件:使用 SyntheticPointerEvent,事件名 onPointerLeave/onPointerEnter
- 生成合成事件 target、relatedTarget 真实 DOM
- from 为 null(从页面移入):target 使用 window
- to 为 null(移出页面):relatedTarget 使用 window
- 构造 leave 合成事件(onMouseLeave /onPointerLeave):target = 离开的 DOM,relatedTarget = 进入的 DOM,代表鼠标离开 from 元素
- 条件构造 enter 合成事件(onMouseEnter /onPointerEnter):仅当原生事件触发节点与计算出的 to 完全匹配时,才创建 enter 事件,enter 事件 target 为进入的 DOM,relatedTarget 为离开的 DOM
- 调用专用收集函数 accumulateEnterLeaveTwoPhaseListeners,收集移入、移出回调并入队
设计意义
解决原生 mouseover 冒泡冗余触发问题:原生 mouseover 会层层冒泡,父、子元素都会触发 over 事件,当来源是 React 内部 DOM,直接放弃生成 enter 事件
辅助函数
移入移出两阶段事件回调收集:accumulateEnterLeaveTwoPhaseListeners
专门处理 mouseover/mouseout / pointerover/pointerout 这类带 relatedTarget 的移入移出事件
- 调用 getLowestCommonAncestor 以 getParent(取 Fiber.return)为遍历规则,计算新旧元素最低公共祖先 common。若 from 为空(鼠标从页面外移入)或 to 为空(鼠标移出页面),common = null,遍历无边界限制,一路向上到根节点
- 处理移出阶段 mouseout:从离开元素向上走到公共祖先,收集所有 onMouseOut 回调存入队列直到公共祖先停止,无离开元素(页面外移入)直接跳过
- 处理移入阶段 mouseover:从进入元素向上走到公共祖先,收集所有 onMouseOver 回调存入队列直到公共祖先停止,无目标元素 / 无移入事件模板直接跳过
export function accumulateEnterLeaveTwoPhaseListeners(
dispatchQueue,
// 移出侧合成事件模板(mouseout / pointerout)
leaveEvent,
// 移入侧合成事件模板(mouseover / pointerover)
enterEvent,
// relatedTarget,鼠标离开的 DOM 对应 Fiber
from,
// 当前触发事件的 DOM 对应 Fiber
to,
) {
// from 和 to 的最低公共祖先 Fiber,作为遍历终止边界
const common =
from && to ? getLowestCommonAncestor(from, to, getParent) : null;
if (from !== null) {
// 单方向收集移入 / 移出回调
accumulateEnterLeaveListenersForEvent(
dispatchQueue,
leaveEvent,
from,
common,
false,
);
}
if (to !== null && enterEvent !== null) {
accumulateEnterLeaveListenersForEvent(
dispatchQueue,
enterEvent,
to,
common,
true,
);
}
}
设计意义
鼠标从 A 元素移动到 B 元素时,两者公共祖先以上的组件不会同时触发移入 + 移出,只需遍历到公共祖先就停止,划定移入、移出遍历的终止边界,防止公共祖先上层组件重复触发事件,逻辑错乱,同时大幅减少 Fiber 遍历次数,优化性能,提升 mousemove 高频场景性能,大幅降低连续 mousemove 拖拽、绘图场景的计算开销
移入/移出单条链路的回调收集:accumulateEnterLeaveListenersForEvent
专门处理 mouseover/mouseout / pointerover/pointerout 移入 / 移出单条链路的回调收集
- 从起点 target 向上遍历 Fiber,遇到公共祖先 common 立刻终止循环
- 捕获阶段从前到后插入数组(unshift),冒泡阶段顺序追加(push),对齐 W3C 捕获先执行、冒泡后执行的顺序
- 只收集真实 DOM 宿主组件(HostComponent)上绑定的移入移出事件,过滤无效组件,减少 props 读取、循环判断开销,面向高频滑动场景做性能优化
function accumulateEnterLeaveListenersForEvent(
dispatchQueue,
event,
target,
common,
inCapturePhase,
) {
const registrationName = event._reactName;
const listeners = [];
let instance = target;
while (instance !== null) {
if (instance === common) {
break;
}
const {alternate, stateNode, tag} = instance;
if (alternate !== null && alternate === common) {
break;
}
if (
(tag === HostComponent ||
tag === HostHoistable ||
tag === HostSingleton) &&
stateNode !== null
) {
const currentTarget = stateNode;
if (inCapturePhase) {
const captureListener = getListener(instance, registrationName);
if (captureListener != null) {
listeners.unshift(
createDispatchListener(instance, captureListener, currentTarget),
);
}
} else if (!inCapturePhase) {
const bubbleListener = getListener(instance, registrationName);
if (bubbleListener != null) {
listeners.push(
createDispatchListener(instance, bubbleListener, currentTarget),
);
}
}
}
instance = instance.return;
}
if (listeners.length !== 0) {
dispatchQueue.push({event, listeners});
}
}
作用
- 提取事件注册名、初始化存储数组:取出事件对应的 JSX 注册名,初始化回调数组(存放当前链路、当前阶段收集到的标准化回调对象)
- Fiber 向上遍历循环,起点为 target:从传入的起点 Fiber(离开元素 / 进入元素)逐层向上遍历父节点 instance.return
- 命中公共祖先直接跳出循环(性能核心优化):只遍历新旧元素之间的层级,公共祖先及上层不再处理,限制遍历范围,避免整棵组件树循环,大幅减少循环次数,优化高频 mousemove
- 当前遍历到的 Fiber 等于公共祖先 common,停止遍历
- 兼容双 Fiber 树(current/alternate),若当前节点的备用树是公共祖先,同样终止
- 仅处理真实 DOM 宿主组件(HostComponent 系列):currentTarget 缓存当前 DOM,用于封装调度监听对象
- 捕获阶段逻辑
- 调用 getListener 从 Fiber.props 读取捕获事件回调 onMouseOverCapture
- unshift 头部插入:上层祖先捕获回调先执行,符合捕获阶段从外到内执行顺序
- 冒泡阶段逻辑
- 调用 getListener 从 Fiber.props 读取普通冒泡事件 onMouseOver
- push 尾部追加:从内到外执行,符合冒泡传播顺序
- 捕获阶段逻辑
- 指针上移,遍历父 Fiber,循环向上遍历祖先节点
- 存在回调则推入全局调度队列
查找 DOM 对应的 Fiber:getClosestInstanceFromNode
输入一个原生 DOM 节点,向上逐层查找距离该节点最近、由 React 管理的 Fiber 实例
- 尝试读取当前节点自身绑定的 Fiber,如果当前 DOM 本身就是 HostComponent,直接返回对应 Fiber
- 现代模式:从全局 WeakMap 根据 DOM 取 Fiber
- 旧模式:读取 DOM 上挂载的私有 internalInstanceKey Symbol
- 初始化父节点遍历指针,向上循环:当前节点无 Fiber,逐层向上遍历父 DOM,直到 parentNode = null(到文档根)
- 读取父 DOM 上存储的 Fiber(优先容器实例,再普通实例)
- 找到有效 Fiber,进入 hydration 边界特殊判断:当前 Fiber 存在子节点(current 树或 alternate 备用树有子节点),说明是容器类 Fiber(div、根容器等) ,需要走水合边界修正逻辑
- 向上遍历水合边界,查找真实挂载 Fiber:解决 SSR 水合阶段 DOM 与 Fiber 匹配错位问题
- 调用 getParentHydrationBoundary 获取服务端渲染水合边界父 DOM
- 容器 DOM 场景,不直接返回当前容器 Fiber,而是向上找到水合边界对应的真实 Fiber
- 非水合容器场景,直接返回找到的父 Fiber
- 指针上移,继续向上遍历父 DOM
- 整棵 DOM 树遍历完毕,无任何 React 管理节点:DOM 完全不在 React 渲染树内(外部 iframe、第三方 DOM、空白 body),返回 null
export function getClosestInstanceFromNode(targetNode) {
let targetInst;
if (enableInternalInstanceMap) {
// 使用 WeakMap(internalInstanceMap) 存储 DOM → Fiber(现代模式,不污染 DOM 属性)
targetInst = internalInstanceMap.get(targetNode);
} else {
// 直接在 DOM 对象挂载内部 Symbol 键(旧模式)
targetInst = targetNode[internalInstanceKey];
}
if (targetInst) {
return targetInst;
}
let parentNode = targetNode.parentNode;
while (parentNode) {
if (enableInternalInstanceMap) {
// internalContainerInstanceKey:React 根容器 / Portal 容器标记
targetInst =
parentNode[internalContainerInstanceKey] ||
internalInstanceMap.get(parentNode);
} else {
targetInst =
parentNode[internalContainerInstanceKey] ||
parentNode[internalInstanceKey];
}
if (targetInst) {
const alternate = targetInst.alternate;
if (
targetInst.child !== null ||
(alternate !== null && alternate.child !== null)
) {
let hydrationInstance = getParentHydrationBoundary(targetNode);
while (hydrationInstance !== null) {
const targetFiber = enableInternalInstanceMap
? internalInstanceMap.get(hydrationInstance)
: hydrationInstance[internalInstanceKey];
if (targetFiber) {
return targetFiber;
}
hydrationInstance = getParentHydrationBoundary(hydrationInstance);
}
}
return targetInst;
}
targetNode = parentNode;
parentNode = targetNode.parentNode;
}
return null;
}
定位 SSR 水合区块顶层边界:getParentHydrationBoundary
SSR 水合注释标记机制:React SSR 服务端输出 HTML 时,会插入特殊 HTML 注释节点作为分割标记(水合边界标记),用来识别 Suspense/Activity 边界
- <!--$--> / <!--$!--> / <!--$?-->/ <!--$~-->:Suspense 开始标记
- <!--/$-->:Suspense 结束标记
- / :`Activity` 配套起止注释
向左遍历同层前置兄弟注释,匹配 Suspense/Activity 边界专属注释标记,找到当前节点所属的水合区块起始注释节点;用于修复水合阶段 DOM ↔ Fiber 映射错位问题。天然区分 CSR / SSR 环境,CSR 页面无任何 SSR 注释,遍历后直接返回 null,仅 SSR 页面、水合阶段才会返回有效边界注释
export function getParentHydrationBoundary(
targetInstance,
) {
// 从当前节点的前一个兄弟节点开始向左遍历
let node = targetInstance.previousSibling;
while (node) {
if (node.nodeType === COMMENT_NODE) {
const data = node.data;
// 匹配各类 Suspense / Activity 起始注释
if (
data === SUSPENSE_START_DATA ||
data === SUSPENSE_FALLBACK_START_DATA ||
data === SUSPENSE_PENDING_START_DATA ||
data === SUSPENSE_QUEUED_START_DATA ||
data === ACTIVITY_START_DATA
) {
// 找到顶层边界(depth归零),直接返回该注释节点作为水合边界
if (depth === 0) {
return node;
} else {
// 内层嵌套边界,层级-1,继续往左找外层边界
depth--;
}
} else if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) {
// 遇到结束注释,说明进入嵌套区块,层级+1
depth++;
}
}
// 继续向左遍历上一个兄弟
node = node.previousSibling;
}
// 同层没有找到任何水合起始注释,返回null
return null;
}
查找完成挂载的最近祖先 Fiber:getNearestMountedFiber
输入任意 Fiber,向上查找最近一个已经完成挂载、稳定可用的祖先 Fiber;若最终根节点不是 HostRoot(脱离 React 树),返回 null
- 过滤临时未提交 Fiber:仅无 alternate 的临时节点进入循环,持续向上追溯,跳过刚创建、未渲染完成的节点
- 识别 Placement/Hydrating 未稳定节点:标记存在代表 DOM 还未完全挂载 / 水合,不能作为 relatedTarget 事件目标,记录其父节点作为稳定替代
- 追溯到全局根节点做合法性校验:防止处理已经卸载、脱离 React 根树的游离 Fiber
- 如果顶层根是 HostRoot(标准 React 应用根),返回之前缓存的最近稳定挂载祖先 nearestMounted
- 如果根不是 HostRoot(节点已卸载、脱离渲染树、Portal 隔离等),直接返回 null,代表无有效挂载节点
export function getNearestMountedFiber(fiber) {
let node = fiber;
let nearestMounted;
let nextNode = node;
while (nextNode && !nextNode.alternate) {
node = nextNode;
// 判断当前节点是否处于新增/水合未完成状态
if ((node.flags & (Placement | Hydrating)) !== NoFlags) {
// 当前节点未挂载,它的父节点是候选稳定节点
nearestMounted = node.return;
}
// 向上走父节点
nextNode = node.return;
}
while (node.return) {
node = node.return;
}
if (node.tag === HostRoot) {
return nearestMounted;
}
return null;
}
到此这篇关于React 不冒泡的移入 / 移出事件插件的实现的文章就介绍到这了,更多相关React 不冒泡的移入 / 移出事件内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
