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