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

Я в разработке ещё до 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)


  1. cmyser
    08.04.2026 09:33

    Любая непонятная технология - магия )


    1. 900k Автор
      08.04.2026 09:33

      В будущем выпущу статью про lifecycle компонента. Там на самом деле все просто как автомат калашникова)


  1. LyuMih
    08.04.2026 09:33

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


    1. 900k Автор
      08.04.2026 09:33

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


  1. ec5tasy
    08.04.2026 09:33

    Круто! Но зачем доллары?


    1. 900k Автор
      08.04.2026 09:33

      Это на выбор, просто я для себя сам таким образом выделяю rx переменные


  1. Lodin
    08.04.2026 09:33

    Критическая проблема данного подхода — это неподсвечиваемые, невалидируемые (через линт) и неформатируемые выражения внутри строк. Собственно, для решения этой проблемы и появился JSX. Он позволяет использовать все возможности JS без ограничений, и при этом всё ещё иметь выразительный язык шаблонов. Ангуляр, кстати, тоже от этой проблемы страдает, но это уже просто наследие ранних подходов.

    Но если так хочется совместить строки и pure JS, почему не воспользоваться Lit? (точнее, lit-html, веб компоненты тянуть не обязательно). Lit как раз отлично решает проблему: это прямые строки, никакой компиляции, и в то же время можно свободно пользоваться возможностями языка


    1. 900k Автор
      08.04.2026 09:33

      Я написал extension для visual studio code. Есть подсветка, подсказки и форматирование самого шаблона в getHTML
      Lit интересен. А там можно выполнить частичный пересчет шаблона? Я именно про калькуляцию спрашиваю, а не про применение изменений к dom


      1. Lodin
        08.04.2026 09:33

        Он там частичный абсолютно всегда. Любое изменение дёргает только конткретные атрибуты/поля/ивенты. Изначальный код HTML заворачивается в template, далее в местах разрыва tag literal регистрируются "дырки" (holes или values), которые при повторном рендере обновляются по изменению value по конкретному индексу. В общем, все преимущества Virtual DOM без его недостатков. Свои caveats там, конечно, тоже есть (например, нельзя вдруг поменять имя тэга, это требует пересчёта всего элемента и теряет все преимущества), но по сравнению с virtual dom они гораздо менее существенны. И работает безо всякой транспиляции, прямо в браузере.

        Я написал extension для visual studio code

        А если я пользкюсь Intellij IDEA? )


        1. 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 может работать по другому, пересчет может быть частичный


          1. 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, да ещё и неимоверно?


            1. 900k Автор
              08.04.2026 09:33

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


              1. Lodin
                08.04.2026 09:33

                Функции в шаблон Lit передавать не рекомендуют. Для реакции на события есть @-поле, оно там отдельно оптимизировано (там используется объект { handleEvent }, который заведует подпиской на событие, и у которого просто заменяется функция, так что на слушателей shallow check не распространяется).

                Обход по всем значениям это не круто

                Это уже экономия на спичках. Цикл — одна из самых оптимизированных операций в JS, тем более тот, который не использует итераторы. Вряд ли вы будете создавать шаблон с миллионом значений, а с таким объёмом JS справляется за миллисекунды.


                1. 900k Автор
                  08.04.2026 09:33

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


                  1. Lodin
                    08.04.2026 09:33

                    Вы в комментариях выше доказывали мне что частичный всегда, а оказалось нет…

                    Ээ, простите, вы о чём? Не вижу связи


  1. Iworb
    08.04.2026 09:33

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


    1. 900k Автор
      08.04.2026 09:33

      Да, по той же причине Angular мне больше нравится чем React
      Роутер есть https://cruzo.org/#/docs/router, скоро будут children


      1. Iworb
        08.04.2026 09:33

        Тогда неплохое начало. Но у меня все еще складывается ощущение, что не соблюден баланс меджу “простым” и “функциональным”. Невозможно простым и лаконичным набором команд описать сложную функциональность. Можно конечно всё запихнуть под условный вызов одной функции, но как только конфигурация начинает отличаться от стандартно задуманной заканчивается лаконичность.

        Вообще, было бы интересно увидеть сравнение еще и в производительности одинаковых проектов на разных языках. Как я понимаю, Cruzo предназначен для не особо сложных проектов, но также себя позиционирует тот же $mol и еще пачка других (Vue, Ember и т.п.).

        Я считаю, что важно выбрать нишу и в этой нише показать в чем превосходство над другими фреймворками, иначе есть банальный риск утонуть среди других.


        1. 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


          1. Iworb
            08.04.2026 09:33

            Немного оффтопа. У меня до сих пор есть классические NgRx сторы с редьюсерами, эффектами и всеми вытекающими. Но в новых проектах есть signalStore от NgRx и как же всё стало удобнее. А за Cruzo понаблюдаю. Едва ли в ближайшее время буду его использовать, т.к. не хватает функционала, но наблюдать интересно. Наперёд отвечу, что именно лично мне в моих проектах нужно для начала, чего не хватает пока что тут:

            • работа со стилями. Я понимаю, описывать логику и структуру компонента - это уже половину дела, но нельзя опускать такой большой пласт как стили.

            • формы. Полноценная работа с валидацией. Можно, конечно, на сторонние зависимости откупиться и собрать своего Франкенштейна, по крайней мере с формами это возможно.

            • i18n. Зачастую это добавляется в самом начале, чтобы потом было не так больно переделывать. И оно есть почти всегда. Поэтому это довольно важная часть. К сожалению, сторонние библиотеки тут не помогут.

            • библиотека компонентов. Я видел несколько стандартных, но этого безумно мало.


            1. 900k Автор
              08.04.2026 09:33

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


  1. 1-Holopsicon-1
    08.04.2026 09:33

    А чем другие не устроили, Vue например или Svelte? Svelte как по мне выигрывает у всех этих


    1. 900k Автор
      08.04.2026 09:33

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


  1. denisemenov
    08.04.2026 09:33

    Как опознать сгенерированную статью:

    Без лишнего пафоса


    1. 900k Автор
      08.04.2026 09:33

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


      1. denisemenov
        08.04.2026 09:33

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


        1. 900k Автор
          08.04.2026 09:33

          Ну да, понимаю, о чём ты. Тут, наверное, просто кому как звучит.


  1. Artur_frontDev
    08.04.2026 09:33

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


  1. S1908
    08.04.2026 09:33

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


    1. 900k Автор
      08.04.2026 09:33

      А что за реакт типизированный html? JSX?


      1. S1908
        08.04.2026 09:33

        Ну да


        1. 900k Автор
          08.04.2026 09:33

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


          1. Lodin
            08.04.2026 09:33

            Не задавались вопросом почему вместо class в JSX className?

            Потому что разрабам реакта было влом переименовать переменную. Все остальные фреймворки спокойно пользуются class и в ус не дуют, это только реакт такой особенный.


            1. 900k Автор
              08.04.2026 09:33

              Потому что jsx это js, а там class зарезервированное слово


              1. S1908
                08.04.2026 09:33

                Так я не про js. А то что там можно использовать теги html внутри.

                Мне он нравится за html типизацию, а не красивые глаза.


              1. Lodin
                08.04.2026 09:33

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

                Примеры:


                1. 900k Автор
                  08.04.2026 09:33

                  https://react.dev/learn/writing-markup-with-jsx
                  Since class is a reserved word, in React you write className instead, 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
                  Просто скажите что связи не видите, так проще всего... Это в какой-то мере снимает с вас ответственность за ваши слова