
Иногда возникает странное ощущение, что фронтенд уже не про решение задач.
А про поддержание сложности.
Я в разработке ещё до AngularJS и React. Тогда всё было просто: HTML и немного JavaScript — и этого хватало даже для приложений с rich UI.
Потом пришли фреймворки.
Один из первых — AngularJS — и это был вау-эффект.
Ты больше не трогаешь DOM руками. Просто описываешь, что хочешь получить.
Потом: Flux, Redux, TypeScript, Angular 2+. Фронтенд в этот момент стал высокотехнологичным, но в то же время неприятным. Нужно писать кучу обслуживающего кода, не всегда понятно, как оно работает, возникают сложности с отладкой.
Где стало больно
Я работал на стеке с Angular. И главная проблема — не в том, что это плохо.
А в том, что этого слишком много. Помимо огромного бандла Angular люди еще обычно используют RxJS, там можно сделать одни и теже вещи большим количеством способов. А если еще вдобавок NgRx со сторами, редьюсерами и прочим...
Вообщем, я по своей природе люблю минимализм и мне не очень удобно слишком много кода, который не решает бизнес-задачу.
React?
Честно — я не писал на нём огромные проекты.
Но изначально не зашло:
JSX
сборка стека вручную
«возьми роутер отдельно, HTTP отдельно, состояние отдельно»
Каждый проект — как сборка конструктора.
Смотрел на $mol.
Очень интересно. Быстрый.
Но слишком другой.
И вот этот «слишком другой» просто не зашёл. Возможно, это вкусовщина. Для меня это тоже важно — пусть даже порой в ущерб производительности — чтобы код был красивым и мотивировал работать.
В какой-то момент появилась простая мысль:
А сколько нам вообще нужно, чтобы строить интерфейсы?
Не в теории.
А реально.
Так появился Cruzo.
Что хотелось получить
Без лишнего пафоса:
минималистичный и красивый синтаксис
минимум обслуживающего кода
реактивность
небольшой бандл
Как это выглядит
Компоненты
React
import { useState } from "react"; export function Counter() { const [count, setCount] = useState(0); return ( <button onClick={() => setCount(count + 1)}> Count: {count} </button> ); }
Angular c signals
import { Component, signal } from '@angular/core'; @Component({ selector: 'app-counter', template: ` <button (click)="count.set(count() + 1)"> Count: {{ count() }} </button> ` }) export class CounterComponent { count = signal(0); }
Cruzo
class CounterComponent extends AbstractComponent { static selector = "counter-component"; count$ = this.newRx(0); getHTML() { return ` <button onclick="{{root.count$.update(root.count$::rx + 1)}}"> ping: {{root.count$::rx}} </button> `; } }
В чём разница ощущений
Во всех случаях задача решается одинаково — кнопка увеличивает счётчик.
Разница в том, как это ощущается при написании кода:
в React ты работаешь внутри JSX — это отдельный синтаксический слой поверх JavaScript
в Angular есть собственная модель шаблонов и правил биндинга
в Cruzo шаблон остаётся максимально близким к обычному HTML
То есть вместо перехода в «другой язык» ты продолжаешь писать в привычной модели:
HTML + немного JavaScript
С добавлением реактивности и контролируемого исполнения.
При этом у нас нет жёстко заданного шаблона — есть функция getHTML, и мы можем собирать шаблон на ходу. В Angular это можно сделать только через условия внутри шаблона.
getHTML() { let extHTML = ``; if (this.config.myParam) { extHTML = `<div class="ext-block"></div>`; } return `${extHTML} <button onclick="{{root.count$.update(root.count$::rx + 1)}}"> ping: {{root.count$::rx}} </button> `; }
Шаблоны
Внутри {{ }} — подмножество обычного JavaScript, но с оговорками ( ::rx и once::).
class DemoExpressionsComponent extends AbstractComponent { static selector = "demo-expressions-component"; user$ = this.newRx({ name: "John", tags: ["admin", "editor"], meta: { lastLogin: Date.now() }, }); html$ = this.newRx("<b>bold</b>"); upperTags(tags: string[]) { return tags?.map((t) => t.toUpperCase()).join(", ") ?? "-"; } formatDate(ts: number) { return ts ? new Date(ts).toLocaleString() : "-"; } isAdmin(tags: string[]) { return tags?.includes("admin") ?? false; } getHTML() { return ` <div let-name="{{root.user$::rx.name}}" let-tags="{{root.user$::rx.tags}}"> <div> Name: <b>{{name ?? "Anonymous"}}</b> </div> <div class="mt_s"> Tags: <b>{{root.upperTags(tags)}}</b> </div> <div class="mt_s"> Last login: <b>{{root.formatDate(root.user$::rx.meta?.lastLogin)}}</b> </div> <div class="mt_s"> Role: <b>{{root.isAdmin?.(tags) ? "admin" : "user"}}</b> </div> <div class="mt_s"> Object shorthand: <b>{{({ name, tags }).name}}</b> </div> <div class="mt_s"> <span inner-html="{{root.html$::rx}}"></span> </div> </div> `; } }
В выражениях есть ограничения:
нельзя объявлять функции или использовать
=>нельзя создавать объекты через
newнет присваиваний (
=,++)нет операторов/инструкций вроде
if,for,try
Для нетривиальной логики можно использовать методы компонента и вызывать их из шаблона.
То есть это не «eval в шаблоне». Выражения внутри {{ }} выглядят как JavaScript, но выполняются через собственную VM. Это даёт баланс между гибкостью и контролем исполнения, что важно для энтерпрайза.
Также из приятных плюшек — блоковые let-* переменные и shorthand прямо в шаблоне.
Реактивность
Ничего сложного:
count$ = this.newRx(0);
Обновление:
this.count$.update(this.count$::rx + 1);
Если значение поменялось — UI обновляется.
RxFunc — вычисления
Но одного Rx мало.
Нужны производные значения.
Для этого есть RxFunc.
this.newRxFunc( (a, b) => result, a$, b$ );
Пример:
class FullNameComponent extends AbstractComponent { static selector = "full-name-component"; firstName$ = this.newRx("Marat"); lastName$ = this.newRx("Bektemirov"); fullName$ = this.newRxFunc( (firstName, lastName) => `${firstName} ${lastName}`, this.firstName$, this.lastName$ ); getHTML() { return ` <div> <div>First: {{root.firstName$::rx}}</div> <div>Last: {{root.lastName$::rx}}</div> <div class="mt_s"> Full: <b>{{root.fullName$::rx}}</b> </div> </div> `; } }
RxBucket — связь компонентов
Prop drilling, конфиги и прокидывание через 3–4 уровня. Хотелось решить эти проблемы на уровне фреймворка.
class DemoBucketComponent extends AbstractComponent { static selector = "demo-bucket-component"; dependencies = new Set([ InputComponent.selector, ButtonGroupComponent.selector, ]); innerBucket = new RxBucket({ input: { config: InputConfig({ placeholder: "Name" }), }, buttonGroup: { config: ButtonGroupConfig({ items: [ { label: "A", value: "a" }, { label: "B", value: "b" }, ], }), }, }); inputValue$ = this.newRxValueFromBucket(this.innerBucket, "input"); choice$ = this.newRxValueFromBucket(this.innerBucket, "buttonGroup"); getHTML() { return ` <input-component component-id="input" bucket-id="${this.innerBucket.id}"> </input-component> <button-group-component component-id="buttonGroup" bucket-id="${this.innerBucket.id}"> </button-group-component> <div class="mt_s"> Input: <b>{{root.inputValue$::rx}}</b> · Choice: <b>{{root.choice$::rx}}</b> </div> `; } }
Производительность
Скажу честно: строгих бенчмарков ещё не делал, но сделаю.
Субъективно — работает быстро, даже при большом DOM и большом количестве подписок.
Можно посмотреть примеры и тесты здесь:
https://cruzo.org/#/tests
Попробовать
GitHub: https://github.com/MaratBektemirov/cruzo
Официальный сайт и примеры: https://cruzo.org
VS Code extension: https://marketplace.visualstudio.com/items?itemName=cruzo.cruzo-syntax
Вместо вывода
Cruzo — это попытка ответить на простой вопрос:
сколько нам действительно нужно, чтобы строить интерфейсы?
Иногда оказывается — гораздо меньше, чем кажется.
И самое важное для меня как для человека, который активно использует этот минималистичный инструмент, — получать удовольствие от написания кода.
Потому что в какой-то момент становится ясно: дело не в количестве возможностей, а в том, насколько легко тебе думать.
И если инструмент этому не мешает — значит, он делает всё, что нужно.
Комментарии (38)

