React 交互跟踪 Interaction Tracking 源码

监听的艺术:React 交互跟踪源码深度巡礼

欢迎来到今天的技术讲座。我是你们的领路人,一个在代码堆里打滚了十年的资深工程师。今天我们不谈那些花里胡哨的组件库,也不聊怎么用 Tailwind CSS 写出漂亮的 UI。今天我们要聊聊一个更底层、更隐秘,但同样至关重要的东西——监听

如果你是 React 的开发者,你每天都在写 onClickonScrollonFocus。你觉得你很懂交互?嘿,别急着吹牛。React 告诉你“我处理了事件”,但它没告诉你它到底是怎么偷偷摸摸把你的手指动作记下来的。今天,我们要撕开 React 的外套,看看它肚子里那个负责“窃听”的特工机构——Interaction Tracking(交互跟踪)。

准备好了吗?系好安全带,我们要深入 DOM 的泥潭了。


第一部分:React 的懒人哲学与事件委托

首先,我们要搞清楚一个核心概念:事件委托

想象一下,你是一个极其懒惰的保安队长。你的手下有 1000 个保安,分布在工厂的每一个角落(每个 DOM 节点)。如果每个保安都要时刻盯着,谁负责看大门?谁负责看仓库?你累死,他们也累死。

聪明的 React 做了什么?它把所有保安撤了,只留了一个超级保安,坐在工厂正门口(document)。

当工厂里任何一个角落发生“有人点击了按钮”这种事时,这个超级保安会跑过来问:“嘿,谁干的?”然后根据你的指令,把信息传给具体负责那个角落的保安。

这就是 React 的事件委托机制。

代码示例:原生 vs React

在原生 JS 里,你很可能会写出这样的噩梦代码:

// 原生 JS 的地狱模式
document.getElementById('btn1').addEventListener('click', handler);
document.getElementById('btn2').addEventListener('click', handler);
document.getElementById('btn3').addEventListener('click', handler);
// ... 如果有 1000 个呢?你的 JS 文件会变成垃圾堆。

// React 的优雅模式
function App() {
  return (
    <div>
      <button onClick={handler}>按钮 1</button>
      <button onClick={handler}>按钮 2</button>
      <button onClick={handler}>按钮 3</button>
    </div>
  );
}

React 内部是怎么做到的?它会在挂载阶段,把 onClick 这种名字,映射到一个真正的浏览器事件类型(比如 click),然后把这个监听器挂载到 document 上。

源码探秘:注册阶段

在 React 源码的 ReactDefaultInjection.js 或者相关的注册模块中,你会看到这样一段逻辑:

// 模拟 React 内部注册逻辑
function injectEventPluginsByName() {
  // React 会把所有的事件插件都注册进来
  // 比如 SimpleEventPlugin (负责 click, focus 等)
  // 比如 EnterLeaveEventPlugin (负责 mouseEnter, mouseLeave)

  // 它会建立一个映射表:'onClick' -> 'SimpleEventPlugin'
  registrationNameModules = {
    onClick: SimpleEventPlugin,
    onScroll: ScrollEventPlugin,
    // ... 其他事件
  };
}

当你写 <button onClick={...} /> 时,React 并不是在浏览器里添加了 onclick 属性,而是往这个映射表里记了一笔:“嘿,这个组件需要监听 onClick 事件,交给 SimpleEventPlugin 去处理。”


第二部分:合成事件

React 为什么要搞个“合成事件”?它不能直接用原生的 MouseEvent 吗?

不能。因为浏览器是个混乱的世界。IE6 的 event 对象长这样,Chrome 的长那样,Firefox 又是另一套。而且,在 React 的虚拟 DOM 渲染完成之前,真实的 DOM 节点根本还没生成,你哪去抓原生事件?

于是,React 打造了一个 SyntheticEvent(合成事件)池。

核心特性:跨浏览器一致性

无论你在 iPhone 上用手指点,还是在 Windows 上用鼠标点,React 给你的 e 对象都是一样的。它封装了所有差异。

代码示例:SyntheticEvent 的生命周期

