На дворе октябрь, в npm залита новая версия фреймворка Chorda 3.0. Можно, наконец, устроиться поудобнее за чашечкой кофе и подвести некоторые итоги

Про сам фреймворк можно почитать здесь, посмотреть тут и пощупать там

В прошлый раз я рассказывал о дизайн-функции, чертежах и построении API компонентов, а в этот раз попробую заинтересовать вас некоторыми очевидными решениями и не очень очевидными следствиями, которые появились во время работы с Chorda

Очевидные решения

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

Расширение библиотечных компонентов

Начну с декларативного подхода и примесей - краеугольного камня Chorda. Для наглядности нам понадобится немного кода.

Создадим простой компонент на JSX (React) с кнопкой и текстом. Задача: при клике по кнопке меняется текст

const MyComponent = () => {

	const [data, changeData] = useState('')

	const hahdleClick = (e) => {
		changeData('Hello')
	}

	return <div>
  	<button onClick={handleClick}>Click me</button>
  	<p>{data}</p>
	</div>

}

// вот так выглядит применение компонента
<MyComponent/>

Сделаем похожий функционал, используя чертеж Chorda

// вырожденная дизайн-функция
const MyComponent = () => {
    return {
        templates: {
            button: {
                tag: 'button',
                text: 'Click me',
                events: {
                    // обработка событий VDOM
                    $dom: {
                        click: (evt, {data}) => {
                            data.$value = 'Hello'
                        }
                    }
                }
            },
            text: {
                tag: 'p',
                reactions: {
                    // реакции компонента на изменение переменной скоупа
                    data: v => patch({text: v})
                }
            }
        },
        initials: {
            // инициализация переменной в скоупе
            data: () => observable('')
        }
    }
}

// создаем чертеж
MyComponent()

Ну, все. Расходимся, ребята. Очевидно же, что шаблонный синтаксис намного проще и понятнее.

Но

Давайте посмотрим, что происходит с нашим JSX компонентом дальше. Итак, мы выполнили задачу, и теперь передаем наши наработки коллеге, скажем, в составе корпоративной или публичной библиотеки. Через некоторое время от коллеги приходит просьба: хочу, чтобы компонент можно было стилизовать. Не вопрос. Самый простой и быстрый способ это сделать - дать возможность управлять классом корневого компонента через пропсы.

Поехали

// Придется залезть в библиотеку (!) и сделать пару правок

const MyComponent = (props) => {

    const {rootClassName} = props

    /* Тут ничего не меняется. Пропускаем */

    return <div className={rootClassName}>
        <button onClick={handleClick}>Click me</button>
        <p>{data}</p>
    </div>
}

// рендерим
<MyComponent rootClassName="custom" />

Отлично!

Тем временем в Chorda

// Менять оригинальный чертеж необходимости нет

// В месте применения создадим примесь
mix(MyComponent(), {
    css: 'custom',
})

Естественно, стилизацией обычно дело не заканчивается. Чем дальше, тем больше хотелок и тем больше пропсов нам понадобится добавить.

На самом деле подобные извращения следует пресекать в зародыше, и сразу предоставлять возможность потребителю "слотировать" вложенные компоненты. Однако, в нашем примере компоненты оказались жестко связаны state-параметром data. Просто так вытащить их не получится, поэтому посмотрим, во что может превратиться реализация с пропсами

const MyComponent = (props) => {

    const {rootProps, buttonProps, text: MyText} = props

    /* тут ничего не меняется */

    return <div {...rootProps} >
        <button onClick={handleClick} {...buttonProps} >Click me</button>
        <MyText>{data}</MyText>
    </div>
}

<MyComponent 
    rootProps={{className: 'custom'}} 
    buttonProps={{className: 'custom-button'}}
    text={props => <p className="custom-text">{props.children}</p>}
    />

В ситуации с чертежом без особых именений

// Расширяем примесь
mix(MyComponent(), {
    css: 'custom',
    templates: {
        button: {
            css: 'custom-button'
        },
        text: {
            css: 'custom-text'
        }
    }
})

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

Стоит признать, с примесями тоже не все так гладко. Чтобы сделать хорошо расширяемый компонент, его необходимо сильно декомпозировать, а это напрямую влияет на производительность и восприятие кода в целом (по второму пункту полезно почитать о причинах появления setup во Vue 3)

Так к чему все это сравнение? Тот же React может предложить много вариантов и подходов для расширения функционала, один экзотичнее другого. Chorda же предлагает один путь (на самом деле нет), что на мой взгляд экономит на выборе уйму времени

Этот компонент мне не подходит

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

Тогда на выбор:

1. Делаем форк, вносим в него правки. Делаем PR в репо библиотеки. Пока ждем влития, пользуемся форком

2. Делаем свой компонент. Используем его вместо библиотечного. Ждем новой версии библиотеки

3. (для обладателей особого дара убеждения) Объясняем автору библиотеки в чем он не прав и почему он должен внести нужные вам правки как можно скорее. Профит!

4. Забиваем

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

Mount или не Mount?

Наверно, правильнее этот вопрос надо задать так: в какой момент должна начинаться обработка бизнес задач?

