Как мы пытались сделать фреймворк для фронтенда которого можно выучить за 5 минут и что из этого вышло

Привет!

Сегодня я расскажу о своём опыте в создании фреймворка для фронтенд-разработки. Цель была ясна, как день: сделать так, чтобы всё можно было выучить за 5 минут, с расчётом на то, что человек уже знает React, Vue или Angular.

Как создать компонент

Вариантов тут много. В React это просто функция. В Vue это файл. Мне лично нравится возможность в React создавать несколько вспомогательных компонентов внутри файла, поэтому мы решили, что компонент будет функцией и объявляется он следующим образом:

export const MyComponent = component(() => {
    // код тут
});

Реактивные состояния

Для сохранения состояния были придуманы переменные, и мы будем их использовать.

Правила просты:

  1. Если название переменной начинается с $ — значит, она будет реактивной.

  2. Если название переменной не начинается с $ — значит, мы её не меняем.

Если нам нужен derived/computed state, то мы описываем константу с нужным значением. Даже если использовать let, то некоторые линтеры автоматически будут менять его на const, поэтому константа — это канон.

Пример кода:

export const MyComponent = component(() => {
    let $a = 2;
    let $b = 3;
    const $sum = $a + $b;
    const $sum2 = sum($a, $b);
});

Эффекты

Сама функция компонента выполняется всего 1 раз, поэтому для того чтобы управлять эффектами, есть следующие функции:

Следующий код

export const MyComponent = component(() => {
    let $state = 'init';
    watch(() => { console.log($state) });
    beforeMount(() => { $state = "before" });
    afterMount(() => { $state = "after" });
});

будет выводить в консоль:

init
before
after

DOM

Для описания узлов DOM используется HTML-код, прописанный напрямую в функцию, исключение только для событий: onclick, onpress и т.д., они получают функцию в качестве значения. Всё это работает через JSX.

Описание узлов

Пример кнопки со счётчиком:

export const MyComponent = component(() => {
    let $count = 0;
    function inc() {
        $count++;
    }
    <button class="btn" onclick={inc}>You clicked {$count} times</button>;
});

Для class есть возможность передать массив строк для удобства, а для style — объект свойств. Но это уже плюшки.

Обратная связь

Для того чтобы вручную что-то менять/создавать, подключать сторонние библиотеки, используется обратная связь — функция, которая вызывается, когда узел и все его дочерние элементы добавлены в DOM.

Пример использования обратной связи:

export const MyComponent = component(() => {
    function sideEffect(input: HTMLInputElement) {
        input.showPicker();
    }
    <input type="date" callback={sideEffect}/>;
});

Передача данных между компонентами

Данные можно передать между компонентами следующими путями:

Передача данных через свойства

Свойства — это объект, к названию полей применяются такие же правила, как к названию переменных: то есть если поле начинается с $, то оно передаёт реактивные данные, иначе это обычное поле.

Пример передачи данных через свойства:

interface Props {
    userId: string;
    $userName: string;
}
const Child = component(({userId, $userName}: Props) => {
    <div>{userId} is named {$userName}</div>;
});
const Parent = component(() => {
    const id = 1;
    let $name = "First";

    // Когда мы здесь обновляем имя,
    // оно будет автоматически обновлено в дочернем элементе
   <Child userId={id} $userName={$name}/>;
});

Передача данных через обратную связь

Компонент, как функция, может что-то возвращать, это значение передаётся родительскому компоненту через обратную связь.

Пример использования в качестве альтернативы forwardRef из React:

const Child = component(() => {
    let input: HTMLInputElement | null = null;
    <input callback={element => input = element}/>;
    return input;
});
const Parent = component(() => {
   <Child callback={input => { console.log(input) }}/>;
});

Передача данных через слоты

Слоты от дочернего элемента к родителю передают свойства, а от родителя к дочернему — DOM-представление:

interface Props {
    $title: string;
    slot?(props: { $name: string }): void;
}
const Child = component(({$title, slot}: Props) => {
    <div>
        <Slot model={slot} $name={`${$title} is amazing`}/>
    </div>;
});
const Parent = component(() => {
    let $title = "MyApp";
    <Child $title={$title} slot={(($name) => {
        <span>{$name}</span>;
    })}/>;
});