function handleClick(e) {
  // 1. e.preventDefault() 是安全的
  // 在原生 JS 中,如果不阻止默认行为,浏览器会跳转页面。
  // 在 React 中,e.preventDefault() 只阻止 React 的默认行为(如果是 a 标签的话),
  // 而不会阻止浏览器的默认行为(除非你明确调用了 e.stopPropagation())。
  e.preventDefault();

  // 2. 检查事件类型
  console.log(e.type); // 'click'
  console.log(e.target); // 被点击的那个 DOM 节点
  console.log(e.currentTarget); // 绑定了监听器的那个 DOM 节点(通常是 button)

  // 3. 持久化事件
  // 注意:React 的合成事件池是复用的。如果事件处理函数是异步的(比如在 setTimeout 里),
  // 你必须调用 e.persist() 才能保存数据,否则数据会被清空。
  e.persist();
  setTimeout(() => {
    console.log(e.target); // 此时依然有效
  }, 100);
}

源码探秘:事件池

ReactEventEmitter.js 中,你会看到 pooledClass 的逻辑。事件对象被创建后,不会销毁,而是放回池子里,下次再取出来用。这极大地提高了性能。


第三部分:如何实现“上帝视角”的交互跟踪?

好了,我们现在知道了 React 怎么处理组件内部的事件。但是,如果我们想做一个全局交互跟踪系统,比如做一个“点击热力图”或者“用户行为分析 SDK”,我们需要监听 React 没有直接暴露出来的东西,或者我们需要绕过 React 的封装。

这时候,我们需要一种更激进的手段。

方案一:直接监听 Document(最暴力但最有效)

既然 React 把监听器挂载在 document 上,那我们也可以。我们监听 clickpointerdownscrollinput 等事件。

代码示例:全局交互监听器

import { useEffect, useRef } from 'react';

export const useGlobalInteractionTracker = () => {
  const eventsRef = useRef(new Set<string>());

  useEffect(() => {
    // 我们要监听哪些事件?Pointer Events 是现代 Web 的首选
    // 它统一了鼠标、触摸和笔输入
    const targetEvents = [
      'pointerdown', // 比 click 更早,能捕获到点击的瞬间
      'pointermove',
      'pointerup',
      'scroll',
      'keydown',
      'input',
    ];

    const handleGlobalEvent = (e: PointerEvent) => {
      // 获取点击的目标路径
      const path = e.composedPath();

      // 深度遍历 DOM,找到最近的 React 组件
      // 这是一个比较痛苦的过程,因为 React 没有给每个 DOM 节点挂载 ID
      let targetElement = e.target as HTMLElement;

      // 这里我们可以做一些递归查找,看看 target 是否在某个特定的容器内
      // 或者我们可以直接记录所有点击,哪怕不在我们的应用内
      console.log('Global Event:', {
        type: e.type,
        target: targetElement.tagName,
        x: e.clientX,
        y: e.clientY,
        path: path.map(el => (el as HTMLElement).tagName),
      });
    };

    targetEvents.forEach(evtName => {
      document.addEventListener(evtName, handleGlobalEvent, true); // 使用捕获阶段
      eventsRef.current.add(evtName);
    });

    return () => {
      eventsRef.current.forEach(evtName => {
        document.removeEventListener(evtName, handleGlobalEvent, true);
      });
    };
  }, []);
};

技巧:利用 pointer-events: none

如果你想在页面上显示一个“幽灵按钮”来捕获所有的点击,而不影响布局,CSS 是你的好朋友。

.ghost-catcher {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none; /* 关键:让点击穿透,但事件依然会被捕获 */
  z-index: 9999;
}

/* 但如果我们想要它真的能“捕获”事件,我们需要在 JS 里动态插入一个 div */

第四部分:性能陷阱——节流与防抖

现在,我们有了监听器,我们有了数据。但是,如果你在 scroll 事件里做复杂计算,你的页面就会变成PPT。

React 交互跟踪最大的敌人不是浏览器,而是用户的操作频率

节流 vs 防抖

  • 防抖: 用户停手了,我再干活。适合:搜索框输入、调整窗口大小。
  • 节流: 用户每 100ms 干一次活,我就干一次。适合:滚动监听、点击流记录。

