Как я сделал «клик по элементу → открыть в VS Code» за один вечер

от автора

Началось всё банально. Зашёл коллега, говорит: «Где у нас хлебные крошки в шапке лежат?». Проект — около 150 компонентов, всё именуется по-своему, структура папок местами загадочная. Я начал тыкать в React DevTools, искать по тексту «Breadcrumb» в файлах… В общем, минут через пять нашёл. Это в очередной раз раздражало.

Так появился vite-plugin-debug-meta — плагин, который позволяет зажать Ctrl + Shift, навести на любой элемент в браузере, кликнуть — и нужный файл откроется в редакторе на нужной строке.

npm install -D vite-plugin-debug-meta

Сначала я потратил час на поиск готового решения

Если честно, я был уверен, что такое уже сто раз написали. Порылся в npm — есть react-dev-inspector, есть vite-plugin-react-inspector… Смотришь на зависимости — там тянется половина вселенной, или нужен специальный babel-preset, или работает только с определённой версией React. Для нашего проекта ничего не подошло без плясок.

В итоге решил написать своё. Оказалось, что базовую версию реально сделать за один вечер. Вот что из этого вышло.


Секрет, о котором я сам не знал год

Первое открытие — у Vite из коробки есть эндпоинт /__open-in-editor. Честно, я работал с Vite больше года и понятия не имел о его существовании. Его используют Vue DevTools и кое-что ещё под капотом, но нигде особо не афишируют.

Работает просто:

/__open-in-editor?file=src/components/Header.tsx:42:1

Vite принимает этот запрос в режиме npm run dev и через пакет launch-editor (его написал Эван Ю) открывает ваш редактор на нужной строке. Он сам пытается угадать запущенную IDE, а если не угадывает — читает переменную LAUNCH_EDITOR.

Попробуйте прямо сейчас в консоли вашего Vite-проекта:

fetch('/__open-in-editor?file=src/App.tsx:1:1')

Скорее всего, VS Code (или что у вас там) уже открылся. Это магия, и именно на ней держится весь плагин — никаких вебсокетов, никакого отдельного сервера.


Что плагин делает и как

Идея в двух словах:

  1. На этапе сборки Babel обходит все .tsx/.jsx файлы и добавляет к каждому JSX-тегу два атрибута: data-debug-file (путь к файлу + номер строки) и data-debug-component (имя компонента). Это происходит только в dev-режиме, в продакшн это не попадает.

  2. В HTML-страницу инжектируется маленький скрипт, который слушает Ctrl + Shift. Пока зажаты — в браузере включается режим инспекции: элементы подсвечиваются зелёной рамкой, над ними появляется бейджик с именем компонента и его размерами. Кликаете — файл открывается в редакторе.


Часть 1. Трансформация JSX

Это Vite-плагин, поэтому нам нужен хук transform. Для разбора JSX берём три пакета из Babel-экосистемы: @babel/parser, @babel/traverse, @babel/generator.

