Cruzo – минималистичный UI фреймворк с роутером, компонентами и разными плюшками (~14 kb gzip)
Хотел рассказать вам о своих наработках, делаю этот фреймворк с 2020г, с появлением LLM нагенерил доков и UI-тестов, убрал с себя рутину, наконец-то зарелизил. Сам фреймворк без зависимостей.
При этом есть: шаблонизатор с реактивностью, компоненты, роутер, HTTP-обертка, шаблоны {{ }} через байткод (без eval, CSP-friendly).
Всё, что нужно для SPA:
import {
Template,
AbstractComponent,
RxBucket,
routerService,
RouteUrlBucket,
HttpClient,
componentsRegistryService,
} from "cruzo";
~44 KB min / ~14 KB gzip в бандле приложения (production, tree-shake).
UI-kit (input, select, modal…) — отдельные импорты, в цифру не входит.
Сопоставимая комплектация
«Маленькое SPA: UI-runtime + роутер + HTTP + реактивный стейт», без тяжёлого UI-kit:
| Стек | Что входит | gzip (порядок) |
|---|---|---|
| cruzo | template VM, components, rx, bucket, router, http | ~14 KB |
| React | react + react-dom + react-router-dom | ~55–60 KB |
| Angular | core + router + common/http (+ zone в классической сборке) | ~90–130 KB только framework-chunk |
React легче только пока у тебя одна кнопка без роутера. Angular — для простой админки часто overkill и по KB, и по голове.
Счётчик
import { AbstractComponent, componentsRegistryService } from "cruzo";
class Counter extends AbstractComponent {
static selector = "counter";
count$ = this.newRx(0);
getHTML() {
return `
<button onclick="{{ root.inc() }}">
ping: {{ root.count$::rx }}
</button>
`;
}
inc() {
this.count$.update(this.count$.actual + 1);
}
}
componentsRegistryService.define(Counter);
componentsRegistryService.initApp();
<counter></counter>
Форма на RxBucket
import { AbstractComponent, RxBucket } from "cruzo";
import { InputConfig } from "cruzo/ui-components/input";
import { SelectConfig } from "cruzo/ui-components/select";
class SearchPanel extends AbstractComponent {
static selector = "search-panel";
innerBucket = new RxBucket({
search: { config: InputConfig({ placeholder: "найти..." }) },
sort: {
config: SelectConfig({
placeholder: "сортировка",
getItems: async () => [
{ label: "Новые", value: "new" },
{ label: "Старые", value: "old" },
],
}),
},
});
query$ = this.newRxValueFromBucket(this.innerBucket, "search");
sort$ = this.newRxValueFromBucket(this.innerBucket, "sort");
getHTML() {
return `
<input-component component-id="search" bucket-id="${this.innerBucket.id}"></input-component>
<select-component component-id="sort" bucket-id="${this.innerBucket.id}"></select-component>
<pre>query: {{ root.query$::rx }}</pre>
<pre>sort: {{ root.sort$::rx }}</pre>
`;
}
}
Роутер + ленивая подгрузка
import { RouteUrlBucket, delay, routerService } from "cruzo";
export const routes = new RouteUrlBucket({
home: {
url: "/",
componentSelectorUnbox: () => "home-page",
routeSelectorUnbox: () => "#app",
},
lazy: {
url: "/lazy-demo",
componentSelectorUnbox: () => "lazy-page",
routeSelectorUnbox: () => "#app",
loadResources: async () => {
await delay(2000); // демо-задержка
await import("./lazy-page.js");
},
},
});
routerService.update();
HTTP из того же пакета
import { HttpClient } from "cruzo";
const api = new HttpClient("https://api.example.com", {
params: async (_m, _url, opts) => {
opts.headers ??= {};
opts.headers.Authorization = "Bearer " + token();
},
});
const me = await api.get("/me");
Всё это — cruzo, те самые ~14 KB gzip.
Почему я решил сделать VM, а не eval, ведь бандл был бы намного меньше!
Выражения в {{ }} компилируются в байткод и исполняются маленькой VM. Подмножество JS — в {{ }} не весь язык, а фиксированный набор. Парсер отсекает лишнее до исполнения, защита от выполнения произвольного JS.
Ну еще, если есть тег Content-Security-Policy: default-src ‘self’; script-src ‘self’, то eval(…) просто не выполнится.
Ссылки
- npm i cruzo
- https://cruzo.org (примеры использования)
- https://github.com/MaratBektemirov/cruzo
- https://github.com/MaratBektemirov/cruzo-starter