LyuMih
08.04.2026 09:33Для полного сравнения примера счётчиками интересно было бы видеть их реализацию на чистом JS, vue и $mol )

900k Автор
08.04.2026 09:33Спасибо, по делу. Один и тот же пример в разных реализациях был бы нагляднее — возможно, сделаю отдельное сравнение, в последующих статьях. Это была вводная статья, на что хватило сил, так сказать

Lodin
08.04.2026 09:33Критическая проблема данного подхода — это неподсвечиваемые, невалидируемые (через линт) и неформатируемые выражения внутри строк. Собственно, для решения этой проблемы и появился JSX. Он позволяет использовать все возможности JS без ограничений, и при этом всё ещё иметь выразительный язык шаблонов. Ангуляр, кстати, тоже от этой проблемы страдает, но это уже просто наследие ранних подходов.
Но если так хочется совместить строки и pure JS, почему не воспользоваться Lit? (точнее, lit-html, веб компоненты тянуть не обязательно). Lit как раз отлично решает проблему: это прямые строки, никакой компиляции, и в то же время можно свободно пользоваться возможностями языка

900k Автор
08.04.2026 09:33
Я написал extension для visual studio code. Есть подсветка, подсказки и форматирование самого шаблона в getHTML
Lit интересен. А там можно выполнить частичный пересчет шаблона? Я именно про калькуляцию спрашиваю, а не про применение изменений к dom
Lodin
08.04.2026 09:33Он там частичный абсолютно всегда. Любое изменение дёргает только конткретные атрибуты/поля/ивенты. Изначальный код HTML заворачивается в template, далее в местах разрыва tag literal регистрируются "дырки" (holes или values), которые при повторном рендере обновляются по изменению value по конкретному индексу. В общем, все преимущества Virtual DOM без его недостатков. Свои caveats там, конечно, тоже есть (например, нельзя вдруг поменять имя тэга, это требует пересчёта всего элемента и теряет все преимущества), но по сравнению с virtual dom они гораздо менее существенны. И работает безо всякой транспиляции, прямо в браузере.
Я написал extension для visual studio code
А если я пользкюсь Intellij IDEA? )

