Привет!

Сегодня я расскажу о своём опыте в создании фреймворка для фронтенд-разработки. Цель была ясна, как день: сделать так, чтобы всё можно было выучить за 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 раз, поэтому для того чтобы управлять эффектами, есть следующие функции:

  • watch выполняет функцию каждый раз, когда меняются реактивные данные внутри.

  • beforeMount выполняет функцию после инициализации данных и перед тем, как начать обновлять DOM.

  • afterMount выполняет код после того, как DOM был обновлён.

  • beforeDestroy выполняет код до того, как удалить ноды из DOM.

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

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

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

Комментарии (25)


  1. artptr86
    07.11.2025 22:23

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


    1. vasille Автор
      07.11.2025 22:23

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


      1. artptr86
        07.11.2025 22:23

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


        1. vasille Автор
          07.11.2025 22:23

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


  1. m6atom
    07.11.2025 22:23

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

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


    1. vasille Автор
      07.11.2025 22:23

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


      1. goldexer
        07.11.2025 22:23

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


        1. vasille Автор
          07.11.2025 22:23

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


  1. grammidin4eg
    07.11.2025 22:23

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


    1. vasille Автор
      07.11.2025 22:23

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


  1. isumix
    07.11.2025 22:23

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

    const ClickCounter = ({count = 0}) => (
      <button click_e_update={() => count++}>Clicked {() => count} times</button>
    );
    


    1. vasille Автор
      07.11.2025 22:23

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

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

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

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

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

      const ClickCounter = component(() => {
        let $count = 0;
        <button onclick={() => $count++}>Clicked {$count} times</button>;
      });
      


      1. isumix
        07.11.2025 22:23

        • {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()));
        


        1. vasille Автор
          07.11.2025 22:23

          Вот стандарт: 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>
          

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


          1. isumix
            07.11.2025 22:23

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

              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

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


            1. vasille Автор
              07.11.2025 22:23

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


    1. artptr86
      07.11.2025 22:23

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


      1. vasille Автор
        07.11.2025 22:23

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


        1. artptr86
          07.11.2025 22:23

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


      1. isumix
        07.11.2025 22:23

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


        1. artptr86
          07.11.2025 22:23

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


  1. GeorgeBobrov
    07.11.2025 22:23

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

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

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


    1. vasille Автор
      07.11.2025 22:23

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


    1. artptr86
      07.11.2025 22:23

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

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


  1. Prologos
    07.11.2025 22:23

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

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