React Grab 原理篇:它是怎么"偷窥" React 的?

353 阅读6分钟

前言

上一篇我们介绍了 React Grab 是什么、怎么用。

这一篇,我们来聊点硬核的——它到底是怎么做到的?

当你按下 ⌘C 点击一个按钮,它是怎么知道这个按钮:

  • 住在哪个文件
  • 第几行代码
  • 属于哪个组件
  • 有什么样式

让我们掀开它的底裤看看。


整体架构:三层蛋糕

React Grab 的架构可以理解为三层蛋糕:

graph TB
    subgraph ReactGrab["React Grab"]
        subgraph Layers["三层架构"]
            Event["🎯 事件层<br/>(原生 DOM)"]
            Analysis["🔍 分析层<br/>(bippy)"]
            UI["🎨 UI 层<br/>(Solid.js)"]
        end

        Core["⚙️ 核心协调器<br/>(core.tsx)"]
        Clipboard["📋 剪贴板 API"]

        Event --> Core
        Analysis --> Core
        UI --> Core
        Core --> Clipboard
    end

    style Event fill:#e1f5fe
    style Analysis fill:#fff3e0
    style UI fill:#f3e5f5
    style Core fill:#e8f5e9
    style Clipboard fill:#fce4ec

第一层:事件层 - 监听你的键盘和鼠标,知道你什么时候想"抓"东西

第二层:分析层 - 拿到你点击的元素后,去 React 内部"偷"信息

第三层:UI 层 - 在页面上画高亮框、显示标签

最后,核心协调器把所有信息打包,扔进剪贴板。


第一层:事件监听

它怎么知道你要抓?

用户交互的状态机:

stateDiagram-v2
    [*] --> 待命: 页面加载

    待命 --> 抓取模式: 按下 ⌘C / Ctrl+C
    抓取模式 --> 待命: 松开按键

    抓取模式 --> 悬停高亮: 鼠标移动到元素
    悬停高亮 --> 抓取模式: 鼠标移开

    悬停高亮 --> 执行抓取: 鼠标点击
    执行抓取 --> 复制完成: 写入剪贴板
    复制完成 --> 抓取模式: 继续选择其他元素
    复制完成 --> 待命: 松开按键

React Grab 监听三类事件:

// 使用 AbortController 统一管理事件监听器
const controller = new AbortController();

// 1. 键盘事件:检测 Cmd+C / Ctrl+C
window.addEventListener('keydown', (e) => {
  if ((e.metaKey || e.ctrlKey) && e.key === 'c') {
    activateGrabMode();  // 进入"抓取模式"
  }
}, { signal: controller.signal });

window.addEventListener('keyup', (e) => {
  if (e.key === 'Meta' || e.key === 'Control') {
    deactivateGrabMode();  // 退出"抓取模式"
  }
}, { signal: controller.signal });

// 2. 鼠标移动:追踪悬停位置
window.addEventListener('mousemove', handleMouseMove, { signal: controller.signal });

// 3. 鼠标点击:执行抓取
window.addEventListener('mousedown', handleMouseDown, { signal: controller.signal });
window.addEventListener('mouseup', handleMouseUp, { signal: controller.signal });

为什么高亮不会"抖"?

如果鼠标稍微动一下就切换高亮元素,体验会很差。

React Grab 用了一个小技巧——稳定检测

  • 鼠标移动超过 200px 才重新计算
  • 停留 100ms 后才显示高亮
  • 只有"稳定"悬停时才触发

这就是为什么你快速划过元素时,高亮框不会疯狂闪烁。


第二层:React Fiber 访问(核心黑魔法)

这是整个工具最精彩的部分。

什么是 Fiber?

Fiber 是 React 16+ 的内部数据结构。你可以把它理解为 React 的"私人笔记本",记录了每个组件的:

  • 它是谁(组件类型)
  • 它长什么样(props)
  • 它现在的状态(state)
  • 它的家人是谁(父/子/兄弟节点)
  • 它从哪里来(源代码位置)

最后一条是关键——React 在开发模式下,会偷偷记录每个组件的源代码位置。

怎么访问 Fiber?

React Grab 使用了一个叫 bippy 的库(也是作者 Aiden Bai 写的)来访问 Fiber:

import { _fiberRoots as fiberRoots, instrument } from "bippy";

// 注册一个"间谍",监听 React 的 commit 阶段
instrument({
  onCommitFiberRoot(_, fiberRoot) {
    // 每次 React 渲染完成,就把 fiber 根节点收集起来
    fiberRoots.add(fiberRoot);
  },
});

