Зміст
На поверхні 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-інтерпретатор
+ граф областей видимості
+ Proxy-відстеження
+ дрібнозернисті ефекти
+ прямі записи в DOM
= Voodoo.js
Каркас не доводить, що компілятор більше не потрібен. Він доводить інше: компілятор не зобов’язаний бути умовою достатньо потужної сучасної реактивної системи інтерфейсу. Ціна рішення очевидна. Сам факт, що зв’язка працює — уже цінний інженерний експеримент.
Чотири питання «на потім»: чи можна прискорити інтерпретатор до конкуренції з каркасами на базі компілятора на реальних застосунках; чи має сенс винести розбірник у WebAssembly; як генерувати типи й інструменти з HTML; і чи може ШІ стати тим компілятором, якого тут принципово немає.
Цього тижня: відкрийте інструменти розробника на демонстрації проєкту, поставте точку зупину на оновленні тексту лічильника й пройдіть шлях від кліку до textContent — один раз очима, без README. Це швидше за будь-яку абстрактну схему.
Лабораторна рамка цього блогу: зафіксуйте межу експерименту (що саме ви перевіряєте — політику безпеки вмісту, списки чи зручність розробки без збірки), інакше зворотна інженерія розповзається в нескінченний конспект без критерію «достатньо».