900k Автор
08.04.2026 09:33Еще раз, я не говорил про изменения в dom. Lit меняет dom частично и cruzo тоже, но в cruzo вдобавок частичный пересчет, что экономит CPU
https://github.com/lit/lit/blob/main/dev-docs/design/how-lit-html-works.md
“Update: Iterate over the dynamic JS values and associated Lit Part only committing the values that have changed to the DOM.”
Как вы трактуете эту строку? Я понимаю что он lit бежит по всем значениям (жрет CPU неимоверно, что плохо для смартфонов в первую очередь) и делает апдейт только по тем которые изменились. А cruzo может работать по другому, пересчет может быть частичный
Lodin
08.04.2026 09:33Ммм, вы говорите про shallow check, что ли? Если сильно (очень сильно!) упрощать, то он выглядит как-то так:
const registry = new WeakMap<TemplateStringsArray, TemplateResult>(); function html(strings: TemplateStringsArray, values: readonly unknown[]): TemplateResult { if (registry.has(strings)) { const result = registry.get(strings)!; for (let i = 0; i < strings.length; i++) { if (result.get(i).value !== values[i]) { result.update(values[i]); } } } else { // Init code } } function exec(txt: string) { render(html`<div>${txt}</div>`, document.getElementById('root')); }Что тут может нагружать CPU, да ещё и неимоверно?

900k Автор
08.04.2026 09:33Обход по всем значениям это не круто, в большом шаблоне их может быть много, и еще к тому же это могут быть функции

Lodin
08.04.2026 09:33Функции в шаблон Lit передавать не рекомендуют. Для реакции на события есть
@-поле, оно там отдельно оптимизировано (там используется объект{ handleEvent }, который заведует подпиской на событие, и у которого просто заменяется функция, так что на слушателей shallow check не распространяется).Обход по всем значениям это не круто
Это уже экономия на спичках. Цикл — одна из самых оптимизированных операций в JS, тем более тот, который не использует итераторы. Вряд ли вы будете создавать шаблон с миллионом значений, а с таким объёмом JS справляется за миллисекунды.

900k Автор
08.04.2026 09:33Вы в комментариях выше доказывали мне что частичный всегда, а оказалось нет… Опять же в реальной жизни легко сделать приложение которое будет тормозить с подходом lit, если постоянно пересчитывать весь шаблон. Это его в принципе архитектурная проблема

Lodin
08.04.2026 09:33Вы в комментариях выше доказывали мне что частичный всегда, а оказалось нет…
Ээ, простите, вы о чём? Не вижу связи

Iworb
08.04.2026 09:33Для мелких проектов может и подойдёт, но вот в чем же тогда разница с упомянутым реактом? Где тут роутинг, работа с формами и т.п.? Я пишу на ангуляре и выбираю его как раз за то, что там не нужны тонны зависимостей, чтобы реализовать приложение полностью. А с появлением сигналов и ресурсов со временем и от rxjs откажутся. Проект интересный, но я не вижу пока практического ему применения.

900k Автор
08.04.2026 09:33Да, по той же причине Angular мне больше нравится чем React
Роутер есть https://cruzo.org/#/docs/router, скоро будут children
Iworb
08.04.2026 09:33Тогда неплохое начало. Но у меня все еще складывается ощущение, что не соблюден баланс меджу “простым” и “функциональным”. Невозможно простым и лаконичным набором команд описать сложную функциональность. Можно конечно всё запихнуть под условный вызов одной функции, но как только конфигурация начинает отличаться от стандартно задуманной заканчивается лаконичность.
Вообще, было бы интересно увидеть сравнение еще и в производительности одинаковых проектов на разных языках. Как я понимаю, Cruzo предназначен для не особо сложных проектов, но также себя позиционирует тот же $mol и еще пачка других (Vue, Ember и т.п.).
Я считаю, что важно выбрать нишу и в этой нише показать в чем превосходство над другими фреймворками, иначе есть банальный риск утонуть среди других.

900k Автор
08.04.2026 09:33Тут сильно зависит от того как пишешь и как строится архитектура фронт-энда. Вообще задуман cruzo для энтерпрайза. Для этого и писалась стековая vm для выражений. Если сервер отдает Content-Security-Policy: script-src 'self', тогда не будет работать eval или new Function.
RxBucket решает проблемы props drilling. Ну сторы как бы тоже их решают, но как. До сих пор с содроганием вспоминаю NgRx и все эти сторы и редьюсеры... Куча обслуживающего кода пишется.
Это пока вводная статья, но будет думаю еще цикл статей в т.ч. про архитектуру приложений на Cruzo. Постараюсь сделать сравнение с Angular, React, $mol
Iworb
08.04.2026 09:33Немного оффтопа. У меня до сих пор есть классические NgRx сторы с редьюсерами, эффектами и всеми вытекающими. Но в новых проектах есть signalStore от NgRx и как же всё стало удобнее. А за Cruzo понаблюдаю. Едва ли в ближайшее время буду его использовать, т.к. не хватает функционала, но наблюдать интересно. Наперёд отвечу, что именно лично мне в моих проектах нужно для начала, чего не хватает пока что тут:
работа со стилями. Я понимаю, описывать логику и структуру компонента - это уже половину дела, но нельзя опускать такой большой пласт как стили.
формы. Полноценная работа с валидацией. Можно, конечно, на сторонние зависимости откупиться и собрать своего Франкенштейна, по крайней мере с формами это возможно.
i18n. Зачастую это добавляется в самом начале, чтобы потом было не так больно переделывать. И оно есть почти всегда. Поэтому это довольно важная часть. К сожалению, сторонние библиотеки тут не помогут.
библиотека компонентов. Я видел несколько стандартных, но этого безумно мало.

900k Автор
08.04.2026 09:33Насчет форм согласен. Формошлепство важная сфера, нужно добавить какой-нибудь инструмент для этого) Я работал в проектах где ~90% функционала – это формы)

