Иногда возникает странное ощущение, что фронтенд уже не про решение задач.
А про поддержание сложности.

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

Иногда оказывается — гораздо меньше, чем кажется.

И самое важное для меня как для человека, который активно использует этот минималистичный инструмент, — получать удовольствие от написания кода.

Потому что в какой-то момент становится ясно: дело не в количестве возможностей, а в том, насколько легко тебе думать.

И если инструмент этому не мешает — значит, он делает всё, что нужно.