这段代码做了什么?

  1. React 每次渲染完成(commit),都会触发这个回调
  2. bippy 把 Fiber 根节点存起来
  3. 之后我们就可以从这些根节点遍历整棵 Fiber 树

从 DOM 到 Fiber

当你点击一个 DOM 元素,React Grab 需要找到它对应的 Fiber 节点:

flowchart LR
    DOM["🖱️ DOM 元素<br/>(HTMLElement)"]
    Fiber["🧬 Fiber 节点<br/>(_debugSource)"]
    Source["📍 源代码位置<br/>(fileName, lineNumber)"]

    DOM -->|"__reactFiber$xxx"| Fiber
    Fiber -->|"读取 _debugSource"| Source

    style DOM fill:#e3f2fd
    style Fiber fill:#fff8e1
    style Source fill:#e8f5e9

具体怎么做?React 会在 DOM 元素上挂一个隐藏属性,指向对应的 Fiber 节点。bippy 封装了这个逻辑:

import {
  getSourceFromHostInstance,
  normalizeFileName,
  isSourceFile
} from "bippy/dist/source";

// 从 DOM 元素获取源代码位置
const source = getSourceFromHostInstance(domElement);
// 返回: { fileName: 'src/Button.tsx', lineNumber: 42, columnNumber: 5 }

一行代码,就拿到了文件路径和行号。


源代码位置是哪来的?

你可能好奇:React 怎么知道每个组件在源代码的哪一行?

答案是:Babel 插件

编译时注入

整个流程是这样的:

flowchart TB
    subgraph Dev["开发时"]
        JSX["📝 JSX 代码<br/>&lt;Button&gt;提交&lt;/Button&gt;"]
        Babel["🔧 Babel 编译"]
        JS["📄 带 __source 的 JS"]
    end

    subgraph Runtime["运行时"]
        React["⚛️ React 创建 Fiber"]
        Fiber["🧬 Fiber 节点<br/>_debugSource"]
    end

    subgraph Tool["React Grab"]
        Grab["🎯 读取 _debugSource"]
        Output["📋 输出位置信息"]
    end

    JSX -->|"@babel/plugin-transform-react-jsx-source"| Babel
    Babel --> JS
    JS -->|"React.createElement()"| React
    React --> Fiber
    Fiber -->|"bippy"| Grab
    Grab --> Output

    style JSX fill:#e3f2fd
    style Babel fill:#fff3e0
    style JS fill:#e8f5e9
    style React fill:#e1f5fe
    style Fiber fill:#fff8e1
    style Grab fill:#f3e5f5
    style Output fill:#fce4ec

当你写 JSX:

<Button onClick={handleClick}>提交</Button>

Babel 在开发模式下会把它转换成:

React.createElement(Button, {
  onClick: handleClick,
  __source: {
    fileName: "/src/App.tsx",
    lineNumber: 42,
    columnNumber: 5
  }
}, "提交")

看到了吗?__source 属性是 Babel 自动加的。

这是由 @babel/plugin-transform-react-jsx-source 插件完成的,React 脚手架(Create React App、Vite、Next.js)在开发模式下都会自动启用它。

存储在 Fiber 中

React 拿到 __source 后,会把它存到 Fiber 节点的 _debugSource 字段:

fiber._debugSource = {
  fileName: "/src/App.tsx",
  lineNumber: 42,
  columnNumber: 5
}

这就是 React Grab 能精准定位源代码的秘密——React 自己就记着呢,它只是把信息读出来而已。


第三层:UI 渲染

一个有趣的选择

React Grab 的 UI(高亮框、标签)是用 Solid.js 写的,不是 React。

为什么?

import { createSignal, createMemo, createRoot } from 'solid-js';

// 响应式状态
const [isActive, setIsActive] = createSignal(false);
const [hoveredElement, setHoveredElement] = createSignal<Element | null>(null);

// 派生状态:高亮框的位置
const highlightStyle = createMemo(() => {
  const el = hoveredElement();
  if (!el) return null;
  const rect = el.getBoundingClientRect();
  return {
    top: rect.top + 'px',
    left: rect.left + 'px',
    width: rect.width + 'px',
    height: rect.height + 'px'
  };
});

为什么不用 React?

四个原因:

  1. 隔离性 - 不会和你应用的 React 冲突。想象一下,一个检测 React 的工具自己也用 React,那不是套娃吗?
  2. 轻量 - Solid.js 的运行时只有几 KB,React 要大得多
  3. 性能 - Solid.js 是细粒度响应式,更新 UI 更高效
  4. 无依赖 - 不需要完整的 React 运行时

