← Усі статті

Voodoo.js зсередини: як власний інтерпретатор оживляє звичайний HTML

Розбір Voodoo.js зсередини: від DOM після HTML-розбірника браузера до лексера, дерева виразів, інтерпретатора, ефектів через Proxy і прямої запису у вузли без eval.

Voodoo.js зсередини: як власний інтерпретатор оживляє звичайний HTML
Зміст

На поверхні 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 * ca + (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 на працюючу програму. Спрощений порядок:

  1. фрагмент / текст / елемент;
  2. уже ініціалізовано? script / style / noscript? v-ignore / v-pre?
  3. зібрати директиви;
  4. спочатку термінальні v-for / v-if;
  5. створити область видимості за потреби;
  6. виконати директиви;
  7. очистити службові атрибути;
  8. обійти дітей.

Порядок критичний. Для <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-документ. Звідси питання зі стовпового матеріалу: чи може модель стати тим «компілятором», якого каркас принципово не вимагає?

Ціна моделі часу виконання

Не можна забувати вартість:

  1. Розбір у браузері — вирази розбираються на клієнті.
  2. Інтерпретатор — дорожчий за заздалегідь скомпільований JS.
  3. Книга обліку реактивності — у кожного прив’язаного шматка DOM є ефект.
  4. Метадані часу виконання — області видимості, ефекти, оригінальні атрибути.
  5. v-for — складні алгоритми нікуди не поділись.
  6. Розмір пакета — що товстіший «один script», то слабший лозунг простоти.
  7. Інструменти — без компілятора гірше зі статичним аналізом, типами й підказками в середовищі розробки «з коробки».

Фундаментальний ризик мови виразів: надто проста предметно-орієнтована мова незручна; надто потужна — «друга 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 уже не виглядає «ще одним HTML-каркасом із директивами». Технічно це:

орієнтована на час виконання реактивна система поверх існуючого DOM, зі своєю мовою виразів і інтерпретатором.

Формула ядра:

HTML
+ відновлення з DOM
+ AST-інтерпретатор
+ граф областей видимості
+ Proxy-відстеження
+ дрібнозернисті ефекти
+ прямі записи в DOM
= Voodoo.js

Каркас не доводить, що компілятор більше не потрібен. Він доводить інше: компілятор не зобов’язаний бути умовою достатньо потужної сучасної реактивної системи інтерфейсу. Ціна рішення очевидна. Сам факт, що зв’язка працює — уже цінний інженерний експеримент.

Чотири питання «на потім»: чи можна прискорити інтерпретатор до конкуренції з каркасами на базі компілятора на реальних застосунках; чи має сенс винести розбірник у WebAssembly; як генерувати типи й інструменти з HTML; і чи може ШІ стати тим компілятором, якого тут принципово немає.

Цього тижня: відкрийте інструменти розробника на демонстрації проєкту, поставте точку зупину на оновленні тексту лічильника й пройдіть шлях від кліку до textContent — один раз очима, без README. Це швидше за будь-яку абстрактну схему.

Лабораторна рамка цього блогу: зафіксуйте межу експерименту (що саме ви перевіряєте — політику безпеки вмісту, списки чи зручність розробки без збірки), інакше зворотна інженерія розповзається в нескінченний конспект без критерію «достатньо».