Содержание
На поверхности Voodoo.js выглядит почти как фокус: один HTML-файл, один script, счётчик в фигурных скобках — и страница уже реагирует на клики. В обзорном материале «прорыв или возвращение к простому вебу» мы разбирали смысл идеи. Здесь — другой слой: как строка {items.map(...)} проходит путь от уже разобранного браузером DOM до AST, интерпретатора, реактивного эффекта и конкретной записи в узел.
Ключевые выводы
Главный трюк — не синтаксис, а входные данные. Браузер «ломает» разметку в духе JSX на текстовые узлы и элементы. Voodoo.js собирает выражение обратно и только потом гоняет его через свой конвейер.
Отказ от eval / new Function — архитектурное ограничение, не маркетинг. Лексер → Pratt-разборщик → AST → интерпретатор обхода дерева + закрытый список глобалов позволяют жить при строгой политике безопасности содержимого без unsafe-eval.
Единица обновления — реактивный эффект, а не полный перерендер. Чтение свойства через Proxy регистрирует подписку; запись вызывает только связанные эффекты и пишет в «свои» узлы DOM.
«Без сравнения деревьев» — правда для обычных привязок и полуправда для списков. v-for всё равно согласовывает реальные блоки DOM: журнал мутаций, сканирование ключей, даже наибольшую возрастающую подпоследовательность при перестановке.
Прорыв — в комбинации слоёв. Proxy, эффекты, планировщик и согласователь списков по отдельности давно известны. Необычно связать восстановление JSX из DOM, свой интерпретатор, мелкозернистые эффекты, отсутствие компилятора и отсутствие eval.
Почему этот разбор важнее README
В новостном конспекте и столбовом материале про HTML-первый подход достаточно понять нишу: прототипы, внутренние панели, приложения без обязательной сборки. Чтобы оценить зрелость и риски, нужно увидеть движок.
Архитектурный документ проекта фиксирует четыре жёстких ограничения: нет обязательного шага сборки; нет eval / new Function; нет виртуального DOM; нет зависимостей времени выполнения. Всё остальное — следствия. Ниже — путь по слоям: ядро (язык) → состояние (реактивность) → DOM (обходчик и директивы) → каркас. Разбор опирается на публичный ARCHITECTURE.md и структуру репозитория (состояние на сентябрь 2026); где нужна независимая проверка в бою — это отмечено.
Минимальный пример, с которого обычно начинается интерес:
<script src="voodoo.full.min.js" defer></script>
<div v-data="{ count: 0 }">
<button @click="count--">-</button>
<strong>{count}</strong>
<button @click="count++">+</button>
</div>
Вопрос не «как написать счётчик», а как каркас понимает {count} внутри уже существующего HTML.
Браузер делает «неправильную» вещь — и это становится преимуществом
Рассмотрим:
<ul>
{fruits.map((fruit) => (
<li>{fruit}</li>
))}
</ul>
Браузер не знает, что {fruits.map(...)} — JavaScript. Он разбирает HTML. Концептуально DOM выглядит примерно так:
<ul>
├── Text: "{fruits.map((fruit) => ("
├── Element: <li>{fruit}</li>
└── Text: "))}"
</ul>
Обычный JSX идёт другим путём: исходник → компилятор → JavaScript → браузер → DOM. У Voodoo.js цепочка перевёрнута:
HTML
→ HTML-разборщик браузера
→ DOM
→ сканер Voodoo.js
→ восстановленная строка выражения
→ лексер → разборщик → AST → интерпретатор
→ снова DOM
Каркас обнаруживает текст до и после элемента, понимает, что внутри фигурных скобок выражение, и восстанавливает конструкцию, подставляя заполнитель вместо DOM-элемента между фрагментами. Восстановленная строка попадает в обычный конвейер выражений.
То есть Voodoo.js не учит браузер понимать JSX. Он использует последствия работы HTML-разборщика как промежуточное представление. Это необычный архитектурный приём и одновременно источник хрупкости: любой странный HTML, который браузер нормализует иначе, чем ожидает сканер, становится краевым случаем.
Почему нельзя просто вызвать eval
Самый простой путь — eval(expression) или new Function. Тогда почти исчезает нужда в своём разборщике. Но оба варианта плохо живут со строгой политикой безопасности содержимого: нужен unsafe-eval.
Поэтому выражения идут через:
source → Lexer → tokens → Pratt parser → AST → tree-walking interpreter → value
Это уже не «шаблонизатор с подстановками». Это маленькая интерпретируемая языковая среда внутри страницы — с сознательно урезанной мощностью относительно полного JavaScript.
Разделение ответственности:
| Слой | Вопрос |
|---|---|
| DOM-сканер | Где находится выражение? |
| Лексер | Из каких лексем оно состоит? |
| Разборщик | Какая у него структура? |
| Интерпретатор | Что эта структура означает в данной области видимости? |
Лексер, Pratt-разборщик и AST
Допустим, на вход пришло items.filter(item => item.active). Лексер отдаёт последовательность вроде: identifier(items), dot, identifier(filter), скобки, стрелка, доступ к active. Дальше разборщик работает с токенами, не со строкой.
Выбран подход в духе Pratt: удобно для операторов с разным приоритетом (a + b * c → a + (b * c)). Уровни концептуально: присваивание → условие → логика → сравнение → сложение → умножение → член/вызов → первичное.
После разбора строка больше не исполняется. Есть AST. Например, count + price * 2 становится деревом Binary(+) с вложенным Binary(*). Интерпретатор рекурсивно обходит узлы. Один и тот же AST может вычисляться в разных областях видимости — это важно для v-for, компонентов и вложенных v-data.
В архитектуре проекта AST кэшируется (в документации упоминается лимит кэша), чтобы повторные вычисления директив не парсили одну и ту же строку снова и снова.
Область видимости, «магии» $ и allowedGlobals
Когда интерпретатор видит user, он не лезет сразу в глобальный объект. Поиск идёт по цепочке:
текущая область → родительская → … → magics → allowedGlobals → undefined
Типичная вложенность: корневая область → v-data → итерация v-for → область компонента. Запись следует той же логике: существующий ключ меняется у владельца, новый создаётся в текущей области. Поэтому {count} внутри v-data не становится случайной глобальной переменной.
После обычных имён проверяются «магии» ($refs, $event, $root, $owner и др.). Они могут быть ленивыми контейнерами относительно текущего контекста: одно и то же $refs.button означает разное в разных местах выполнения.
Если имя не найдено — закрытый набор allowedGlobals. Это граница безопасности: не любое имя автоматически означает свойство глобального окружения JavaScript. Именно поэтому свой интерпретатор может существовать без eval: опасные конструкции вроде восстановления Function через цепочки constructor режутся моделью доступа (детали — в SECURITY.md репозитория; перед боем сверяйте сами).
Как интерпретатор строит реактивность
Возьмём <strong>{count}</strong>. При вычислении Identifier("count") происходит поиск. Значение лежит в реактивном Proxy. Чтение state.count проходит через get — и система регистрирует: эффект №N зависит от count.
То есть вычисление — не только получение значения. Это способ построить граф зависимостей:
evaluate → read reactive property → track dependency
Структура концептуально:
WeakMap(target → Map(property → Set<ReactiveEffect>))
Изменился count — запускаются только эффекты, подписанные на это свойство, а не «перерисовка всего приложения».
Путь от count++ до записи в DOM
Клик по <button @click="count++">:
click
→ директива события
→ интерпретатор оценивает "count++"
→ Proxy.set
→ trigger
→ найти эффекты по ключу
→ queueJob
→ microtask
→ effect.run (очистка старых зависимостей, повторный evaluate)
→ textContent = новое значение
Несколько синхронных присваиваний (a=1; b=2; c=3) группируются: один Promise.resolve().then(flushJobs) на тик. В архитектуре указан лимит рекурсии (порядка 100 повторных запусков одного эффекта за сброс очереди) — защита от бесконечного цикла.
После основного сброса есть очередь после сброса: mounted / updated, наблюдатели с flush: 'post', v-init. nextTick() может опираться на тот же промис сброса как на точку «DOM уже обновлён».
Сравните с моделью React состояние → перерендер → дерево → сравнение → DOM. Здесь: состояние → граф зависимостей → эффект → DOM. Это не «оптимизация виртуального DOM», а другая единица работы.
Обходчик DOM: сердце среды выполнения
runtime/walker.ts превращает существующий DOM в работающую программу. Упрощённый порядок:
- фрагмент / текст / элемент;
- уже инициализирован?
script/style/noscript?v-ignore/v-pre? - собрать директивы;
- сначала терминальные
v-for/v-if; - создать область видимости при необходимости;
- выполнить директивы;
- очистить служебные атрибуты;
- обойти детей.
Порядок критичен. Для <li v-for="item in items"><span>{item.name}</span></li> нельзя сначала создать эффект на item.name: item появляется только после области итерации. Поэтому v-for и v-if — высокоприоритетные терминальные директивы: сначала решить, существует ли поддерево и с какими областями, потом оживлять внутренности.
Система директив — отдельный слой. Примерные приоритеты из архитектуры: IGNORE 100, FOR 90, IF 80, DATA 70, COMPONENT 65, REF 60, MODEL 40, BIND 30, DEFAULT 0, INIT −10, TRANSITION −20. Принцип: каждое декларативное поведение в HTML — директива. Обходчик не обязан знать все возможности каркаса.
После обработки атрибуты вроде v-data, @click, :disabled могут исчезнуть из видимого DOM (остаётся «чистая» разметка), а исходные значения живут в WeakMap. Отсюда же directiveIndex: после удаления v-* обычный querySelectorAll('[v-tab]') больше не работает.
При V.config.autoDiscover (по умолчанию) MutationObserver подхватывает узлы, вставленные снаружи через innerHTML, снова гоняет обходчик и при удалении вызывает уничтожение / остановку эффектов. Без EffectScope.stop() реактивный граф копил бы подписки на мёртвые узлы.
Компонент — не функция перерендера
В React компонент часто мыслят как функцию, возвращающую виртуальное дерево. В Voodoo.js ближе к модели: уже существующий элемент DOM + область видимости (состояние, свойства, вычисляемые, методы, наблюдатели, слоты, жизненный цикл). Отдельного шага компиляции и функции перерендера нет.
Свойства вроде :user="currentUser" вычисляются в родительской области реактивным эффектом и передаются ребёнку. По умолчанию область компонента привязывается к корню соответствующего дерева областей (изоляция), а не слепо наследует ближайший v-data; для наследования есть inheritScope.
Это сохраняет границу между «HTML-контекстом страницы» и «контекстом компонента» — полезно для внутренних панелей, где хочется и изоляции, и простоты разметки.
v-for: место, где сравнение всё-таки есть
Говорить «Voodoo.js вообще не делает сравнение» неверно. Обычная привязка текста — без сравнения. Список — другое дело: нужно понять, какие строки добавились, удалились, переехали, какие блоки DOM переиспользовать.
Есть свой согласователь списков по реальным блокам DOM, не согласователь виртуального DOM.
Если пользователь делает rows.splice(5000, 1), реактивная система может знать индекс и длину удаления — журнал мутаций позволяет не сканировать ключи всех 10 000 строк. Если же пришёл новый массив (rows = [...rows]), история операций потеряна: остаётся сравнение ключей, общий префикс/суффикс, изменённый регион — уже линейная сложность по чтению идентичности.
При перестановке используется идея наибольшей возрастающей подпоследовательности (LIS): какие узлы оставить относительно стабильными, какие переместить. Это уже серьёзный алгоритм согласования.
Философия: не сравнивать всё приложение; применять согласование только там, где без него нельзя.
Сравнение с Solid, Svelte и React
По механике обновления Voodoo.js ближе к Solid, чем к React: чтение → отслеживание → запись → точный эффект → прямая запись в DOM. Разница во входе:
Solid: JSX → compiler → fine-grained runtime → DOM
Voodoo: HTML → browser parser → свой parser/interpreter → fine-grained runtime → DOM
Svelte переносит работу на время компиляции: заранее знать операции с DOM. Voodoo.js сознательно платит ценой времени выполнения за отсутствие обязательной сборки.
React: единица работы — перерендер / согласование. У Voodoo.js — реактивный эффект. Формула «React без виртуального DOM» слишком груба.
Рядом по духу HTML-первый подход, но с другой осью — htmx и HTML поверх WebSockets: там чаще сервер двигает разметку. Voodoo.js держит клиентский язык выражений внутри документа. Про цену тяжёлых конвейеров сборки см. также нарезку фрагментов в Turbopack.
Что здесь действительно необычно
Не прорыв по отдельности: Proxy, эффекты, прямой DOM, согласователь списков, планировщик, жизненный цикл. Всё это известно.
Необычно сочетание:
HTML-разборщик браузера
+ восстановление выражений из DOM
+ разборщик JSX/выражений во время выполнения
+ свой интерпретатор без eval
+ мелкозернистые эффекты
+ прямые записи в DOM
+ нет обязательного компилятора
Спектр «время компиляции ↔ время выполнения»: Svelte и Solid ближе к верху; React — в середине с тяжёлым согласованием во время выполнения; Voodoo.js — сознательно внизу: меньше инфраструктуры сборки, больше работы и динамики в браузере, меньше гарантий времени компиляции.
Для генерации ИИ это меняет единицу артефакта: не «проект со сборкой», а самодостаточный HTML-документ. Отсюда вопрос из столбового материала: может ли модель стать тем «компилятором», которого каркас принципиально не требует?
Цена модели времени выполнения
Нельзя забывать стоимость:
- Разбор в браузере — выражения разбираются на клиенте.
- Интерпретатор — дороже заранее скомпилированного JS.
- Книга учёта реактивности — у каждого привязанного куска DOM есть эффект.
- Метаданные времени выполнения — области видимости, эффекты, оригинальные атрибуты.
v-for— сложные алгоритмы никуда не делись.- Размер пакета — чем толще «один
script», тем слабее лозунг простоты. - Инструменты — без компилятора хуже со статическим анализом, типами и подсказками в среде разработки «из коробки».
Фундаментальный риск языка выражений: слишком простой предметно-ориентированный язык неудобен; слишком мощный — «второй JavaScript» внутри HTML. Каждая новая языковая возможность раздувает интерпретатор. При этом настоящий JavaScript никто не запрещает: логика и API могут жить в обычном <script>, а в HTML остаются выражения над реактивным состоянием. Гибрид разумнее попытки заменить весь движок JS.
Частые вопросы
Это тот же разбор, что столбовой материал про «прорыв или сигнал»?
Короткий ответ: нет — столбовой материал про смысл и нишу; эта статья про внутренний конвейер.
Начните с обзора, затем возвращайтесь сюда за лексером / AST / эффектами.
Можно ли доверять заявлению «без eval» в бою?
Короткий ответ: архитектура и белый список выглядят последовательными, но нужен свой аудит под вашу политику безопасности содержимого.
Читайте SECURITY.md, проверяйте политику в реальном браузере, не копируйте слоган с сайта проекта.
Зачем Pratt, если можно было взять готовый разборщик?
Короткий ответ: свой конвейер даёт контроль над грамматикой выражений и границей безопасности.
Цена — сопровождение. Выгода — нет зависимости и нет new Function.
Правда ли, что виртуальный DOM не нужен никогда?
Короткий ответ: для точечных привязок — да в этой модели; для списков согласование всё равно есть.
Спор «виртуальный DOM плох» здесь не главный. Главное — не сравнивать то, что можно обновить адресно.
Насколько это близко к Solid?
Короткий ответ: по мелкозернистым обновлениям — близко; по способу получить код для среды выполнения — наоборот.
Solid опирается на компилятор JSX. Voodoo.js — на DOM после HTML-разборщика и свой интерпретатор.
Стоит ли писать большие приложения на этом интерпретаторе?
Короткий ответ: для ядра продукта обычно рано; для песочниц и внутренних инструментов — осознанно возможно.
Упираетесь в размер языка выражений, отладку и экосистему. Держите тяжёлую логику в обычном JavaScript.
Где смотреть исходники?
Короткий ответ: репозиторий и файл ARCHITECTURE.md; демо — на сайте проекта.
Ориентиры: GitHub, ARCHITECTURE.md, документация.
Дальнейшее чтение
Voodoo.js: прорыв или возвращение к простому вебу?Voodoo.js: архитектура среды выполнения без компилятора, Virtual DOM и eval — горизонтальная карта системы (этот материал — вертикальное глубокое погружение).Voodoo.js: реактивность прямо в HTML (новостной разбор)- htmx 4.0
- HTML поверх WebSockets
- Как Turbopack режет JavaScript на фрагменты
- Внешне: архитектура, сравнительные тесты
Заключение
После разбора исходной архитектуры Voodoo.js уже не выглядит «ещё одним HTML-каркасом с директивами». Технически это:
ориентированная на время выполнения реактивная система поверх существующего DOM, со своим языком выражений и интерпретатором.
Формула ядра:
HTML
+ восстановление из DOM
+ AST-interpreter
+ Scope graph
+ Proxy-tracking
+ fine-grained effects
+ прямые записи в DOM
= Voodoo.js
Каркас не доказывает, что компилятор больше не нужен. Он доказывает другое: компилятор не обязан быть условием достаточно мощной современной реактивной системы интерфейса. Цена решения очевидна. Сам факт, что связка работает — уже ценный инженерный эксперимент.
Четыре вопроса «на потом»: можно ли ускорить интерпретатор до конкуренции с каркасами на базе компилятора на реальных приложениях; имеет ли смысл вынести разборщик в WebAssembly; как генерировать типы и инструменты из HTML; и может ли ИИ стать тем компилятором, которого здесь принципиально нет.
На этой неделе: откройте инструменты разработчика на демо проекта, поставьте точку останова на обновлении текста счётчика и пройдите путь от клика до textContent — один раз глазами, без README. Это быстрее любой абстрактной схемы.
Лабораторная рамка этого блога: зафиксируйте границу эксперимента (что именно вы проверяете — политику безопасности содержимого, списки или удобство разработки без сборки), иначе обратная инженерия расползается в бесконечный конспект без критерия «достаточно».