1-Holopsicon-1
08.04.2026 09:33А чем другие не устроили, Vue например или Svelte? Svelte как по мне выигрывает у всех этих

900k Автор
08.04.2026 09:33Касательно Svelte, я его рассматривал. Но его синтаксис далек от обычного html (#if, #each, #key...). Одна из идей Cruzo – это дать возможность писать в привычной модели: HTML + немного JavaScript, с добавлением реактивности и контролируемого исполнения

denisemenov
08.04.2026 09:33Как опознать сгенерированную статью:
Без лишнего пафоса

900k Автор
08.04.2026 09:33Во времена нейрослопа можно на каждого пальцем показать и попробуй докажи потом что ты не верблюд... А до LLM как будто люди не использовали эту фразу?

denisemenov
08.04.2026 09:33Не могу припомнить, чтобы её использовали в русском так, как сейчас повсеместно делает это llm. Это как будто даже не очень по-русски звучит (как будто эта фраза добавляет только больше пафоса). Это какое-то llm'ное клише, от которого авторы статей не могут/хотят избавляться.

Artur_frontDev
08.04.2026 09:33Круто! но я бы добавлял кодстайл больше похожий на React, мб дело вкуса

S1908
08.04.2026 09:33О боже зачем???)))) Если есть реакт типизированный html. Представляю вашу боль)

