Как я сделал «клик по элементу → открыть в VS Code» за один вечер
- среда, 27 мая 2026 г. в 00:00:08

Началось всё банально. Зашёл коллега, говорит: «Где у нас хлебные крошки в шапке лежат?». Проект — около 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 (или что у вас там) уже открылся. Это магия, и именно на ней держится весь плагин — никаких вебсокетов, никакого отдельного сервера.
Идея в двух словах:
На этапе сборки Babel обходит все .tsx/.jsx файлы и добавляет к каждому JSX-тегу два атрибута: data-debug-file (путь к файлу + номер строки) и data-debug-component (имя компонента). Это происходит только в dev-режиме, в продакшн это не попадает.
В HTML-страницу инжектируется маленький скрипт, который слушает Ctrl + Shift. Пока зажаты — в браузере включается режим инспекции: элементы подсвечиваются зелёной рамкой, над ними появляется бейджик с именем компонента и его размерами. Кликаете — файл открывается в редакторе.

Это 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} выиграет, но случайной перезаписи не будет.
Тут всё просто — используем хук 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-инструментов.
Вот здесь было самое интересное.
Допустим, у вас на 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
Когда зажаты 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-проектам — попробуйте, возможно пригодится.