代码示例:一个高性能的节流 Hook

// 避免在渲染函数里写复杂的逻辑,这是 React 的铁律
function useThrottle<T extends (...args: any[]) => any>(fn: T, delay: number) {
  const lastRun = useRef(0);
  const timerRef = useRef<NodeJS.Timeout | null>(null);

  return ((...args: Parameters<T>) => {
    const now = Date.now();
    const remaining = delay - (now - lastRun.current);

    if (remaining <= 0 || remaining > delay) {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
      lastRun.current = now;
      fn(...args);
    } else {
      if (!timerRef.current) {
        timerRef.current = setTimeout(() => {
          lastRun.current = Date.now();
          fn(...args);
          timerRef.current = null;
        }, remaining);
      }
    }
  }) as T;
}

// 使用场景:滚动追踪
export const useScrollTracker = () => {
  useEffect(() => {
    const handleScroll = useThrottle((e: Event) => {
      // 这里记录滚动位置
      const target = e.target as HTMLElement;
      console.log('Scrolling:', target.scrollTop);
    }, 100); // 每 100ms 最多记录一次

    window.addEventListener('scroll', handleScroll, { passive: true });
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
};

源码探秘:React 的 useEffect 时机

React 在处理 useEffect 里的监听器时,会尽量把它们放在 requestIdleCallback 之后的微任务队列里执行,以避免阻塞主线程渲染。


第五部分:源码深度巡礼——ReactEventListener

现在,让我们把镜头拉近,看看 React 内部那个负责监听的类——ReactEventListener。这是 packages/react-dom/events/ReactEventListener.js

核心逻辑流程

  1. 初始化: ReactEventListener.setTopLevelTarget,告诉它全局监听的容器是谁。
  2. 监听: ReactEventListener.trapBubbledEvent。这里会调用浏览器的 addEventListener
  3. 分发: 当事件发生时,浏览器调用 dispatchEvent
  4. 提取: ReactEventListener.handleTopLevel。这是最关键的一步。

源码逻辑解读(伪代码)

class ReactEventListener {
  constructor() {
    this.topLevelEvents = new Set();
    this.topLevelListeners = new Map();
  }

  // 注册顶层事件(比如 'click', 'scroll')
  setTopLevelTarget(container) {
    this.topLevelTarget = container;
  }

  // 添加监听器
  trapBubbledEvent(topLevelType, handler) {
    // 实际上是在 document 上监听,或者 container 上(如果是挂载点)
    // 这里使用的是捕获阶段
    document.addEventListener(topLevelType, this.handleTopLevel.bind(this), true);
  }

  // 事件分发入口
  handleTopLevel(event) {
    // 1. 获取事件目标
    const nativeEvent = event.nativeEvent;
    // React 需要把 DOM 事件转换成它自己的 SyntheticEvent
    const syntheticEvent = extractEvents(
      eventSystemFlags,
      targetInst, // React Fiber 节点
      nativeEvent,
      targetContainer
    );

    // 2. 调度
    // 这里的 dispatchEvent 会沿着 Fiber 树向上冒泡,找到所有挂载了对应 props 的组件
    ReactDOMEventListener.dispatchEvent(syntheticEvent);
  }
}

关键点:React Fiber 节点

你可能会问,ReactEventListener 怎么知道哪个 DOM 节点对应哪个 React 组件?

答案是 Fiber 节点

在 React 的内部,每个 DOM 节点都有一个对应的 Fiber 节点(或者是 Suspense 节点)。ReactEventListener 会根据 nativeEvent.target,通过 DOM 路径回溯,找到最近的那个 Fiber 节点。

这就是为什么 e.target 在 React 事件处理函数里,通常指向的是你点击的那个 DOM 元素(比如 <button>),而不是 <div> 容器。虽然 nativeEvent.target 是原始 DOM,但 React 内部通过 Fiber 树找到了对应的组件实例。


第六部分:实战案例——构建一个“上帝之眼”交互追踪器

让我们把前面所有的知识点串起来,写一个真正能用的、生产级别的交互追踪 Hook。这个工具可以用来做点击热力图、用户行为分析或者性能监控。

核心功能

  1. 全屏监听:监听 pointerdown
  2. 数据收集:记录时间、坐标、目标元素信息、按键状态。
  3. 节流优化:防止数据洪水淹死服务器。
  4. 虚拟 DOM 映射:尝试找到对应的 React 组件名称(通过查找 Fiber 树)。

代码实现

import { useEffect, useRef } from 'react';
import { unstable_DebugTracing } from 'react-dom';

// 辅助函数:将 DOM 路径转换为 Fiber 节点
// 这是一个高级技巧,直接操作 React 内部 API
function getFiberFromDOM(node: Node): any {
  // React 18+ 的 Fiber 查找方式
  // 这里为了演示,假设我们有一个全局的 findFiberByHostInstance
  // 实际上可以通过 document.getElementById 或 querySelector 找到 root,然后遍历 fiber 树
  // 为了代码简洁,这里只做模拟
  return null; 
}

export const useGodEyeTracker = () => {
  const eventsRef = useRef<Set<string>>(new Set());
  const lastTrackTime = useRef(0);

  useEffect(() => {
    const handleGlobalPointerDown = (e: PointerEvent) => {
      const now = Date.now();

      // 节流:每 50ms 最多记录一次,避免刷屏
      if (now - lastTrackTime.current < 50) return;
      lastTrackTime.current = now;

      // 1. 获取元素信息
      const target = e.target as HTMLElement;
      const rect = target.getBoundingClientRect();

      // 2. 尝试查找 Fiber 节点(用于分析)
      // 在实际工程中,你可以通过注入一个全局的 findFiber 函数来实现
      // const fiber = getFiberFromDOM(target); 

      // 3. 构造数据
      const interactionData = {
        timestamp: now,
        type: e.pointerType, // mouse, touch, pen
        x: e.clientX,
        y: e.clientY,
        targetTag: target.tagName.toLowerCase(),
        targetId: target.id,
        targetClass: target.className,
        // targetComponent: fiber ? fiber.type?.name : 'Unknown', // 这里的实现取决于你的工程化配置
        path: e.composedPath().map(el => (el as HTMLElement).tagName).join(' > '),
      };

      // 4. 发送到分析服务(这里用 console 模拟)
      // 在真实场景中,这里会调用 window.analytics.track('interaction', interactionData);
      console.log('[God Eye Tracking]', interactionData);
    };

    // 监听 pointer events,因为它比 click 更底层
    const events = ['pointerdown', 'pointerup', 'pointermove', 'pointerenter', 'pointerleave'];

    events.forEach(evt => {
      document.addEventListener(evt, handleGlobalPointerDown, { passive: true });
      eventsRef.current.add(evt);
    });

    // 清理工作
    return () => {
      eventsRef.current.forEach(evt => {
        document.removeEventListener(evt, handleGlobalPointerDown, { passive: true });
      });
    };
  }, []);
};

进阶技巧:点击热力图实现

如果你想做一个像 Hotjar 那样的热力图,你需要记录大量的点击坐标。这时候,React 的 click 事件处理函数可能不够用,因为 React 的 click 事件在 pointerdown 之后触发,而且可能会因为某些组件的 stopPropagation 而丢失。

最佳实践:

  1. 使用 pointerdown:这是最早能捕获到用户意图的时机。
  2. 全局监听:不要依赖组件内部的 onClick
  3. 坐标校正e.clientX/Y 是相对于视口的。如果你有滚动条或者缩放,你需要计算 e.clientX - rect.left 来得到相对于元素左上角的坐标。
// 计算相对坐标
const relativeX = e.clientX - rect.left;
const relativeY = e.clientY - rect.top;

// 存入数据库
// { elementId: 'submit-btn', x: 45, y: 12, timestamp: ... }

第七部分:事件冒泡与捕获的博弈

在 React 源码中,有一个非常经典的逻辑:捕获阶段 vs 冒泡阶段

默认情况下,React 事件处理程序是在冒泡阶段执行的(就像气泡从水底浮上来)。

但是,React 提供了 onCapture 属性(例如 onClickCapture),这会让事件在捕获阶段执行(就像气泡还没浮上来之前,你在水底抓住了它)。

为什么这很重要?

当你有一个父组件和子组件都绑定了 onClick,并且你希望父组件先处理(比如拦截点击),子组件后处理(比如执行逻辑),你需要用捕获阶段。

function Parent() {
  return (
    <div onClick={() => console.log('Parent Bubble')}>
      <div onClickCapture={() => console.log('Parent Capture')}>
        <button onClick={() => console.log('Child Bubble')}>
          点击我
        </button>
      </div>
    </div>
  );
}

执行顺序是:

  1. Parent Capture (父组件捕获)
  2. Child Capture (子组件捕获) – 注意:子组件的 onClickCapture 会比父组件的 onClickCapture 更早触发,因为子组件在 DOM 树更下面
  3. Child Bubble (子组件冒泡)
  4. Parent Bubble (父组件冒泡)

这个顺序对于全局交互跟踪非常重要。如果你想在所有组件逻辑执行之前捕获事件,你应该监听捕获阶段

源码探秘:ReactEventListener 的 listenTo

// React 源码简化版
function listenToTopLevel(type, container) {
  const listenerSet = getListenerSetForEventName(type);
  const isCapturePhase = isListenerPhaseCapture(type);

  if (!listenerSet.has(listenerSetKey)) {
    // 添加监听器
    document.addEventListener(type, handleTopLevel, isCapturePhase);
    listenerSet.add(listenerSetKey);
  }
}

第八部分:Pointer Events —— 终结者

最后,我要隆重介绍 Pointer Events API

在 React 交互跟踪中,MouseEventTouchEvent 已经过时了。为什么?因为它们是分离的。

  • mousedown + mouseup = Click
  • touchstart + touchend = Click

如果用户用手指在手机上点击,会有 20 多个事件冒泡。如果用鼠标,又是 20 多个。这对交互跟踪来说简直是灾难。

Pointer Events 是一个统一的接口。

  • pointerdown:手指按下或鼠标按下。
  • pointerup:手指抬起或鼠标抬起。
  • pointercancel:用户取消操作(比如手指滑出屏幕)。
  • pointertype:告诉你当前是鼠标 (mouse)、触摸 (touch) 还是笔 (pen)。

React 源码中的支持:

React 16.8+ 已经支持 onPointerDown, onPointerEnter 等。在源码中,PointerEventPlugin 负责将原生的 Pointer 事件映射到 React 的合成事件系统。

代码示例:统一处理鼠标和触摸

function handleInteraction(e: PointerEvent) {
  console.log(`Pointer Type: ${e.pointerType}`); // 'mouse', 'touch', or 'pen'
  console.log(`Button: ${e.button}`); // 0: 左键, 1: 中键, 2: 右键
  console.log(`Pressure: ${e.pressure}`); // 0.0 - 1.0,支持压感笔
}

// 使用 pointer events
<div onPointerDown={handleInteraction} />

使用 Pointer Events,你的交互跟踪代码可以减少 50% 的代码量,并且能完美支持 iPad 和 Surface Pro。


结语:交互跟踪的艺术

好了,朋友们,我们已经从 React 的事件委托机制聊到了 Pointer Events,从 SyntheticEvent 的池化技术聊到了如何构建一个全局的“上帝之眼”追踪器。

React 的交互跟踪系统,本质上是一个高效的事件分发系统。它利用事件委托减少内存占用,利用合成事件屏蔽浏览器差异,利用 Fiber 树建立 DOM 与组件的桥梁。

而对于我们这些高级开发者来说,理解这些机制是为了在需要时打破规则。当我们需要做深度分析、做热力图、做性能监控时,我们不能仅仅依赖 React 提供的 onClick,我们需要直接介入 document,直接操作 PointerEvents,直接去和浏览器底层的 API 打交道。

记住,监听不仅仅是代码,它是用户与数字世界对话的桥梁。如果你能精准地监听每一次交互,你就拥有了控制这个世界的钥匙。

现在,去写一个属于你自己的交互追踪器吧。别让你的用户在黑暗中摸索,给他们点亮一盏灯!

(讲座结束)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注