900k Автор
08.04.2026 09:33А что за реакт типизированный html? JSX?

S1908
08.04.2026 09:33Ну да

900k Автор
08.04.2026 09:33JSX это не разметка, это код который генерирует разметку. Не задавались вопросом почему вместо class в JSX className? Еще один уровень абстракции, это и меня не устраивало

Lodin
08.04.2026 09:33Не задавались вопросом почему вместо class в JSX className?
Потому что разрабам реакта было влом переименовать переменную. Все остальные фреймворки спокойно пользуются
classи в ус не дуют, это только реакт такой особенный.
900k Автор
08.04.2026 09:33Потому что jsx это js, а там class зарезервированное слово

S1908
08.04.2026 09:33Так я не про js. А то что там можно использовать теги html внутри.
Мне он нравится за html типизацию, а не красивые глаза.

Lodin
08.04.2026 09:33Это не так, иначе бы JSX не могли использовать другие фреймворки. А оно спокойно существует и в SolidJS, и в Stencil, и в Vue, кстати говоря (по крайней мере, можно было в той версии, с которой я знакомился). И если мне память не изменяет, из всех них только реакт использует
className. И да, даже если вы в реакте напишетеclass, он не умрёт на этапе компиляции, а честно создаст объект с полемclass.Примеры:

900k Автор
08.04.2026 09:33https://react.dev/learn/writing-markup-with-jsx
Sinceclassis a reserved word, in React you writeclassNameinstead, named after the corresponding DOM property (Выдержка с официального сайта)
Далее
https://www.typescriptlang.org/docs/handbook/jsx.html
JSX is an embeddable XML-like syntax. It is meant to be transformed into valid JavaScript
Просто скажите что связи не видите, так проще всего... Это в какой-то мере снимает с вас ответственность за ваши слова
cmyser
Любая непонятная технология - магия )
900k Автор
В будущем выпущу статью про lifecycle компонента. Там на самом деле все просто как автомат калашникова)