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

Часть 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-проектам — попробуйте, возможно пригодится.






















