
Иногда возникает странное ощущение, что фронтенд уже не про решение задач.
А про поддержание сложности.
Я в разработке ещё до 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 — это попытка ответить на простой вопрос:
сколько нам действительно нужно, чтобы строить интерфейсы?
Иногда оказывается — гораздо меньше, чем кажется.
И самое важное для меня как для человека, который активно использует этот минималистичный инструмент, — получать удовольствие от написания кода.
Потому что в какой-то момент становится ясно: дело не в количестве возможностей, а в том, насколько легко тебе думать.
И если инструмент этому не мешает — значит, он делает всё, что нужно.