В случаях когда дочерний компонент ничего не передаёт родителю, содержимое можно написать внутри тега: <Child><span>Text</span></Child>.

Также внутри тега Slot можно добавить содержимое, которое будет отображаться, если родитель не заполнил слот.

Слот — это не просто функция, а полноценный маленький компонент, то внутри него можно добавить derived/computed состояния, эффекты через watch, beforeMount, afterMount и даже beforeDestroy.

Логика и циклы

Это всё работает через специальные встроенные компоненты If, Else, ElseIf и For.

Пример условного текста:

const MyComponent = component(() => {
    let $count = 0;
    <If $condition={$count > 2}>
        Count is too big!
    </If>;
});

Пример цикла:

const MyComponent = component(() => {
    const arr = [1, 2, 3];
    <For model={arr} slot={number => {
        Number is {number}
    }}/>;
});

Правила про названия свойств относятся и к встроенным компонентам. То есть If будет реагировать на изменения в $condition, а For не будет реагировать на изменения модели — это значит, что модель надо обновлять через push, pull и т.д.

Выводы

Тут есть необходимый минимум, чтобы создать SPA. Это всё уже работает, и дальше — больше: есть стили для компонентов, скрипты для сборки как приложения, так и библиотек под фреймворк. Последнее, что добавили, — это SSG и условия в стиле React как альтернатива тегам If/Else. Проблема в том, что TypeScript иногда ругается.

Проект с открытым исходным кодом, но не знаю, будет ли публикация его названия считаться рекламой. Если хотите помочь, можете заполнить опрос под CustDev: https://docs.google.com/forms/d/e/1FAIpQLSej4oupzzzN1Iy2Yk9gMe4lJyhdAkUJS_WnkRgqW9BzdQo8jA/viewform?usp=publish-editor

Спасибо за внимание!

@vasille
08.11.2025 03:07 UTC
Первоисточник

Комментарии

@artptr86
07.11.2025 22:23 UTC
+1

Кажется, вы изобрели Solid.js, только со слотами

@vasille
08.11.2025 05:04 UTC
+1

Интересное наблюдение, я лично думал что он сильно похож на Svelte 4.

08.11.2025 09:49 UTC
+1

В плане использования $ для пометки реактивных переменных — да, на Svelte, но в общем как-то больше ассоциируется с Solid.

08.11.2025 10:03 UTC
+1

Solid очень классный, по этому сочту за комплимент )

@m6atom
08.11.2025 01:44 UTC
0

Как мы пытались сделать фреймворк для фронтенда которого можно выучить за 5 минут и что из этого вышло

Что-то с падежом напутали

@vasille
08.11.2025 05:06 UTC
+2

Русский не мой родной язык, так что может быть ошибки, я пока что не понимаю какой падеж напутал.

08.11.2025 07:29 UTC
0

Кстати, если вам сложно, используйте профридеры - приложения, которые подсвечивают ошибки в тексте и исправляют по нажатию кнопки. В основном они умеют объяснять причины исправлений. Я вот, например, английские тексты так правлю. А то знаний мало и пишу как селянин, меня очень выручает.

08.11.2025 09:22 UTC
+3

Можешь отправить ссылку пожалуйста?

@grammidin4eg
08.11.2025 09:06 UTC
0

Очередной Фреймворк, который ничего нового не делает. Можно было взять существующий и не тратить зря деньги бизнеса.

@vasille
08.11.2025 09:25 UTC
0

На самом деле это я вечерами чтобы расслабиться после работы написал.

@isumix
08.11.2025 11:25 UTC
-1

Оставлю тут пример компонента счетчика нажатий на своей библиотеке. Если ваш фреймворк имеющий стейт-менеджер и шаблонизатор для циклов и условий и который учится за 5 минут, то моя библиотека не имеющая ничего этого должна учиться за 2 минуты, ЛОЛ.

const ClickCounter = ({count = 0}) => (
  <button click_e_update={() => count++}>Clicked {() => count} times</button>
);
@vasille
08.11.2025 12:46 UTC
+2

