Это моя первая статья на Хабре. Буду рад конструктивной критике в комментариях.
Каждый раз, когда я хотел поправить отступ или цвет в процессе разработки, я делал одно и то же:
открыл DevTools → нашёл элемент → поменял значение → понравилось → скопировал → переключился в редактор → нашёл файл → вставил.
Это семь шагов ради однострочного изменения. Я сделал LiveStyleSync, чтобы это был один шаг.
Что это такое
LiveStyleSync добавляет небольшую панель поверх вашего Vite-приложения в режиме разработки. Вы кликаете на любой элемент, редактируете CSS-свойства прямо в панели, и изменение записывается в исходный файл. Vite HMR подхватывает изменение мгновенно — без перезагрузки страницы.
Клик на элемент → редактируем значение → Vite HMR обновляет браузер → исходник обновлён
Никакого копи-паста. Никакого переключения вкладок.
Быстрый старт
npm install livestylesync-overlay livestylesync-vite-plugin
// vite.config.tsimport { liveStyleSync } from "livestylesync-vite-plugin";export default defineConfig({ plugins: [liveStyleSync()],});
// main.tsimport { mount } from "livestylesync-overlay";if (import.meta.env.DEV) { mount();}
Всё. Панель появится в углу приложения.
Как это работает изнутри
Мост между CSSOM и исходным файлом
Это главная техническая задача в проекте. Браузер знает CSS-правило, но не знает, в какой строке какого файла оно объявлено. Нужно связать document.styleSheets с конкретным .css или .scss файлом на диске.
У объекта CSSStyleSheet есть два пути получить источник:
Случай 1 — внешний файл. Если CSS подключён через <link>, у листа есть sheet.href вида http://localhost:5173/src/styles.css. Из этого URL можно вытащить путь.
Случай 2 — <style> тег. SCSS, CSS Modules, Vue scoped — Vite компилирует их и вставляет как <style> теги прямо в <head>. У таких тегов href равен null. Но Vite добавляет атрибут data-vite-dev-id с абсолютным путём к исходнику:
<style type="text/css" data-vite-dev-id="/home/user/project/src/styles.scss"> .card { background: #1a1a2e; }</style>
Код в оверлее читает этот атрибут:
for (const sheet of Array.from(document.styleSheets)) { let fileUrl = sheet.href; // для <link> тегов if (!fileUrl && sheet.ownerNode instanceof HTMLElement) { fileUrl = sheet.ownerNode.getAttribute("data-vite-dev-id"); // для <style> тегов } if (!fileUrl) continue; // cross-origin или без источника — пропускаем}
Получив fileUrl, мы знаем два из трёх нужных координат: файл и CSS-правило (из CSSOM). Третья координата — конкретная строка в файле — это уже задача PostCSS на сервере.
Почему PostCSS, а не regex
Первое, о чём думаешь — найти нужную строку через регулярку или string.replace. Это не работает по нескольким причинам.
Проблема 1: двоеточие в разных контекстах.
CSS использует : в трёх несвязанных местах: селекторах (.foo:hover), значениях (content: "a: b"), самих декларациях (color: red). Regex по color: попадёт не туда.
Проблема 2: форматирование.
Реальный CSS бывает в разных форматах:
/* вариант 1 */.card { background: #fff; }/* вариант 2 */.card { background: #fff;}/* вариант 3 — с комментарием */.card { background: #fff; /* default */}
Regex нужно поддерживать все варианты или нормализовывать файл — что разрушает форматирование.
Проблема 3: SCSS-нестинг и @media внутри правила.
.card { background: #fff; @media (max-width: 768px) { background: #000; } &:hover { background: #eee; }}
Найти нужное объявление в этой структуре регуляркой — нетривиально. Нужно знать, на каком уровне вложенности находится декларация.
PostCSS разбирает файл в AST. Каждый узел имеет тип: Rule (селектор), Declaration (свойство: значение), AtRule (@media, @container). Нужно найти Rule с нужным selector, в нём найти Declaration с нужным prop — и заменить value. Всё остальное (отступы, комментарии, переносы строк) PostCSS хранит в raws и воспроизводит при toString().
root.walkRules((rule) => { if (rule.selector !== targetSelector) return; rule.walkDecls(prop, (decl) => { decl.value = newValue; // меняем только значение, всё остальное не трогаем });});writeFileSync(filePath, root.toString()); // форматирование сохранено
Для SCSS используется postcss-scss — он понимает синтаксис SCSS включая $variables, нестинг и миксины, которые стандартный PostCSS не парсит.
HMR: от setTimeout к подтверждению
Первая версия выглядела так: после отправки патча по WebSocket ждать 400 миллисекунд, потом перечитать CSSOM.
send({ fileUrl, selector, prop, value });setTimeout(() => editor.refresh(), 400); // фиксированное ожидание
Проблема: после записи файла Vite проходит несколько шагов — файловый watcher обнаруживает изменение, Vite перекомпилирует модуль, отправляет HMR-обновление клиенту через свой WebSocket, браузер применяет новый CSS. Это занимает разное время в зависимости от размера файла и нагрузки. На медленных машинах 400 мс не хватало и оверлей показывал устаревший CSSOM. На быстрых — зря ждал.
Решение: сервер отправляет подтверждение только после записи файла. Клиент ждёт этот сигнал, а не таймер.
// сервер — после writeFileSync:socket.send(JSON.stringify({ type: "patched" }));// клиент:if (msg.type === "patched") { setTimeout(() => editor.refresh(), 300); // небольшой буфер для HMR}
Теперь 300 мс отсчитываются от момента, когда файл уже записан — а не от момента отправки запроса. Разница не кажется большой, но при медленном диске или сложном SCSS-файле это существенно.
Псевдо-состояния: как редактировать :hover который не активен
Браузер применяет правило .button:hover { color: red } только когда пользователь держит мышь над элементом. Соответственно, el.matches(".button:hover") возвращает false для элемента, на который только что кликнули.
Если коллектировать правила только через matches, все псевдо-состояния выпадут — пользователь не увидит ни одного hover/focus/active правила в панели.
Решение — двухшаговый матчинг. Сначала пробуем матч как есть. Если не прошёл и в селекторе есть интерактивный псевдо-класс — strip его и пробуем снова:
const INTERACTIVE_PSEUDOS = [":hover", ":focus", ":active", ":checked", /* ... */];function stripInteractivePseudos(selector: string): string { let s = selector; for (const p of INTERACTIVE_PSEUDOS) { s = s.split(p).join(""); // убираем ":hover" из ".button:hover" } return s.trim(); // ".button"}// при сборе правил:let matches = el.matches(effectiveSelector);if (!matches && isPseudoRule) { matches = el.matches(stripInteractivePseudos(effectiveSelector));}
.button:hover → strip → .button → матч прошёл. Правило попадает в панель с пометкой, что это :hover-состояние.
Для визуального превью пока используется простой подход: значение устанавливается через element.style.setProperty() — inline-стиль, который видно всегда, не только при ховере. Это компромисс для dev-режима: можно увидеть, как будет выглядеть значение, хотя и без условия псевдокласса.
Vue scoped: хэши в селекторах
Vue <style scoped> — это когда стили применяются только к компоненту, а не глобально. Vue добавляет уникальный атрибут к каждому элементу компонента (data-v-3f7bd2) и переписывает все CSS-селекторы, добавляя к ним этот атрибут:
/* исходник в .vue файле */.card { background: #fff; }/* в браузере после компиляции */.card[data-v-3f7bd2] { background: #fff; }
Это создаёт два несовпадения между тем, что видит браузер, и тем, что в исходнике:
1. Селектор. CSSOM показывает .card[data-v-3f7bd2], но в файле написано просто .card. Если отправить на сервер .card[data-v-3f7bd2] — PostCSS его не найдёт.
2. URL файла. Vite обслуживает Vue-стили под URL вида main.vue?vue&type=style&index=0&scoped=7bd2. Реальный файл называется main.vue.
Оба случая решаются нормализацией перед отправкой:
// убираем хэш из селектораconst selector = effectiveSelector .replace(/\[data-v-[a-f0-9]+\]/g, "") .trim(); // ".card[data-v-3f7bd2]" → ".card"// убираем query-параметры из URLconst isVue = fileUrl.includes("?vue&type=style");const cleanUrl = isVue ? fileUrl.split("?")[0] : fileUrl; // → "main.vue"
На сервере patchVue парсит .vue файл как текст, вычленяет содержимое <style> блока, прогоняет его через PostCSS, находит .card — и записывает обратно только <style> блок, не трогая <template> и <script>.
Технические грабли, на которые я наступил
Универсальный селектор *
В document.styleSheets есть правила вроде , ::before, ::after { box-sizing: border-box }. Без фильтрации эти правила появлялись в панели для каждого элемента на странице. Фикс — пропускать правила, где хотя бы одна часть селектора через запятую равна или начинается с *::
const selParts = selector.split(",").map((s) => s.trim());if (selParts.some((p) => p === "*" || p.startsWith("*:"))) continue;
CSSContainerRule нет в TypeScript lib
@container правила в TypeScript не типизированы в стандартной библиотеке. instanceof CSSContainerRule — не компилируется. Пришлось определять через duck-typing: у @container есть conditionText, но нет instanceof CSSMediaRule:
if ( !(rule instanceof CSSMediaRule) && !(rule instanceof CSSSupportsRule) && (rule as any).conditionText !== undefined) { // это @container}
Inline-стили перекрывают откат
При откате изменений через историю стили возвращались в файл, но (element as HTMLElement).style оставался с перезаписанными inline-значениями, которые перекрывали восстановленные стили из файла. CSS-специфичность: inline-стили всегда побеждают правила из таблицы стилей.
Фикс — явно очищать inline-свойство при откате:
(selected as HTMLElement).style.setProperty(prop, oldValue);// или если oldValue пустой:(selected as HTMLElement).style.removeProperty(prop);
Два механизма undo разъехались
В какой-то момент у меня оказалось два независимых стека отмены: один внутри useStyleEditor (только для CSS), второй в общей истории сессии (CSS + SCSS переменные + CSS custom properties). При смешанной сессии они показывали разные состояния. Это открытый баг, рефакторю под единый стек.
Что умеет
|
Фича |
Описание |
|---|---|
|
Element picker |
Клик на любой элемент |
|
Поиск элементов |
Поиск по |
|
DOM breadcrumbs |
Навигация по предкам элемента |
|
@media и @container |
Отдельные вкладки для каждого брейкпоинта/контейнера |
|
Псевдо-состояния |
Редактирование |
|
CSS custom properties |
Браузер и редактор |
|
SCSS $переменные |
Серверный скан всех |
|
Создание правил |
Добавить CSS к элементу, у которого нет исходника |
|
История сессии |
Git-style диффы всех изменений, откат батчами |
|
Tailwind detection |
Предупреждение вместо попытки патчить утилиты |
Поддержка форматов CSS
|
Формат |
Чтение |
Патч |
|---|---|---|
|
Обычный |
✅ |
✅ |
|
|
✅ |
✅ |
|
CSS Modules |
✅ |
✅ |
|
Vue |
✅ |
✅ |
|
Tailwind-утилиты |
⚠️ определяет, предупреждает |
— |
|
Inline styles |
❌ |
— |
Работает с любым фреймворком на Vite
React, Vue, Nuxt, SvelteKit, Astro, Solid — всё, что использует Vite как dev-сервер. Оверлей не имеет peer-зависимости от React: Preact собирается внутрь бандла и изолирован от приложения.
Стек проекта
-
Монорепо на pnpm workspaces
-
Оверлей — Preact + TypeScript, собирается через tsup в один файл без внешних зависимостей
-
Vite-плагин — Node.js +
ws(WebSocket) + PostCSS + postcss-scss -
Тесты — Vitest для патчеров (CSS/SCSS/Vue)
Попробовать
GitHub: https://github.com/Artyx71/livestylesync
npm install livestylesync-overlay livestylesync-vite-plugin
Буду рад фидбэку — особенно если попробуете на проекте, отличном от React, или наткнётесь на кейс с нестандартной структурой CSS. Открывайте issue или пишите в комментарии.
ссылка на оригинал статьи https://habr.com/ru/articles/1041454/