Порой нужно реализовать прототип небольшого приложения для проверки какой-нибудь гипотезы. Недавний пример с работы: мы решили попробовать work-in-progress лимиты (из методологии kanban), но наш трекер не умеет с ними работать. Появилась задача по-быстрому собрать дашборд с тикетами из спринта с указанием лимитов.
React или любой другой современный фреймворк (библиотека) для прототипа может быть слишком избыточным (еще и придется ставить кучу всего из экосистемы - стейт-менеджер, роутер и научится все это собирать - на это может уйти не один день). Да-да, есть create-react-app и подобные инструменты, но сколько там всего под капотом. Хочется использовать минимум библиотек, минимум конфигов, TypeScript, JSX (почему нет) и максимально быстро собрать приложение. Хочется минимализма.
Вы можете сказать, что есть же Svetle? Да, его я тоже очень хотел попробовать и попробую в следующий раз, возможно даже в таком же стиле выпущу пост по основным концепнциям библиотеки.
Сегодня соберем такое приложение на Hyperapp. Это супер-маленький фреймворк (1.1кб) для построения UI с one-way data binding стейт-менеджертом (как Redux или Vuex) и виртуальным DOM-ом (как в React-е). Несмотря на почти 19к звезд на гитхабе, сообщество не очень большое, как и материалов на русском. Поэтому первую часть поста посмотрим на возможности и реализуем бизнес-логику приложения Counter (а на чем же еще показывать возможности библиотеки), а во второй части - рассмотрим готовый стартер для проекта.
Что получилось по итогу:
Hyperapp позиционируется как полноценное решение для построения сложных интерфейсов. Но из-за довольно ограниченного туллинга и маленькой экосистемы для больших приложений библиотека не подойдет.
Hyperapp состоит из 4 основных составляющих: представление (используется hypertext формат, что позволит использовать JSX), экшены, эффекты и подписки для работы с состоянием приложения. Но прежде, чем перейти к ним, стоит остановиться на кое чем более важном.. на архитектуре. Архитектура Hyperapp основана на концепции State machine и конечных автоматов (Finite-state machine, далее просто FSM). Подробнее можете почитать статью Антона Субботина про конечные автоматы в реальной жизни.
FSM - это такая абстрактная модель системы, которая имеет конечное число состояний, и правила перехода между которыми заранее известны. Автомат умеет взаимодействовать с внешним миром через входы и выходы. Входы (inputs) - различные способы реакции состояния из внешнего мира, а выходы (outputs) - влияние на внешний мир. Если подытожить, FSM определяется списком его состояний, начальным состоянием и инпутами (входы и выходы).
Стейт (наш FSM), это обычный JavaScript объект, для которого нужно указать начальное состояние:
type State = {
counter: number;
};
const initialState: State = {
counter: 0,
};
Так как все Hyperapp приложения - это FSM, то изменять свое состояние они могут через входы, которые называются экшенами. Экшены - это чистые функцию, которые в качестве аргумента принимают стейт и возвращают новой стейт (привет, Redux):
import type {Action} from 'hyperapp';
const add: Action<State> = state => ({...state, counter: state.counter + 1});
const subract: Action<State> = state => ({...state, counter: state.counter - 1});
Все TypeScript тайпинги взяты из оффициального index.d.ts. К сожалению, они не очень хорошо расширяются и код получается не всегда
хорошоправильно типизирован.
Еще не забыли, что у FSM есть выходы, которые после изменения состояния влияют на внешний мир? В качестве одного из выходов выступает View-слой, который обновляется после каждого изменения состояния и отрисовывает DOM-ноды:
Представления можно писать с помощью JSX, и как в React, вместо работы с настоящим DOM-ом, работа происходит с виртуальным деревом:
import type {VNode} from 'hyperapp';
function View(state: State): VNode<State> {
return (
<main>
<h1>state.counter: {state.counter}</h1>
<button onclick={add}>Add</button>
<button onclick={subract}>Subtract</button>
</main>
);
}
Функция принимает стейт и возвращает виртуальную DOM-ноду. В качестве атрибутов с названием on-*
можно передавать наши экшены и как вы можете заметить, что они написаны в нижнем регистре (в React-е например они указываются как onClick и это просто особенность реакта). Т.е. View может производить экшены, которые изменяют состояние:
Похоже на типичную работу стандартного react-redux приложения. А можно сделать ее еще более похожей, если view-слой разделить с помощью композиции и вынести повторяющие часты в отдельные вьюхи (компоненты):
import type {VNode} from 'hyperapp';
type ButtonProps = {
onclick: Action<State>,
};
function Button(props: ButtonProps, children: VNode<unknown>) {
const {onclick} = props;
return <button onclick={onclick}>{children}</button>;
}
И перепишем View
:
import type {VNode} from 'hyperapp';
function View(state: State): VNode<State> {
return (
<main>
<h1>state.counter: {state.counter}</h1>
<Button onclick={add}>Add</Button>
<Button onclick={subract}>Subtract</Button>
</main>
);
}
Важно, что у Hyperapp нет изолированного стейта у отдельных компонентов (как в React-е), поэтому все состояния хранятся в одном месте. С помощью композиции состояния можно самому проектировать структуру стейта.
Для изменения состояния из внешнего мира (веб-сокеты, таймеры, изменения в адресе страницы) используются подписки. Это функции, которые принимают стейт и какой-нибудь пэйлоад, и подписываются на необходимых слушателей. Например, можно подписаться на событие keydown
и слушать события нажатия на кнопки стрелок:
import type {Subscription} from 'hyperapp';
const keyDownSubscription: Subscription<State> = [
(dispatch, {onup, ondown}) => {
let handler = (event: KeyboardEvent) => {
if (event.key === 'ArrowUp') {
dispatch(onup);
}
if (event.key === 'ArrowDown') {
dispatch(ondown);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
},
{onup: add, ondown: subract},
];
Подписку можно сделать опциональной при подключении и в таком случае нужно отписаться от подписки (для этого из функции нужно вернуть коллбек для отписки). Опять же аналогия из мира реакта - useEffect
. Подписки являются выходами, поэтому схема с FSM будет выглядеть следующим образом:
Асинхронные экшены (например, http-запросы) делать в Hyperapp нельзя, поэтому есть еще одна составляющая - это эффекты. С помощью них можно сделать асинхронную операцию и после вызвать экшен. По сути с помощью них можно делать любые сайд эффекты.
Создадим эффект addAsyncEffect
, который будет увеличивать счетчик через 1 секунду после клика на кнопку:
import type {Effect} from 'hyperapp';
const addAsyncEffect: Effect<State> = [
dispatch => {
setTimeout(() => {
dispatch(add);
}, 1000);
},
null,
];
Вторым элементом массива Effect ожидает какой-нибудь пэйлоад. У нас его нет, поэтому просто указываем null.
Кто работал с Redux-thunk или Vuex, сразу увидит аналогию и схожесть в работе. Чтобы инициировать эффект, нужно обернуть его в экшен (прокинув его вторым элементом массива):
import type {Action} from 'hyperapp';
const addAsync: Action<State> = state => [
// состояние можно обновить, но в данном экшене этого делать не нужно
state,
addAsyncEffect,
];
Т.е. вместо возврата состояния, нужно вернуть массив (картеж), где первым элементов - будет новых стейт, а втором - эффект. Таким образом, эффекты - это выходы, которые "эффектят" на внешний мир и генерируют экшены. Финальная схема выглядит следующим образом:
Добавим новую кнопку на View
:
import type {VNode} from 'hyperapp';
function View(state: State): VNode<State> {
return (
<main>
<h1>state.counter: {state.counter}</h1>
<Button onclick={add}>Add</Button>
<Button onclick={addAsync}>Add async</Button>
<Button onclick={subract}>Subtract</Button>
</main>
);
}
И наконец, соберем все вместе с помощью функции app
, которая создаст приложение и примаунтит его в реальный DOM (привет, React.render
):
import {h, app} from 'hyperapp';
// ...
app<State>({
init: initialState,
view: View,
subscriptions: () => [keyDownSubscription],
node: document.querySelector('#app')!,
});
Собственно и все, с учетом импортов, TypeScript-типов собрали приложение асинхронного счетчика (значение которого можно изменять с клавиатуры) из 80 строк кода. Финальный вариант можно посмотреть в Github-репозитории noveogroup-amorgunov/hyperapp-counter.
К сожалению, пример выше не заведется, так как Hyperapp не работает с JSX из коробки. Нужно сделать 3 шага: указать в typescript правильную обработку JSX (чтобы TS подтягивал тайпинги), настроить babel плагин @babel/plugin-transform-react-jsx
(чтобы создавались h-функции, а не React.createElement
) и переопределить дефолтный h
из hyperapp
(работающую версию h.jsx
можно посмотреть в гитхабе).
В собранном бандле теперь код компонентов будет выглядеть вот так:
var _h = require('~/src/h');
function Button(props, children) {
const {onclick} = props;
return _h.h('button', {onclick: onclick}, children);
}
Весь JSX будет заменяться на вызов функции h
, в данном случае переопределенной нами.
Чтобы не настраивать конфиги с нуля, я создал стартер noveogroup-amorgunov/hyperapp-starter. На самом деле подобные стартеры уже есть, но либо они не работают с TypeScript, либо с JSX, либо вообще написаны для первой версии Hyperapp (которая несовместима со второй). Собирается проект с помощью zero-config сборщика Parcel.
На самом деле завести Parcel + TypeScript + JSX + Hyperapp оказалось не такой и простой задачей. Parcel по умолчанию использует свой tsconfig
, поэтому переопределять h
функцию нужно на уровне babelrc
. Ниже пара интересных моментов из стартера.
По умолчанию state
приложения недоступен в дочерних компонентах, так как в Hyperapp нет контекста. Это можно обойти, обернув каждую ноду в high order function, которая будет предоставлять состояние. Ниже реализация:
import {VNode} from 'hyperapp';
type FnWithState<S> = (...args: any[]) => (state: S) => VNode<any>;
type Fn = (...args: any[]) => VNode<any>;
type View<S> = Fn | FnWithState<S>;
export const stateProvider = <S>(view: View<S>) => (state: S) =>
(function provide(target): VNode<any> {
if (typeof target === 'function') {
return provide(target(state));
}
if (target && target.children) {
// @ts-expect-error children is readonly prop
target.children = target.children.map(child => provide(child));
}
return target;
})(view(state));
Если вместо VNode
компонент будет возвращать функцию, то хелпер вызывает ее, прокидывая в нее стейт. В компонентах это будет выглядеть вот так:
export const View = () => (state: State) => {
return (/* ... */);
};
Подключить хелпер очень просто, нужно обернуть рутовый компонент при инициализации приложения:
app<State>({
- view: View,
+ view: stateProvider<State>(View),
// ...
});
Следующая фича, сохранение состояния в localStorage (либо еще где-нибудь). Это можно сделать с помощью подписок, которые вызываются на каждое изменения стейта. Ниже реализация подписки, которая сохраняет сериализованный стейт.
import type {Subscription} from 'hyperapp';
const STORAGE_KEY = '__store';
function persistFx<S>(_: unknown, state: S) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
return () => true;
}
export function persist<S>(state: S): Subscription<S> {
return [persistFx, state];
}
При инициализации приложения достаточно просто считать значения из localStorage:
export function getInitialState<S>(initialState: S): S {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) as string) || initialState;
} catch (err) {
return initialState;
}
}
Подключаются функции в вызове app:
app<State>({
view: stateProvider<State>(View),
+ init: getInitialState<State>({}),
+ subscriptions: state => [persist(state)],
// ...
});
Один из главных вопросов, с которым сталкиваются при работе с hyperapp, как создать локальный стейт у компонентов? Все примеры с каунтером (из поста выше, в том числе) будут работать с одним глобальным состоянием. Это можно сделать, выделяя для каждого компонента отдельное подсостояние.
Делать это вручную уже на втором компоненте станет больно, поэтому можно автоматически выделять место в стейте под каждый инстанс компонента. Компонент должен предоставлять начальный стейт, поэтому удобнее использовать другое название, например, модуль (который принимает View слой и его состояние). При первом рендере (вызове функции) проверяем, пустой ли стейт и если пустой, инициализируем его.
Возможно проблема коллизии имен. Но ее можно решить, запоминая id в каком-нибудь массиве и при использовании одного имени в разных местах, выкидывать ошибку.
Так же помимо инициализации локальной области в глобальной стейте нужно позаботиться, чтобы экшен изменял именно свой локальный стейт, а не соседнего инстанса. А подписки на уровне таких модулей вообще отказываются работать, так как они запускаются на глобальном уровне приложения.
Исходных код можно посмотреть в репозитории здесь (а пример модуля здесь). Для использования нужно вручную передать path в стейте через свойство id
:
export const View = () => (state: AppState) => {
return (
<main>
<Counter id="__foo__" />
<Counter id="__bar__" />
<br />
<pre>
<code>{JSON.stringify(state, null, ' ')}</code>
</pre>
</main>
);
};
В итоге получаем на каждый инстанс свой подстейт с указанным путем через свойство id
.
Сам я собрал пару приложений, например, UI для написания постов в телеграмме:
Могу сказать, что очень не хватает хуков (не думал, что я когда-нибудь буду по ним так скучать), а если быть точнее - useState
. Если вынести обсуждение на уровень выше, то не хватает локального состояния компонентов. Далее, ограниченный набор связанных библиотек и как я уже выше упомянул, неидеальные TypeScript тайпинги. Но для простых приложений подходит, и даже необычно, что уровень кода одновременно очень близок к React-у и к нативному.
Дальше можете заглянуть на https://github.com/jorgebucaran/hyperawesome, где собраны различные материалы, туториалы и библиотеки из экосистемы.
Еще материал по теме:
А дальше - больше, в следующем материале соберем приложение на TypeScript и esm-модулями, используя Superfine (View-слой из Hyperapp, о библиотеке я упоминал в посте о создании своего Virtual DOM) и не используя сборщиков! На практике особо не применимо, но по ходу разберем много чего интересного. Чтобы не пропустить пост, подписывайтесь на телеграмм канал, где я анонсирую все посты в блоге.