По этому коду тут вопросов очень много:

  • {count = 0} почему состояние это поле объекта в параметре функций?

  • click_e_update что означает e_update? Как добавить обработчик для touchstart например?

  • Clicked {() => count} times - для чего тут функция если это шаблон и по идею встроим значение?

Тот же пример у нас:

const ClickCounter = component(() => {
  let $count = 0;
  <button onclick={() => $count++}>Clicked {$count} times</button>;
});
08.11.2025 16:04 UTC
0
  • {count = 0} - это деструктурирование параметров функции, где мы получаем переменную count со значение по умолчанию 0

  • click_e - означает установка обработчика событий, для события можно добавлять модификаторы, например: touchstart_e_capture_once_update. Помимо событий есть еще модификаторы для атрибутов и пропсов. Все это сделано чтобы в полной мере отражать спецификацию W3C стандарта.

  • Clicked {() => count} times - функция тут нужна чтобы подставлять в DOM динамически значения при вызове фукции update которая декларируется в нашем модификаторе. Также можно явно ее вызывать как описано тут. Статическое значение можно было вставить не оборачивая в функцию.

Также существует альтернативный (не JSX) синтаксис:

import {getElement} from '@fusorjs/dom';
import {button, div} from '@fusorjs/dom/html';

const ClickCounter = (count = 0) =>
  button({click_e_update: () => count++}, 'Clicked ', () => count, ' times');

const App = () => div(ClickCounter(), ClickCounter(22), ClickCounter(333));

document.body.append(getElement(App()));
08.11.2025 17:05 UTC
+1

Вот стандарт: https://dom.spec.whatwg.org/#eventtarget в нём чётка указано что обработчик события устанавливается тремя параметрами: тип события, обработчик и опции. Вот часть стандарта https://html.spec.whatwg.org/multipage/webappapis.html#event-handlers-on-elements,-document-objects,-and-window-objects которая описыввает какие атрибуты у элемента под какие типы события, соответственно нам остаётся указать 1 или 2 параметра. примеры:

<button onclick={() => $count++}>Clicked {$count} times</button>
<button onclick={[() => $count++, true]}>Clicked {$count} times</button>
<button onclick={[() => $count++, {capture: true, once: true}]}>Clicked {$count} times</button>

И вот такое как раз и соответствует стандартам, а вот эти хитроумные свойства надо выучить. Понимаешь разницу в подходе?

09.11.2025 10:10 UTC
0

Модификатором проще это делать, в вашем способе также надо разобраться, хотя ваш способ тоже интересный, возвращение тюпла, во Фьюзоре примерно также можно сделать:

  click_e={{
    handle: (event, self) => 'Clicked!',
    capture: true,
    once: true,
    passive: true,
    signal: AbortSignal,
    update: true, // update target component after event handler completes
  }}

Другое дело что во Фьзоре помимо эвентов поддерживаются еще и другие типы модификаторов

  • a - attribute

  • an - attribute namespaced

  • p - property

  • ps - property static

  • e - event handler

Но мы тут уходим в детали синтаксиса, который легко подправить. Главное отличие Фьюзора - то что это библиотека, она не имеет встроенного стейт-менеджера, и позволяет все делать ЯВНО.

09.11.2025 14:57 UTC
0

Я не говорил что так хуже, а то что это всё надо выучить, особенно пугает ручное обновление компонента. Ещё мне непонятно будет ли Typescript это проверять, IDE предлагать авто-дополнение этих названий и сколько всего вариантов там в списке будут.

10.11.2025 18:35 UTC
0

Автроматическое обновление подключается на пропсу mount. Причем можно использовать лучший алгоритм для каждого компонента, а не один "средний" для всех. Поддержка Typescript есть на сколько хватило моих в нем компетенции - уверен что можно допилить ее на много лучше, также как и автодополнения IDE.

FYI @artptr86

10.11.2025 19:02 UTC
+1

Это довольно интересный подход.

12.11.2025 00:58 UTC
0

В реальном приложении тогда будут тысячи рукописных маунтов для всех реактивных переменных. Этот код писать нудно и неудобно. Кроме того, реактивные фреймворки позволяют изменять текстовые ноды, а в Фузоре можно обновить только ноду тега со всеми дочерними. Но самое главное: в показанном примере есть утечка памяти: маунт подписывается на обновление переменной, но никогда не отписывается. В памяти также останется сохранённый в замыкании self.