Хорошо проиллюстрирует мою мысль пример с загрузкой данных в store приложении при открытии страницы. Как правило, загрузка выполняется по событию монтирования узла виртуального DOM, что есть странно - зачем что-то добавлять в DOM, если данных еще нет? Почему бы нам сначала не загрузить данные, а потом решать - рендерить что-то или нет. Тут ситуацию немного спасает Suspense и понятие асинхронных компонентов, когда у каждого из них есть своя отложенная задача и, соответственно, отложенная отрисовка

Мне же больше нравится вариант, когда загрузка вообще никак не связана с рендерингом. В Chorda бизнес-логика находится на уровне дерева компонентов, а результаты выполнения бизнес-задач влияют только на store/state, не касаясь отрисовки напрямую

Все мы вместе и каждый сам по себе

В Chorda состояние компонента определяется скоупом (что-то вроде локального store). Компонент видит только свой скоуп и работает только с ним, считая, что вокруг никого нет. Это позволяет спокойно выполнять смешивание, не опасаясь сломать жесткие связи между компонентами

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

Здесь мы подходим к такой штуке, как конвейер обработки (вычисление-патч-отрисовка). Он, как и любое другое значение, попадает к компонентам через скоуп, и его, так же как и любое другое значение, можно настроить локально. К примеру, стратегия базового конвейера дает построение дерева компонентов в ширину с удержанием задач отрисовки до окончания обработки всех патчей. Если для вашего компонента или блока компонентов такое решение не подходит, вы можете подключить к ним свой кастомный конвейер

Неочевидные следствия

А вот некоторые моменты не являлись изначальной целью, но проявились по мере развития фреймворка

Встраивание в существующие проекты

Реализация виртуального DOM не входит в состав Chorda, т.к. разработка еще одного нового отрисовщика не решала моих проблем. Поэтому я собирался использовать какой-нибудь из уже существующих. Для того, чтобы попробовать Chorda в деле, я собрался переписать с нуля пару домашних React-проектов. Но приступив, почти сразу понял, что это совсем не обязательно. Можно постепенно заменять отдельные компоненты, подключив правильный рендерер, и так потихоньку съесть всего слона целиком.

Интересный вопрос: если фреймворк использует React, то можно ли сказать, что приложение, которое использует данный фреймворк, написано на React?

Загружаем и работаем

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

Правда вот, SSR превратился в нетривиальную задачу

Поведенческие компоненты

Тут скорее интересное наблюдение. Раньше я сталкивался только с таким подходом к "глупым" компонентам: есть простой компонент без логики работы, который знает как ему рисоваться, затем поверх него создается "умный" компонент, который включает в себя "глупый", добавляя некоторое поведение.

Так вот в Chorda (из-за того, что можно легко менять порядок смешивания) у меня появился набор компонентов, в которых есть только поведение без правил отрисовки (обычно это набор стандартных реакций). И получилось, что, наоборот, уже к поведению начинает примешиваться способ отображения

Как это может выглядеть:

export default () => {
    // поведенческий компонент Text
    return Text({
        as: Paragraph, // "глупый" компонент Paragraph
        text$: $ => $.user.name
    })
}

Что в итоге?

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