这是一个很聪明的架构决策。


最后一步:剪贴板

数据结构

React Grab 把收集到的信息打包成一个结构化对象:

interface GrabData {
  // 源代码信息
  source: {
    fileName: string;
    lineNumber: number;
    columnNumber: number;
  };

  // 组件信息
  component: {
    name: string;
    ancestors: string[];  // 组件层级链
  };

  // DOM 信息
  dom: {
    selector: string;
    tagName: string;
    className: string;
    dimensions: { width: number; height: number };
  };

  // HTML 片段
  htmlSnippet: string;
}

CSS 选择器生成

为了让 AI 能精准定位元素,还需要生成一个唯一的 CSS 选择器:

import { finder } from '@medv/finder';

const selector = finder(element);
// 返回: "#app > div.container > button.submit-btn"

@medv/finder 是一个专门干这个的库,它会生成最短且唯一的选择器。

写入剪贴板

最后一步,把数据写入剪贴板:

// 使用现代 Clipboard API
navigator.clipboard.write([
  new ClipboardItem({
    'text/html': new Blob([htmlContent], { type: 'text/html' }),
    'text/plain': new Blob([plainText], { type: 'text/plain' })
  })
]);

注意它同时写了两种格式:

  • text/html - 结构化数据,AI 工具可以解析
  • text/plain - 纯文本,直接粘贴也能看

技术栈总结

React Grab 用到的核心技术:

mindmap
  root((React Grab))
    事件层
      原生 DOM API
      键盘事件
      鼠标事件
      AbortController
    分析层
      bippy
      Fiber 访问
      _debugSource
      组件层级
    UI 层
      Solid.js
      高亮框
      标签提示
      响应式更新
    输出层
      Clipboard API
      @medv/finder
      结构化数据
模块技术作用
事件监听原生 DOM API捕获键盘/鼠标事件
Fiber 访问bippy读取 React 内部数据
源码定位Babel 插件编译时注入位置信息
选择器生成@medv/finder生成唯一 CSS 选择器
UI 渲染Solid.js绘制高亮框和标签
数据传递Clipboard API复制到剪贴板

几个精妙的设计

1. 不侵入应用代码

React Grab 完全是"外挂"式的:

  • 不需要修改你的组件代码
  • 不需要安装 Babel 插件(React 自带的就够了)
  • 不会影响应用的正常运行

2. 开发模式限定

它依赖的 _debugSource 只在开发模式存在,所以:

  • 生产环境天然无效
  • 不会泄露源码信息
  • 不会增加生产包体积

3. 轻量且独立

用 Solid.js 而不是 React 来做 UI,既保证了轻量,又避免了和应用 React 的冲突。


局限性

当然,这套方案也有局限:

flowchart LR
    subgraph Works["✅ 能用"]
        React["React 16+"]
        Dev["开发模式"]
        Modern["现代浏览器"]
    end

    subgraph NotWork["❌ 不能用"]
        Vue["Vue"]
        Angular["Angular"]
        Svelte["Svelte"]
        Prod["生产环境"]
        IE["IE 浏览器"]
    end

    React -.->|"Fiber 架构"| Works
    Dev -.->|"_debugSource"| Works

    Vue -.->|"无 Fiber"| NotWork
    Angular -.->|"不同架构"| NotWork
    Svelte -.->|"编译时框架"| NotWork
    Prod -.->|"无调试信息"| NotWork

    style Works fill:#e8f5e9
    style NotWork fill:#ffebee

只能用于 React

它深度依赖 React Fiber 架构,所以:

  • ❌ Vue - 没有 Fiber
  • ❌ Angular - 完全不同的架构
  • ❌ Svelte - 编译时框架,运行时没有组件树

只能用于开发模式

生产环境的代码:

  • 没有 _debugSource
  • 被压缩混淆了
  • Source Map 通常也不开放

所以只能在开发时用。

依赖 Source Map

如果你的构建工具没有生成 Source Map,或者禁用了 JSX source 插件,定位就会失效。


总结

React Grab 的原理可以用一句话概括:

利用 React 在开发模式下自带的源码位置信息,通过 Fiber 树反向查找,把元素的"身份证"复制给 AI。

它不是什么黑科技,而是巧妙地利用了 React 已有的调试机制。

这也是为什么它能做到:

  • 零配置
  • 零侵入
  • 零性能影响

因为脏活累活,React 和 Babel 已经帮你干完了。


相关资源