13.11.2025 09:10 UTC
0

Достаточно будет написать одну функцию "маунтер" для каждой библиотеки стейт менеджера, и проблема решена, тем более это очень простая функция - одна строка в примере.

Добавить обновление только для текстовых нод без элемента, есть такая идея, но пока в реальных кейсах не пригождалось, нужно собрать больше данных в каких сценариях это может быть полезно.

Если вы приглядитесь к примеру, то обнаружите функцию usubscribe, которая передается в mount, которая в свою очередь и вызовет ее на событии unmount. Так что нет утечки.

@artptr86
08.11.2025 18:13 UTC
0

Для чего вообще кому-то может быть нужна библиотека для создания счётчиков нажатий?

08.11.2025 20:09 UTC
0

Это такой тренд, почти на всех сайтах фреймворках находится этот пример, он простой и даёт первичное впечатление о том как строится компоненты. А сам счётчик можешь найти на маркетплейсах когда нажимаешь кнопку "Добавить в корзину".

08.11.2025 20:18 UTC
0

Это такой же плохой тренд, как и приложение Todo MVC когда-то. Такие примеры не дают полноценного понимания data flow. В данном случае единственная «киллер-фича» библиотеки в том, что она может автоматически обновлять компонент-источник события, а больше она ничего и не умеет.

09.11.2025 10:15 UTC
0

Прикол в том что эта библиотека всего лишь абстракция над document.createElement без стейт менеджмента. Но тем не менее она может делать все то же самое, что делают "большие" фреймворки, потребляя в разы меньшие ресурсы. Там есть также два полноценных приложения.

09.11.2025 10:47 UTC
0

У больших фреймворков есть управление состоянием. У вас этого нет, поэтому сравнивать их нельзя.

@GeorgeBobrov
08.11.2025 21:53 UTC
+1

Объясните пожалуйста, зачем нужны псевдокомпоненты If, Else, ElseIf и For?

Ведь у нас внутри компонента уже есть JavaScript (или TypeScript), которые имеют if, else, for и весь арсенал для проверок/ветвлений и обхода циклов?

Я не веб-разработчик, так, для десктопа пишу, мне действительно не очевидно

@vasille
09.11.2025 05:54 UTC
0

С большим удовольствием отвечаю, я сам разработчик C++ Qt (middle) по этому отлично понимаю ваше недоразумение.
Больше всего писал на QML, это всё по сути заменяет такие компоненты в нём как Loader и Repeater.
Когда используешь более классический способ у тебя есть доступ к указателя на каждый Layout и в нём можно что-то добавить и удалить без проблем, это работает на основе архитектуры MVC. Да и там всё равно есть различные ListView, TableView и подобные.
В моём фреймворке используется архитектура MVVM, по этому любые изменения на экраны идут через так называемые биндинги, т.е. меняешь данные и интерфейс сам обновляется, в таком случае программист не имеет необходимость в прямой доступ к составляющих интерфейса (а сам доступ он есть).

@artptr86
09.11.2025 21:35 UTC
+1

Собственно потому, что функция компонента исполняется всего один раз для его конструирования, а все зависимости от данных внутри него перевычисляются реактивно. Поэтому, например, псевдокомпонент If декларативно задаёт, что показывать его детей нужно по изменению соответствующей переменной, а For — что дочерний компонент нужно итерировать (причём по возможности переиспользуя существующие элементы) согласно переданному массиву.

А вот в React, в котором функция компонента вызывается каждый раз при изменении данных, действительно используются встроенные в JS возможности для циклов и условий.

@Prologos
10.11.2025 14:52 UTC
0

Проект с открытым исходным кодом, но не знаю, будет ли публикация его названия считаться рекламой.

Спасибо за статью, хотелось бы ещё увидеть ссылку на гитхаб например чтобы поиграться (обычно на хабре делятся такой ссылкой)

@vasille
10.11.2025 18:59 UTC
0

Сейчас идёт ребрэндинг, изначально проект не имел по сути название, пару дней назад только что наконец-то придумал. Но код и документацию можно посмотреть тут: https://github.com/vasille-js/vasille-js