React 不冒泡的移入 / 移出事件插件

基于原生 over/out 事件计算鼠标 / 指针进出关系,屏蔽原生冒泡缺陷、抹平浏览器与 SSR / 并发渲染差异,生成语义化非冒泡 onMouseEnter/onMouseLeave/onPointerEnter/onPointerLeave 合成事件,配套专用 Fiber 遍历、回调收集逻辑,支撑页面 hover 悬浮、移入移出交互,大幅降低拖拽、绘图、hover 悬浮等高频率场景计算开销

注册事件:registerEvents

浏览器原生 mouseenter/mouseleavepointerenter/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
javascript 复制代码
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 leaveB enter
    • 组件内部微小移动(无事件)
javascript 复制代码
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;当鼠标从页面外移入 / 移出时,relatedTargetnull,使用 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
  • 离开、进入为同一个元素,无需触发事件,直接返回
  • 根据原生事件区分鼠标 / 指针两套合成事件构造器与事件名
    • 鼠标事件:使用 SyntheticMouseEvent,事件名 onMouseLeave/onMouseEnter
    • 触屏 / 手写笔指针事件:使用 SyntheticPointerEvent,事件名 onPointerLeave/onPointerEnter
  • 生成合成事件 targetrelatedTarget 真实 DOM
    • fromnull(从页面移入):target 使用 window
    • tonull(移出页面):relatedTarget 使用 window
  • 构造 leave 合成事件(onMouseLeave /onPointerLeave):target = 离开的 DOMrelatedTarget = 进入的 DOM,代表鼠标离开 from 元素
  • 条件构造 enter 合成事件(onMouseEnter /onPointerEnter):仅当原生事件触发节点与计算出的 to 完全匹配时,才创建 enter 事件,enter 事件 target 为进入的 DOMrelatedTarget 为离开的 DOM
  • 调用专用收集函数 accumulateEnterLeaveTwoPhaseListeners,收集移入、移出回调并入队

设计意义

解决原生 mouseover 冒泡冗余触发问题 :原生 mouseover 会层层冒泡,父、子元素都会触发 over 事件,当来源是 React 内部 DOM,直接放弃生成 enter 事件


辅助函数

移入移出两阶段事件回调收集:accumulateEnterLeaveTwoPhaseListeners

专门处理 mouseover/mouseout / pointerover/pointerout 这类带 relatedTarget 的移入移出事件

  • 调用 getLowestCommonAncestorgetParent(取 Fiber.return)为遍历规则,计算新旧元素最低公共祖先 common。若 from 为空(鼠标从页面外移入)或 to 为空(鼠标移出页面),common = null,遍历无边界限制,一路向上到根节点
  • 处理移出阶段 mouseout:从离开元素向上走到公共祖先,收集所有 onMouseOut 回调存入队列直到公共祖先停止,无离开元素(页面外移入)直接跳过
  • 处理移入阶段 mouseover:从进入元素向上走到公共祖先,收集所有 onMouseOver 回调存入队列直到公共祖先停止,无目标元素 / 无移入事件模板直接跳过
javascript 复制代码
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 读取、循环判断开销,面向高频滑动场景做性能优化
javascript 复制代码
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,用于封装调度监听对象
    • 捕获阶段逻辑
      • 调用 getListenerFiber.props 读取捕获事件回调 onMouseOverCapture
      • unshift 头部插入:上层祖先捕获回调先执行,符合捕获阶段从外到内执行顺序
    • 冒泡阶段逻辑
      • 调用 getListenerFiber.props 读取普通冒泡事件 onMouseOver
      • push 尾部追加:从内到外执行,符合冒泡传播顺序
  • 指针上移,遍历父 Fiber,循环向上遍历祖先节点
  • 存在回调则推入全局调度队列

查找 DOM 对应的 Fiber:getClosestInstanceFromNode

输入一个原生 DOM 节点,向上逐层查找距离该节点最近、由 React 管理的 Fiber 实例

  • 尝试读取当前节点自身绑定的 Fiber,如果当前 DOM 本身就是 HostComponent,直接返回对应 Fiber
    • 现代模式:从全局 WeakMap 根据 DOMFiber
    • 旧模式:读取 DOM 上挂载的私有 internalInstanceKey Symbol
  • 初始化父节点遍历指针,向上循环:当前节点无 Fiber,逐层向上遍历父 DOM,直到 parentNode = null(到文档根)
  • 读取父 DOM 上存储的 Fiber(优先容器实例,再普通实例)
  • 找到有效 Fiber,进入 hydration 边界特殊判断:当前 Fiber 存在子节点(current 树或 alternate 备用树有子节点),说明是容器类 Fiberdiv、根容器等) ,需要走水合边界修正逻辑
  • 向上遍历水合边界,查找真实挂载 Fiber:解决 SSR 水合阶段 DOMFiber 匹配错位问题
    • 调用 getParentHydrationBoundary 获取服务端渲染水合边界父 DOM
    • 容器 DOM 场景,不直接返回当前容器 Fiber,而是向上找到水合边界对应的真实 Fiber
  • 非水合容器场景,直接返回找到的父 Fiber
  • 指针上移,继续向上遍历父 DOM
  • 整棵 DOM 树遍历完毕,无任何 React 管理节点:DOM 完全不在 React 渲染树内(外部 iframe、第三方 DOM、空白 body),返回 null
javascript 复制代码
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 页面、水合阶段才会返回有效边界注释

javascript 复制代码
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,代表无有效挂载节点
javascript 复制代码
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;
}
相关推荐
YFF菲菲兔1 小时前
React 表单控件 onChange 事件插件
react.js
濮水大叔4 小时前
Hooks 之后,为什么前端仍需要“包裹”能力?
vue.js·react.js·前端框架
小粉粉hhh4 小时前
React(三)——Redux
前端·javascript·react.js
YFF菲菲兔4 小时前
React 基础事件插件
react.js
qq_529599388 小时前
react js循环滚动
前端·javascript·react.js
索西引擎8 小时前
【React】严格模式:开发阶段质量保障的静态分析机制
前端·react.js·前端框架
小程故事多_801 天前
拆解SOP+ReAct对话Agent,告别硬编码,解锁智能对话的柔性迭代能力
前端·人工智能·react.js·前端框架
皮卡穆1 天前
React 中使用 react-org-tree 实现组织结构树
前端·react.js·前端框架
asdzx671 天前
使用 Spire.WordJS 在 React 中实现在线 Word 文档编辑
前端·react.js·word