javascript

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

  • среда, 27 мая 2026 г. в 00:00:08
https://habr.com/ru/articles/1039568/

Началось всё банально. Зашёл коллега, говорит: «Где у нас хлебные крошки в шапке лежат?». Проект — около 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, начиная с кликнутого элемента и поднимаясь выше по DOM
var 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.ts
import { 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-проектам — попробуйте, возможно пригодится.