transform(code: string, id: string) {  // Только наши файлы, без node_modules  if (!/\.(tsx|jsx)$/.test(id) || id.includes("node_modules")) return null;  const relativePath = path.relative(process.cwd(), id).replace(/\\/g, "/");  const componentName = path.basename(id).replace(/\.(tsx|jsx)$/, "");  const ast = parser.parse(code, {    sourceType: "module",    plugins: ["typescript", "jsx", "decorators-legacy"],  });  traverse(ast, {    JSXOpeningElement(nodePath: any) {      const nameNode = nodePath.node.name;      // Фрагменты пропускаем — у них нет DOM-узла, туда атрибут не суёшь      const isFragment =        (nameNode.type === "JSXIdentifier" && nameNode.name === "Fragment") ||        (nameNode.type === "JSXMemberExpression" &&          nameNode.object.name === "React" &&          nameNode.property.name === "Fragment");      if (isFragment) return;      const line = nodePath.node.loc?.start.line ?? 1;      const debugFile = `${relativePath}#${line}`;      // Не добавляем дубли, если атрибут уже есть      const hasAttr = nodePath.node.attributes.some(        (attr: any) => attr.type === "JSXAttribute" && attr.name.name === "data-debug-file"      );      if (hasAttr) return;      const makeAttr = (name: string, value: string) => ({        type: "JSXAttribute" as const,        name: { type: "JSXIdentifier" as const, name },        value: {          type: "JSXExpressionContainer" as const,          expression: { type: "StringLiteral" as const, value }        }      });      const attrs = [makeAttr("data-debug-file", debugFile), makeAttr("data-debug-component", componentName)];      // Если есть spread-пропсы (...props), вставляем ДО них,      // иначе пользователь может случайно их перезаписать своими data-*       const spreadIndex = nodePath.node.attributes.findIndex(        (attr: any) => attr.type === "JSXSpreadAttribute"      );      if (spreadIndex !== -1) {        nodePath.node.attributes.splice(spreadIndex, 0, ...attrs);      } else {        nodePath.node.attributes.push(...attrs);      }    }  });  const result = generate(ast, { sourceMaps: true, sourceFileName: id }, code);  return { code: result.code, map: result.map };}

Небольшая тонкость со spread-атрибутами: если у компонента есть {...props}, а где-то выше по коду в props попадает data-debug-file, то наш атрибут будет перезаписан. Поэтому вставляем до spread-а — тогда явный {...props} выиграет, но случайной перезаписи не будет.


Часть 2. Инъекция скрипта и стилей

Тут всё просто — используем хук transformIndexHtml, который позволяет добавить теги в <head> страницы:

apply: "serve", // плагин не трогает продакшн-сборку вообщеtransformIndexHtml() {  return [    {      tag: "script",      attrs: { type: "text/javascript" },      children: inspectorScript,      injectTo: "head",    },    {      tag: "style",      children: `        /* В режиме инспекции disabled-элементы не перехватывают клики */        .debug-inspect-mode [disabled],        .debug-inspect-mode :disabled,        .debug-inspect-mode .disabled {          pointer-events: none !important;        }        /* Подсвечиваем самый вложенный элемент под курсором */        .debug-inspect-mode [data-debug-file]:hover:not(:has([data-debug-file]:hover)) {          outline: 2px solid #10B981 !important;          outline-offset: -2px !important;          cursor: crosshair !important;        }      `.trim(),      injectTo: "head",    }  ];}

Селектор :not(:has([data-debug-file]:hover)) — это «выбери элемент с атрибутом, у которого нет дочернего элемента с тем же атрибутом под курсором». Немного по-читерски, зато работает чисто и без JS. Браузерная поддержка у :has() уже вполне приличная для dev-инструментов.


Часть 3. Проблема «я кликнул на кнопку, а хочу попасть на страницу»

Вот здесь было самое интересное.

Допустим, у вас на DashboardPage.tsx стоит кнопка <SaveButton />. Вы кликаете по ней с Ctrl + Shift. DOM-атрибут data-debug-file укажет на SaveButton.tsx — потому что именно там находится <button> в JSX. Но вы-то хотели попасть в DashboardPage.tsx, чтобы поменять текст или убрать кнопку!

Решение — читать React Fiber. React хранит внутреннее дерево компонентов прямо в DOM-узлах, в свойстве с именем вида __reactFiber$xxxx. Пройдя по дереву Fiber вверх (curr.return), можно собрать полный стек: SaveButton → DashboardPage → App → ...

// Ищем Fiber, начиная с кликнутого элемента и поднимаясь выше по DOMvar fiber = null;var currentEl = e.target;while (currentEl && !fiber) {  var keys = Object.keys(currentEl);  for (var i = 0; i < keys.length; i++) {    if (keys[i].startsWith("__reactFiber$") || keys[i].startsWith("__reactInternalInstance$")) {      fiber = currentEl[keys[i]];      break;    }  }  if (!fiber) currentEl = currentEl.parentElement;}// Собираем стек компонентовvar trace = [];var curr = fiber;while (curr) {  if (curr._debugSource) {    var src = curr._debugSource;    var fileName = src.fileName.replace(/\\/g, "/");    if (!fileName.includes("node_modules")) {      var srcIndex = fileName.indexOf("/src/");      var relativePath = srcIndex !== -1 ? fileName.slice(srcIndex + 1) : fileName;      var compName = curr.type?.displayName || curr.type?.name || "Unknown";      trace.push({ relativePath, line: src.lineNumber, component: compName });    }  }  curr = curr.return;}

Дальше простая эвристика: берём первый элемент стека (самый глубокий) и идём вверх. Как только файл меняется — вот это и есть место использования. Туда и открываем редактор.

В консоли при этом печатается полный стек — удобно, когда хочешь понять всю цепочку:

🔍 Debug Inspect:👉 <SaveButton> at src/components/SaveButton.tsx:12   <DashboardPage> at src/pages/DashboardPage.tsx:47   <Router> at src/App.tsx:23

Часть 4. Бейдж при наведении

Когда зажаты Ctrl + Shift и курсор движется по странице, хочется сразу видеть, на что ты смотришь. Делаем плавающий бейджик:

function updateBadge(el) {  if (!el) { badge.style.display = "none"; return; }  var rect = el.getBoundingClientRect();  var compName = el.getAttribute("data-debug-component") || "";  var size = Math.round(rect.width) + " × " + Math.round(rect.height);  badge.textContent = compName ? compName + " | " + size : size;  var top = rect.top - 24;  badge.style.top = (top < 0 ? rect.top + 4 : top) + "px";  badge.style.left = Math.max(rect.left, 4) + "px";  badge.style.display = "block";}

Выглядит так: HeaderNav | 1280 × 64. Элемент при этом подсвечен зелёным outline. На самом деле это сильно помогает понять реальные размеры блоков без открытия DevTools.


Установка и использование

npm install -D vite-plugin-debug-meta
// vite.config.tsimport { defineConfig } from "vite";import react from "@vitejs/plugin-react";import { debugMetaPlugin } from "vite-plugin-debug-meta";export default defineConfig({  plugins: [    react(),    debugMetaPlugin({      editor: "code" // "webstorm", "cursor", "rider", "zed" и т.д.    })  ]});

Параметр editor нужен, если Vite не угадывает вашу IDE автоматически. Под капотом он просто выставляет переменную LAUNCH_EDITOR — вы можете сделать то же самое в .env.local, если не хотите трогать конфиг.


Несколько вещей, которые я понял в процессе

_debugSource работает только если React скомпилирован в режиме development. В обычном Vite + React это так по умолчанию, но если вы где-то принудительно выставили NODE_ENV=production, Fiber не будет содержать информацию об источнике.

Фрагменты надо явно пропускать. Если добавить data-debug-file к <React.Fragment> или <>...</>, React бросает ошибку: фрагменты не принимают произвольные пропсы. Потратил на это минут двадцать.

Со spread-атрибутами надо быть аккуратным. Если компонент принимает {...props} и при этом кто-то снаружи передаёт data-debug-file — наш атрибут перезапишется. Это редкий кейс, но он существует.


Итого

Плагин работает только в dev-режиме (apply: "serve"), ничего лишнего в продакшн не попадает. Основные зависимости — три пакета Babel для парсинга AST, всё остальное уже есть в Vite.

Код открыт на GitHub под лицензией MIT — sozercaniekosmosa/vite-plugin-debug-meta. Если у вас похожая боль с навигацией по большим React-проектам — попробуйте, возможно пригодится.

ссылка на оригинал статьи https://habr.com/ru/articles/1039568/