Если фреймворк вас заинтересовал, или у вас есть предложения по его развитию - всегда пожалуйста.

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


  1. dom1n1k
    24.10.2021 14:07
    +4

    Проблема и этой статьи, и документации - нет постановки задачи. Совершенно непонятно, зачем нужен этот фреймворк и какую проблему предшественников призван решать. Вы сходу ныряете в код, в котором вроде примерно понятно что происходит, но непонятно зачем и почему. В документации то же самое: открываю раздел "быстрый старт" - npm блабла, import тратата. Но зачем? Какие я должен получить преимущества в итоге?

    Походив по ссылкам, я набрел на статью 2-летней давности, которая пытается отвечать на этот вопрос. Но во-первых, как-то многословно, сходу въехать сложно. А во-вторых, её надо было ещё найти. Большинство читателей вряд ли стали заморачиваться.


    1. eliace Автор
      24.10.2021 20:57

      Спасибо. Добавил короткое введение. Действительно, с ним стало лучше


  1. kubk
    24.10.2021 15:35

    Зашёл в документацию - ничего не понял. Что это за инструмент? Какую проблему решает? Даже несколько раз кликнул на главную с целью найти описание. Но описания нет, сразу пишем какой-то код.


  1. nin-jin
    24.10.2021 19:56

    Смотрите, я немного упростил ваш чертёж, используя view.tree:

    $my_greeter $mol_view
    	data?next \
    	greet_message @ \Hello
    	sub /
    		<= Greet $mol_button_minor
    			title @ \Greet me
    			click?event <=> greet?event null
    		<= Message $mol_paragraph
    			title <= message \
    namespace $.$$ {
    	export class $my_greeter extends $.$my_greeter {
    		greet() {
    			this.message( this.greet_message() )
    		}
    	}
    }

    Как видите, кода получилось гораздо меньше. При этом синтаксис мотивировал нас написать везде говорящие имена, а не белиберду. Идём дальше..

     хочу, чтобы компонент можно было стилизовать

    Любой компонент на любом уровне вложенности уже можно стилизовать либо через css, либо через $mol_style (который генерирует тот же CSS). Например:

    /* Для примера экземпляр $my_greeter имеет имя welcome в компоненте $my_app */
    [my_app_welcome] {
      стили для корневого элемента компонента
    }
    [my_app_welcome_greet] {
      стили для кнопки
    }
    [my_app_welcome_message] {
      стили для параграфа
    }

    Вы остановились на стилизации, но можно ведь пойти и дальше, меняя в том числе и поведение компонента.

    зачем что-то добавлять в DOM, если данных еще нет?

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

    Мне же больше нравится вариант, когда загрузка вообще никак не связана с рендерингом. 

    То есть у вас возможны ошибки двух типов:

    1. Загрузили что-то и не показали (лишняя загрузка).

    2. Показали то, что забыли загрузить.

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

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

    Это классный принцип, весь $mol на нём построен. Но для его раоты необходим автотрекинг зависимостей, так как связи становятся строго динамическими.

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

    Емнип так работали контексты в AngularJS. И частая проблема с этим подходом - неявная зависимость компонента на окружение. Это когда компонент в одном месте работает исправно, а при переносе в другое место вдруг ломается, ибо родительские контексты сложились иначе.

    Правда вот, SSR превратился в нетривиальную задачу

    Не понятно почему. Достаточно отрендерить через JSDOM, получить DOM и его сериализовать.

    Если фреймворк вас заинтересовал, или у вас есть предложения по его развитию - всегда пожалуйста.

    Ещё через пару лет вы переизобретёте $mol, где уже как 5 лет есть это всё и даже больше. Не буду предлагать вам скипнуть пару лет и сразу перейти на $mol. Но подсмотреть что-то из него всё же стоит.)


    1. eliace Автор
      25.10.2021 12:09

      Смотрите, я немного упростил ваш чертёж, используя view.tree:

      Обуждение синтаксиса в данном случае бесперспективная тема. $mol фактически предлагает выучить новый язык программирования (надеюсь, только шаблонов). Chorda это хорошо знакомые JS/TS.

      Вы остановились на стилизации, но можно ведь пойти и дальше, меняя в том числе и поведение компонента.

      Пойти дальше не позволили рамки статьи :) Переопределение поведения, конечно, есть. Но это сложная и любопытная тема.

      1. Загрузили что-то и не показали (лишняя загрузка).

      2. Показали то, что забыли загрузить.

      Я правильно понимаю, что вы не рассматриваете варианты агрегации данных перед отображением?

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

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

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

      Это когда компонент в одном месте работает исправно, а при переносе в другое место вдруг ломается, ибо родительские контексты сложились иначе.

      Очень интересная тема. Тут два момента:

      1. Если речь про неявную завязку на контекст, то это изначально плохо. Не надо так делать

      2. При явном же подключении необходим контроль типов контекста. Обычно этого достаточно

      И, кстати, проблема характерна не только для Angular

      Не понятно почему. Достаточно отрендерить через JSDOM, получить DOM и его сериализовать.

      Тут речь скорее о восстановлении компонентов на стороне браузера

      Ещё через пару лет вы переизобретёте $mol, где уже как 5 лет есть это всё и даже больше

      Я все же очень рассчитываю, что Chorda идет по другому пути нежели $mol. Сосуществование с другими библиотеками мне нравится больше, чем война с ними

      Но подсмотреть что-то из него всё же стоит.)

      Подсматриваем, конечно :) На данный момент интересен кейс использования $mol в качестве отрисовщика


      1. nin-jin
        25.10.2021 14:20

        $mol фактически предлагает выучить новый язык

        Выучили же люди как-то JSX и ничего, не померли.

        Я правильно понимаю, что вы не рассматриваете варианты агрегации данных перед отображением?

        Только отображению известно какую часть каких данных оно собирается показать.

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

        Тут всё же проблема не в отображении, а в кривом апи Реакта.

        При явном же подключении необходим контроль типов контекста. Обычно этого достаточно

        Типы могут совпасть и случайно. Классический пример - глобальная переменная name строкового типа.

        Тут речь скорее о восстановлении компонентов на стороне браузера

        Гидратация? SSR нужен роботам, а не браузерам. Браузеру быстрее скачать данные без html-мишуры и отрендерить нужную их часть.

        На данный момент интересен кейс использования $mol в качестве отрисовщика

        Вот рендереры из $mol.


  1. qbz
    25.10.2021 03:29

    Пожалуй единственный фреймворк, который кроме "мой синтаксис лучше - это ж очевидно" приносит реальное решение реальных проблем так это Крэнк. На данный момент, это, имо, самый трезвый подход к решению веба. https://crank.js.org/blog/introducing-crank


    1. nin-jin
      25.10.2021 06:01

      Этот подход приносит новые проблемы: всё начинает дико тормозить, ибо асинхронные функции и генераторы - это дико медленная абстракция. И когда их много всё встаёт колом.